完善运营大屏、业务流程图、溯源总览及营养用餐模块

运营大屏:新增时间范围选择器、快捷入口(溯源/流程图/食安)、设备在线率、
营养预警人数、告警处理/忽略操作。
业务流程图:新增"业务闭环"视图(三大闭环流程可视化),供应链主线节点增加
数据来源标注(传感器/智能秤/设备检测/人工录入/系统计算/AI视频)。
溯源总览:改为按日期+用户+餐次反向溯源,展示就餐餐品→烹饪→食材→产地链路。
用户管理:扩展五维信息(静态基础/健康档案/口味偏好/禁忌关联/动态生理),
新增基于五维信息自动生成推荐食谱功能。
餐线管理:补充加餐提示、实时取餐数据、监控视频入口。
取餐管理:新增AI取餐结构分析饼图。
终端管理:补充秤编号、故障代码、启用时长、最近上报。
模型管理:补充级别(大/专用/小)、训练参数、API接口。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 09:51:46 +08:00
co-authored by Claude Opus 4.7
parent 1e423a0b7d
commit 38edfa2cb1
8 changed files with 687 additions and 152 deletions
+101 -29
View File
@@ -8,6 +8,7 @@
<a-radio-button value="chain">供应链主线</a-radio-button>
<a-radio-button value="matrix">关联矩阵</a-radio-button>
<a-radio-button value="detail">模块详情</a-radio-button>
<a-radio-button value="loop">业务闭环</a-radio-button>
</a-radio-group>
</a-card>
@@ -39,6 +40,7 @@
<span class="node-dot" :style="{ background: stage.color }"></span>
<span class="node-label">{{ node.label }}</span>
<span class="node-badge" v-if="node.badge">{{ node.badge }}</span>
<a-tag v-if="node.dataSource" :color="dataSourceColor(node.dataSource)" size="small" style="font-size:10px;margin-left:4px">{{ node.dataSource }}</a-tag>
</div>
</div>
<div class="stage-arrow" v-if="si < supplyChain.length - 1"></div>
@@ -221,6 +223,29 @@
</a-col>
</a-row>
</div>
<!-- ==================== 业务闭环视图 ==================== -->
<div v-if="viewMode === 'loop'" class="loop-view">
<a-row :gutter="16">
<a-col :span="8" v-for="loop in businessLoops" :key="loop.title">
<a-card :title="loop.title" :bordered="false" size="small" :head-style="{ background: loop.color, color: '#fff' }">
<p style="color:#666;margin-bottom:12px">{{ loop.desc }}</p>
<a-steps direction="vertical" size="small" :current="loop.steps.length - 1">
<a-step v-for="(step, i) in loop.steps" :key="i" :title="step.name">
<template #description>
<div style="font-size:12px;color:#999">{{ step.detail }}</div>
<a-tag :color="dataSourceColor(step.source)" size="small" style="margin-top:4px;font-size:10px">{{ step.source }}</a-tag>
</template>
</a-step>
</a-steps>
<a-divider style="margin:12px 0" />
<div style="text-align:center">
<a-tag v-for="page in loop.pages" :key="page" color="blue" style="margin:2px;cursor:pointer" @click="goTo(page)">{{ page }}</a-tag>
</div>
</a-card>
</a-col>
</a-row>
</div>
</div>
</template>
@@ -229,7 +254,7 @@ import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const viewMode = ref<'chain' | 'matrix' | 'detail'>('chain')
const viewMode = ref<'chain' | 'matrix' | 'detail' | 'loop'>('chain')
const activeStage = ref('green')
const hoveredNode = ref('')
const hoverCell = ref('')
@@ -247,49 +272,49 @@ const supplyChain = [
{
id: 'green', name: '绿色种采', icon: '🌱', color: '#52c41a',
nodes: [
{ key: 'land-env', label: '土地环境' },
{ key: 'planting-monitor', label: '种养监控' },
{ key: 'harvest-storage', label: '收成入库', badge: '出' },
{ key: 'supplier-mgmt', label: '供商管理' },
{ key: 'procurement-mgmt', label: '采管理', badge: '出' },
{ key: 'land-env', label: '土地环境', dataSource: '传感器' },
{ key: 'planting-monitor', label: '种养监控', dataSource: '传感器' },
{ key: 'harvest-storage', label: '收成入库', badge: '出', dataSource: '智能秤' },
{ key: 'supplier-mgmt', label: '供商管理', dataSource: '人工录入' },
{ key: 'procurement-mgmt', label: '采管理', badge: '出', dataSource: '系统生成' },
],
},
{
id: 'hygiene', name: '卫生供应', icon: '🧪', color: '#1890ff',
nodes: [
{ key: 'hygiene-test', label: '卫生检测', badge: '验' },
{ key: 'raw-storage', label: '毛菜入库', badge: '入' },
{ key: 'clean-process', label: '净菜加工' },
{ key: 'clean-storage', label: '净菜入库' },
{ key: 'clean-internal', label: '净菜内供', badge: '出' },
{ key: 'clean-external', label: '净菜外售', badge: '出' },
{ key: 'hygiene-test', label: '卫生检测', badge: '验', dataSource: '设备检测' },
{ key: 'raw-storage', label: '毛菜管理', badge: '入', dataSource: '智能秤' },
{ key: 'clean-process', label: '净菜加工', dataSource: '智能秤' },
{ key: 'clean-storage', label: '净菜入库', dataSource: '智能秤' },
{ key: 'clean-internal', label: '净菜内供', badge: '出', dataSource: '系统计算' },
{ key: 'clean-external', label: '净菜外售', badge: '出', dataSource: '人工录入' },
],
},
{
id: 'health', name: '健康生产', icon: '👨‍🍳', color: '#faad14',
nodes: [
{ key: 'food-safety', label: '食安监控', badge: '验' },
{ key: 'recipe-plan', label: '食谱规划' },
{ key: 'clean-order', label: '净菜订单', badge: '入' },
{ key: 'clean-cabinet', label: '净菜进柜' },
{ key: 'clean-assemble', label: '净菜组配' },
{ key: 'cook-by-order', label: '按单烹制' },
{ key: 'serving-monitor', label: '上餐监控', badge: '出' },
{ key: 'surplus-disposal', label: '余量处置' },
{ key: 'production-stats', label: '生产统计' },
{ key: 'food-safety', label: '食安监控', badge: '验', dataSource: 'AI视频' },
{ key: 'recipe-plan', label: '食谱规划', dataSource: '系统计算' },
{ key: 'clean-order', label: '净菜订单', badge: '入', dataSource: '系统生成' },
{ key: 'clean-cabinet', label: '净菜进柜', dataSource: '智能秤' },
{ key: 'clean-assemble', label: '净菜组配', dataSource: '人工录入' },
{ key: 'cook-by-order', label: '按单烹制', dataSource: '智能秤' },
{ key: 'serving-monitor', label: '上餐监控', badge: '出', dataSource: '传感器' },
{ key: 'surplus-disposal', label: '余量处置', dataSource: '智能秤' },
{ key: 'production-stats', label: '生产统计', dataSource: '系统计算' },
],
},
{
id: 'nutrition', name: '营养用餐', icon: '🍽️', color: '#722ed1',
nodes: [
{ key: 'nutrition-test', label: '营养检验', badge: '验' },
{ key: 'food-composition', label: '食物成分' },
{ key: 'dish-inventory', label: '餐品总库' },
{ key: 'recipe-mgmt', label: '食谱管理' },
{ key: 'nutrition-order', label: '营养点餐' },
{ key: 'pickup-mgmt', label: '取餐管理' },
{ key: 'consumption-mgmt', label: '消费管理' },
{ key: 'nutrition-mgmt', label: '营养管理' },
{ key: 'nutrition-test', label: '营养检验', badge: '验', dataSource: '设备检测' },
{ key: 'food-composition', label: '食物成分', dataSource: '人工录入' },
{ key: 'dish-inventory', label: '餐品总库', dataSource: '人工录入' },
{ key: 'recipe-mgmt', label: '食谱管理', dataSource: '人工录入' },
{ key: 'nutrition-order', label: '营养点餐', dataSource: '系统计算' },
{ key: 'pickup-mgmt', label: '取餐管理', dataSource: '硬件自动' },
{ key: 'consumption-mgmt', label: '消费管理', dataSource: '系统计算' },
{ key: 'nutrition-mgmt', label: '营养管理', dataSource: '系统计算' },
],
},
]
@@ -519,6 +544,53 @@ const moduleDetails: Record<string, any> = {
}
const selectedModuleData = computed(() => moduleDetails[selectedModule.value])
function dataSourceColor(source: string) {
const map: Record<string, string> = { '硬件自动': 'blue', '设备检测': 'purple', '人工录入': 'orange', '系统计算': 'cyan', '系统生成': 'cyan', 'AI视频': 'red', '智能秤': 'blue', '传感器': 'blue' }
return map[source] || 'default'
}
const businessLoops = [
{
title: '闭环1:食谱驱动反向采购',
color: '#1890ff',
desc: '基于食谱规划自动反算原料需求,生成采购订单,校验到货量,确保数据一致',
steps: [
{ name: '食谱规划', detail: '编排周/日食谱,系统反算净菜/毛菜需求', source: '系统计算' },
{ name: '生成采购订单', detail: '自动生成净菜采购订单推送加工端', source: '系统生成' },
{ name: '净菜进柜校验', detail: '智能秤校验订单量 vs 实收量', source: '智能秤' },
{ name: '库存动态表', detail: '送多少、用多少、产多少数据一致', source: '系统计算' },
],
pages: ['recipe-plan', 'clean-order', 'clean-cabinet', 'clean-assemble'],
},
{
title: '闭环2:按单烹饪与熟重计量',
color: '#52c41a',
desc: '从生重到熟重全链路追踪,结合AI视频解析工艺动作',
steps: [
{ name: '生重称重', detail: '锅具上秤即采,自动记录生重', source: '智能秤' },
{ name: '调料时间点', detail: '采集放盐/油/花椒时刻', source: '传感器' },
{ name: '工艺动作识别', detail: '翻炒/颠勺/正反转等动作识别', source: 'AI视频' },
{ name: '熟重称重', detail: '烹饪完成自动称重', source: '智能秤' },
{ name: '上餐+温度衰减', detail: '记录上餐时间,监控温度衰减', source: '传感器' },
{ name: '开餐/加餐流转', detail: '效期预警,加餐自动触发', source: '系统计算' },
],
pages: ['cook-by-order', 'serving-monitor'],
},
{
title: '闭环3:餐线调度与取餐治理',
color: '#722ed1',
desc: '以秤编号为单位,实时统计取餐量,智能补餐和双模识别',
steps: [
{ name: '秤绑定菜品序列', detail: '记录全天菜品更换序列', source: '智能秤' },
{ name: '取餐量实时统计', detail: '各时段取餐量自动汇总', source: '智能秤' },
{ name: '补餐阈值触发', detail: '存量<1/5自动触发补餐提醒', source: '系统计算' },
{ name: '时间过滤', detail: '可配置禁加餐时间', source: '系统计算' },
{ name: '双模取餐识别', detail: '人脸识别+RFID夹子绑定', source: '硬件自动' },
],
pages: ['meal-line', 'pickup-mgmt', 'consumption-mgmt'],
},
]
</script>
<style scoped lang="scss">
+68 -2
View File
@@ -1,8 +1,13 @@
<template>
<div class="dashboard">
<a-page-header title="数据概览" subtitle="智能餐养运营总览">
<a-page-header title="运营大屏" subtitle="智能餐养运营总览">
<template #extra>
<a-space>
<a-radio-group v-model:value="timeRange" size="small" button-style="solid">
<a-radio-button value="today">今日</a-radio-button>
<a-radio-button value="week">本周</a-radio-button>
<a-radio-button value="month">本月</a-radio-button>
</a-radio-group>
<a-select v-model:value="filterCanteen" style="width: 140px">
<a-select-option value="">全部食堂</a-select-option>
<a-select-option v-for="c in d.canteenStats" :key="c.name" :value="c.name">{{ c.name }}</a-select-option>
@@ -12,6 +17,28 @@
</template>
</a-page-header>
<!-- ============ 快捷入口 ============ -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="8">
<a-card hoverable :bordered="false" class="shortcut-card" @click="$router.push({ name: 'trace-overview' })">
<div class="shortcut-icon" style="background:#1890ff"></div>
<div><div class="shortcut-title">溯源总览</div><div class="shortcut-desc">按用户就餐反向追踪食材来源</div></div>
</a-card>
</a-col>
<a-col :span="8">
<a-card hoverable :bordered="false" class="shortcut-card" @click="$router.push({ name: 'business-flow' })">
<div class="shortcut-icon" style="background:#52c41a"></div>
<div><div class="shortcut-title">业务流程图</div><div class="shortcut-desc">全链路业务关联与闭环视图</div></div>
</a-card>
</a-col>
<a-col :span="8">
<a-card hoverable :bordered="false" class="shortcut-card" @click="$router.push({ name: 'food-safety' })">
<div class="shortcut-icon" style="background:#f5222d"></div>
<div><div class="shortcut-title">食安监控</div><div class="shortcut-desc">视频监控环境安全餐品留样</div></div>
</a-card>
</a-col>
</a-row>
<!-- ============ 第一行核心 KPI ============ -->
<a-row :gutter="[16, 16]">
<a-col :span="4" v-for="kpi in kpiCards" :key="kpi.title">
@@ -108,6 +135,10 @@
<span class="supply-label">库存天数</span>
<span class="supply-val" :style="{ color: d.supplyChain.stockDays < 2 ? '#f5222d' : '#333' }">{{ d.supplyChain.stockDays }}</span>
</div>
<div class="supply-item">
<span class="supply-label">设备在线率</span>
<span class="supply-val" style="color:#52c41a">{{ deviceOnlineRate }}%</span>
</div>
</div>
</a-card>
</a-col>
@@ -167,6 +198,7 @@
{{ d.nutritionOverview.structureOk ? '营养结构合理' : '营养结构偏离' }}
</a-tag>
<span class="verdict-alert">{{ d.nutritionOverview.alertCount }} 条营养预警</span>
<a-tag color="red" style="margin-left:8px">{{ warningUserCount }}人需关注</a-tag>
</div>
</a-col>
</a-row>
@@ -237,6 +269,13 @@
<template v-if="column.key === 'module'">
<a-tag>{{ record.module }}</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button v-if="!record.handled" type="link" size="small" @click="handleAlert(record)">处理</a-button>
<a-tag v-else color="green">已处理</a-tag>
<a-button v-if="!record.handled" type="link" size="small" @click="ignoreAlert(record)">忽略</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
@@ -245,13 +284,18 @@
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { message } from 'ant-design-vue'
import ChartPanel from '@/components/common/ChartPanel.vue'
import { genDashboardData } from '@/mock/data'
import { genDashboardData, randomNum } from '@/mock/data'
const d = reactive(genDashboardData())
const filterCanteen = ref('')
const trendMetric = ref<'orders' | 'revenue'>('orders')
const alertFilter = ref('')
const timeRange = ref('today')
const deviceOnlineRate = computed(() => randomNum(88, 98))
const warningUserCount = computed(() => randomNum(3, 12))
function refreshData() {
Object.assign(d, genDashboardData())
@@ -302,15 +346,37 @@ const alertColumns = [
{ title: '模块', key: 'module', width: 100 },
{ title: '内容', dataIndex: 'message', ellipsis: true },
{ title: '时间', dataIndex: 'time', width: 150 },
{ title: '操作', key: 'action', width: 140 },
]
const filteredAlerts = computed(() =>
alertFilter.value ? d.alerts.filter(a => a.type === alertFilter.value) : d.alerts
)
function handleAlert(record: any) {
record.handled = true
message.success('告警已处理')
}
function ignoreAlert(record: any) {
record.handled = true
message.info('告警已忽略')
}
</script>
<style scoped lang="scss">
.dashboard {
.shortcut-card {
:deep(.ant-card-body) { display: flex; align-items: center; gap: 16px; padding: 16px; }
}
.shortcut-icon {
width: 48px; height: 48px; border-radius: 12px; color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 20px; font-weight: 700; flex-shrink: 0;
}
.shortcut-title { font-size: 15px; font-weight: 600; }
.shortcut-desc { font-size: 12px; color: #999; margin-top: 2px; }
// KPI
.kpi-card {
text-align: center;
+184 -57
View File
@@ -1,82 +1,209 @@
<template>
<div class="trace-overview">
<a-page-header title="溯源总览" sub-title="输入批次号追踪从农田到餐桌的完整链路" />
<a-page-header title="溯源总览" sub-title="按日期用户餐次反向追踪就餐餐品的完整链路" />
<a-card :bordered="false" style="margin-bottom:16px">
<a-input-search
v-model:value="batchNo"
placeholder="请输入批次号(如 HV20260415123"
enter-button="溯源查询"
size="large"
style="max-width:600px"
@search="handleSearch"
/>
<a-card :bordered="false" style="margin-bottom: 16px">
<a-space wrap size="middle">
<a-date-picker v-model:value="queryDate" placeholder="选择日期" style="width: 160px" />
<a-select v-model:value="queryUser" placeholder="选择用户" style="width: 180px" allow-clear>
<a-select-option v-for="user in users" :key="user" :value="user">{{ user }}</a-select-option>
</a-select>
<a-select v-model:value="queryMeal" placeholder="选择餐次" style="width: 140px" allow-clear>
<a-select-option value="早餐">早餐</a-select-option>
<a-select-option value="午餐">午餐</a-select-option>
<a-select-option value="晚餐">晚餐</a-select-option>
</a-select>
<a-button type="primary" @click="handleSearch">溯源查询</a-button>
</a-space>
</a-card>
<a-card v-if="traceResult" :bordered="false">
<a-steps :current="traceResult.steps.length - 1" direction="vertical" size="small">
<a-step v-for="(step, i) in traceResult.steps" :key="i" :title="step.stage" :description="step.time">
<template #icon>
<span :style="{ color: stageColors[i] }">{{ i + 1 }}</span>
<a-card v-if="mealRecords.length" title="用户就餐餐品" :bordered="false" style="margin-bottom: 16px">
<a-table
:columns="mealColumns"
:data-source="mealRecords"
:pagination="false"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-button type="link" size="small" @click="selectMeal(record)">查看溯源</a-button>
</template>
<template #subTitle>
<a-tag :color="stageColors[i]">{{ step.module }}</a-tag>
</template>
</a-step>
</a-steps>
</template>
</a-table>
</a-card>
<a-divider />
<a-descriptions title="溯源详情" bordered :column="2" size="small">
<a-descriptions-item label="批次号">{{ traceResult.batchNo }}</a-descriptions-item>
<a-descriptions-item label="品种">{{ traceResult.material }}</a-descriptions-item>
<a-descriptions-item label="产地">{{ traceResult.origin }}</a-descriptions-item>
<a-descriptions-item label="供应商">{{ traceResult.supplier }}</a-descriptions-item>
<a-descriptions-item label="检测结果">
<a-tag color="green">合格</a-tag>
</a-descriptions-item>
<a-descriptions-item label="加工损耗率">{{ traceResult.lossRate }}</a-descriptions-item>
<a-descriptions-item label="最终菜品">{{ traceResult.dish }}</a-descriptions-item>
<a-descriptions-item label="上餐时间">{{ traceResult.serveTime }}</a-descriptions-item>
<a-card v-if="selectedMeal" title="餐品溯源详情" :bordered="false">
<a-descriptions bordered :column="2" size="small" style="margin-bottom: 16px">
<a-descriptions-item label="用户">{{ selectedMeal.user }}</a-descriptions-item>
<a-descriptions-item label="就餐日期">{{ selectedMeal.date }}</a-descriptions-item>
<a-descriptions-item label="餐次">{{ selectedMeal.mealType }}</a-descriptions-item>
<a-descriptions-item label="取餐时间">{{ selectedMeal.pickupTime }}</a-descriptions-item>
<a-descriptions-item label="餐品名称">{{ selectedMeal.dish }}</a-descriptions-item>
<a-descriptions-item label="取餐重量/份数">{{ selectedMeal.quantity }}</a-descriptions-item>
</a-descriptions>
<a-timeline>
<a-timeline-item color="green">
<template #label>{{ traceDetail?.serveInfo?.serveTime }}</template>
<div class="trace-title">上餐信息</div>
<div>餐线{{ traceDetail?.serveInfo?.mealLine }}</div>
<div>上餐位置{{ traceDetail?.serveInfo?.location }}</div>
<div>上餐温度{{ traceDetail?.serveInfo?.temperature }}</div>
</a-timeline-item>
<a-timeline-item color="blue">
<template #label>{{ traceDetail?.cookInfo?.cookTime }}</template>
<div class="trace-title">烹饪信息</div>
<div>厨师{{ traceDetail?.cookInfo?.chef }}</div>
<div>烹制时间{{ traceDetail?.cookInfo?.cookTime }}</div>
<div>生重/熟重{{ traceDetail?.cookInfo?.rawWeight }} / {{ traceDetail?.cookInfo?.cookedWeight }}</div>
<div>加工工艺{{ traceDetail?.cookInfo?.process }}</div>
<div>开餐时间{{ traceDetail?.cookInfo?.openTime }}</div>
</a-timeline-item>
<a-timeline-item color="orange">
<template #label>{{ traceDetail?.ingredientInfo?.processTime }}</template>
<div class="trace-title">食材批次信息</div>
<div v-for="item in traceDetail?.ingredientInfo?.items || []" :key="item.name" class="ingredient-item">
<div><strong>{{ item.name }}</strong></div>
<div>净菜批次{{ item.cleanBatch }}</div>
<div>毛菜批次{{ item.rawBatch }}</div>
<div>损耗率{{ item.lossRate }}</div>
</div>
</a-timeline-item>
<a-timeline-item color="red">
<template #label>{{ traceDetail?.sourceInfo?.harvestTime }}</template>
<div class="trace-title">产地来源信息</div>
<div>食材产地{{ traceDetail?.sourceInfo?.origin }}</div>
<div>地块/基地{{ traceDetail?.sourceInfo?.land }}</div>
<div>供应商{{ traceDetail?.sourceInfo?.supplier }}</div>
<div>检测结果<a-tag color="green">{{ traceDetail?.sourceInfo?.testResult }}</a-tag></div>
</a-timeline-item>
</a-timeline>
</a-card>
<a-empty v-if="searched && !traceResult" description="未找到该批次的溯源信息" />
<a-empty v-if="searched && !mealRecords.length" description="未找到该用户当日就餐记录" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import dayjs, { Dayjs } from 'dayjs'
import { message } from 'ant-design-vue'
import { randomDate, randomTime, pick } from '@/mock/data'
import { pick, randomDate, randomTime, randomNum } from '@/mock/data'
const batchNo = ref('')
const traceResult = ref<any>(null)
const users = ['张伟', '李芳', '王娜', '刘敏', '陈强', '杨磊', '黄艳', '赵勇']
const dishes = ['红烧肉', '清炒时蔬', '番茄鸡蛋', '糖醋里脊', '宫保鸡丁', '鱼香肉丝']
const mealLines = ['1号中餐线', '2号中餐线', '3号西餐线']
const origins = ['A区稻田', 'B区菜地', 'C区果园', 'D区养殖场']
const suppliers = ['集团种养公司', '集团净菜公司', '绿源农业', '丰禾食品']
const lands = ['A01地块', 'B02地块', 'C03基地', 'D04养殖区']
const ingredients = ['白菜', '番茄', '猪里脊', '鸡胸肉', '青椒', '土豆']
const queryDate = ref<Dayjs | null>(dayjs())
const queryUser = ref<string>()
const queryMeal = ref<string>()
const searched = ref(false)
const stageColors = ['#52c41a', '#1890ff', '#faad14', '#722ed1', '#f5222d']
const mealRecords = ref<any[]>([])
const selectedMeal = ref<any>(null)
const traceDetail = ref<any>(null)
const mealColumns = [
{ title: '用户', dataIndex: 'user', key: 'user' },
{ title: '日期', dataIndex: 'date', key: 'date' },
{ title: '餐次', dataIndex: 'mealType', key: 'mealType' },
{ title: '餐品', dataIndex: 'dish', key: 'dish' },
{ title: '取餐时间', dataIndex: 'pickupTime', key: 'pickupTime' },
{ title: '取餐重量/份数', dataIndex: 'quantity', key: 'quantity' },
{ title: '操作', key: 'action', width: 100 },
]
function genMealRecords() {
const date = queryDate.value?.format('YYYY-MM-DD') || randomDate(3)
const user = queryUser.value || pick(users)
const mealType = queryMeal.value || pick(['早餐', '午餐', '晚餐'])
const count = randomNum(2, 4)
return Array.from({ length: count }, (_, i) => ({
id: `${user}-${i}`,
user,
date,
mealType,
dish: pick(dishes),
pickupTime: `${date} ${randomTime()}`,
quantity: `${randomNum(1, 2)}份 / ${randomNum(180, 350)}g`,
}))
}
function genTraceDetail(meal: any) {
const pickedIngredients = Array.from({ length: randomNum(2, 3) }, () => pick(ingredients))
return {
serveInfo: {
serveTime: meal.pickupTime,
mealLine: pick(mealLines),
location: pick(['A窗口', 'B窗口', 'C窗口']),
temperature: `${randomNum(65, 78)}`,
},
cookInfo: {
cookTime: `${meal.date} ${randomTime()}`,
chef: pick(['厨师A', '厨师B', '厨师C', '厨师D']),
rawWeight: `${randomNum(20, 40)}kg`,
cookedWeight: `${randomNum(15, 30)}kg`,
process: pick(['炒', '炖', '煮', '烤']),
openTime: `${meal.date} ${randomTime()}`,
},
ingredientInfo: {
processTime: `${meal.date} ${randomTime()}`,
items: pickedIngredients.map((name, idx) => ({
name,
cleanBatch: `CS${meal.date.replace(/-/g, '')}${idx + 1}`,
rawBatch: `RS${meal.date.replace(/-/g, '')}${idx + 1}`,
lossRate: `${randomNum(5, 18)}%`,
})),
},
sourceInfo: {
harvestTime: `${randomDate(20)} ${randomTime()}`,
origin: pick(origins),
land: pick(lands),
supplier: pick(suppliers),
testResult: '合格',
},
}
}
function handleSearch() {
searched.value = true
if (!batchNo.value) { message.warning('请输入批次号'); traceResult.value = null; return }
traceResult.value = {
batchNo: batchNo.value,
material: pick(['白菜', '番茄', '猪里脊', '鸡胸肉', '土豆']),
origin: pick(['A区稻田', 'B区菜地', 'C区果园']),
supplier: pick(['集团种养公司', '绿源农业', '丰禾食品']),
lossRate: '12.5%',
dish: pick(['红烧肉', '清炒时蔬', '番茄鸡蛋']),
serveTime: randomDate(3) + ' 11:30',
steps: [
{ stage: '种养采收', module: '绿色种养', time: randomDate(30) + ' ' + randomTime() + ' — 收成入库,质量等级:优等' },
{ stage: '卫生检测', module: '卫生供应', time: randomDate(25) + ' ' + randomTime() + ' — 农残/重金属/化肥残留检测合格' },
{ stage: '净菜加工', module: '卫生供应', time: randomDate(20) + ' ' + randomTime() + ' — 毛菜45kg → 净菜39.4kg,损耗率12.5%' },
{ stage: '烹饪生产', module: '健康生产', time: randomDate(3) + ' ' + randomTime() + ' — 按单烹制,生重2.5kg → 熟重2.1kg' },
{ stage: '上餐用餐', module: '营养用餐', time: randomDate(3) + ' 11:30 — 1号中餐线上餐,温度72℃' },
],
if (!queryDate.value || !queryUser.value) {
message.warning('请选择日期和用户')
return
}
message.success('溯源查询完成')
searched.value = true
mealRecords.value = genMealRecords()
selectedMeal.value = null
traceDetail.value = null
message.success('查询完成')
}
function selectMeal(record: any) {
selectedMeal.value = record
traceDetail.value = genTraceDetail(record)
}
</script>
<style scoped>
.trace-overview { padding: 0; }
</style>
.trace-overview {
padding: 0;
}
.trace-title {
font-weight: 600;
margin-bottom: 6px;
}
.ingredient-item {
margin-top: 8px;
padding: 8px 12px;
background: #fafafa;
border-radius: 6px;
}
</style>
@@ -21,24 +21,37 @@
<a-tag :color="statusMap[line.status]?.color">{{ statusMap[line.status]?.text }}</a-tag>
</div>
<a-row :gutter="12" class="stats">
<a-col :span="12">
<a-col :span="8">
<a-statistic title="今日菜品" :value="line.todayDishes" />
</a-col>
<a-col :span="12">
<a-col :span="8">
<a-statistic title="今日服务" :value="line.todayServed" suffix="人" />
</a-col>
<a-col :span="8">
<a-statistic title="取餐速率" :value="line.pickupRate" suffix="人/分" :value-style="{ fontSize: '16px' }" />
</a-col>
</a-row>
<a-descriptions :column="1" size="small" class="desc">
<a-descriptions-item label="所属食堂">{{ line.canteen }}</a-descriptions-item>
<a-descriptions-item label="餐线容量">{{ line.capacity }}</a-descriptions-item>
<a-descriptions-item label="当前存量">
<a-progress :percent="line.stockPercent" size="small"
:stroke-color="line.stockPercent < 20 ? '#f5222d' : line.stockPercent < 50 ? '#faad14' : '#52c41a'" />
</a-descriptions-item>
<a-descriptions-item label="补餐状态">
<a-tag :color="line.stockPercent < 20 ? 'red' : 'green'">
{{ line.stockPercent < 20 ? '需补餐' : '正常' }}
</a-tag>
<a-tag v-if="line.addMealDisabled" color="default">禁加餐</a-tag>
</a-descriptions-item>
<a-descriptions-item label="负责人">{{ line.manager }}</a-descriptions-item>
<a-descriptions-item label="设备数">{{ line.equipment }}</a-descriptions-item>
<a-descriptions-item label="评分">
<a-rate :value="line.rating" disabled allow-half style="font-size: 14px" />
</a-descriptions-item>
</a-descriptions>
<a-alert v-if="line.stockPercent < 20" type="warning" show-icon
:message="`存量不足1/5,建议立即补餐`" style="margin-bottom:8px" />
<div class="actions">
<a-button size="small" @click="message.info('编辑 ' + line.name)">编辑</a-button>
<a-button size="small" @click="message.info('查看监控视频 - ' + line.name)">监控视频</a-button>
<a-button size="small" type="primary" @click="message.info('查看详情')">查看详情</a-button>
</div>
</a-card>
@@ -49,9 +62,14 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { genMealLineData, statusMap, canteenNames } from '@/mock/data'
import { genMealLineData, statusMap, canteenNames, randomNum } from '@/mock/data'
const list = ref(genMealLineData())
const list = ref(genMealLineData().map((l: any) => ({
...l,
pickupRate: randomNum(2, 12),
stockPercent: randomNum(5, 95),
addMealDisabled: Math.random() > 0.7,
})))
const filterCanteen = ref<string | undefined>(undefined)
const filteredList = computed(() =>
@@ -5,7 +5,7 @@
<a-table :columns="columns" :data-source="list" :pagination="{ pageSize: 10 }" row-key="id">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="typeColor[record.type]">{{ record.type }}</a-tag>
<a-tag :color="typeColor[record.level]">{{ record.level }}</a-tag>
</template>
<template v-else-if="column.key === 'accuracy'">
<a-progress :percent="record.accuracy" size="small" />
@@ -29,25 +29,27 @@
import { ref } from 'vue'
import { message } from 'ant-design-vue'
const typeColor: Record<string, string> = { '推荐': 'blue', '营养分析': 'green', '预测': 'purple' }
const typeColor: Record<string, string> = { '大模型': 'red', '专用模型': 'blue', '小模型': 'green' }
const list = ref([
{ id: 1, name: '智能点餐推荐', version: 'v2.1.3', type: '推荐', accuracy: 92, status: '在线', updateTime: '2026-04-01' },
{ id: 2, name: '营养评分模型', version: 'v3.0.1', type: '营养分析', accuracy: 88, status: '在线', updateTime: '2026-03-28' },
{ id: 3, name: '消费预测模型', version: 'v1.4.0', type: '预测', accuracy: 85, status: '在线', updateTime: '2026-03-25' },
{ id: 4, name: '食材损耗预测', version: 'v1.2.0', type: '预测', accuracy: 79, status: '离线', updateTime: '2026-03-15' },
{ id: 5, name: '过敏原识别', version: 'v2.0.0', type: '营养分析', accuracy: 95, status: '在线', updateTime: '2026-04-03' },
{ id: 6, name: '个性化菜谱', version: 'v1.8.2', type: '推荐', accuracy: 87, status: '离线', updateTime: '2026-03-20' },
{ id: 1, name: '智能点餐推荐', level: '大模型', version: 'v2.1.3', trainParams: 'batch=64, lr=0.001, epochs=50', apiEndpoint: '/api/model/recommend', accuracy: 92, status: '在线', updateTime: '2026-04-01' },
{ id: 2, name: '营养评分模型', level: '专用模型', version: 'v3.0.1', trainParams: 'batch=32, lr=0.005, epochs=100', apiEndpoint: '/api/model/nutrition-score', accuracy: 88, status: '在线', updateTime: '2026-03-28' },
{ id: 3, name: '消费预测模型', level: '专用模型', version: 'v1.4.0', trainParams: 'batch=128, lr=0.002, epochs=80', apiEndpoint: '/api/model/consumption-predict', accuracy: 85, status: '在线', updateTime: '2026-03-25' },
{ id: 4, name: '食材损耗预测', level: '小模型', version: 'v1.2.0', trainParams: 'batch=16, lr=0.01, epochs=30', apiEndpoint: '/api/model/loss-predict', accuracy: 79, status: '离线', updateTime: '2026-03-15' },
{ id: 5, name: '过敏原识别', level: '专用模型', version: 'v2.0.0', trainParams: 'batch=64, lr=0.003, epochs=120', apiEndpoint: '/api/model/allergen-detect', accuracy: 95, status: '在线', updateTime: '2026-04-03' },
{ id: 6, name: '个性化菜谱', level: '大模型', version: 'v1.8.2', trainParams: 'batch=64, lr=0.001, epochs=60', apiEndpoint: '/api/model/personal-recipe', accuracy: 87, status: '离线', updateTime: '2026-03-20' },
])
const columns = [
{ title: '模型名称', dataIndex: 'name' },
{ title: '版本', dataIndex: 'version' },
{ title: '类型', key: 'type' },
{ title: '准确率', key: 'accuracy', width: 200 },
{ title: '状态', key: 'status' },
{ title: '更新时间', dataIndex: 'updateTime' },
{ title: '操作', key: 'actions' },
{ title: '级别', key: 'type', width: 90 },
{ title: '版本', dataIndex: 'version', width: 80 },
{ title: '训练参数', dataIndex: 'trainParams', ellipsis: true },
{ title: 'API接口', dataIndex: 'apiEndpoint', ellipsis: true },
{ title: '准确率', key: 'accuracy', width: 160 },
{ title: '状态', key: 'status', width: 80 },
{ title: '更新时间', dataIndex: 'updateTime', width: 110 },
{ title: '操作', key: 'actions', width: 120 },
]
const toggle = (r: any) => {
@@ -1,10 +1,28 @@
<template>
<div class="page-container">
<a-page-header title="取餐管理" sub-title="实时取餐订单监控">
<a-page-header title="取餐管理" sub-title="实时取餐监控与AI取餐结构分析">
<template #extra>
<a-button type="primary" @click="refresh">刷新</a-button>
</template>
</a-page-header>
<!-- AI取餐结构分析 -->
<a-row :gutter="16" style="margin-bottom:16px">
<a-col :span="8">
<a-card title="取餐结构分布" size="small">
<ChartPanel type="pie" :data="structureData" :height="200" :colors="['#1890ff', '#52c41a', '#faad14', '#722ed1']" />
</a-card>
</a-col>
<a-col :span="16">
<a-row :gutter="16">
<a-col :span="6"><a-card><a-statistic title="今日取餐" :value="orders.length" suffix="人次" /></a-card></a-col>
<a-col :span="6"><a-card><a-statistic title="主食占比" :value="structurePct.staple" suffix="%" :value-style="{ color: '#1890ff' }" /></a-card></a-col>
<a-col :span="6"><a-card><a-statistic title="荤菜占比" :value="structurePct.meat" suffix="%" :value-style="{ color: '#52c41a' }" /></a-card></a-col>
<a-col :span="6"><a-card><a-statistic title="素菜占比" :value="structurePct.veg" suffix="%" :value-style="{ color: '#faad14' }" /></a-card></a-col>
</a-row>
</a-col>
</a-row>
<a-card>
<a-table :columns="columns" :data-source="orders" :pagination="{ pageSize: 10 }" row-key="id">
<template #bodyCell="{ column, record }">
@@ -21,9 +39,10 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { randomId, randomNum, randomTime, randomDate, pick, pickMulti } from '@/mock/data'
import ChartPanel from '@/components/common/ChartPanel.vue'
const statusColor: Record<string, string> = {
'待取餐': 'blue',
@@ -66,6 +85,22 @@ const refresh = () => {
orders.value = gen()
message.success('已刷新')
}
const structureData = computed(() => [
{ label: '主食', value: randomNum(200, 400) },
{ label: '荤菜', value: randomNum(300, 600) },
{ label: '素菜', value: randomNum(250, 500) },
{ label: '汤品', value: randomNum(100, 300) },
])
const structurePct = computed(() => {
const total = structureData.value.reduce((s, d) => s + d.value, 0) || 1
return {
staple: Math.round(structureData.value[0].value / total * 100),
meat: Math.round(structureData.value[1].value / total * 100),
veg: Math.round(structureData.value[2].value / total * 100),
}
})
</script>
<style scoped lang="scss">
@@ -8,12 +8,17 @@
<div class="dev-name">{{ d.name }}</div>
<div class="dev-type">{{ d.type }}</div>
</div>
<a-badge :status="d.status === 'online' ? 'success' : 'error'" :text="d.status === 'online' ? '在线' : '离线'" />
<a-badge :status="d.status === 'online' ? 'success' : d.status === 'fault' ? 'error' : 'default'" :text="d.status === 'online' ? '在线' : d.status === 'fault' ? '故障' : '离线'" />
</div>
<a-descriptions :column="1" size="small" class="desc">
<a-descriptions-item label="编号">{{ d.scaleId }}</a-descriptions-item>
<a-descriptions-item label="位置">{{ d.location }}</a-descriptions-item>
<a-descriptions-item label="心跳">{{ d.heartbeat }}</a-descriptions-item>
<a-descriptions-item label="版本">{{ d.version }}</a-descriptions-item>
<a-descriptions-item label="故障代码">
<span :style="{ color: d.faultCode !== '-' ? '#f5222d' : '' }">{{ d.faultCode }}</span>
</a-descriptions-item>
<a-descriptions-item label="心跳周期">{{ d.heartbeat }}</a-descriptions-item>
<a-descriptions-item label="启用时长">{{ d.uptime }}</a-descriptions-item>
<a-descriptions-item label="最近上报">{{ d.lastReport }}</a-descriptions-item>
</a-descriptions>
<div class="actions">
<a-button size="small" @click="message.info('重启 ' + d.name)">重启</a-button>
@@ -29,14 +34,14 @@ import { ref } from 'vue'
import { message } from 'ant-design-vue'
const devices = ref([
{ id: 1, name: '点餐机-01', type: '点餐机', location: '一楼大厅A', status: 'online', heartbeat: '2026-04-07 10:23', version: 'v2.3.1' },
{ id: 2, name: '点餐机-02', type: '点餐机', location: '一楼大厅B', status: 'online', heartbeat: '2026-04-07 10:24', version: 'v2.3.1' },
{ id: 3, name: '取餐柜-01', type: '取餐柜', location: '一楼出餐口', status: 'online', heartbeat: '2026-04-07 10:24', version: 'v1.5.0' },
{ id: 4, name: '取餐柜-02', type: '取餐柜', location: '二楼出餐口', status: 'offline', heartbeat: '2026-04-06 18:30', version: 'v1.5.0' },
{ id: 5, name: '显示屏-01', type: '显示屏', location: '一楼大厅', status: 'online', heartbeat: '2026-04-07 10:25', version: 'v3.0.2' },
{ id: 6, name: '显示屏-02', type: '显示屏', location: '二楼大厅', status: 'online', heartbeat: '2026-04-07 10:25', version: 'v3.0.2' },
{ id: 7, name: '点餐机-03', type: '点餐机', location: '楼大厅A', status: 'offline', heartbeat: '2026-04-06 22:10', version: 'v2.3.0' },
{ id: 8, name: '取餐柜-03', type: '取餐柜', location: '楼出餐口', status: 'online', heartbeat: '2026-04-07 10:23', version: 'v1.5.0' },
{ id: 1, scaleId: 'CH00001', name: '智能秤-01', type: '智能秤', location: '1号中餐线', status: 'online', faultCode: '-', heartbeat: '30s', uptime: '128天', lastReport: '2026-04-23 10:23' },
{ id: 2, scaleId: 'CH00002', name: '智能秤-02', type: '智能秤', location: '2号中餐线', status: 'online', faultCode: '-', heartbeat: '30s', uptime: '95天', lastReport: '2026-04-23 10:24' },
{ id: 3, scaleId: 'CH00003', name: '智能秤-03', type: '智能秤', location: '3号西餐线', status: 'offline', faultCode: 'E102', heartbeat: '-', uptime: '62天', lastReport: '2026-04-22 18:30' },
{ id: 4, scaleId: 'CH00004', name: '智能秤-04', type: '智能秤', location: 'VIP餐线', status: 'online', faultCode: '-', heartbeat: '30s', uptime: '210天', lastReport: '2026-04-23 10:25' },
{ id: 5, scaleId: 'CH00005', name: '智能秤-05', type: '智能秤', location: '面点窗口', status: 'fault', faultCode: 'E205', heartbeat: '-', uptime: '45天', lastReport: '2026-04-22 08:10' },
{ id: 6, scaleId: 'CH00006', name: '智能秤-06', type: '智能秤', location: '小吃窗口', status: 'online', faultCode: '-', heartbeat: '30s', uptime: '180天', lastReport: '2026-04-23 10:25' },
{ id: 7, scaleId: 'TM00001', name: '点餐机-01', type: '点餐机', location: '楼大厅A', status: 'online', faultCode: '-', heartbeat: '60s', uptime: '300天', lastReport: '2026-04-23 10:23' },
{ id: 8, scaleId: 'TM00002', name: '取餐柜-01', type: '取餐柜', location: '楼出餐口', status: 'online', faultCode: '-', heartbeat: '60s', uptime: '250天', lastReport: '2026-04-23 10:24' },
])
</script>
@@ -1,31 +1,136 @@
<template>
<div class="page-container">
<a-page-header title="用户管理" sub-title="就餐用户档案" />
<a-page-header title="用户管理" sub-title="就餐用户五维档案与个性化推荐食谱" />
<a-card>
<div class="toolbar">
<a-input-search v-model:value="search" placeholder="搜索姓名/电话" style="width: 240px" allow-clear />
<a-select v-model:value="deptFilter" style="width: 140px" placeholder="部门" allow-clear>
<a-select-option v-for="d in departments" :key="d" :value="d">{{ d }}</a-select-option>
</a-select>
<a-button type="primary" @click="modalOpen = true">+ 新增用户</a-button>
<a-button type="primary" @click="openAddModal">+ 新增用户</a-button>
</div>
<a-table :columns="columns" :data-source="filtered" :pagination="{ pageSize: 10 }" row-key="id">
<a-table :columns="columns" :data-source="filtered" :pagination="{ pageSize: 10 }" row-key="id" size="middle">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'isMember'">
<a-tag :color="record.isMember ? 'gold' : 'default'">{{ record.isMember ? '会员' : '普通' }}</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<template v-if="column.key === 'status'">
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
</template>
<template v-else-if="column.key === 'allergens'">
<a-tag v-for="a in record.allergens" :key="a" color="red">{{ a }}</a-tag>
<template v-if="column.key === 'taboo'">
<a-tag v-for="t in record.tabooList" :key="t" color="red" style="margin:1px">{{ t }}</a-tag>
<span v-if="!record.tabooList?.length" style="color:#999"></span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="openDetail(record)">详情</a-button>
<a-button type="link" size="small" @click="openRecipe(record)">推荐食谱</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
<a-modal v-model:open="modalOpen" title="新增用户" @ok="addUser">
<a-form :model="form" layout="vertical">
<!-- 五维信息详情抽屉 -->
<a-drawer v-model:open="detailVisible" title="用户五维档案" :width="620">
<template v-if="currentUser">
<a-tabs v-model:activeKey="detailTab">
<a-tab-pane key="basic" tab="静态基础">
<a-descriptions bordered :column="2" size="small">
<a-descriptions-item label="姓名">{{ currentUser.name }}</a-descriptions-item>
<a-descriptions-item label="部门">{{ currentUser.department }}</a-descriptions-item>
<a-descriptions-item label="年龄">{{ currentUser.age }}</a-descriptions-item>
<a-descriptions-item label="性别">{{ currentUser.gender }}</a-descriptions-item>
<a-descriptions-item label="身高">{{ currentUser.height }}cm</a-descriptions-item>
<a-descriptions-item label="体重">{{ currentUser.weight }}kg</a-descriptions-item>
<a-descriptions-item label="BMI">{{ currentUser.bmi }}</a-descriptions-item>
<a-descriptions-item label="活动水平">{{ currentUser.activityLevel }}</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<a-tab-pane key="health" tab="健康档案">
<a-descriptions bordered :column="1" size="small">
<a-descriptions-item label="疾病史">
<a-tag v-for="d in currentUser.diseases" :key="d" color="orange" style="margin:2px">{{ d }}</a-tag>
<span v-if="!currentUser.diseases?.length" style="color:#999"></span>
</a-descriptions-item>
<a-descriptions-item label="体检报告">{{ currentUser.examReport || '暂无' }}</a-descriptions-item>
<a-descriptions-item label="最近体检日期">{{ currentUser.lastExamDate }}</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<a-tab-pane key="taste" tab="口味偏好">
<a-descriptions bordered :column="2" size="small">
<a-descriptions-item label="口味偏好">
<a-tag v-for="t in currentUser.tastePrefer" :key="t" color="blue" style="margin:2px">{{ t }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="地域菜系">
<a-tag v-for="c in currentUser.cuisinePrefer" :key="c" color="purple" style="margin:2px">{{ c }}</a-tag>
</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<a-tab-pane key="taboo" tab="禁忌关联">
<a-descriptions bordered :column="1" size="small">
<a-descriptions-item label="饮食禁忌">
<div v-for="item in currentUser.tabooRelations" :key="item.disease" style="margin-bottom:6px">
<a-tag color="red">{{ item.disease }}</a-tag>
<span style="margin:0 6px"></span>
<a-tag v-for="f in item.forbidden" :key="f" color="orange">{{ f }}</a-tag>
</div>
<span v-if="!currentUser.tabooRelations?.length" style="color:#999">无禁忌</span>
</a-descriptions-item>
<a-descriptions-item label="过敏原">
<a-tag v-for="a in currentUser.allergens" :key="a" color="red" style="margin:2px">{{ a }}</a-tag>
<span v-if="!currentUser.allergens?.length" style="color:#999"></span>
</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<a-tab-pane key="dynamic" tab="动态生理">
<a-descriptions bordered :column="2" size="small">
<a-descriptions-item label="日均运动量">{{ currentUser.dailyExercise }}</a-descriptions-item>
<a-descriptions-item label="体重趋势">{{ currentUser.weightTrend }}</a-descriptions-item>
<a-descriptions-item label="体脂率">{{ currentUser.bodyFatRate }}%</a-descriptions-item>
<a-descriptions-item label="数据来源">{{ currentUser.dataSource }}</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
</a-tabs>
</template>
</a-drawer>
<!-- 推荐食谱弹窗 -->
<a-modal v-model:open="recipeVisible" :title="`${recipeUser?.name} - 个人推荐食谱`" width="700px" @ok="confirmRecipe">
<template v-if="recipeUser">
<a-alert type="info" show-icon style="margin-bottom:12px"
:message="`基于五维信息自动生成:BMI ${recipeUser.bmi} / 活动水平 ${recipeUser.activityLevel} / 每日目标 ${recipeUser.targetCalories}kcal`" />
<div v-for="meal in recommendedRecipe" :key="meal.mealType" style="margin-bottom:16px">
<div style="font-weight:600;margin-bottom:8px">{{ meal.mealType }}</div>
<a-table :columns="recipeCols" :data-source="meal.dishes" :pagination="false" size="small" row-key="name">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'tabooFlag'">
<a-tag v-if="record.tabooFlag" color="red">已过滤</a-tag>
<a-tag v-else color="green">安全</a-tag>
</template>
<template v-if="column.key === 'adjust'">
<a-button type="link" size="small" danger @click="removeDish(meal, record)">移除</a-button>
</template>
</template>
</a-table>
</div>
<a-divider />
<a-descriptions :column="3" size="small">
<a-descriptions-item label="总热量">{{ totalCalories }}kcal</a-descriptions-item>
<a-descriptions-item label="目标热量">{{ recipeUser.targetCalories }}kcal</a-descriptions-item>
<a-descriptions-item label="匹配度">
<a-tag :color="matchRate > 90 ? 'green' : matchRate > 75 ? 'orange' : 'red'">{{ matchRate }}%</a-tag>
</a-descriptions-item>
</a-descriptions>
</template>
</a-modal>
<!-- 新增用户弹窗 -->
<a-modal v-model:open="addModalOpen" title="新增用户" @ok="addUser" width="600px">
<a-form :model="form" :label-col="{ span: 6 }">
<a-form-item label="姓名"><a-input v-model:value="form.name" /></a-form-item>
<a-form-item label="电话"><a-input v-model:value="form.phone" /></a-form-item>
<a-form-item label="部门">
@@ -33,6 +138,9 @@
<a-select-option v-for="d in departments" :key="d" :value="d">{{ d }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="年龄"><a-input-number v-model:value="form.age" :min="1" :max="120" style="width:100%" /></a-form-item>
<a-form-item label="身高(cm)"><a-input-number v-model:value="form.height" :min="50" :max="250" style="width:100%" /></a-form-item>
<a-form-item label="体重(kg)"><a-input-number v-model:value="form.weight" :min="20" :max="300" style="width:100%" /></a-form-item>
</a-form>
</a-modal>
</div>
@@ -41,27 +149,76 @@
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { message } from 'ant-design-vue'
import { genUserMgmtData, statusMap } from '@/mock/data'
const list = ref(genUserMgmtData(20))
const search = ref('')
const deptFilter = ref<string>()
const modalOpen = ref(false)
const form = reactive({ name: '', phone: '', department: '' })
import { genUserMgmtData, statusMap, randomNum, randomFloat, pick } from '@/mock/data'
const departments = ['研发部', '市场部', '财务部', '人事部', '行政部', '销售部']
const tasteOptions = ['酸', '甜', '苦', '辣', '咸', '清淡']
const cuisineOptions = ['川菜', '粤菜', '鲁菜', '苏菜', '湘菜', '西餐']
const diseaseOptions = ['糖尿病', '高血压', '高血脂', '痛风', '冠心病']
const activityLevels = ['久坐', '轻度活动', '中度活动', '重度活动']
const dishPool = ['红烧肉', '清炒时蔬', '番茄鸡蛋', '糖醋里脊', '宫保鸡丁', '鱼香肉丝', '蒜蓉西兰花', '蛋炒饭', '紫菜蛋花汤', '水煮鱼', '凉拌黄瓜', '蒸鲈鱼']
// 扩展用户数据,增加五维信息
const list = ref(genUserMgmtData(20).map((u: any) => {
const height = randomNum(155, 185)
const weight = randomNum(45, 95)
const bmi = +(weight / (height / 100) ** 2).toFixed(1)
const diseases = diseaseOptions.filter(() => Math.random() > 0.8)
const tabooMap: Record<string, string[]> = {
'糖尿病': ['高糖', '甜食'], '高血压': ['高钠', '腌制品'], '高血脂': ['高脂', '油炸'],
'痛风': ['高嘌呤', '海鲜'], '冠心病': ['高脂', '高胆固醇'],
}
return {
...u,
age: randomNum(20, 65),
gender: pick(['男', '女']),
height, weight, bmi,
activityLevel: pick(activityLevels),
diseases,
examReport: pick(['2026年度体检报告', '2025年度体检报告', '']),
lastExamDate: `2026-0${randomNum(1, 3)}-${randomNum(10, 28)}`,
tastePrefer: tasteOptions.filter(() => Math.random() > 0.6),
cuisinePrefer: cuisineOptions.filter(() => Math.random() > 0.7),
tabooList: diseases.flatMap(d => tabooMap[d]?.slice(0, 1) || []),
tabooRelations: diseases.map(d => ({ disease: d, forbidden: tabooMap[d] || [] })),
dailyExercise: randomNum(2000, 15000),
weightTrend: pick(['稳定', '上升', '下降']),
bodyFatRate: randomFloat(12, 35, 1),
dataSource: pick(['体脂秤+智能手表', '小程序填报', '体检系统同步']),
targetCalories: bmi > 24 ? randomNum(1600, 1900) : randomNum(1900, 2300),
}
}))
const search = ref('')
const deptFilter = ref<string>()
const detailVisible = ref(false)
const detailTab = ref('basic')
const currentUser = ref<any>(null)
const addModalOpen = ref(false)
const form = reactive({ name: '', phone: '', department: '', age: 30, height: 170, weight: 65 })
// 推荐食谱
const recipeVisible = ref(false)
const recipeUser = ref<any>(null)
const recommendedRecipe = ref<any[]>([])
const columns = [
{ title: '姓名', dataIndex: 'name', key: 'name' },
{ title: '电话', dataIndex: 'phone', key: 'phone' },
{ title: '部门', dataIndex: 'department', key: 'department' },
{ title: '餐卡号', dataIndex: 'mealCard', key: 'mealCard' },
{ title: '余额', dataIndex: 'balance', key: 'balance' },
{ title: '会员', key: 'isMember' },
{ title: '累计订单', dataIndex: 'totalOrders', key: 'totalOrders' },
{ title: '最近点餐', dataIndex: 'lastOrder', key: 'lastOrder' },
{ title: '状态', key: 'status' },
{ title: '过敏原', key: 'allergens' },
{ title: '姓名', dataIndex: 'name', width: 80 },
{ title: '部门', dataIndex: 'department', width: 80 },
{ title: '年龄', dataIndex: 'age', width: 60 },
{ title: 'BMI', dataIndex: 'bmi', width: 70 },
{ title: '活动水平', dataIndex: 'activityLevel', width: 90 },
{ title: '禁忌', key: 'taboo', width: 120 },
{ title: '状态', key: 'status', width: 70 },
{ title: '操作', key: 'action', width: 160 },
]
const recipeCols = [
{ title: '菜品', dataIndex: 'name', width: 120 },
{ title: '热量(kcal)', dataIndex: 'calories', width: 100 },
{ title: '口味匹配', dataIndex: 'tasteMatch', width: 90 },
{ title: '禁忌过滤', key: 'tabooFlag', width: 90 },
{ title: '操作', key: 'adjust', width: 80 },
]
const filtered = computed(() =>
@@ -72,9 +229,62 @@ const filtered = computed(() =>
})
)
const addUser = () => {
const totalCalories = computed(() =>
recommendedRecipe.value.reduce((s, m) => s + m.dishes.reduce((ss: number, d: any) => ss + d.calories, 0), 0)
)
const matchRate = computed(() => {
if (!recipeUser.value) return 0
const target = recipeUser.value.targetCalories
return Math.max(0, Math.min(100, Math.round(100 - Math.abs(totalCalories.value - target) / target * 100)))
})
function openDetail(record: any) {
currentUser.value = record
detailTab.value = 'basic'
detailVisible.value = true
}
function openRecipe(record: any) {
recipeUser.value = record
recommendedRecipe.value = genRecipe(record)
recipeVisible.value = true
}
function genRecipe(user: any) {
const meals = ['早餐', '午餐', '晚餐']
return meals.map(mealType => ({
mealType,
dishes: Array.from({ length: mealType === '早餐' ? 2 : 3 }, () => {
const name = pick(dishPool)
return {
name,
calories: randomNum(mealType === '早餐' ? 120 : 180, mealType === '早餐' ? 300 : 450),
tasteMatch: pick(['匹配', '匹配', '一般']),
tabooFlag: user.tabooList?.length ? Math.random() > 0.8 : false,
}
}).filter((d: any) => !d.tabooFlag),
}))
}
function removeDish(meal: any, dish: any) {
meal.dishes = meal.dishes.filter((d: any) => d.name !== dish.name)
message.success('已移除')
}
function confirmRecipe() {
message.success(`${recipeUser.value.name} 的推荐食谱已确认`)
recipeVisible.value = false
}
function openAddModal() {
Object.assign(form, { name: '', phone: '', department: '', age: 30, height: 170, weight: 65 })
addModalOpen.value = true
}
function addUser() {
message.success('用户已添加')
modalOpen.value = false
addModalOpen.value = false
}
</script>