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