feat:搭建基本框架

This commit is contained in:
2025-06-23 19:39:45 +08:00
parent de5537ee1b
commit e09aed87d1
38 changed files with 2106 additions and 83 deletions

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 KiB

View File

@@ -0,0 +1,13 @@
<template>
<span style="color: darkgrey; align-self: center;">
已经到底咯~
</span>
</template>
<script setup lang="ts">
</script>
<style lang="scss">
</style>

View File

@@ -0,0 +1,111 @@
<template>
<div class="daily-card record-card-item">
<div class="card-item__icon">
<slot name="icon"></slot>
</div>
<div class="card-item__body">
<span class="card-item__title">
{{ props.title }}
</span>
<div class="card-item__content">
<span>{{ props.content }}</span>
<slot name="content"></slot>
</div>
</div>
<div class="card-item__extra">
<slot name="extra"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
const props = defineProps({
title: {
type: String,
required: true
},
content: {
type: String,
required: true
}
})
</script>
<style lang="scss">
.daily-card {
padding: 0 5px;
height: 60px;
display: grid;
grid-template-columns: 40px 1fr 100px;
justify-items: center;
align-items: center;
gap: 5px;
.card-item__icon {
.iconfont {
font-size: 28px;
}
.el-avatar {
background: var(--el-color-primary);
font-size: 1.2rem;
}
}
.card-item__body {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-evenly;
justify-self: left;
.card-item__title {
font-size: 1rem;
font-weight: bold;
}
.card-item__content {
display: flex;
gap: 5px;
font-size: 0.9rem;
color: #A9A9A9;
}
}
.card-item__extra {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-evenly;
align-items: center;
justify-self: flex-end;
}
.card-item__extra span:nth-child(1) {
font-size: 1rem;
}
.card-item__extra span:nth-child(2) {
font-size: 0.9rem;
color: #A9A9A9;
}
hr {
height: 1px;
background-color: #cccccc;
border: none;
}
}
.daily-card:hover {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<div class="daily-summary">
<span class="date">{{ props.date }}</span>
<div class="daily-summary__extra">
<slot name="extra"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
const props = defineProps({
date: {
type: String,
required: true
}
})
</script>
<style lang="scss">
.daily-summary {
padding: 0 5px;
display: flex;
justify-content: space-between;
color: gray;
font-size: 0.9rem;
font-weight: bold;
.daily-summary__extra {
display: flex;
gap: 5px;
}
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<el-button ref="buttonRef" type="primary" :icon="Plus"
circle size="large" :style="style" style="font-size: 1.8rem;"
@mousedown="startDrag" @touchstart="startDrag"/>
</template>
<script setup lang="ts">
import { computed, defineEmits, onMounted, reactive, ref } from 'vue'
import { Plus } from '@element-plus/icons-vue'
const buttonRef = ref()
const buttonPos = reactive({
x: 0,
y: 0,
width: 0,
height: 0
})
const startPos = reactive({
x: 0,
y: 0
})
const offsetPos = reactive({
x: 0,
y: 0
})
/**
* 最小拖到阈值
*/
const dragThreshold = 5
const emit = defineEmits(['click'])
/**
* 实时更新位置
*/
const style = computed(() => ({
position: 'absolute',
left: `${buttonPos.x}px`,
top: `${buttonPos.y}px`,
touchAction: 'none'
}))
onMounted(() => {
initButtonPosition()
})
const initButtonPosition = () => {
buttonPos.x = window.innerWidth - 50
buttonPos.y = window.innerHeight - 120
buttonPos.width = buttonRef.value.$el.offsetWidth
buttonPos.height = buttonRef.value.$el.offsetHeight
}
const startDrag = (event) => {
event.preventDefault()
const touch = event.touches ? event.touches[0] : event
startPos.x = touch.clientX
startPos.y = touch.clientY
offsetPos.x = touch.clientX - buttonPos.x
offsetPos.y = touch.clientY - buttonPos.y
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: false })
document.addEventListener('touchend', stopDrag)
}
const onDrag = (event) => {
const touch = event.touches ? event.touches[0] : event
// 计算新位置,并限制在边界内
const newX = touch.clientX - offsetPos.x
const newY = touch.clientY - offsetPos.y
buttonPos.x = Math.max(0, Math.min(newX, window.innerWidth - buttonPos.width))
buttonPos.y = Math.max(40, Math.min(newY, window.innerHeight - buttonPos.height - 70))
}
const stopDrag = (event) => {
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
const touch = event.changedTouches ? event.changedTouches[0] : event
const deltaX = Math.abs(touch.clientX - startPos.x)
const deltaY = Math.abs(touch.clientY - startPos.y)
// 此时为点击
if (deltaX <= dragThreshold && deltaY <= dragThreshold) {
emit('click', event)
}
}
</script>

View File

@@ -0,0 +1,129 @@
<template>
<div class="life-record-card card-item">
<el-carousel v-if="props.foodItem?.recordList.length > 0"
arrow="always" @change="changeFoodEvent">
<el-carousel-item v-for="(item, index) in getFoodRecord(props.foodItem?.recordList)"
:key="index">
<el-image :src="serviceFileRootPath + item.imageUrl"
fit="contain" alt="暂无成果">
<template #error>
<div class="image-slot">
<span>暂无图片</span>
</div>
</template>
</el-image>
</el-carousel-item>
</el-carousel>
<el-empty v-else description="暂无成果" />
<div class="record-info">
<div class="top-container">
<div class="info-list">
<div class="info-item">
<span class="title">{{ props.foodItem.name }}</span>
</div>
</div>
<div class="button-info">
<el-button type="primary" :icon="Edit" circle :size="'small'"
@click="editFoodRecipeClick"/>
<el-button type="primary" :icon="MoreFilled" circle :size="'small'"
@click="viewFoodRecipeClick()" />
<el-button type="danger" :icon="DeleteFilled" circle :size="'small'"
@click="deleteFoodRecipeClick()" />
</div>
</div>
<div class="bottom-container">
<div class="info-list">
<div class="info-item category-item">
<i class="fa-solid fa-utensils"></i>
{{ props.foodItem.category }}
</div>
</div>
<div v-if="props.foodItem?.recordList.length > 0" class="info-list">
<div class="info-item">
<i class="fa-solid fa-flag"></i>
{{ props.foodItem.recordList[foodCardInfo.currentFoodIndex].person}}
</div>
<div class="info-item">
<i class="fa-regular fa-calendar-check"></i>
{{ formatDate(props.foodItem.recordList[foodCardInfo.currentFoodIndex].date) }}
</div>
</div>
<div v-else>
<span>暂无成果</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Edit, MoreFilled, DeleteFilled } from '@element-plus/icons-vue'
import { useFoodStore } from '@/store/food'
import { defineProps, onMounted, PropType, reactive } from 'vue'
import { IFoodRecipe, IFoodRecord } from '@/types/food.ts'
import { useRouter } from 'vue-router'
import { formatDate } from 'vue3-common/utils/dateUtil'
import { deepCopyObject } from 'vue3-common/utils/dataUtil'
import { commonElMessageBox } from 'vue3-common/utils/elUtil'
import { serviceFileRootPath } from '@/utils'
import { isMobile } from 'vue3-common/utils/layoutUtil'
const router = useRouter()
const foodStore = useFoodStore()
const foodCardInfo = reactive({
recommendRate: 0,
currentFoodIndex: 0
})
const props = defineProps({
foodItem: {
required: true,
type: Object as PropType<IFoodRecipe>
}
})
onMounted(() => {
foodCardInfo.recommendRate = props.foodItem?.recommendRate as number
})
const getFoodRecord = (recordList: IFoodRecord[]) => {
return recordList.filter((item) => item.imageUrl)
}
const editFoodRecipeClick = async () => {
const id = props.foodItem?.id
foodStore.foodRecipeApiType = 'UPDATE'
await foodStore.queryFoodRecipe(id as number)
// foodStore.foodRecipeDialog = {
// title: '编辑菜谱',
// visible: true,
// data: deepCopyObject(foodStore.currentFoodRecipe)
// }
}
const viewFoodRecipeClick = async () => {
await router.push({ name: 'MobileHomeFoodDetailId', params: { id: props.foodItem?.id } })
}
const deleteFoodRecipeClick = () => {
foodStore.foodRecipeApiType = 'DELETE'
commonElMessageBox(`请确认是否删除该菜谱: ${props.foodItem?.name}?`).then(() => {
foodStore.handleFoodRecipeApi(props.foodItem?.id as number)
})
}
const changeFoodEvent = (current: number) => {
foodCardInfo.currentFoodIndex = current
}
</script>
<style lang="scss">
@use "@/styles/life.record.module.scss";
</style>

View File

@@ -0,0 +1,114 @@
<template>
<div class="food-daily">
<v-calendar ref="calendarRef" :attributes='dailyInfo.attrs'
@dayclick="dateClick" @did-move="changeMonthClick"
:is-dark="appStore.isDark"
class="food-calendar card-item"/>
<food-daily-item :date="dailyInfo.calendarDate"/>
<draggable-button @click="addClick"/>
</div>
</template>
<script setup lang="ts">
import FoodDailyItem from '@/components/Food/FoodDailyItem.vue'
import DraggableButton from '@/components/DraggableButton.vue'
import { reactive, watch, ref, onMounted } from 'vue'
import { useFoodStore } from '@/store/food.ts'
import { formatDate } from 'vue3-common/utils/dateUtil'
import { useAppStore } from '@/store/app'
import { useRouter } from 'vue-router'
const calendarRef = ref()
const foodStore = useFoodStore()
const appStore = useAppStore()
const router = useRouter()
onMounted(() => {
const { month } = foodStore.queryRecord
calendarRef.value.move(new Date(month))
})
watch(() => foodStore.isRefresh, () => {
updateInfo()
})
const dailyInfo = reactive({
calendarDate: '',
attrs: [] as any[]
})
const addClick = () => {
foodStore.foodRecordApiType = 'ADD'
foodStore.currentFoodRecord = foodStore.getEmptyFoodRecord()
router.push({ name: 'MobileHomeFoodRecordForm' })
}
const updateInfo = () => {
getCalendarMoveDate()
getCalendarAttrDates()
}
const getCalendarMoveDate = () => {
dailyInfo.calendarDate = formatDate(new Date())
const foodLength = foodStore.foodRecordList.length
if (foodLength > 0) {
const today = formatDate(new Date())
if (foodStore.foodRecordList.some((item) => item.date === today)) {
dailyInfo.calendarDate = today
} else {
dailyInfo.calendarDate = formatDate(foodStore.foodRecordList[foodLength - 1].date)
}
} else {
dailyInfo.calendarDate = ''
}
}
const getCalendarAttrDates = () => {
dailyInfo.attrs = []
dailyInfo.attrs.push({
key: 'today',
highlight: true,
dates: new Date()
})
const dateList = foodStore.foodRecordList.map((item) => item.date)
dailyInfo.attrs.push({
dot: 'red',
dates: [...new Set(dateList)]
})
}
/**
* 选择日期事件
* @param day 日期
*/
const dateClick = (day) => {
dailyInfo.calendarDate = formatDate(day.date)
}
/**
* 选择月份事件
*/
const changeMonthClick = async (value) => {
foodStore.queryRecord.month = value[0].id
await foodStore.refreshInfo()
}
</script>
<style lang="scss">
.food-daily {
height: 100%;
display: flex;
flex-direction: column;
gap: 1vh;
.food-calendar {
width: 100%;
justify-self: center;
align-self: center;
}
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<el-empty v-if="dailyItemInfo.itemList.length === 0" />
<div v-else class="food-daily-item card-item">
<daily-summary :date="dailyItemInfo.date"/>
<div class="daily-list">
<daily-card v-for="(item, index) in dailyItemInfo.itemList"
:key="index" @click="selectFoodRecordClick(item)"
:title="item.name" :content="item.category">
<template #icon>
<el-avatar>{{ item.category.charAt(0) }}</el-avatar>
</template>
<template #extra>
<span>{{ item.person }}</span>
<span>{{ item.date }}</span>
</template>
</daily-card>
</div>
</div>
</template>
<script setup lang="ts">
import DailySummary from '@/components/DailySummary.vue'
import DailyCard from '@/components/DailyCard.vue'
import { defineProps, onMounted, reactive, watch } from 'vue'
import { useFoodStore } from '@/store/food.ts'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/store/app.ts'
import { formatChineseDate } from 'vue3-common/utils/dateUtil'
import { IFoodRecord } from '@/types/food.ts'
const foodStore = useFoodStore()
const appStore = useAppStore()
const router = useRouter()
const props = defineProps({
date: {
required: true,
type: String
}
})
watch(() => props.date, () => {
getFoodDailyByDate(props.date as string)
})
watch(() => foodStore.isRefresh, () => {
getFoodDailyByDate(props.date as string)
})
onMounted(() => {
getFoodDailyByDate(props.date as string)
})
const dailyItemInfo = reactive({
date: '',
itemList: [] as IFoodRecord[]
})
/**
* 根据日期获取当天的美食记录
* @param date
*/
const getFoodDailyByDate = (date: string) => {
if (date) {
dailyItemInfo.date = formatChineseDate(date, 'MM月DD日 dddd')
dailyItemInfo.itemList = []
foodStore.foodRecordList.forEach((item) => {
if (item.date === date) {
dailyItemInfo.itemList.push(item)
}
})
} else {
dailyItemInfo.itemList = []
}
}
/**
* 点击美食记录按钮
* @param foodRecord 美食记录
*/
const selectFoodRecordClick = async (foodRecord: IFoodRecord) => {
foodStore.foodRecordApiType = 'UPDATE'
appStore.isShowMobileBack = true
foodStore.currentFoodRecord = foodRecord
await router.push({ name: 'MobileHomeFoodRecordForm' })
}
</script>
<style lang="scss">
.food-daily-item {
display: flex;
flex-direction: column;
gap: 1vh;
padding: 1vh 0.5vw;
.daily-list {
display: flex;
flex-direction: column;
gap: 1vh;
overflow: auto;
}
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<div ref="foodRecipeRef" class="food-recipe">
<div class="query-item card-item">
<span>分类</span>
<el-segmented v-model="foodStore.queryRecipe.category"
:options="foodCategoryList"
@change="queryFoodSegmented"/>
</div>
<div class="food-list">
<el-empty v-if="foodStore.foodRecipeList.length === 0" description="暂无美食记录"/>
<div v-else class="list">
<food-card v-for="(item, index) in foodStore.foodRecipeList"
:key="index" :food-item="item"/>
</div>
</div>
<arrive-bottom v-if="foodStore.isScrollEnd"/>
<draggable-button @click="addClick"/>
</div>
</template>
<script lang="ts" setup>
import FoodCard from '@/components/Food/FoodCard.vue'
import ArriveBottom from '@/components/ArriveButton.vue'
import DraggableButton from '@/components/DraggableButton.vue'
import { useFoodStore } from '@/store/food.ts'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const foodRecipeRef = ref()
const foodStore = useFoodStore()
const router = useRouter()
const foodCategoryList = [{
label: '全部',
value: ''
},
{
label: '家常菜',
value: '家常菜'
}]
onMounted(async () => {
foodRecipeRef.value.addEventListener('scroll', handleScroll)
})
onBeforeUnmount(() => {
foodRecipeRef.value.removeEventListener('scroll', handleScroll)
})
const addClick = () => {
foodStore.foodRecordApiType = 'ADD'
foodStore.currentFoodRecord = foodStore.getEmptyFoodRecord()
router.push({ name: 'MobileHomeFoodRecordForm' })
}
const queryFoodSegmented = async () => {
foodStore.pageInfo.currentPage = 1
foodStore.foodRecipeList = []
await foodStore.queryFoodRecipeList()
}
const handleScroll = () => {
const el = foodRecipeRef.value
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 10) {
const { currentPage, pageSize, totalSize } = foodStore.pageInfo
foodStore.isScrollEnd = pageSize * currentPage > totalSize
if (!foodStore.isScrollEnd) {
foodStore.pageInfo.currentPage++
foodStore.queryFoodRecipeList()
}
}
}
</script>
<style lang="scss">
.food-recipe {
display: flex;
flex-direction: column;
gap: 10px;
.query-item {
padding: 5px 10px;
}
.food-list {
.list {
display: flex;
flex-direction: column;
gap: 10px;
}
}
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div class="food-timeline">
<el-date-picker
v-model="currentYear"
type="year" placeholder="请选择年份"
style="width: 100px;"
@change="changeDateEvent"
/>
<el-timeline v-if="foodStore.foodRecordList.length > 0">
<el-timeline-item v-for="(item, index) in foodStore.foodRecordList"
:key="index" :timestamp="formatDate(item.date)" placement="top">
<div class="timeline-info card-item">
<span>{{ item.name }}</span>
<el-image :src="item.imageUrl"
fit="contain" alt="暂无成果">
<template #error>
<div class="image-slot">
<span>暂无图片</span>
</div>
</template>
</el-image>
</div>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无日记"/>
</div>
</template>
<script setup lang="ts">
import { useFoodStore } from '@/store/food.ts'
import { onMounted, ref } from 'vue'
import { formatDate, getCurrentYear, getStartEndDate } from 'vue3-common/utils/dateUtil'
import { serviceFileRootPath } from '@/utils'
const currentYear = ref(new Date())
const foodStore = useFoodStore()
onMounted(async () => {
const dateInfo = getStartEndDate(getCurrentYear(), 'year')
foodStore.queryDateList[0] = dateInfo[0]
foodStore.queryDateList[1] = dateInfo[1]
await foodStore.refreshInfo()
})
const changeDateEvent = async (value: Date) => {
const dateInfo = getStartEndDate(value, 'year')
foodStore.queryDateList[0] = dateInfo[0]
foodStore.queryDateList[1] = dateInfo[1]
await foodStore.refreshInfo()
}
</script>
<style lang="scss">
.food-timeline {
display: flex;
flex-direction: column;
.el-date-editor {
align-self: center;
}
.el-timeline {
padding: 10px;
.timeline-info {
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
}
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<div class="moment-card card-item">
<el-avatar src="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png" />
<div class="moment-content">
<span class="username">Cxx0822</span>
<p>今天烧了个红烧鱼 非常不错</p>
<el-image style="width: 200px; height: 200px"
src="https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg" fit="fill" />
<p>1小时前</p>
<div class="button-container">
<el-button circle size="small">
<i class="fa-regular fa-thumbs-up"></i>
</el-button>
<el-button circle size="small"
@click="momentInfo.isShowComment = !momentInfo.isShowComment">
<i class="fa-regular fa-comment"></i>
</el-button>
</div>
<div v-if="momentInfo.isShowComment" class="moment-comment">
<el-input
v-model="momentInfo.comment"
maxlength="100" placeholder="平等表达 友善交流"
show-word-limit type="textarea" :rows="3"
@blur="momentInfo.isShowComment = false"
/>
<el-popover ref="emojiPopoverRef" placement="bottom" :width="300" trigger="click">
<template #default>
<div class="emoji-panel">
<EmojiPicker :native="true" @select="onSelectEmoji" />
</div>
</template>
<template #reference>
<el-button circle>
<i class="fa-regular fa-face-smile"></i>
</el-button>
</template>
</el-popover>
<el-button type="primary"
:disabled="momentInfo.comment.length === 0"
@click="sendCommentClick"
class="send-button">发送</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import EmojiPicker from 'vue3-emoji-picker'
import 'vue3-emoji-picker/css'
import { Edit } from '@element-plus/icons-vue'
import { reactive } from 'vue'
const momentInfo = reactive({
isShowComment: false,
comment: ''
})
const onSelectEmoji = (emoji) => {
}
const sendCommentClick = async () => {
momentInfo.comment = ''
momentInfo.isShowComment = false
}
</script>
<style lang="scss">
.moment-card {
padding: 10px;
display: grid;
grid-template-columns: 50px 1fr;
gap: 10px;
.moment-content {
display: flex;
flex-direction: column;
gap: 10px;
p {
margin: 0;
}
.username {
font-weight: bold;
font-size: larger;
}
.moment-comment {
display: grid;
grid-template-columns: 1fr 40px 60px;
justify-items: center;
gap: 5px;
}
}
}
</style>

View File

@@ -0,0 +1,172 @@
<template>
<div class="food-chart"
element-loading-text="正在加载中...">
<div class="chart-container card-item">
<div class="content">
<span class="title">📊记录统计</span>
</div>
<div class="chart" ref="foodDailyChartRef"/>
</div>
<div class="chart-container card-item">
<div class="content">
<span class="title">📊类别统计</span>
</div>
<div class="chart" ref="foodCategoryChartRef"/>
</div>
<div class="chart-container card-item">
<div class="content">
<span class="title">📊排行榜</span>
</div>
<div class="chart rank-chart">
<food-rank />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import FoodRank from '@/components/Stats/FoodRank.vue'
import { onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import { lineChartOptions, barChartOptions, pieChartOptions } from 'vue3-common/utils/eChartsUtil'
import { useFoodStore } from '@/store/food'
import { useAppStore } from '@/store/app.ts'
import type { IStatsChart } from 'vue3-common/types'
import { isMobile } from 'vue3-common/utils/layoutUtil'
const foodStore = useFoodStore()
const appStore = useAppStore()
const foodDailyChartRef = ref<HTMLElement>()
const foodCategoryChartRef = ref<HTMLElement>()
const billRankChartRef = ref<HTMLElement>()
let foodDailyChart: echarts.ECharts
let foodCategoryChart: echarts.ECharts
let billRankChart: echarts.ECharts
watch(() => foodStore.isRefresh, () => {
updateChart()
})
onMounted(() => {
foodDailyChart = echarts.init(foodDailyChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
foodCategoryChart = echarts.init(foodCategoryChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
// billRankChart = echarts.init(billRankChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
})
const updateChart = () => {
const MAX_CATEGORY_COUNT = 5
const MAX_RANK_COUNT = 10
// 记录统计图
// const dailyXAxis = foodStore.billDateStatsList.map((item) => item.name)
// const dailyYAxis = foodStore.billDateStatsList.map((item) => item.value)
const dailyXAxis = ['2024-01', '2024-02']
const dailyYAxis = ['10', '6']
foodDailyChart.setOption(lineChartOptions('日期', dailyXAxis, '次数(次)', dailyYAxis))
//
// // 账本统计图
// const bookXAxis = foodStore.billBookStatsList.map((item) => item.name)
// const bookYAxis = foodStore.billBookStatsList.map((item) => item.value)
// billBookChart.setOption(barChartOptions('账本', bookXAxis, '金额(元)', bookYAxis))
// billBookChart.setOption({
// series: [{
// itemStyle: {
// color: foodStore.billType === 'expensive' ? '#CA6C65' : '#6FBB69'
// },
// markPoint: {
// label: {
// color: '#FFFFFF'
// }
// }
// }]
// })
//
// 类别统计图
const foodCategoryList = [
{ value: 12, name: '家常菜' },
{ value: 10, name: '鲁菜' },
{ value: 8, name: '川菜' },
{ value: 5, name: '淮扬菜' },
{ value: 2, name: '其他' }
]
foodCategoryChart.setOption(pieChartOptions('category', '次', foodCategoryList))
//
// // 排行榜统计图
// const billRankList: IStatsChart[] = []
// const resultList = foodStore.billRecordList.filter((item) => item.type === foodStore.billType)
// resultList.sort((a, b) => b.amount - a.amount).forEach((item) => {
// billRankList.push({
// name: item.content,
// value: item.amount
// })
// })
//
// if (!isMobile()) {
// billRankChart.setOption(pieChartOptions('rank', '元', billRankList.slice(0, MAX_RANK_COUNT)))
// }
}
</script>
<style lang="scss">
.food-chart {
display: grid;
grid-template-rows: repeat(3, 1fr);
gap: 10px;
.chart-container {
display: grid;
grid-template-rows: 40px 1fr;
.content {
padding: 0 5px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #009FA8;
.title {
font-size: 1em;
font-weight: bold;
}
}
.chart {
height: 300px;
width: 100%;
}
.rank-chart {
overflow: auto;
display: flex;
flex-direction: column;
gap: 5px;
.rank-item {
padding: 10px;
display: grid;
grid-template-columns: 20px 1fr 80px;
align-items: center;
.rank-amount {
justify-self: flex-end;
}
}
.rank-item:nth-child(1) {
color: #d4af37;
font-weight: bold;
}
.rank-item:nth-child(2) {
color: #a8a8a8;
}
.rank-item:nth-child(3) {
color: #b87333;
}
}
}
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<div v-for="(item, index) in foodRankList.slice(0, MAX_RANK_COUNT)"
:key="index" class="rank-item">
<span class="rank-index">{{ index+1 }}.</span>
<span class="rank-content">{{ item.value }}</span>
<span class="rank-amount">{{ item.count }} </span>
</div>
</template>
<script setup lang="ts">
import { useFoodStore } from '@/store/food'
import { computed } from 'vue'
const foodStore = useFoodStore()
const MAX_RANK_COUNT = 10
const foodRankList = computed(() => {
return [{
value: '红烧肉',
count: 10
}, {
value: '红烧鱼',
count: 8
}, {
value: '排骨汤',
count: 5
}, {
value: '红烧带鱼',
count: 2
}]
})
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div class="common-statistics">
<stats-card
title="菜谱总数" color="#CA6C65" unit="个" icon="iconfont icon-caipu"
:value="foodStore.foodStats.recipeCount"/>
<stats-card
title="菜谱类别" color="#6FBB69" unit="种" icon="iconfont icon-leibie"
:value="foodStore.foodStats.categoryCount"/>
<stats-card
title="做菜次数" color="#E4BF62" unit="次" icon="iconfont icon-cishu"
:value="foodStore.foodStats.workCount"/>
<stats-card
title="平均次数" color="#5470C6" unit="次/月" icon="iconfont icon-pinlv"
:value="getAverageCount()"/>
</div>
</template>
<script setup lang="ts">
import StatsCard from '@/components/StatsCard.vue'
import { onMounted } from 'vue'
import { useFoodStore } from '@/store/food'
const foodStore = useFoodStore()
onMounted(async () => {
await foodStore.queryFoodStats()
})
const getAverageCount = () => {
if (foodStore.queryDateType === 'month') {
return foodStore.foodRecordList.length
} else {
const result = {}
// 遍历数据,按月统计出现次数
foodStore.foodRecordList.forEach((item) => {
const date = new Date(item.date)
const month = String(date.getMonth() + 1)
if (result[month]) {
result[month].count += 1
} else {
result[month] = { count: 1 }
}
})
return foodStore.foodRecordList.length / Object.keys(result).length
}
}
</script>

View File

@@ -0,0 +1,92 @@
<template>
<div class="statistics-card card-item" :style="{color: props.color}">
<span class="icon">
<i :class="props.icon"></i>
</span>
<div class="item">
<span class="title">
{{ props.title }}
</span>
<div class="value">
<span class="amount">
{{ props.value }}
</span>
<span class="unit">
{{ props.unit }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
const props = defineProps({
icon: {
required: true,
type: String
},
title: {
required: true,
type: String
},
value: {
required: true,
type: [String, Number]
},
unit: {
required: true,
type: String
},
color: {
required: true,
type: String
}
})
</script>
<style lang="scss">
.statistics-card {
width: 100%;
padding: 10px;
display: grid;
grid-template-columns: 30% 1fr;
justify-items: center;
align-items: center;
.iconfont, .icon {
font-size: 2.5rem;
}
.item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1vh;
.title {
font-size: 1.5rem;
}
.value {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5vw;
.amount {
font-weight: bold;
font-size: 1.5rem;
}
.unit {
font-size: 1.5rem;
}
}
}
}
</style>

View File

@@ -1,14 +1,24 @@
<template>
<div class="tab-bar">
<router-link to="/app/record" @click="routerClick()" class="item">
<i class="fa-solid fa-house"></i>
<router-link to="/record" @click="routerClick()" class="item">
<i class="iconfont icon-jilu"></i>
<span>记录</span>
</router-link>
<router-link to="/app/stats" @click="routerClick()" class="item">
<i class="fa-solid fa-house"></i>
<router-link to="/stats" @click="routerClick()" class="item">
<i class="iconfont icon-tongji"></i>
<span>统计</span>
</router-link>
<router-link to="/moment" @click="routerClick()" class="item">
<i class="iconfont icon-pengyouquan"></i>
<span>朋友圈</span>
</router-link>
<router-link to="/profile" @click="routerClick()" class="item">
<i class="fa-solid fa-user"></i>
<span>个人中心</span>
</router-link>
</div>
</template>

View File

@@ -6,7 +6,7 @@
circle color="#FFFFFF"
@click="goBackClick" />
<div v-else class="logo-container">
<img :src="getAssetsImageFile('logo/logo.png')" alt="暂无图片" class="logo"/>
<img :src="getAssetsImageFile('logo.png')" alt="暂无图片" class="logo"/>
<span>{{ appStore.appName }}</span>
</div>
</div>

View File

@@ -12,6 +12,10 @@ import 'element-plus/theme-chalk/dark/css-vars.css'
import 'dayjs/locale/zh-cn'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
// 引入日历组件
import VCalendar from 'v-calendar'
import 'v-calendar/style.css'
// 引入font-awesome
// import 'font-awesome/css/font-awesome.min.css'
import '@fortawesome/fontawesome-free/css/all.css'
@@ -24,5 +28,6 @@ app.use(router)
app.use(createPinia())
// 使用Element UI Plus
app.use(ElementPlus, { locale: zhCn })
app.use(VCalendar, {})
// 挂载到根组件上
app.mount('#app')

View File

@@ -1,7 +0,0 @@
import Layout from '@/layout/index.vue'
import { getRoutersByModules } from 'vue3-common/utils/routerUtil'
import { routerMeta } from '@/views/meta.ts'
const routes = getRoutersByModules(import.meta.glob('@/views/app/**/*.vue'), Layout, routerMeta)
export default routes

14
src/router/menu.ts Normal file
View File

@@ -0,0 +1,14 @@
import { getRoutersByModules, sortRoutesByOrder } from 'vue3-common/utils/routerUtil'
import Layout from '@/layout/index.vue'
import { menuMeta } from '@/views/meta'
const modules = {
...import.meta.glob('@/views/record/**/*.vue'),
...import.meta.glob('@/views/stats/**/*.vue'),
...import.meta.glob('@/views/moment/**/*.vue'),
...import.meta.glob('@/views/profile/**/*.vue')
}
const menuRoutes = getRoutersByModules(modules, Layout, menuMeta)
export default sortRoutesByOrder(menuRoutes)

235
src/store/food.ts Normal file
View File

@@ -0,0 +1,235 @@
import { defineStore } from 'pinia'
import type {
IFoodRecipe, IFoodMaterial, IFoodStep,
IFoodStats, IFoodRecord
} from '@/types/food'
// import {
// addFoodRecipeApi, addFoodRecordApi, deleteFoodRecipeApi, deleteFoodRecordApi,
// queryFoodNameListApi, queryFoodRecipeByIdApi, queryFoodRecipeApi, queryFoodRecordApi,
// queryFoodStatsApi, updateFoodRecipeApi, updateFoodRecordApi, queryFoodCategoryApi
// } from '@/apis/food.ts'
// import { IDateType, ImagePathEnum } from '@/types'
import type { IDialog, IHandleApi } from 'vue3-common/types'
import { ElMessage } from 'element-plus'
import { getCurrentMonth, getCurrentYear, getStartEndDate } from 'vue3-common/utils/dateUtil'
import { isMobile } from 'vue3-common/utils/layoutUtil'
export const useFoodStore = defineStore('food', {
state: () => ({
foodRecipeList: [] as IFoodRecipe[],
currentFoodRecipe: {
id: 0,
name: '',
category: '',
recommendRate: 0,
remark: '',
materialList: [],
stepList: [],
recordList: []
} as IFoodRecipe,
foodRecordList: [] as IFoodRecord[],
currentFoodRecord: {
id: 0,
name: '',
category: '',
date: '',
person: '',
imageUrl: ''
} as IFoodRecord,
foodNameList: [] as string[],
queryRecipe: {
category: ''
},
queryRecord: {
year: getCurrentYear(),
month: getCurrentMonth()
},
queryDateType: 'month',
queryDateList: [] as string[],
foodRecipeApiType: 'ADD' as IHandleApi,
foodRecipeDialog: {
visible: false,
title: '',
data: {}
} as IDialog<IFoodRecipe>,
foodMaterialApiType: 'ADD' as IHandleApi,
foodMaterialDialog: {
visible: false,
title: '',
data: {}
} as IDialog<IFoodMaterial>,
editFoodMaterialIndex: 0,
foodStepApiType: 'ADD' as IHandleApi,
foodStepDialog: {
visible: false,
title: '',
data: {}
} as IDialog<IFoodStep>,
editFoodStepIndex: 0,
foodRecordApiType: 'ADD' as IHandleApi,
foodRecordDialog: {
visible: false,
title: '',
data: {}
} as IDialog<IFoodRecord>,
editFoodRecordIndex: 0,
foodImagePath: 'food',
rateColorList: ['#E2DEDC', '#F7BA2A', '#009FA8'],
pageInfo: {
currentPage: 1,
pageSize: 3,
totalSize: 1
},
isScrollEnd: false,
foodCategoryList: [] as string[],
foodStats: {
recipeCount: 0,
categoryCount: 0,
workCount: 0
} as IFoodStats,
isRefresh: false
}),
actions: {
async queryFoodRecipeList() {
const { currentPage, pageSize } = this.pageInfo
// const result = await queryFoodRecipeApi(currentPage, pageSize, this.queryRecipe)
const foodRecord: IFoodRecipe = {
category: '家常菜',
materialList: [],
name: '红烧鱼',
recommendRate: 4,
recordList: [],
remark: '',
stepList: []
}
this.foodRecipeList = []
this.foodRecipeList.push(foodRecord)
this.foodRecipeList.push(foodRecord)
// this.foodRecipeList.push(...result.records)
// this.pageInfo.totalSize = result.total
},
async queryFoodRecordList() {
const { year, month } = this.queryRecord
const date = this.queryDateType === 'year' ? year : month
this.queryDateList = getStartEndDate(date, this.queryDateType)
const foodRecord: IFoodRecord = {
category: '家常菜',
date: '2025-06-23',
id: 0,
imageUrl: 'https://ts2.tc.mm.bing.net/th/id/OSK.277ab994364a7e09543c78379bab1d05?w=612&h=408&c=7&rs=1&qlt=80&o=6&cdv=1&cb=ircwebp1&pid=16.1',
name: '红烧鱼',
person: '哥哥'
}
const foodRecord2: IFoodRecord = {
category: '家常菜',
date: '2025-06-22',
id: 0,
imageUrl: 'https://ts2.tc.mm.bing.net/th/id/OSK.277ab994364a7e09543c78379bab1d05?w=612&h=408&c=7&rs=1&qlt=80&o=6&cdv=1&cb=ircwebp1&pid=16.1',
name: '红烧鱼',
person: '哥哥'
}
this.foodRecordList = []
this.foodRecordList.push(foodRecord)
this.foodRecordList.push(foodRecord2)
// this.foodRecordList = await queryFoodRecordApi(this.queryDateList[0], this.queryDateList[1])
},
async queryFoodNameList() {
// this.foodNameList = await queryFoodNameListApi()
},
async queryFoodRecipe(id: number) {
// this.currentFoodRecipe = await queryFoodRecipeByIdApi(id)
},
async queryFoodStats() {
// this.foodStats = await queryFoodStatsApi()
this.isRefresh = !this.isRefresh
},
async queryFoodCategory() {
// this.foodCategoryList = await queryFoodCategoryApi()
},
async handleFoodRecipeApi(id?: number) {
const { data } = this.foodRecipeDialog
switch (this.foodRecipeApiType) {
case 'ADD':
// await addFoodRecipeApi(data)
ElMessage.success('新增菜谱成功')
break
case 'UPDATE':
// await updateFoodRecipeApi(data.id as number, data)
ElMessage.success('更新菜谱成功')
break
case 'DELETE':
// await deleteFoodRecipeApi(id as number)
ElMessage.success('删除菜谱成功')
break
default:
break
}
this.foodRecipeDialog.visible = false
await this.refreshInfo()
},
async handleFoodRecordApi(foodRecord: IFoodRecord) {
switch (this.foodRecordApiType) {
case 'ADD':
// await addFoodRecordApi(foodRecord)
ElMessage.success('新增成果成功')
break
case 'UPDATE':
// await updateFoodRecordApi(foodRecord.id as number, foodRecord)
ElMessage.success('更新成果成功')
break
case 'DELETE':
// await deleteFoodRecordApi(foodRecord.id as number)
ElMessage.success('删除成果成功')
break
default:
break
}
this.foodRecordDialog.visible = false
if (!isMobile()) {
await this.refreshInfo()
}
},
async refreshInfo() {
await this.queryFoodRecipeList()
await this.queryFoodRecordList()
this.isRefresh = !this.isRefresh
},
getEmptyFoodRecipe(): IFoodRecipe {
return {
category: '',
materialList: [],
name: '',
recommendRate: 0,
remark: '',
stepList: [],
recordList: []
}
},
getEmptyFoodMaterial(): IFoodMaterial {
return {
amount: '',
name: '',
type: ''
}
},
getEmptyFoodStep(): IFoodStep {
return {
content: '',
imageUrl: '',
sort: 0
}
},
getEmptyFoodRecord(): IFoodRecord {
return {
category: '',
person: '',
date: '',
id: 0,
imageUrl: '',
name: ''
}
}
}
})

View File

@@ -82,4 +82,4 @@ span, input {
font-style: normal;
}
@import "//at.alicdn.com/t/c/font_4793264_befv3sf3zdg.css";
@import "//at.alicdn.com/t/c/font_4956432_suiec1b32.css";

View File

@@ -0,0 +1,90 @@
$card-image-height: 200px;
.life-record-card {
display: grid;
grid-template-rows: minmax($card-image-height, 26vh) 1fr;
.el-carousel {
border-top-left-radius: 15px;
border-top-right-radius: 15px;
.el-carousel__container {
height: 100%;
}
.el-image {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
}
.record-info {
border-top: 1px solid var(--el-color-primary);
padding: 1vh;
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
.top-container, .bottom-container {
display: flex;
justify-content: space-between;
align-items: center;
.info-list {
display: flex;
align-items: center;
gap: 8px;
.el-rate {
height: auto;
}
.info-item {
display: flex;
align-items: center;
gap: 3px;
.title {
font-size: 1.2rem;
font-weight: bold;
}
}
.category-item {
color: #0d9488;
}
.location-item {
color: #0d9488;
}
.amount-item {
font-size: 1.2rem;
color: #CFB53B;
}
.fa-solid.fa-location-dot {
color: #008000;
}
.fa-solid.fa-flag {
color: #0000FF;
}
.fa-regular.fa-calendar-check {
color: #FF0000;
}
}
}
.bottom-container {
font-size: small;
color: #9C9C9C;
}
}
}

79
src/types/food.ts Normal file
View File

@@ -0,0 +1,79 @@
export type IFoodSegment = 'recipe' | 'daily' | 'timeline'
/**
* 菜谱信息
*/
export interface IFoodRecipe {
id?: number;
/**
* 美食名称
*/
name: string;
/**
* 美食菜系
*/
category: string;
/**
* 推荐指数
*/
recommendRate: number;
/**
* 备注
*/
remark: string;
/**
* 食材
*/
materialList: IFoodMaterial[];
/**
* 步骤
*/
stepList: IFoodStep[];
/**
* 成果
*/
recordList: IFoodRecord[];
}
/**
* 食材信息
*/
export interface IFoodMaterial {
type: string;
name: string;
amount: string;
}
/**
* 步骤信息
*/
export interface IFoodStep {
sort: number;
content: string;
imageUrl: string;
}
/**
* 成果信息
*/
export interface IFoodRecord {
id: number;
name: string;
category: string;
date: string;
person: string;
imageUrl: string;
}
/**
* 统计信息
*/
export interface IFoodStats {
recipeCount: number;
categoryCount: number;
workCount: number;
}
export interface IFoodQuery {
category: string;
}

View File

@@ -1,13 +0,0 @@
<template>
<div class="common-mobile-view">
统计
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
</style>

View File

@@ -1,11 +1,17 @@
<template>
<div>
登录
<el-button type="primary" @click="loginClick">登录</el-button>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const loginClick = () => {
router.push('/record')
}
</script>
<style scoped lang="scss">

View File

@@ -1,6 +1,6 @@
import type { IRouteMetaConfig } from 'vue3-common/types'
export const routerMeta: IRouteMetaConfig = {
export const menuMeta: IRouteMetaConfig = {
'/record': {
path: '/record',
redirect: '/record/index'
@@ -8,5 +8,13 @@ export const routerMeta: IRouteMetaConfig = {
'/stats': {
path: '/stats',
redirect: '/stats/index'
},
'/moment': {
path: '/moment',
redirect: '/moment/index'
},
'/profile': {
path: '/profile',
redirect: '/profile/index'
}
}

View File

@@ -0,0 +1,17 @@
<template>
<div class="common-mobile-view food-moment">
<moment-card v-for="(item, index) in 4" :key="index"/>
</div>
</template>
<script setup lang="ts">
import MomentCard from '@/components/Moment/MomentCard.vue'
</script>
<style lang="scss">
.food-moment {
display: flex;
flex-direction: column;
gap: 10px;
}
</style>

View File

@@ -1,13 +1,12 @@
<template>
<div class="common-mobile-view">
记录
<div class="common-mobile-view profile">
个人中心
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
<style scoped>
</style>

View File

@@ -0,0 +1,85 @@
<template>
<div class="common-mobile-view food-record">
<div class="food-query card-item">
<el-input
v-model="foodInfo.query"
placeholder="请输入菜谱名称" :prefix-icon="Search" clearable
:maxlength="20" show-word-limit
/>
<div class="query-item">
<span>查看类型</span>
<el-segmented v-model="foodInfo.recordSegment"
:options="recordSegmentList"
@change="changeRecordSegmentEvent()"/>
</div>
</div>
<hr style="width: 100%;"/>
<div v-if="foodInfo.recordSegment === 'recipe'" class="food-calendar">
<food-recipe />
</div>
<div v-if="foodInfo.recordSegment === 'daily'" class="food-calendar">
<food-daily />
</div>
<div v-if="foodInfo.recordSegment === 'timeline'" class="food-calendar">
<food-timeline />
</div>
</div>
</template>
<script setup lang="ts">
import FoodRecipe from '@/components/Food/FoodRecipe.vue'
import FoodDaily from '@/components/Food/FoodDaily.vue'
import FoodTimeline from '@/components/Food/FoodTimeline.vue'
import { Search } from '@element-plus/icons-vue'
import { onMounted, reactive } from 'vue'
import { useFoodStore } from '@/store/food'
import { IFoodSegment } from '@/types/food'
const foodStore = useFoodStore()
const foodInfo = reactive({
recordSegment: 'recipe' as IFoodSegment,
query: ''
})
const recordSegmentList = [{
label: '菜谱',
value: 'recipe'
},
{
label: '日历',
value: 'daily'
},
{
label: '时间轴',
value: 'timeline'
}]
onMounted(() => {
foodStore.refreshInfo()
})
const changeRecordSegmentEvent = () => {
foodStore.refreshInfo()
}
</script>
<style scoped lang="scss">
.food-record {
display: flex;
flex-direction: column;
gap: 5px;
.food-query {
padding: 5px 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
}
</style>

20
src/views/stats/index.vue Normal file
View File

@@ -0,0 +1,20 @@
<template>
<div class="common-mobile-view food-stats">
<food-stats />
<food-chart />
</div>
</template>
<script setup lang="ts">
import FoodStats from '@/components/Stats/FoodStats.vue'
import FoodChart from '@/components/Stats/FoodChart.vue'
</script>
<style lang="scss">
.food-stats {
display: flex;
flex-direction: column;
gap: 10px;
}
</style>