初始化

This commit is contained in:
2026-04-08 15:57:05 +08:00
parent b1ac4ac0b0
commit fdbbee16b9
91 changed files with 13324 additions and 13 deletions
-11
View File
@@ -1,11 +0,0 @@
# ---> Vue
# gitignore template for Vue.js projects
#
# Recommended template: Node.gitignore
# TODO: where does this rule come from?
docs/_book
# TODO: where does this rule come from?
test/
-2
View File
@@ -1,2 +0,0 @@
# zhican
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.DS_Store
*.local
.env
.env.*
!.env.example
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智能餐养管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "zhican-admin",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"ant-design-vue": "^4.2.6",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"pinia": "^2.3.0",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"@ant-design/icons-vue": "^7.0.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.2",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0",
"sass": "^1.83.0",
"unplugin-vue-components": "^0.27.5",
"unplugin-auto-import": "^0.18.6"
}
}
+9
View File
@@ -0,0 +1,9 @@
<template>
<a-config-provider :locale="zhCN">
<router-view />
</a-config-provider>
</template>
<script setup lang="ts">
import zhCN from 'ant-design-vue/es/locale/zh_CN'
</script>
+39
View File
@@ -0,0 +1,39 @@
import request from '@/utils/request'
// 绿色种采 - 土地环境
export const landEnvApi = {
list: (params?: any) => request.get('/green-planting/land-env', { params }),
detail: (id: string) => request.get(`/green-planting/land-env/${id}`),
create: (data: any) => request.post('/green-planting/land-env', data),
update: (id: string, data: any) => request.put(`/green-planting/land-env/${id}`, data),
remove: (id: string) => request.delete(`/green-planting/land-env/${id}`),
}
// 卫生供应 - 卫生检测
export const hygieneTestApi = {
list: (params?: any) => request.get('/hygiene-supply/hygiene-test', { params }),
detail: (id: string) => request.get(`/hygiene-supply/hygiene-test/${id}`),
create: (data: any) => request.post('/hygiene-supply/hygiene-test', data),
}
// 健康生产 - 食安监控
export const foodSafetyApi = {
list: (params?: any) => request.get('/health-production/food-safety', { params }),
detail: (id: string) => request.get(`/health-production/food-safety/${id}`),
}
// 营养用餐 - 用户管理
export const userMgmtApi = {
list: (params?: any) => request.get('/nutrition-dining/users', { params }),
detail: (id: string) => request.get(`/nutrition-dining/users/${id}`),
create: (data: any) => request.post('/nutrition-dining/users', data),
update: (id: string, data: any) => request.put(`/nutrition-dining/users/${id}`, data),
remove: (id: string) => request.delete(`/nutrition-dining/users/${id}`),
}
// 登录
export const authApi = {
login: (data: { username: string; password: string }) => request.post('/auth/login', data),
logout: () => request.post('/auth/logout'),
profile: () => request.get('/auth/profile'),
}
@@ -0,0 +1,22 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 3px;
}
+88
View File
@@ -0,0 +1,88 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+75
View File
@@ -0,0 +1,75 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AAlert: typeof import('ant-design-vue/es')['Alert']
AAvatar: typeof import('ant-design-vue/es')['Avatar']
ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup']
ACol: typeof import('ant-design-vue/es')['Col']
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
ADivider: typeof import('ant-design-vue/es')['Divider']
ADrawer: typeof import('ant-design-vue/es')['Drawer']
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
AEmpty: typeof import('ant-design-vue/es')['Empty']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AInput: typeof import('ant-design-vue/es')['Input']
AInputGroup: typeof import('ant-design-vue/es')['InputGroup']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
ALayout: typeof import('ant-design-vue/es')['Layout']
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
AList: typeof import('ant-design-vue/es')['List']
AListItem: typeof import('ant-design-vue/es')['ListItem']
AListItemMeta: typeof import('ant-design-vue/es')['ListItemMeta']
AMenu: typeof import('ant-design-vue/es')['Menu']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
AModal: typeof import('ant-design-vue/es')['Modal']
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
AProgress: typeof import('ant-design-vue/es')['Progress']
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
ARate: typeof import('ant-design-vue/es')['Rate']
ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASlider: typeof import('ant-design-vue/es')['Slider']
ASpace: typeof import('ant-design-vue/es')['Space']
AStatistic: typeof import('ant-design-vue/es')['Statistic']
AStep: typeof import('ant-design-vue/es')['Step']
ASteps: typeof import('ant-design-vue/es')['Steps']
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
ASwitch: typeof import('ant-design-vue/es')['Switch']
ATable: typeof import('ant-design-vue/es')['Table']
ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATree: typeof import('ant-design-vue/es')['Tree']
ChartPanel: typeof import('./components/common/ChartPanel.vue')['default']
RelatedModules: typeof import('./components/common/RelatedModules.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
StatCard: typeof import('./components/common/StatCard.vue')['default']
StatusDot: typeof import('./components/common/StatusDot.vue')['default']
}
}
@@ -0,0 +1,233 @@
<template>
<div class="chart-panel">
<div class="chart-header" v-if="title">
<span class="chart-title">{{ title }}</span>
<slot name="extra" />
</div>
<div class="chart-body" :style="{ height: height + 'px' }">
<!-- Simple SVG Bar Chart -->
<svg v-if="type === 'bar'" :viewBox="`0 0 ${data.length * 60 + 40} ${height}`" class="svg-chart">
<g v-for="(item, i) in normalizedData" :key="i">
<rect
:x="i * 60 + 30"
:y="height - 30 - item.h"
:width="40"
:height="item.h"
:fill="colors[i % colors.length]"
rx="4"
class="bar"
>
<title>{{ item.label }}: {{ item.value }}</title>
</rect>
<text
:x="i * 60 + 50"
:y="height - 10"
text-anchor="middle"
font-size="11"
fill="#666"
>{{ item.label }}</text>
<text
:x="i * 60 + 50"
:y="height - 35 - item.h"
text-anchor="middle"
font-size="11"
fill="#333"
font-weight="bold"
>{{ item.value }}</text>
</g>
</svg>
<!-- Simple SVG Pie Chart -->
<svg v-else-if="type === 'pie'" :viewBox="`0 0 ${height} ${height}`" class="svg-chart">
<g :transform="`translate(${height/2}, ${height/2})`">
<path
v-for="(slice, i) in pieSlices"
:key="i"
:d="slice.path"
:fill="colors[i % colors.length]"
stroke="#fff"
stroke-width="2"
class="pie-slice"
>
<title>{{ slice.label }}: {{ slice.value }} ({{ slice.pct }}%)</title>
</path>
</g>
<g>
<g v-for="(slice, i) in pieSlices" :key="'l'+i" :transform="`translate(${height - 100}, ${20 + i * 22})`">
<rect :fill="colors[i % colors.length]" width="12" height="12" rx="2" />
<text x="18" y="11" font-size="12" fill="#333">{{ slice.label }} {{ slice.pct }}%</text>
</g>
</g>
</svg>
<!-- Simple SVG Line Chart -->
<svg v-else-if="type === 'line'" :viewBox="`0 0 ${data.length * 60 + 40} ${height}`" class="svg-chart">
<polyline
:points="linePoints"
fill="none"
:stroke="colors[0]"
stroke-width="2.5"
stroke-linejoin="round"
/>
<g v-for="(item, i) in normalizedData" :key="i">
<circle
:cx="i * 60 + 50"
:cy="height - 30 - item.h"
r="4"
:fill="colors[0]"
stroke="#fff"
stroke-width="2"
>
<title>{{ item.label }}: {{ item.value }}</title>
</circle>
<text
:x="i * 60 + 50"
:y="height - 10"
text-anchor="middle"
font-size="11"
fill="#666"
>{{ item.label }}</text>
</g>
</svg>
<!-- Progress Bars -->
<div v-else-if="type === 'progress'" class="progress-list">
<div v-for="(item, i) in data" :key="i" class="progress-item">
<div class="progress-label">{{ item.label }}</div>
<div class="progress-bar-wrap">
<div class="progress-bar" :style="{ width: item.pct + '%', background: colors[i % colors.length] }"></div>
</div>
<div class="progress-value">{{ item.value }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface DataItem {
label: string
value: number
pct?: number
}
const props = withDefaults(defineProps<{
title?: string
type?: 'bar' | 'pie' | 'line' | 'progress'
data: DataItem[]
height?: number
colors?: string[]
}>(), {
type: 'bar',
height: 220,
colors: () => ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2', '#eb2f96', '#fa8c16'],
})
const maxVal = computed(() => Math.max(...props.data.map(d => d.value), 1))
const normalizedData = computed(() =>
props.data.map(d => ({
...d,
h: (d.value / maxVal.value) * (props.height - 60),
}))
)
const linePoints = computed(() =>
normalizedData.value.map((d, i) => `${i * 60 + 50},${props.height - 30 - d.h}`).join(' ')
)
const pieSlices = computed(() => {
const total = props.data.reduce((s, d) => s + d.value, 0)
let cum = 0
const r = props.height / 2 - 20
return props.data.map(d => {
const start = cum
cum += d.value
const sa = (start / total) * Math.PI * 2 - Math.PI / 2
const ea = (cum / total) * Math.PI * 2 - Math.PI / 2
const la = ea - sa > Math.PI ? 1 : 0
const x1 = r * Math.cos(sa), y1 = r * Math.sin(sa)
const x2 = r * Math.cos(ea), y2 = r * Math.sin(ea)
return {
...d,
pct: ((d.value / total) * 100).toFixed(1),
path: `M0,0 L${x1},${y1} A${r},${r} 0 ${la},1 ${x2},${y2} Z`,
}
})
})
</script>
<style scoped lang="scss">
.chart-panel {
background: #fff;
border-radius: 8px;
padding: 16px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.chart-title {
font-size: 15px;
font-weight: 600;
color: #333;
}
.chart-body {
width: 100%;
overflow-x: auto;
}
.svg-chart {
width: 100%;
height: 100%;
}
.bar {
transition: opacity 0.2s;
cursor: pointer;
&:hover { opacity: 0.8; }
}
.pie-slice {
transition: opacity 0.2s;
cursor: pointer;
&:hover { opacity: 0.85; }
}
.progress-list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 0;
}
.progress-item {
display: flex;
align-items: center;
gap: 12px;
}
.progress-label {
width: 80px;
font-size: 13px;
color: #666;
text-align: right;
flex-shrink: 0;
}
.progress-bar-wrap {
flex: 1;
height: 16px;
background: #f0f0f0;
border-radius: 8px;
overflow: hidden;
}
.progress-bar {
height: 100%;
border-radius: 8px;
transition: width 0.6s ease;
}
.progress-value {
width: 50px;
font-size: 13px;
color: #333;
font-weight: 600;
}
</style>
@@ -0,0 +1,47 @@
<template>
<div class="related-modules" v-if="links.length">
<span class="related-label">关联</span>
<a-tag
v-for="link in links"
:key="link.key"
:color="link.color || 'default'"
class="related-tag"
@click="$router.push({ name: link.key })"
>
{{ link.arrow || '' }} {{ link.label }}
</a-tag>
</div>
</template>
<script setup lang="ts">
export interface RelatedLink {
key: string
label: string
color?: string
arrow?: string
}
defineProps<{ links: RelatedLink[] }>()
</script>
<style scoped>
.related-modules {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
padding: 8px 0;
}
.related-label {
font-size: 12px;
color: #999;
flex-shrink: 0;
}
.related-tag {
cursor: pointer;
font-size: 12px;
}
.related-tag:hover {
opacity: 0.8;
}
</style>
@@ -0,0 +1,36 @@
<template>
<a-card :bordered="false" class="stat-card" :style="{ borderLeft: `4px solid ${color}` }">
<a-statistic :title="title" :value="value" :suffix="suffix" :value-style="{ color }">
<template #prefix>
<slot name="icon" />
</template>
</a-statistic>
<div class="stat-footer" v-if="$slots.footer">
<slot name="footer" />
</div>
</a-card>
</template>
<script setup lang="ts">
defineProps<{
title: string
value: number | string
suffix?: string
color?: string
}>()
</script>
<style scoped lang="scss">
.stat-card {
border-radius: 8px;
transition: box-shadow 0.3s;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
.stat-footer {
margin-top: 8px;
font-size: 13px;
color: #999;
}
</style>
@@ -0,0 +1,29 @@
<template>
<div class="status-dot" :class="type">
<span class="dot"></span>
<span class="label">{{ label }}</span>
</div>
</template>
<script setup lang="ts">
defineProps<{ type: 'success' | 'warning' | 'error' | 'info' | 'default'; label: string }>()
</script>
<style scoped lang="scss">
.status-dot {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.success .dot { background: #52c41a; }
.warning .dot { background: #faad14; }
.error .dot { background: #f5222d; }
.info .dot { background: #1890ff; }
.default .dot { background: #d9d9d9; }
</style>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+210
View File
@@ -0,0 +1,210 @@
<template>
<a-layout class="main-layout">
<a-layout-sider v-model:collapsed="collapsed" :trigger="null" collapsible width="220">
<div class="logo">
<h1 v-show="!collapsed">智能餐养</h1>
<h1 v-show="collapsed"></h1>
</div>
<a-menu
v-model:selectedKeys="selectedKeys"
v-model:openKeys="openKeys"
theme="dark"
mode="inline"
>
<a-menu-item key="dashboard">
<span>数据概览</span>
</a-menu-item>
<a-menu-item key="business-flow">
<span>业务流程</span>
</a-menu-item>
<a-sub-menu key="green-planting">
<template #title>
<span>绿色种采</span>
</template>
<a-menu-item key="land-env">土地环境</a-menu-item>
<a-menu-item key="planting-monitor">种养监控</a-menu-item>
<a-menu-item key="harvest-storage">收成入库</a-menu-item>
<a-menu-item key="supplier-mgmt">供商管理</a-menu-item>
<a-menu-item key="procurement-mgmt">采供管理</a-menu-item>
</a-sub-menu>
<a-sub-menu key="hygiene-supply">
<template #title>
<span>卫生供应</span>
</template>
<a-menu-item key="hygiene-test">卫生检测</a-menu-item>
<a-menu-item key="raw-storage">毛菜入库</a-menu-item>
<a-menu-item key="clean-process">净菜加工</a-menu-item>
<a-menu-item key="clean-storage">净菜入库</a-menu-item>
<a-menu-item key="clean-internal">净菜内供</a-menu-item>
<a-menu-item key="clean-external">净菜外售</a-menu-item>
</a-sub-menu>
<a-sub-menu key="health-production">
<template #title>
<span>健康生产</span>
</template>
<a-menu-item key="food-safety">食安监控</a-menu-item>
<a-menu-item key="recipe-plan">食谱规划</a-menu-item>
<a-menu-item key="clean-order">净菜订单</a-menu-item>
<a-menu-item key="clean-cabinet">净菜进柜</a-menu-item>
<a-menu-item key="clean-assemble">净菜组配</a-menu-item>
<a-menu-item key="cook-by-order">按单烹制</a-menu-item>
<a-menu-item key="serving-monitor">上餐监控</a-menu-item>
<a-menu-item key="surplus-disposal">余量处置</a-menu-item>
<a-menu-item key="production-stats">生产统计</a-menu-item>
</a-sub-menu>
<a-sub-menu key="nutrition-dining">
<template #title>
<span>营养用餐</span>
</template>
<a-menu-item key="nutrition-test">营养检验</a-menu-item>
<a-menu-item key="food-composition">食物成分</a-menu-item>
<a-menu-item key="dish-inventory">餐品总库</a-menu-item>
<a-menu-item key="algorithm-standard">算法标准</a-menu-item>
<a-menu-item key="recipe-mgmt">食谱管理</a-menu-item>
<a-menu-item key="nutrition-order">营养点餐</a-menu-item>
<a-menu-item key="user-mgmt">用户管理</a-menu-item>
<a-menu-item key="meal-line">餐线管理</a-menu-item>
<a-menu-item key="pickup-mgmt">取餐管理</a-menu-item>
<a-menu-item key="consumption-mgmt">消费管理</a-menu-item>
<a-menu-item key="nutrition-mgmt">营养管理</a-menu-item>
<a-menu-item key="ingredient-benefit">食材效益</a-menu-item>
<a-menu-item key="terminal-mgmt">终端管理</a-menu-item>
<a-menu-item key="model-mgmt">模型管理</a-menu-item>
<a-menu-item key="flavor-innovation">味养创新</a-menu-item>
</a-sub-menu>
<a-sub-menu key="system">
<template #title>
<span>系统管理</span>
</template>
<a-menu-item key="org-structure">组织机构</a-menu-item>
<a-menu-item key="employee-mgmt">员工管理</a-menu-item>
<a-menu-item key="role-mgmt">角色管理</a-menu-item>
<a-menu-item key="canteen-mgmt">食堂管理</a-menu-item>
</a-sub-menu>
</a-menu>
</a-layout-sider>
<a-layout>
<a-layout-header class="header">
<menu-unfold-outlined
v-if="collapsed"
class="trigger"
@click="collapsed = false"
/>
<menu-fold-outlined
v-else
class="trigger"
@click="collapsed = true"
/>
<div class="header-right">
<a-dropdown>
<span class="user-info">
<a-avatar size="small"></a-avatar>
<span class="username">管理员</span>
</span>
<template #overlay>
<a-menu>
<a-menu-item key="logout">退出登录</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</a-layout-header>
<a-layout-content class="content">
<router-view />
</a-layout-content>
</a-layout>
</a-layout>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { MenuUnfoldOutlined, MenuFoldOutlined } from '@ant-design/icons-vue'
const route = useRoute()
const router = useRouter()
const collapsed = ref(false)
const selectedKeys = ref<string[]>([])
const openKeys = ref<string[]>([])
watch(
() => route.meta.menuKey as string,
(key) => {
if (key) {
selectedKeys.value = [key]
}
},
{ immediate: true }
)
watch(selectedKeys, (keys) => {
if (keys.length) {
router.push({ name: keys[0] })
}
})
</script>
<style scoped lang="scss">
.main-layout {
height: 100%;
}
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
h1 {
color: #fff;
font-size: 18px;
margin: 0;
white-space: nowrap;
}
}
.header {
background: #fff;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.trigger {
font-size: 18px;
cursor: pointer;
transition: color 0.3s;
&:hover {
color: #1890ff;
}
}
.header-right {
.user-info {
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
}
.content {
margin: 16px;
padding: 24px;
background: #fff;
border-radius: 4px;
overflow: auto;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './assets/styles/global.scss'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+616
View File
@@ -0,0 +1,616 @@
import { ref } from 'vue'
// ==================== 通用工具 ====================
export const randomId = () => Math.random().toString(36).slice(2, 10)
export const randomNum = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min
export const randomFloat = (min: number, max: number, dec = 1) => +(Math.random() * (max - min) + min).toFixed(dec)
export const randomDate = (days = 30) => {
const d = new Date()
d.setDate(d.getDate() - randomNum(0, days))
return d.toISOString().slice(0, 10)
}
export const randomTime = () => `${String(randomNum(0, 23)).padStart(2, '0')}:${String(randomNum(0, 59)).padStart(2, '0')}`
export const pick = <T>(arr: T[]): T => arr[randomNum(0, arr.length - 1)]
export const pickMulti = <T>(arr: T[], n: number): T[] => arr.sort(() => 0.5 - Math.random()).slice(0, n)
// 状态标签
export const statusMap: Record<string, { color: string; text: string }> = {
normal: { color: 'green', text: '正常' },
warning: { color: 'orange', text: '预警' },
error: { color: 'red', text: '异常' },
pending: { color: 'blue', text: '待处理' },
processing: { color: 'cyan', text: '进行中' },
done: { color: 'green', text: '已完成' },
passed: { color: 'green', text: '合格' },
failed: { color: 'red', text: '不合格' },
reviewing: { color: 'orange', text: '审核中' },
approved: { color: 'green', text: '已审核' },
rejected: { color: 'red', text: '已驳回' },
stored: { color: 'green', text: '已入库' },
delivered: { color: 'blue', text: '已配送' },
cooking: { color: 'orange', text: '烹制中' },
served: { color: 'green', text: '已上餐' },
}
// ==================== 绿色种采 ====================
const landNames = ['A区稻田', 'B区菜地', 'C区果园', 'D区养殖场', 'E区温室大棚', 'F区有机农场']
const cropTypes = ['水稻', '小麦', '玉米', '番茄', '黄瓜', '青椒', '白菜', '萝卜', '苹果', '草莓']
const soilTypes = ['黏土', '壤土', '沙壤土', '砂土']
const supplierNames = ['绿源农业', '丰禾食品', '天然牧场', '鲜美水产', '有机之家', '大地农庄', '春风蔬果', '碧水渔业']
const materialNames = ['有机大米', '土鸡蛋', '新鲜猪肉', '活鱼', '时令蔬菜', '有机牛奶', '散养鸡', '绿色面粉', '菜籽油', '食用盐']
export function genLandEnvData(n = 12) {
return Array.from({ length: n }, (_, i) => ({
id: randomId(),
name: pick(landNames),
area: randomFloat(5, 200, 0) + '亩',
soilType: pick(soilTypes),
soilPH: randomFloat(5.5, 8.0),
humidity: randomFloat(30, 85, 0) + '%',
temperature: randomFloat(8, 35) + '°C',
organicMatter: randomFloat(1.5, 4.5) + '%',
status: pick(['normal', 'warning', 'error']),
lastCheck: randomDate(7),
crop: pick(cropTypes),
}))
}
export function genPlantingMonitorData(n = 10) {
const stages = ['播种期', '发芽期', '生长期', '开花期', '结果期', '成熟期', '收获期']
return Array.from({ length: n }, () => ({
id: randomId(),
crop: pick(cropTypes),
land: pick(landNames),
stage: pick(stages),
plantDate: randomDate(90),
expectedHarvest: randomDate(-30),
growthRate: randomFloat(70, 100, 0) + '%',
healthScore: randomNum(60, 100),
pestAlert: pick([true, false, false, false]),
waterStatus: pick(['normal', 'warning']),
lastUpdate: randomDate(1) + ' ' + randomTime(),
images: [`/mock/crop${randomNum(1, 5)}.jpg`],
}))
}
export function genHarvestData(n = 15) {
return Array.from({ length: n }, () => ({
id: randomId(),
batchNo: 'HV' + randomDate(30).replace(/-/g, '') + randomNum(100, 999),
crop: pick(cropTypes),
land: pick(landNames),
harvestDate: randomDate(30),
quantity: randomFloat(100, 5000, 0) + 'kg',
quality: pick(['A', 'B', 'C']),
status: pick(['stored', 'pending', 'processing']),
inspector: pick(['张三', '李四', '王五']),
warehouse: pick(['1号仓', '2号仓', '3号冷库']),
remark: '',
}))
}
export function genSupplierData(n = 8) {
return Array.from({ length: n }, (_, i) => ({
id: randomId(),
name: supplierNames[i % supplierNames.length],
category: pick(['蔬菜', '肉类', '水产', '粮油', '蛋奶', '水果']),
contact: '1' + randomNum(30, 99) + String(randomNum(10000000, 99999999)),
address: pick(['上海市浦东新区', '江苏省南京市', '浙江省杭州市', '安徽省合肥市']) + '某路' + randomNum(1, 200) + '号',
qualification: pick(['approved', 'reviewing', 'rejected']),
rating: randomFloat(3.0, 5.0),
cooperationYears: randomNum(1, 10),
totalOrders: randomNum(50, 500),
lastDelivery: randomDate(10),
}))
}
export function genProcurementData(n = 12) {
return Array.from({ length: n }, () => ({
id: randomId(),
orderNo: 'PO' + randomDate(30).replace(/-/g, '') + randomNum(100, 999),
material: pick(materialNames),
supplier: pick(supplierNames),
quantity: randomFloat(50, 2000, 0) + 'kg',
unitPrice: '¥' + randomFloat(2, 80),
totalPrice: '¥' + randomFloat(500, 50000, 0),
orderDate: randomDate(30),
deliveryDate: randomDate(10),
status: pick(['pending', 'processing', 'done', 'approved']),
purchaser: pick(['采购员A', '采购员B', '采购员C']),
}))
}
// ==================== 卫生供应 ====================
const testItems = ['农药残留', '重金属', '微生物', '添加剂', '兽药残留']
const vegetableNames = ['白菜', '萝卜', '番茄', '黄瓜', '茄子', '青椒', '芹菜', '菠菜', '土豆', '胡萝卜', '豆角', '西兰花']
const meatNames = ['猪里脊', '鸡胸肉', '牛腱子', '羊腿肉', '鱼片', '虾仁']
export function genHygieneTestData(n = 15) {
return Array.from({ length: n }, () => ({
id: randomId(),
batchNo: 'HT' + randomDate(14).replace(/-/g, '') + randomNum(100, 999),
material: pick([...vegetableNames, ...meatNames]),
testItem: pick(testItems),
testDate: randomDate(14),
result: pick(['passed', 'passed', 'passed', 'failed']),
value: randomFloat(0, 5) + 'mg/kg',
standard: '≤5mg/kg',
inspector: pick(['检测员A', '检测员B', '检测员C']),
report: `RPT${randomNum(10000, 99999)}`,
}))
}
export function genRawStorageData(n = 14) {
return Array.from({ length: n }, () => ({
id: randomId(),
batchNo: 'RS' + randomDate(14).replace(/-/g, '') + randomNum(100, 999),
material: pick([...vegetableNames, ...meatNames]),
supplier: pick(supplierNames),
quantity: randomFloat(20, 500, 0) + 'kg',
receiveDate: randomDate(14),
warehouse: pick(['毛菜仓A', '毛菜仓B', '冷库C']),
temperature: randomFloat(2, 8) + '°C',
status: pick(['stored', 'processing', 'pending']),
shelfLife: randomNum(1, 7) + '天',
receiver: pick(['仓管员A', '仓管员B']),
}))
}
export function genCleanProcessData(n = 12) {
return Array.from({ length: n }, () => {
const raw = randomFloat(50, 300, 0)
const net = +(raw * randomFloat(0.6, 0.9)).toFixed(0)
return {
id: randomId(),
batchNo: 'CP' + randomDate(14).replace(/-/g, '') + randomNum(100, 999),
material: pick(vegetableNames),
rawWeight: raw + 'kg',
netWeight: net + 'kg',
lossRate: ((1 - net / raw) * 100).toFixed(1) + '%',
processLine: pick(['加工线1', '加工线2', '加工线3']),
startTime: randomDate(7) + ' ' + randomTime(),
endTime: randomDate(5) + ' ' + randomTime(),
operator: pick(['操作员A', '操作员B', '操作员C']),
status: pick(['processing', 'done']),
}
})
}
export function genCleanStorageData(n = 14) {
return Array.from({ length: n }, () => ({
id: randomId(),
batchNo: 'CS' + randomDate(14).replace(/-/g, '') + randomNum(100, 999),
material: pick(vegetableNames),
quantity: randomFloat(10, 200, 0) + 'kg',
storeDate: randomDate(7),
warehouse: pick(['净菜仓A', '净菜仓B', '净菜冷库']),
temperature: randomFloat(0, 5) + '°C',
shelfLife: randomNum(1, 5) + '天',
status: pick(['stored', 'delivered', 'pending']),
remark: '',
}))
}
export function genCleanInternalData(n = 10) {
return Array.from({ length: n }, () => ({
id: randomId(),
orderNo: 'CI' + randomDate(7).replace(/-/g, '') + randomNum(100, 999),
material: pick(vegetableNames),
quantity: randomFloat(5, 100, 0) + 'kg',
requestDept: pick(['中餐厨房', '西餐厨房', '面点间', '凉菜间']),
requestDate: randomDate(7),
deliveryDate: randomDate(3),
status: pick(['pending', 'processing', 'delivered', 'done']),
handler: pick(['配送员A', '配送员B']),
}))
}
export function genCleanExternalData(n = 10) {
return Array.from({ length: n }, () => ({
id: randomId(),
orderNo: 'CE' + randomDate(14).replace(/-/g, '') + randomNum(100, 999),
customer: pick(['阳光餐厅', '幸福食堂', '社区超市', '学校食堂', '企业餐厅']),
items: pickMulti(vegetableNames, randomNum(2, 5)).join('、'),
totalWeight: randomFloat(20, 300, 0) + 'kg',
totalPrice: '¥' + randomFloat(200, 8000, 0),
orderDate: randomDate(14),
deliveryDate: randomDate(5),
status: pick(['pending', 'processing', 'delivered', 'done']),
address: '某市某区某路' + randomNum(1, 200) + '号',
}))
}
// ==================== 食堂管理 ====================
export const canteenNames = ['1号食堂', '2号食堂', '3号食堂']
export const canteenList = [
{ id: 'c1', name: '1号食堂', alias: '总部员工食堂', address: 'A栋1层', manager: '郑涛', phone: '010-1234-6001', capacity: 800, kitchenAreas: ['中餐A区', '中餐B区', '凉菜区'], mealLines: ['1号中餐线', '2号中餐线', '小吃窗口'], status: '营业中', createDate: '2020-05-01' },
{ id: 'c2', name: '2号食堂', alias: '研发楼食堂', address: 'B栋1层', manager: '何静', phone: '010-1234-6002', capacity: 500, kitchenAreas: ['西餐区', '面点区'], mealLines: ['3号西餐线', '面点窗口'], status: '营业中', createDate: '2021-03-01' },
{ id: 'c3', name: '3号食堂', alias: 'VIP接待餐厅', address: 'C栋2层', manager: '冯燕', phone: '010-1234-6003', capacity: 200, kitchenAreas: ['中餐A区'], mealLines: ['VIP餐线'], status: '营业中', createDate: '2022-01-15' },
]
export const kitchenToCanteen: Record<string, string> = {
'中餐A区': '1号食堂', '中餐B区': '1号食堂', '凉菜区': '1号食堂',
'西餐区': '2号食堂', '面点区': '2号食堂',
}
export const mealLineToCanteen: Record<string, string> = {
'1号中餐线': '1号食堂', '2号中餐线': '1号食堂', '小吃窗口': '1号食堂',
'3号西餐线': '2号食堂', '面点窗口': '2号食堂',
'VIP餐线': '3号食堂',
'1号餐线': '1号食堂', '2号餐线': '1号食堂', '3号餐线': '2号食堂',
}
// ==================== 健康生产 ====================
const dishNames = ['红烧肉', '清炒时蔬', '番茄鸡蛋', '糖醋里脊', '宫保鸡丁', '鱼香肉丝', '麻婆豆腐', '蒜蓉西兰花', '酸辣汤', '蛋炒饭', '紫菜蛋花汤', '水煮鱼', '回锅肉', '红烧茄子', '醋溜白菜', '干煸豆角', '凉拌黄瓜', '蒸鲈鱼', '炖牛腩', '小炒肉']
const mealTypes = ['早餐', '午餐', '晚餐']
const kitchenAreas = ['中餐A区', '中餐B区', '西餐区', '面点区', '凉菜区']
export function genFoodSafetyData(n = 10) {
return Array.from({ length: n }, () => {
const area = pick(kitchenAreas)
return {
id: randomId(),
area,
canteen: kitchenToCanteen[area] || pick(canteenNames),
type: pick(['环境卫生', '食材安全', '操作规范', '设备清洁', '人员健康']),
checkTime: randomDate(7) + ' ' + randomTime(),
result: pick(['passed', 'passed', 'passed', 'warning', 'failed']),
score: randomNum(70, 100),
inspector: pick(['督导员A', '督导员B', '督导员C']),
issues: pick(['无', '无', '台面未及时清理', '手套佩戴不规范', '温度偏高']),
camera: 'CAM-' + randomNum(1, 20).toString().padStart(2, '0'),
images: [],
}
})
}
export function genRecipePlanData() {
const days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
return days.map(day => ({
day,
meals: mealTypes.map(meal => ({
meal,
dishes: pickMulti(dishNames, meal === '早餐' ? 4 : 6).map(name => ({
id: randomId(),
name,
calories: randomNum(100, 600) + 'kcal',
tags: pickMulti(['高蛋白', '低脂', '高纤维', '低糖', '补钙', '富铁'], randomNum(1, 3)),
})),
})),
}))
}
export function genCleanOrderData(n = 12) {
return Array.from({ length: n }, () => {
const kitchen = pick(kitchenAreas)
return {
id: randomId(),
orderNo: 'CO' + randomDate(7).replace(/-/g, '') + randomNum(100, 999),
items: pickMulti(vegetableNames, randomNum(2, 5)).map(v => ({ name: v, qty: randomFloat(5, 50, 0) + 'kg' })),
kitchen,
canteen: kitchenToCanteen[kitchen] || pick(canteenNames),
mealType: pick(mealTypes),
orderDate: randomDate(7),
requiredDate: randomDate(3),
status: pick(['pending', 'processing', 'delivered', 'done']),
}
})
}
export function genCookByOrderData(n = 10) {
return Array.from({ length: n }, () => {
const kitchen = pick(kitchenAreas)
return {
id: randomId(),
orderNo: 'CK' + randomDate(3).replace(/-/g, '') + randomNum(100, 999),
dish: pick(dishNames),
quantity: randomNum(10, 200) + '份',
kitchen,
canteen: kitchenToCanteen[kitchen] || pick(canteenNames),
chef: pick(['主厨A', '主厨B', '厨师C', '厨师D']),
startTime: randomDate(1) + ' ' + randomTime(),
status: pick(['pending', 'cooking', 'done', 'served']),
mealType: pick(mealTypes),
temperature: randomFloat(60, 95) + '°C',
}
})
}
export function genServingMonitorData(n = 8) {
return Array.from({ length: n }, () => {
const mealLine = pick(['1号餐线', '2号餐线', '3号餐线', 'VIP餐线'])
return {
id: randomId(),
mealLine,
canteen: mealLineToCanteen[mealLine] || pick(canteenNames),
mealType: pick(mealTypes),
dishes: pickMulti(dishNames, randomNum(4, 8)),
servingStart: randomDate(1) + ' 11:00',
servingEnd: randomDate(1) + ' 13:00',
totalServed: randomNum(100, 800),
temperature: randomFloat(55, 75) + '°C',
status: pick(['pending', 'processing', 'done']),
monitor: pick(['值班员A', '值班员B']),
}
})
}
export function genSurplusData(n = 8) {
return Array.from({ length: n }, () => ({
id: randomId(),
date: randomDate(7),
mealType: pick(mealTypes),
dish: pick(dishNames),
surplusQty: randomFloat(1, 30, 0) + 'kg',
disposal: pick(['员工餐', '捐赠', '堆肥', '废弃']),
handler: pick(['处置员A', '处置员B']),
remark: '',
}))
}
export function genProductionStatsData() {
return {
today: {
totalDishes: randomNum(30, 60),
totalServings: randomNum(800, 2000),
materialsUsed: randomFloat(200, 800, 0) + 'kg',
wasteRate: randomFloat(2, 8) + '%',
},
weeklyTrend: Array.from({ length: 7 }, (_, i) => ({
day: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][i],
servings: randomNum(600, 2000),
materials: randomNum(200, 800),
waste: randomNum(10, 60),
})),
topDishes: pickMulti(dishNames, 10).map(name => ({
name,
servings: randomNum(50, 300),
satisfaction: randomFloat(3.5, 5.0),
})),
}
}
// ==================== 营养用餐 ====================
const nutrientNames = ['蛋白质', '脂肪', '碳水化合物', '膳食纤维', '维生素A', '维生素C', '钙', '铁', '锌']
export function genNutritionTestData(n = 12) {
return Array.from({ length: n }, () => ({
id: randomId(),
dish: pick(dishNames),
testDate: randomDate(14),
calories: randomNum(100, 800) + 'kcal',
protein: randomFloat(2, 40) + 'g',
fat: randomFloat(1, 30) + 'g',
carbs: randomFloat(5, 80) + 'g',
fiber: randomFloat(0.5, 10) + 'g',
sodium: randomNum(100, 1500) + 'mg',
result: pick(['passed', 'passed', 'passed', 'warning']),
tester: pick(['营养师A', '营养师B']),
}))
}
export function genFoodCompositionData(n = 20) {
return [...vegetableNames, ...meatNames].slice(0, n).map(name => ({
id: randomId(),
name,
category: vegetableNames.includes(name) ? '蔬菜' : '肉类',
calories: randomNum(15, 250) + 'kcal/100g',
protein: randomFloat(0.5, 25) + 'g',
fat: randomFloat(0.1, 15) + 'g',
carbs: randomFloat(1, 30) + 'g',
fiber: randomFloat(0.3, 5) + 'g',
vitaminC: randomFloat(0, 80) + 'mg',
calcium: randomNum(5, 200) + 'mg',
iron: randomFloat(0.2, 8) + 'mg',
}))
}
export function genDishInventoryData(n = 20) {
return dishNames.slice(0, n).map(name => ({
id: randomId(),
name,
category: pick(['热菜', '凉菜', '汤品', '主食', '面点']),
calories: randomNum(100, 700) + 'kcal',
price: '¥' + randomFloat(8, 38, 0),
rating: randomFloat(3.5, 5.0),
orderCount: randomNum(50, 2000),
available: pick([true, true, true, false]),
image: `/mock/dish${randomNum(1, 10)}.jpg`,
tags: pickMulti(['高蛋白', '低脂', '高纤维', '低糖', '招牌', '新品', '热销'], randomNum(1, 3)),
}))
}
export function genRecipeMgmtData(n = 8) {
return Array.from({ length: n }, () => ({
id: randomId(),
name: `${randomNum(1, 52)}周食谱`,
startDate: randomDate(30),
endDate: randomDate(10),
mealCount: randomNum(15, 21),
dishCount: randomNum(60, 120),
status: pick(['pending', 'approved', 'processing']),
creator: pick(['营养师A', '营养师B']),
avgCalories: randomNum(1800, 2500) + 'kcal/天',
}))
}
export function genUserMgmtData(n = 15) {
const lastNames = ['张', '李', '王', '刘', '陈', '杨', '黄', '赵', '周', '吴']
const firstNames = ['伟', '芳', '娜', '敏', '强', '磊', '洋', '艳', '勇', '军']
return Array.from({ length: n }, () => ({
id: randomId(),
name: pick(lastNames) + pick(firstNames) + pick(firstNames),
phone: '1' + randomNum(30, 99) + String(randomNum(10000000, 99999999)),
department: pick(['研发部', '市场部', '财务部', '人事部', '行政部', '销售部']),
mealCard: 'MC' + String(randomNum(100000, 999999)),
balance: '¥' + randomFloat(0, 2000, 2),
isMember: pick([true, true, false]),
totalOrders: randomNum(10, 500),
lastOrder: randomDate(7),
status: pick(['normal', 'normal', 'normal', 'warning']),
allergens: pickMulti(['花生', '牛奶', '鸡蛋', '海鲜', '大豆', '无'], 1),
}))
}
export function genConsumptionData(n = 20) {
return Array.from({ length: n }, () => ({
id: randomId(),
orderNo: 'OD' + randomDate(14).replace(/-/g, '') + randomNum(1000, 9999),
user: pick(['张伟', '李芳', '王娜', '刘敏', '陈强']),
mealType: pick(mealTypes),
dishes: pickMulti(dishNames, randomNum(1, 4)).join('、'),
totalPrice: '¥' + randomFloat(8, 60, 1),
payMethod: pick(['餐卡', '微信', '支付宝']),
date: randomDate(14),
time: randomTime(),
mealLine: pick(['1号餐线', '2号餐线', '3号餐线']),
}))
}
export function genNutritionOrderData(n = 12) {
return Array.from({ length: n }, () => ({
id: randomId(),
orderNo: 'NO' + randomDate(7).replace(/-/g, '') + randomNum(100, 999),
user: pick(['张伟', '李芳', '王娜', '刘敏', '陈强']),
mealType: pick(mealTypes),
targetCalories: randomNum(400, 800) + 'kcal',
dishes: pickMulti(dishNames, randomNum(2, 4)).map(d => ({ name: d, calories: randomNum(80, 400) + 'kcal' })),
totalCalories: randomNum(400, 800) + 'kcal',
nutritionScore: randomNum(70, 100),
date: randomDate(7),
status: pick(['pending', 'done', 'served']),
}))
}
export function genMealLineData() {
return ['1号中餐线', '2号中餐线', '3号西餐线', 'VIP餐线', '面点窗口', '小吃窗口'].map(name => ({
id: randomId(),
name,
canteen: mealLineToCanteen[name] || pick(canteenNames),
status: pick(['normal', 'normal', 'warning']),
todayDishes: randomNum(8, 20),
todayServed: randomNum(50, 500),
capacity: randomNum(200, 800) + '人/餐',
manager: pick(['负责人A', '负责人B', '负责人C']),
equipment: randomNum(3, 10) + '台',
rating: randomFloat(3.5, 5.0),
}))
}
// ==================== Dashboard ====================
export function genDashboardData() {
return {
// 核心 KPI
overview: {
todayOrders: randomNum(800, 2000),
todayRevenue: randomFloat(8000, 30000, 0),
activeUsers: randomNum(300, 1000),
satisfactionRate: randomFloat(88, 99),
wasteRate: randomFloat(2, 8),
nutritionScore: randomNum(75, 95),
orderGrowth: randomFloat(-5, 18),
revenueGrowth: randomFloat(-3, 15),
},
// 各食堂运营数据
canteenStats: canteenList.map(c => ({
name: c.name,
alias: c.alias,
todayOrders: randomNum(150, 700),
todayRevenue: randomFloat(2000, 12000, 0),
todayServed: randomNum(100, 600),
capacity: c.capacity,
usageRate: randomFloat(40, 95),
satisfactionRate: randomFloat(85, 99),
wasteRate: randomFloat(1, 10),
topDish: pick(dishNames),
status: c.status,
mealLines: c.mealLines.length,
alerts: randomNum(0, 3),
})),
// 本周订单趋势
weeklyOrders: Array.from({ length: 7 }, (_, i) => ({
day: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][i],
orders: randomNum(600, 2000),
revenue: randomNum(6000, 25000),
})),
// 餐次分布
mealDistribution: [
{ type: '早餐', value: randomNum(200, 500) },
{ type: '午餐', value: randomNum(500, 1200) },
{ type: '晚餐', value: randomNum(300, 800) },
],
// 热门菜品
topDishes: pickMulti(dishNames, 8).map(name => ({
name,
orders: randomNum(50, 300),
rating: randomFloat(3.5, 5.0),
})),
// 供应链状态
supplyChain: {
farmActive: randomNum(3, 6),
farmTotal: 6,
testPassRate: randomFloat(95, 100),
todayTests: randomNum(10, 30),
cleanProcessed: randomFloat(200, 600, 0),
cleanProcessUnit: 'kg',
stockDays: randomFloat(1.5, 5),
},
// 营养概览
nutritionOverview: {
avgCalories: randomNum(1800, 2200),
caloriesTarget: 2000,
avgProtein: randomNum(55, 85),
proteinTarget: 70,
avgFat: randomNum(45, 75),
fatTarget: 60,
avgCarbs: randomNum(250, 340),
carbsTarget: 300,
structureOk: pick([true, true, false]),
alertCount: randomNum(2, 12),
complianceRate: randomFloat(65, 92),
},
// 营养趋势(7日)
nutritionTrend: Array.from({ length: 7 }, (_, i) => ({
day: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][i],
calories: randomNum(1700, 2300),
target: 2000,
})),
// 系统告警
alerts: Array.from({ length: 8 }, () => ({
id: randomId(),
type: pick(['warning', 'error', 'normal']),
module: pick(['绿色种采', '卫生供应', '健康生产', '营养用餐', '系统管理']),
message: pick([
'3号冷库温度偏高(8.5°C',
'猪肉库存不足,预计明日缺货',
'2号加工线设备维护提醒',
'A区菜地土壤湿度偏低',
'新批次蔬菜农残检测合格',
'本周食谱已审核通过',
'供应商"绿源农业"资质即将到期',
'2号食堂午餐蛋白质摄入偏低',
'张伟连续3日热量超标',
'研发部群体维C摄入不足',
'净菜库存低于安全线',
'1号食堂满意度低于目标值',
]),
time: randomDate(3) + ' ' + randomTime(),
})),
// 食材用量
materialUsage: vegetableNames.slice(0, 8).map(name => ({
name,
used: randomNum(20, 200),
total: randomNum(200, 500),
})),
// 业务流水线实时状态
pipeline: {
pendingHarvest: randomNum(0, 5),
pendingTest: randomNum(0, 8),
processing: randomNum(2, 10),
cooking: randomNum(5, 20),
serving: randomNum(3, 12),
delivered: randomNum(50, 200),
},
}
}
+88
View File
@@ -0,0 +1,88 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const modules: RouteRecordRaw[] = [
// ========== 数据概览 ==========
{ path: 'dashboard', name: 'dashboard', meta: { title: '数据概览', menuKey: 'dashboard' }, component: () => import('@/views/dashboard/index.vue') },
{ path: 'business-flow', name: 'business-flow', meta: { title: '业务流程', menuKey: 'business-flow' }, component: () => import('@/views/business-flow/index.vue') },
// ========== 绿色种采 ==========
{ path: 'land-env', name: 'land-env', meta: { title: '土地环境', menuKey: 'land-env' }, component: () => import('@/views/green-planting/land-env/index.vue') },
{ path: 'planting-monitor', name: 'planting-monitor', meta: { title: '种养监控', menuKey: 'planting-monitor' }, component: () => import('@/views/green-planting/planting-monitor/index.vue') },
{ path: 'harvest-storage', name: 'harvest-storage', meta: { title: '收成入库', menuKey: 'harvest-storage' }, component: () => import('@/views/green-planting/harvest-storage/index.vue') },
{ path: 'supplier-mgmt', name: 'supplier-mgmt', meta: { title: '供商管理', menuKey: 'supplier-mgmt' }, component: () => import('@/views/green-planting/supplier-mgmt/index.vue') },
{ path: 'procurement-mgmt', name: 'procurement-mgmt', meta: { title: '采供管理', menuKey: 'procurement-mgmt' }, component: () => import('@/views/green-planting/procurement-mgmt/index.vue') },
// ========== 卫生供应 ==========
{ path: 'hygiene-test', name: 'hygiene-test', meta: { title: '卫生检测', menuKey: 'hygiene-test' }, component: () => import('@/views/hygiene-supply/hygiene-test/index.vue') },
{ path: 'raw-storage', name: 'raw-storage', meta: { title: '毛菜入库', menuKey: 'raw-storage' }, component: () => import('@/views/hygiene-supply/raw-storage/index.vue') },
{ path: 'clean-process', name: 'clean-process', meta: { title: '净菜加工', menuKey: 'clean-process' }, component: () => import('@/views/hygiene-supply/clean-process/index.vue') },
{ path: 'clean-storage', name: 'clean-storage', meta: { title: '净菜入库', menuKey: 'clean-storage' }, component: () => import('@/views/hygiene-supply/clean-storage/index.vue') },
{ path: 'clean-internal', name: 'clean-internal', meta: { title: '净菜内供', menuKey: 'clean-internal' }, component: () => import('@/views/hygiene-supply/clean-internal/index.vue') },
{ path: 'clean-external', name: 'clean-external', meta: { title: '净菜外售', menuKey: 'clean-external' }, component: () => import('@/views/hygiene-supply/clean-external/index.vue') },
// ========== 健康生产 ==========
{ path: 'food-safety', name: 'food-safety', meta: { title: '食安监控', menuKey: 'food-safety' }, component: () => import('@/views/health-production/food-safety/index.vue') },
{ path: 'recipe-plan', name: 'recipe-plan', meta: { title: '食谱规划', menuKey: 'recipe-plan' }, component: () => import('@/views/health-production/recipe-plan/index.vue') },
{ path: 'clean-order', name: 'clean-order', meta: { title: '净菜订单', menuKey: 'clean-order' }, component: () => import('@/views/health-production/clean-order/index.vue') },
{ path: 'clean-cabinet', name: 'clean-cabinet', meta: { title: '净菜进柜', menuKey: 'clean-cabinet' }, component: () => import('@/views/health-production/clean-cabinet/index.vue') },
{ path: 'clean-assemble', name: 'clean-assemble', meta: { title: '净菜组配', menuKey: 'clean-assemble' }, component: () => import('@/views/health-production/clean-assemble/index.vue') },
{ path: 'cook-by-order', name: 'cook-by-order', meta: { title: '按单烹制', menuKey: 'cook-by-order' }, component: () => import('@/views/health-production/cook-by-order/index.vue') },
{ path: 'serving-monitor', name: 'serving-monitor', meta: { title: '上餐监控', menuKey: 'serving-monitor' }, component: () => import('@/views/health-production/serving-monitor/index.vue') },
{ path: 'surplus-disposal', name: 'surplus-disposal', meta: { title: '余量处置', menuKey: 'surplus-disposal' }, component: () => import('@/views/health-production/surplus-disposal/index.vue') },
{ path: 'production-stats', name: 'production-stats', meta: { title: '生产统计', menuKey: 'production-stats' }, component: () => import('@/views/health-production/production-stats/index.vue') },
// ========== 营养用餐 ==========
{ path: 'nutrition-test', name: 'nutrition-test', meta: { title: '营养检验', menuKey: 'nutrition-test' }, component: () => import('@/views/nutrition-dining/nutrition-test/index.vue') },
{ path: 'food-composition', name: 'food-composition', meta: { title: '食物成分', menuKey: 'food-composition' }, component: () => import('@/views/nutrition-dining/food-composition/index.vue') },
{ path: 'dish-inventory', name: 'dish-inventory', meta: { title: '餐品总库', menuKey: 'dish-inventory' }, component: () => import('@/views/nutrition-dining/dish-inventory/index.vue') },
{ path: 'algorithm-standard', name: 'algorithm-standard', meta: { title: '算法标准', menuKey: 'algorithm-standard' }, component: () => import('@/views/nutrition-dining/algorithm-standard/index.vue') },
{ path: 'recipe-mgmt', name: 'recipe-mgmt', meta: { title: '食谱管理', menuKey: 'recipe-mgmt' }, component: () => import('@/views/nutrition-dining/recipe-mgmt/index.vue') },
{ path: 'nutrition-order', name: 'nutrition-order', meta: { title: '营养点餐', menuKey: 'nutrition-order' }, component: () => import('@/views/nutrition-dining/nutrition-order/index.vue') },
{ path: 'user-mgmt', name: 'user-mgmt', meta: { title: '用户管理', menuKey: 'user-mgmt' }, component: () => import('@/views/nutrition-dining/user-mgmt/index.vue') },
{ path: 'meal-line', name: 'meal-line', meta: { title: '餐线管理', menuKey: 'meal-line' }, component: () => import('@/views/nutrition-dining/meal-line/index.vue') },
{ path: 'pickup-mgmt', name: 'pickup-mgmt', meta: { title: '取餐管理', menuKey: 'pickup-mgmt' }, component: () => import('@/views/nutrition-dining/pickup-mgmt/index.vue') },
{ path: 'consumption-mgmt', name: 'consumption-mgmt', meta: { title: '消费管理', menuKey: 'consumption-mgmt' }, component: () => import('@/views/nutrition-dining/consumption-mgmt/index.vue') },
{ path: 'nutrition-mgmt', name: 'nutrition-mgmt', meta: { title: '营养管理', menuKey: 'nutrition-mgmt' }, component: () => import('@/views/nutrition-dining/nutrition-mgmt/index.vue') },
{ path: 'ingredient-benefit', name: 'ingredient-benefit', meta: { title: '食材效益', menuKey: 'ingredient-benefit' }, component: () => import('@/views/nutrition-dining/ingredient-benefit/index.vue') },
{ path: 'terminal-mgmt', name: 'terminal-mgmt', meta: { title: '终端管理', menuKey: 'terminal-mgmt' }, component: () => import('@/views/nutrition-dining/terminal-mgmt/index.vue') },
{ path: 'model-mgmt', name: 'model-mgmt', meta: { title: '模型管理', menuKey: 'model-mgmt' }, component: () => import('@/views/nutrition-dining/model-mgmt/index.vue') },
{ path: 'flavor-innovation', name: 'flavor-innovation', meta: { title: '味养创新', menuKey: 'flavor-innovation' }, component: () => import('@/views/nutrition-dining/flavor-innovation/index.vue') },
// ========== 系统管理 ==========
{ path: 'org-structure', name: 'org-structure', meta: { title: '组织机构', menuKey: 'org-structure' }, component: () => import('@/views/system/org-structure/index.vue') },
{ path: 'employee-mgmt', name: 'employee-mgmt', meta: { title: '员工管理', menuKey: 'employee-mgmt' }, component: () => import('@/views/system/employee-mgmt/index.vue') },
{ path: 'role-mgmt', name: 'role-mgmt', meta: { title: '角色管理', menuKey: 'role-mgmt' }, component: () => import('@/views/system/role-mgmt/index.vue') },
{ path: 'canteen-mgmt', name: 'canteen-mgmt', meta: { title: '食堂管理', menuKey: 'canteen-mgmt' }, component: () => import('@/views/system/canteen-mgmt/index.vue') },
]
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: () => import('@/views/login/index.vue'),
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
redirect: '/dashboard',
children: modules,
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (to.name !== 'login' && !token) {
next({ name: 'login' })
} else {
document.title = `${(to.meta.title as string) || ''} - 智能餐养`
next()
}
})
export default router
+31
View File
@@ -0,0 +1,31 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface UserInfo {
id: string
name: string
avatar?: string
role: string
}
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref<UserInfo | null>(null)
function setToken(val: string) {
token.value = val
localStorage.setItem('token', val)
}
function setUserInfo(info: UserInfo) {
userInfo.value = info
}
function logout() {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
}
return { token, userInfo, setToken, setUserInfo, logout }
})
+40
View File
@@ -0,0 +1,40 @@
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { message } from 'ant-design-vue'
const request: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 15000,
})
request.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
request.interceptors.response.use(
(response: AxiosResponse) => {
const { code, msg, data } = response.data
if (code === 0) {
return data
}
message.error(msg || '请求失败')
return Promise.reject(new Error(msg))
},
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
message.error(error.message || '网络错误')
return Promise.reject(error)
}
)
export default request
@@ -0,0 +1,849 @@
<template>
<div class="business-flow">
<a-page-header title="业务流程图" sub-title="智能餐养全链路业务关联视图" />
<!-- 视图切换 -->
<a-card :bordered="false" class="view-switch">
<a-radio-group v-model:value="viewMode" button-style="solid" size="large">
<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-group>
</a-card>
<!-- ==================== 供应链主线视图 ==================== -->
<div v-if="viewMode === 'chain'" class="chain-view">
<!-- 主流程 -->
<div class="main-flow">
<div
v-for="(stage, si) in supplyChain"
:key="stage.id"
class="stage-block"
:class="{ active: activeStage === stage.id }"
@click="activeStage = stage.id"
>
<div class="stage-header" :style="{ background: stage.color }">
<span class="stage-icon">{{ stage.icon }}</span>
<span class="stage-name">{{ stage.name }}</span>
</div>
<div class="stage-body">
<div
v-for="node in stage.nodes"
:key="node.key"
class="node-item"
:class="{ highlighted: hoveredNode === node.key }"
@mouseenter="hoveredNode = node.key"
@mouseleave="hoveredNode = ''"
@click.stop="goTo(node.key)"
>
<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>
</div>
</div>
<div class="stage-arrow" v-if="si < supplyChain.length - 1"></div>
</div>
</div>
<!-- 数据流连线说明 -->
<a-card title="核心数据流转" :bordered="false" style="margin-top: 24px">
<div class="flow-lines">
<div v-for="flow in dataFlows" :key="flow.id" class="flow-line">
<div class="flow-from">
<a-tag :color="flow.fromColor">{{ flow.from }}</a-tag>
</div>
<div class="flow-arrow">
<div class="flow-arrow-line"></div>
<span class="flow-data">{{ flow.data }}</span>
<div class="flow-arrow-head"></div>
</div>
<div class="flow-to">
<a-tag :color="flow.toColor">{{ flow.to }}</a-tag>
</div>
<span class="flow-desc">{{ flow.desc }}</span>
</div>
</div>
</a-card>
<!-- 横切关注点 -->
<a-row :gutter="16" style="margin-top: 16px">
<a-col :span="8" v-for="cross in crossCuttings" :key="cross.title">
<a-card :title="cross.title" :bordered="false" size="small" :headStyle="{ background: cross.bg, color: '#fff' }">
<div class="cross-items">
<div v-for="item in cross.items" :key="item.key" class="cross-item" @click="goTo(item.key)">
<a-badge :color="cross.dotColor" />
<span>{{ item.label }}</span>
<span class="cross-arrow"></span>
</div>
</div>
<p class="cross-desc">{{ cross.desc }}</p>
</a-card>
</a-col>
</a-row>
</div>
<!-- ==================== 关联矩阵视图 ==================== -->
<div v-if="viewMode === 'matrix'" class="matrix-view">
<a-card :bordered="false">
<div class="matrix-legend">
<span class="legend-item"><span class="legend-dot" style="background:#52c41a"></span> 强关联数据直接流转</span>
<span class="legend-item"><span class="legend-dot" style="background:#faad14"></span> 中关联共享数据实体</span>
<span class="legend-item"><span class="legend-dot" style="background:#d9d9d9"></span> 弱关联 / 无关联</span>
</div>
<div class="matrix-table-wrapper">
<table class="matrix-table">
<thead>
<tr>
<th class="matrix-corner"></th>
<th v-for="mod in matrixModules" :key="mod.key" class="matrix-th" :style="{ background: mod.bg }">
<span>{{ mod.label }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in matrixModules" :key="row.key">
<td class="matrix-row-header" :style="{ background: row.bg }">{{ row.label }}</td>
<td
v-for="col in matrixModules"
:key="col.key"
class="matrix-cell"
:class="getCellClass(row.key, col.key)"
@click="showRelation(row, col)"
@mouseenter="hoverCell = row.key + '-' + col.key"
@mouseleave="hoverCell = ''"
>
<span v-if="row.key === col.key" class="cell-self"></span>
<a-tooltip v-else :title="getRelationTip(row.key, col.key)">
<span class="cell-dot" :style="{ background: getRelationColor(row.key, col.key) }"></span>
</a-tooltip>
</td>
</tr>
</tbody>
</table>
</div>
</a-card>
<!-- 关联详情弹窗 -->
<a-modal v-model:visible="relationModalVisible" :title="relationModalTitle" :footer="null" width="600px">
<div v-if="relationDetail">
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="关联强度">
<a-tag :color="relationDetail.level === 'strong' ? 'green' : 'orange'">
{{ relationDetail.level === 'strong' ? '强关联' : '中关联' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="关联方式">{{ relationDetail.type }}</a-descriptions-item>
<a-descriptions-item label="共享数据">
<a-tag v-for="d in relationDetail.sharedData" :key="d">{{ d }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="业务说明">{{ relationDetail.desc }}</a-descriptions-item>
</a-descriptions>
<a-divider>关联页面</a-divider>
<a-space wrap>
<a-button v-for="page in relationDetail.pages" :key="page.key" size="small" @click="goTo(page.key)">
{{ page.label }}
</a-button>
</a-space>
</div>
</a-modal>
</div>
<!-- ==================== 模块详情视图 ==================== -->
<div v-if="viewMode === 'detail'" class="detail-view">
<a-row :gutter="16">
<a-col :span="6">
<a-card :bordered="false" class="module-list">
<div
v-for="mod in allModules"
:key="mod.key"
class="module-item"
:class="{ active: selectedModule === mod.key }"
@click="selectedModule = mod.key"
>
<span class="module-dot" :style="{ background: mod.color }"></span>
<span>{{ mod.label }}</span>
<a-badge :count="mod.relations" :number-style="{ backgroundColor: '#999', fontSize: '11px' }" />
</div>
</a-card>
</a-col>
<a-col :span="18">
<a-card v-if="selectedModuleData" :bordered="false">
<template #title>
<span :style="{ color: selectedModuleData.color }">{{ selectedModuleData.label }}</span>
<span style="font-size: 13px; color: #999; margin-left: 12px">{{ selectedModuleData.desc }}</span>
</template>
<a-row :gutter="16">
<a-col :span="12">
<h4>上游依赖数据来源</h4>
<a-empty v-if="!selectedModuleData.upstream.length" description="无上游依赖(源头模块)" :image="null" />
<div v-for="up in selectedModuleData.upstream" :key="up.module" class="relation-card upstream">
<div class="relation-header">
<a-tag color="blue">{{ up.module }}</a-tag>
<span class="relation-type">{{ up.type }}</span>
</div>
<p class="relation-desc">{{ up.desc }}</p>
<div class="relation-data">
<span class="data-label">数据</span>
<a-tag v-for="d in up.data" :key="d" size="small">{{ d }}</a-tag>
</div>
</div>
</a-col>
<a-col :span="12">
<h4>下游输出数据去向</h4>
<a-empty v-if="!selectedModuleData.downstream.length" description="无下游输出(终端模块)" :image="null" />
<div v-for="down in selectedModuleData.downstream" :key="down.module" class="relation-card downstream">
<div class="relation-header">
<a-tag color="green">{{ down.module }}</a-tag>
<span class="relation-type">{{ down.type }}</span>
</div>
<p class="relation-desc">{{ down.desc }}</p>
<div class="relation-data">
<span class="data-label">数据</span>
<a-tag v-for="d in down.data" :key="d" size="small">{{ d }}</a-tag>
</div>
</div>
</a-col>
</a-row>
<a-divider>包含功能页面</a-divider>
<a-space wrap>
<a-button
v-for="page in selectedModuleData.pages"
:key="page.key"
@click="goTo(page.key)"
size="small"
>
{{ page.label }}
</a-button>
</a-space>
</a-card>
</a-col>
</a-row>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const viewMode = ref<'chain' | 'matrix' | 'detail'>('chain')
const activeStage = ref('green')
const hoveredNode = ref('')
const hoverCell = ref('')
const selectedModule = ref('green')
const relationModalVisible = ref(false)
const relationModalTitle = ref('')
const relationDetail = ref<any>(null)
function goTo(key: string) {
router.push({ name: key })
}
// ============ 供应链主线 ============
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: '出' },
],
},
{
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: '出' },
],
},
{
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: '生产统计' },
],
},
{
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: '营养管理' },
],
},
]
// ============ 数据流转 ============
const dataFlows = [
{ id: 1, from: '收成入库', fromColor: 'green', to: '毛菜入库', toColor: 'blue', data: '农产品批次', desc: '自有种植基地收成直接进入卫生供应链' },
{ id: 2, from: '采供管理', fromColor: 'green', to: '毛菜入库', toColor: 'blue', data: '采购物资', desc: '外部采购食材经检测后入库' },
{ id: 3, from: '卫生检测', fromColor: 'blue', to: '毛菜入库', toColor: 'blue', data: '检测报告', desc: '食材须通过卫生检测才能入库' },
{ id: 4, from: '净菜入库', fromColor: 'blue', to: '净菜内供', toColor: 'blue', data: '净菜库存', desc: '加工后的净菜供应内部厨房' },
{ id: 5, from: '净菜内供', fromColor: 'blue', to: '净菜订单', toColor: 'orange', data: '配送单', desc: '厨房根据食谱下单领取净菜' },
{ id: 6, from: '食谱规划', fromColor: 'orange', to: '按单烹制', toColor: 'orange', data: '菜品清单', desc: '按食谱计划安排烹制任务' },
{ id: 7, from: '按单烹制', fromColor: 'orange', to: '上餐监控', toColor: 'orange', data: '成品菜', desc: '烹制完成后上线供餐' },
{ id: 8, from: '上餐监控', fromColor: 'orange', to: '余量处置', toColor: 'orange', data: '剩余菜品', desc: '餐后剩余食材进入处置流程' },
{ id: 9, from: '食谱规划', fromColor: 'orange', to: '食谱管理', toColor: 'purple', data: '食谱方案', desc: '生产食谱同步到用餐端供用户查看' },
{ id: 10, from: '餐品总库', fromColor: 'purple', to: '营养点餐', toColor: 'purple', data: '可选菜品', desc: '用户从餐品库中选择点餐' },
{ id: 11, from: '营养点餐', fromColor: 'purple', to: '消费管理', toColor: 'purple', data: '订单记录', desc: '点餐完成后生成消费记录' },
{ id: 12, from: '消费管理', fromColor: 'purple', to: '营养管理', toColor: 'purple', data: '饮食数据', desc: '基于消费记录分析个人营养状况' },
]
// ============ 横切关注点 ============
const crossCuttings = [
{
title: '质量检测线', bg: '#f5222d', dotColor: '#f5222d',
desc: '贯穿从源头到餐桌的全流程质量监控',
items: [
{ key: 'land-env', label: '土地环境检测' },
{ key: 'hygiene-test', label: '食材卫生检测' },
{ key: 'food-safety', label: '生产食安监控' },
{ key: 'nutrition-test', label: '餐品营养检验' },
],
},
{
title: '食堂运营线', bg: '#1890ff', dotColor: '#1890ff',
desc: '以食堂为单位的运营管理维度',
items: [
{ key: 'canteen-mgmt', label: '食堂管理' },
{ key: 'meal-line', label: '餐线管理' },
{ key: 'cook-by-order', label: '按单烹制' },
{ key: 'serving-monitor', label: '上餐监控' },
],
},
{
title: '用户服务线', bg: '#722ed1', dotColor: '#722ed1',
desc: '面向就餐用户的完整服务链路',
items: [
{ key: 'user-mgmt', label: '用户管理' },
{ key: 'nutrition-order', label: '营养点餐' },
{ key: 'pickup-mgmt', label: '取餐管理' },
{ key: 'nutrition-mgmt', label: '营养管理' },
],
},
]
// ============ 关联矩阵 ============
const matrixModules = [
{ key: 'land', label: '土地环境', bg: '#b7eb8f' },
{ key: 'plant', label: '种养监控', bg: '#b7eb8f' },
{ key: 'harvest', label: '收成入库', bg: '#b7eb8f' },
{ key: 'supplier', label: '供商管理', bg: '#b7eb8f' },
{ key: 'procure', label: '采供管理', bg: '#b7eb8f' },
{ key: 'hyTest', label: '卫生检测', bg: '#91d5ff' },
{ key: 'rawStore', label: '毛菜入库', bg: '#91d5ff' },
{ key: 'cleanProc', label: '净菜加工', bg: '#91d5ff' },
{ key: 'cleanStore', label: '净菜入库', bg: '#91d5ff' },
{ key: 'cleanIn', label: '净菜内供', bg: '#91d5ff' },
{ key: 'foodSafe', label: '食安监控', bg: '#ffe58f' },
{ key: 'recipe', label: '食谱规划', bg: '#ffe58f' },
{ key: 'cleanOrd', label: '净菜订单', bg: '#ffe58f' },
{ key: 'cook', label: '按单烹制', bg: '#ffe58f' },
{ key: 'serve', label: '上餐监控', bg: '#ffe58f' },
{ key: 'surplus', label: '余量处置', bg: '#ffe58f' },
{ key: 'dishLib', label: '餐品总库', bg: '#d3adf7' },
{ key: 'recipeMgmt', label: '食谱管理', bg: '#d3adf7' },
{ key: 'nutOrder', label: '营养点餐', bg: '#d3adf7' },
{ key: 'consume', label: '消费管理', bg: '#d3adf7' },
{ key: 'nutMgmt', label: '营养管理', bg: '#d3adf7' },
]
const strongRelations = new Set([
'land-plant', 'plant-harvest', 'harvest-rawStore', 'supplier-procure', 'procure-rawStore',
'hyTest-rawStore', 'rawStore-cleanProc', 'cleanProc-cleanStore', 'cleanStore-cleanIn',
'cleanIn-cleanOrd', 'recipe-cook', 'cook-serve', 'serve-surplus', 'cleanOrd-cook',
'recipe-recipeMgmt', 'dishLib-nutOrder', 'nutOrder-consume', 'consume-nutMgmt',
])
const mediumRelations = new Set([
'land-hyTest', 'harvest-hyTest', 'supplier-rawStore', 'cleanIn-foodSafe',
'recipe-cleanOrd', 'foodSafe-cook', 'foodSafe-serve', 'serve-nutMgmt',
'recipeMgmt-nutOrder', 'cook-surplus', 'surplus-nutMgmt',
'dishLib-recipeMgmt', 'nutOrder-nutMgmt',
])
function getRelKey(a: string, b: string) {
return a < b ? `${a}-${b}` : `${b}-${a}`
}
function getRelationColor(a: string, b: string) {
const key = getRelKey(a, b)
if (strongRelations.has(key)) return '#52c41a'
if (mediumRelations.has(key)) return '#faad14'
return '#f0f0f0'
}
function getCellClass(a: string, b: string) {
const key = getRelKey(a, b)
if (strongRelations.has(key)) return 'cell-strong'
if (mediumRelations.has(key)) return 'cell-medium'
return ''
}
function getRelationTip(a: string, b: string) {
const key = getRelKey(a, b)
if (strongRelations.has(key)) return '强关联:数据直接流转'
if (mediumRelations.has(key)) return '中关联:共享数据实体'
return '无直接关联'
}
const relationDescMap: Record<string, any> = {
'harvest-rawStore': { level: 'strong', type: '数据流转', sharedData: ['农产品批次', '作物名称', '质量等级'], desc: '自有基地收成的农产品直接进入毛菜仓库,携带批次号和质量等级信息', pages: [{ key: 'harvest-storage', label: '收成入库' }, { key: 'raw-storage', label: '毛菜入库' }] },
'procure-rawStore': { level: 'strong', type: '数据流转', sharedData: ['供应商', '物资名称', '采购单号'], desc: '外部采购的食材到货后进入毛菜入库流程', pages: [{ key: 'procurement-mgmt', label: '采供管理' }, { key: 'raw-storage', label: '毛菜入库' }] },
'rawStore-cleanProc': { level: 'strong', type: '数据流转', sharedData: ['食材名称', '批次号', '重量'], desc: '毛菜出库进入净菜加工线进行清洗切割', pages: [{ key: 'raw-storage', label: '毛菜入库' }, { key: 'clean-process', label: '净菜加工' }] },
'cleanStore-cleanIn': { level: 'strong', type: '数据流转', sharedData: ['净菜品种', '库存量', '保质期'], desc: '净菜从仓库调拨到内部厨房使用', pages: [{ key: 'clean-storage', label: '净菜入库' }, { key: 'clean-internal', label: '净菜内供' }] },
'recipe-cook': { level: 'strong', type: '数据流转', sharedData: ['菜品名称', '份数', '餐次'], desc: '按食谱规划下发烹制任务到各厨房', pages: [{ key: 'recipe-plan', label: '食谱规划' }, { key: 'cook-by-order', label: '按单烹制' }] },
'cook-serve': { level: 'strong', type: '数据流转', sharedData: ['成品菜', '温度', '餐线'], desc: '烹制完成的菜品上线供餐', pages: [{ key: 'cook-by-order', label: '按单烹制' }, { key: 'serving-monitor', label: '上餐监控' }] },
'nutOrder-consume': { level: 'strong', type: '数据流转', sharedData: ['订单号', '菜品', '金额'], desc: '用户点餐后生成消费记录和支付流水', pages: [{ key: 'nutrition-order', label: '营养点餐' }, { key: 'consumption-mgmt', label: '消费管理' }] },
'consume-nutMgmt': { level: 'strong', type: '数据流转', sharedData: ['饮食记录', '营养摄入', '用户ID'], desc: '消费数据汇总分析个人营养状况', pages: [{ key: 'consumption-mgmt', label: '消费管理' }, { key: 'nutrition-mgmt', label: '营养管理' }] },
}
function showRelation(row: any, col: any) {
if (row.key === col.key) return
const key = getRelKey(row.key, col.key)
if (!strongRelations.has(key) && !mediumRelations.has(key)) return
relationModalTitle.value = `${row.label}${col.label}`
relationDetail.value = relationDescMap[key] || {
level: strongRelations.has(key) ? 'strong' : 'medium',
type: strongRelations.has(key) ? '数据流转' : '共享数据',
sharedData: ['相关业务数据'],
desc: `${row.label}${col.label}之间存在业务关联`,
pages: [],
}
relationModalVisible.value = true
}
// ============ 模块详情 ============
const allModules = [
{ key: 'green', label: '绿色种采', color: '#52c41a', relations: 3 },
{ key: 'hygiene', label: '卫生供应', color: '#1890ff', relations: 5 },
{ key: 'health', label: '健康生产', color: '#faad14', relations: 6 },
{ key: 'nutrition', label: '营养用餐', color: '#722ed1', relations: 4 },
{ key: 'system', label: '系统管理', color: '#8c8c8c', relations: 2 },
]
const moduleDetails: Record<string, any> = {
green: {
label: '绿色种采', color: '#52c41a',
desc: '从田间到仓库的农产品种植与采购管理',
upstream: [],
downstream: [
{ module: '卫生供应', type: '数据流转', data: ['农产品批次', '供应商信息', '采购物资'], desc: '收成和采购的食材流入卫生供应链进行检测入库' },
],
pages: [
{ key: 'land-env', label: '土地环境' }, { key: 'planting-monitor', label: '种养监控' },
{ key: 'harvest-storage', label: '收成入库' }, { key: 'supplier-mgmt', label: '供商管理' },
{ key: 'procurement-mgmt', label: '采供管理' },
],
},
hygiene: {
label: '卫生供应', color: '#1890ff',
desc: '食材检测、加工和净菜配送管理',
upstream: [
{ module: '绿色种采', type: '数据流转', data: ['农产品批次', '供应商', '采购单'], desc: '接收种采和采购环节的原材料' },
],
downstream: [
{ module: '健康生产', type: '数据流转', data: ['净菜品种', '配送单', '库存量'], desc: '净菜内供到厨房,支撑烹饪生产' },
{ module: '外部客户', type: '数据流转', data: ['净菜订单', '配送地址'], desc: '净菜外售给外部餐饮客户' },
],
pages: [
{ key: 'hygiene-test', label: '卫生检测' }, { key: 'raw-storage', label: '毛菜入库' },
{ key: 'clean-process', label: '净菜加工' }, { key: 'clean-storage', label: '净菜入库' },
{ key: 'clean-internal', label: '净菜内供' }, { key: 'clean-external', label: '净菜外售' },
],
},
health: {
label: '健康生产', color: '#faad14',
desc: '从食谱到餐桌的厨房生产全流程管理',
upstream: [
{ module: '卫生供应', type: '数据流转', data: ['净菜配送', '食材库存'], desc: '厨房从卫生供应环节领取净菜原料' },
],
downstream: [
{ module: '营养用餐', type: '数据流转', data: ['食谱方案', '成品菜品', '供餐记录'], desc: '生产的菜品和食谱同步到用餐端,供餐记录用于营养分析' },
],
pages: [
{ key: 'food-safety', label: '食安监控' }, { key: 'recipe-plan', label: '食谱规划' },
{ key: 'clean-order', label: '净菜订单' }, { key: 'clean-cabinet', label: '净菜进柜' },
{ key: 'clean-assemble', label: '净菜组配' }, { key: 'cook-by-order', label: '按单烹制' },
{ key: 'serving-monitor', label: '上餐监控' }, { key: 'surplus-disposal', label: '余量处置' },
{ key: 'production-stats', label: '生产统计' },
],
},
nutrition: {
label: '营养用餐', color: '#722ed1',
desc: '面向用户的营养分析、点餐和消费管理',
upstream: [
{ module: '健康生产', type: '数据流转', data: ['食谱', '菜品', '供餐状态'], desc: '从生产环节获取可用菜品和食谱数据' },
],
downstream: [],
pages: [
{ key: 'nutrition-test', label: '营养检验' }, { key: 'food-composition', label: '食物成分' },
{ key: 'dish-inventory', label: '餐品总库' }, { key: 'recipe-mgmt', label: '食谱管理' },
{ key: 'nutrition-order', label: '营养点餐' }, { key: 'user-mgmt', label: '用户管理' },
{ key: 'consumption-mgmt', label: '消费管理' }, { key: 'nutrition-mgmt', label: '营养管理' },
],
},
system: {
label: '系统管理', color: '#8c8c8c',
desc: '组织架构、人员和权限的基础配置',
upstream: [],
downstream: [
{ module: '全部模块', type: '基础支撑', data: ['组织架构', '员工信息', '角色权限', '食堂配置'], desc: '为所有业务模块提供组织、人员、权限和食堂基础数据' },
],
pages: [
{ key: 'org-structure', label: '组织机构' }, { key: 'employee-mgmt', label: '员工管理' },
{ key: 'role-mgmt', label: '角色管理' }, { key: 'canteen-mgmt', label: '食堂管理' },
],
},
}
const selectedModuleData = computed(() => moduleDetails[selectedModule.value])
</script>
<style scoped lang="scss">
.business-flow {
.view-switch {
margin-bottom: 16px;
text-align: center;
}
// ===== 供应链视图 =====
.main-flow {
display: flex;
gap: 0;
overflow-x: auto;
padding: 16px 0;
}
.stage-block {
flex: 1;
min-width: 180px;
position: relative;
cursor: pointer;
transition: transform 0.2s;
&:hover { transform: translateY(-2px); }
&.active { transform: translateY(-4px); }
}
.stage-header {
padding: 12px 16px;
border-radius: 8px 8px 0 0;
color: #fff;
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 15px;
}
.stage-icon { font-size: 20px; }
.stage-body {
background: #fff;
border: 1px solid #f0f0f0;
border-top: none;
border-radius: 0 0 8px 8px;
padding: 8px 0;
}
.node-item {
display: flex;
align-items: center;
padding: 6px 16px;
cursor: pointer;
transition: background 0.15s;
gap: 8px;
&:hover, &.highlighted {
background: #f6f6f6;
}
}
.node-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.node-label {
flex: 1;
font-size: 13px;
color: #333;
}
.node-badge {
font-size: 10px;
background: #f0f0f0;
color: #666;
padding: 1px 6px;
border-radius: 4px;
}
.stage-arrow {
position: absolute;
right: -16px;
top: 50%;
transform: translateY(-50%);
font-size: 24px;
color: #d9d9d9;
z-index: 1;
font-weight: bold;
}
// ===== 数据流 =====
.flow-lines {
display: flex;
flex-direction: column;
gap: 12px;
}
.flow-line {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 6px;
transition: background 0.15s;
&:hover { background: #fafafa; }
}
.flow-from, .flow-to {
width: 90px;
flex-shrink: 0;
text-align: center;
}
.flow-arrow {
display: flex;
align-items: center;
gap: 4px;
min-width: 140px;
}
.flow-arrow-line {
flex: 1;
height: 2px;
background: linear-gradient(90deg, #d9d9d9, #999);
}
.flow-data {
font-size: 11px;
color: #1890ff;
background: #e6f7ff;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
}
.flow-arrow-head {
color: #999;
font-size: 10px;
}
.flow-desc {
font-size: 12px;
color: #999;
flex: 1;
}
// ===== 横切 =====
.cross-items {
display: flex;
flex-direction: column;
gap: 6px;
}
.cross-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
cursor: pointer;
font-size: 13px;
&:hover { color: #1890ff; }
}
.cross-arrow {
margin-left: auto;
color: #d9d9d9;
}
.cross-desc {
margin-top: 12px;
font-size: 12px;
color: #999;
border-top: 1px solid #f0f0f0;
padding-top: 8px;
}
// ===== 矩阵 =====
.matrix-legend {
display: flex;
gap: 24px;
margin-bottom: 16px;
justify-content: center;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #666;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.matrix-table-wrapper {
overflow-x: auto;
}
.matrix-table {
border-collapse: collapse;
width: 100%;
font-size: 11px;
th, td {
border: 1px solid #f0f0f0;
padding: 6px;
text-align: center;
min-width: 50px;
}
}
.matrix-corner {
background: #fafafa;
}
.matrix-th {
writing-mode: vertical-rl;
text-orientation: mixed;
padding: 8px 4px !important;
font-weight: 600;
font-size: 11px;
white-space: nowrap;
}
.matrix-row-header {
font-weight: 600;
text-align: left !important;
padding-left: 8px !important;
white-space: nowrap;
}
.matrix-cell {
cursor: pointer;
transition: background 0.15s;
&:hover { background: #e6f7ff !important; }
&.cell-strong { background: rgba(82, 196, 26, 0.08); }
&.cell-medium { background: rgba(250, 173, 20, 0.06); }
}
.cell-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
}
.cell-self {
color: #d9d9d9;
}
// ===== 模块详情 =====
.module-list {
:deep(.ant-card-body) { padding: 8px; }
}
.module-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
cursor: pointer;
border-radius: 6px;
transition: background 0.15s;
&:hover { background: #f6f6f6; }
&.active { background: #e6f7ff; font-weight: 600; }
}
.module-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.relation-card {
padding: 12px;
border-radius: 6px;
margin-bottom: 12px;
border-left: 3px solid;
&.upstream { background: #e6f7ff; border-color: #1890ff; }
&.downstream { background: #f6ffed; border-color: #52c41a; }
}
.relation-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.relation-type {
font-size: 12px;
color: #999;
}
.relation-desc {
font-size: 13px;
color: #666;
margin: 4px 0;
}
.relation-data {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
margin-top: 6px;
}
.data-label {
font-size: 12px;
color: #999;
}
}
</style>
+425
View File
@@ -0,0 +1,425 @@
<template>
<div class="dashboard">
<a-page-header title="数据概览" subtitle="智能餐养运营总览">
<template #extra>
<a-space>
<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>
</a-select>
<a-button @click="refreshData">刷新</a-button>
</a-space>
</template>
</a-page-header>
<!-- ============ 第一行核心 KPI ============ -->
<a-row :gutter="[16, 16]">
<a-col :span="4" v-for="kpi in kpiCards" :key="kpi.title">
<a-card :bordered="false" class="kpi-card" :style="{ borderTop: `3px solid ${kpi.color}` }">
<div class="kpi-value" :style="{ color: kpi.color }">{{ kpi.value }}<span class="kpi-suffix">{{ kpi.suffix }}</span></div>
<div class="kpi-title">{{ kpi.title }}</div>
<div class="kpi-trend" v-if="kpi.trend">
<span :class="kpi.trendUp ? 'trend-up' : 'trend-down'">{{ kpi.trendUp ? '↑' : '↓' }} {{ kpi.trend }}</span>
<span class="trend-label">{{ kpi.trendLabel }}</span>
</div>
</a-card>
</a-col>
</a-row>
<!-- ============ 第二行食堂运营 + 供应链 ============ -->
<a-row :gutter="16" style="margin-top: 16px">
<!-- 各食堂运营卡片 -->
<a-col :span="16">
<a-card title="食堂运营实况" :bordered="false">
<template #extra>
<a-button type="link" size="small" @click="$router.push({ name: 'canteen-mgmt' })">管理食堂</a-button>
</template>
<a-row :gutter="12">
<a-col :span="8" v-for="c in d.canteenStats" :key="c.name">
<div class="canteen-card" @click="filterCanteen = c.name">
<div class="canteen-header">
<span class="canteen-name">{{ c.name }}</span>
<a-tag :color="c.status === '营业中' ? 'green' : 'orange'" size="small">{{ c.status }}</a-tag>
</div>
<div class="canteen-alias">{{ c.alias }}</div>
<div class="canteen-metrics">
<div class="metric">
<span class="metric-val">{{ c.todayOrders }}</span>
<span class="metric-label">今日订单</span>
</div>
<div class="metric">
<span class="metric-val">{{ c.todayServed }}</span>
<span class="metric-label">就餐人数</span>
</div>
<div class="metric">
<span class="metric-val">{{ c.satisfactionRate }}%</span>
<span class="metric-label">满意度</span>
</div>
</div>
<div class="canteen-bar">
<span class="bar-label">使用率</span>
<div class="bar-track">
<div class="bar-fill" :style="{ width: c.usageRate + '%', background: c.usageRate > 85 ? '#f5222d' : c.usageRate > 60 ? '#faad14' : '#52c41a' }"></div>
</div>
<span class="bar-val">{{ c.usageRate }}%</span>
</div>
<div class="canteen-footer">
<span>热销{{ c.topDish }}</span>
<a-badge v-if="c.alerts > 0" :count="c.alerts" :number-style="{ backgroundColor: '#faad14', fontSize: '10px' }" />
</div>
</div>
</a-col>
</a-row>
</a-card>
</a-col>
<!-- 供应链实时状态 -->
<a-col :span="8">
<a-card title="供应链状态" :bordered="false" class="pipeline-card">
<template #extra>
<a-button type="link" size="small" @click="$router.push({ name: 'business-flow' })">流程图</a-button>
</template>
<div class="pipeline-nodes">
<div class="pipe-node" v-for="node in pipelineNodes" :key="node.label">
<div class="pipe-icon" :style="{ background: node.color }">{{ node.count }}</div>
<div class="pipe-label">{{ node.label }}</div>
</div>
<div class="pipe-connector" v-for="i in 5" :key="'c'+i"></div>
</div>
<a-divider style="margin: 16px 0 12px" />
<div class="supply-stats">
<div class="supply-item">
<span class="supply-label">基地运作</span>
<span class="supply-val">{{ d.supplyChain.farmActive }}/{{ d.supplyChain.farmTotal }}</span>
</div>
<div class="supply-item">
<span class="supply-label">检测通过率</span>
<span class="supply-val" style="color:#52c41a">{{ d.supplyChain.testPassRate }}%</span>
</div>
<div class="supply-item">
<span class="supply-label">今日检测</span>
<span class="supply-val">{{ d.supplyChain.todayTests }}</span>
</div>
<div class="supply-item">
<span class="supply-label">净菜加工</span>
<span class="supply-val">{{ d.supplyChain.cleanProcessed }}kg</span>
</div>
<div class="supply-item">
<span class="supply-label">库存天数</span>
<span class="supply-val" :style="{ color: d.supplyChain.stockDays < 2 ? '#f5222d' : '#333' }">{{ d.supplyChain.stockDays }}</span>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- ============ 第三行订单趋势 + 营养分析 ============ -->
<a-row :gutter="16" style="margin-top: 16px">
<!-- 订单趋势 -->
<a-col :span="12">
<a-card :bordered="false">
<template #title>
<span>本周订单趋势</span>
<a-radio-group v-model:value="trendMetric" size="small" style="margin-left: 16px">
<a-radio-button value="orders">订单量</a-radio-button>
<a-radio-button value="revenue">营收</a-radio-button>
</a-radio-group>
</template>
<ChartPanel
type="bar"
:data="trendChartData"
:height="220"
:colors="[trendMetric === 'orders' ? '#1890ff' : '#52c41a']"
/>
</a-card>
</a-col>
<!-- 营养健康概览 -->
<a-col :span="12">
<a-card title="全员营养健康" :bordered="false">
<template #extra>
<a-button type="link" size="small" @click="$router.push({ name: 'nutrition-mgmt' })">详细分析</a-button>
</template>
<a-row :gutter="12">
<a-col :span="8">
<div class="nutrition-ring">
<a-progress
type="circle"
:percent="d.nutritionOverview.complianceRate"
:size="90"
:stroke-color="d.nutritionOverview.complianceRate >= 80 ? '#52c41a' : '#faad14'"
/>
<div class="ring-label">达标率</div>
</div>
</a-col>
<a-col :span="16">
<div class="nutrition-bars">
<div class="nut-bar-row" v-for="nut in nutritionBars" :key="nut.label">
<span class="nut-label">{{ nut.label }}</span>
<div class="nut-bar-track">
<div class="nut-bar-fill" :style="{ width: Math.min(nut.pct, 100) + '%', background: nut.pct >= 80 && nut.pct <= 120 ? '#52c41a' : '#faad14' }"></div>
</div>
<span class="nut-val">{{ nut.actual }}/{{ nut.target }}{{ nut.unit }}</span>
</div>
</div>
<div class="nutrition-verdict">
<a-tag :color="d.nutritionOverview.structureOk ? 'green' : 'orange'">
{{ d.nutritionOverview.structureOk ? '营养结构合理' : '营养结构偏离' }}
</a-tag>
<span class="verdict-alert">{{ d.nutritionOverview.alertCount }} 条营养预警</span>
</div>
</a-col>
</a-row>
<a-divider style="margin: 12px 0" />
<ChartPanel type="line" :data="d.nutritionTrend.map(t => ({ label: t.day, value: t.calories }))" :height="120" :colors="['#722ed1']" />
<div class="nut-trend-label">
<span class="nut-trend-tag">近7日人均热量趋势</span>
<span class="nut-trend-target">推荐:2000 kcal/日</span>
</div>
</a-card>
</a-col>
</a-row>
<!-- ============ 第四行:餐次分布 + 热门菜品 + 食材用量 ============ -->
<a-row :gutter="16" style="margin-top: 16px">
<a-col :span="6">
<a-card title="餐次分布" :bordered="false">
<ChartPanel type="pie" :data="d.mealDistribution.map(m => ({ label: m.type, value: m.value }))" :height="200" />
</a-card>
</a-col>
<a-col :span="10">
<a-card title="热门菜品 TOP8" :bordered="false">
<div class="top-dishes">
<div v-for="(dish, i) in d.topDishes" :key="dish.name" class="dish-row">
<span class="dish-rank" :class="i < 3 ? 'rank-top' : ''">{{ i + 1 }}</span>
<span class="dish-name">{{ dish.name }}</span>
<div class="dish-bar-track">
<div class="dish-bar-fill" :style="{ width: (dish.orders / maxDishOrders * 100) + '%' }"></div>
</div>
<span class="dish-orders">{{ dish.orders }}份</span>
<span class="dish-rating">{{ dish.rating }}</span>
</div>
</div>
</a-card>
</a-col>
<a-col :span="8">
<a-card title="食材用量 TOP8 (kg)" :bordered="false">
<ChartPanel
type="bar"
:data="d.materialUsage.map(m => ({ label: m.name, value: m.used }))"
:height="200"
:colors="['#13c2c2']"
/>
</a-card>
</a-col>
</a-row>
<!-- ============ 第五行:系统告警 ============ -->
<a-card title="系统告警" :bordered="false" style="margin-top: 16px">
<template #extra>
<a-space>
<a-radio-group v-model:value="alertFilter" size="small">
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="error">异常</a-radio-button>
<a-radio-button value="warning">预警</a-radio-button>
<a-radio-button value="normal">正常</a-radio-button>
</a-radio-group>
</a-space>
</template>
<a-table :columns="alertColumns" :data-source="filteredAlerts" :pagination="{ pageSize: 5 }" size="small">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-badge :status="record.type === 'error' ? 'error' : record.type === 'warning' ? 'warning' : 'success'" />
<a-tag :color="record.type === 'error' ? 'red' : record.type === 'warning' ? 'orange' : 'green'" style="margin-left:4px">
{{ record.type === 'error' ? '异常' : record.type === 'warning' ? '预警' : '正常' }}
</a-tag>
</template>
<template v-if="column.key === 'module'">
<a-tag>{{ record.module }}</a-tag>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { useRouter } from 'vue-router'
import ChartPanel from '@/components/common/ChartPanel.vue'
import { genDashboardData } from '@/mock/data'
const router = useRouter()
const d = reactive(genDashboardData())
const filterCanteen = ref('')
const trendMetric = ref<'orders' | 'revenue'>('orders')
const alertFilter = ref('')
function refreshData() {
Object.assign(d, genDashboardData())
}
// KPI 卡片
const kpiCards = computed(() => [
{ title: '今日订单', value: d.overview.todayOrders, suffix: '单', color: '#1890ff', trend: Math.abs(d.overview.orderGrowth).toFixed(1) + '%', trendUp: d.overview.orderGrowth > 0, trendLabel: '较昨日' },
{ title: '今日营收', value: d.overview.todayRevenue, suffix: '元', color: '#52c41a', trend: Math.abs(d.overview.revenueGrowth).toFixed(1) + '%', trendUp: d.overview.revenueGrowth > 0, trendLabel: '较昨日' },
{ title: '活跃用户', value: d.overview.activeUsers, suffix: '人', color: '#722ed1', trend: (d.overview.activeUsers * 5) + '人次', trendUp: true, trendLabel: '本周累计' },
{ title: '满意度', value: d.overview.satisfactionRate, suffix: '%', color: '#13c2c2', trend: '95%', trendUp: d.overview.satisfactionRate >= 95, trendLabel: '目标' },
{ title: '废弃率', value: d.overview.wasteRate, suffix: '%', color: d.overview.wasteRate > 5 ? '#f5222d' : '#faad14', trend: '5%', trendUp: d.overview.wasteRate <= 5, trendLabel: '目标' },
{ title: '营养评分', value: d.overview.nutritionScore, suffix: '分', color: d.overview.nutritionScore >= 80 ? '#52c41a' : '#faad14', trend: d.overview.nutritionScore >= 80 ? '良好' : '需改善', trendUp: d.overview.nutritionScore >= 80, trendLabel: '' },
])
// 订单趋势
const trendChartData = computed(() =>
d.weeklyOrders.map(w => ({ label: w.day, value: trendMetric.value === 'orders' ? w.orders : w.revenue }))
)
// 营养条
const nutritionBars = computed(() => {
const n = d.nutritionOverview
return [
{ label: '热量', actual: n.avgCalories, target: n.caloriesTarget, unit: 'kcal', pct: Math.round(n.avgCalories / n.caloriesTarget * 100) },
{ label: '蛋白质', actual: n.avgProtein, target: n.proteinTarget, unit: 'g', pct: Math.round(n.avgProtein / n.proteinTarget * 100) },
{ label: '脂肪', actual: n.avgFat, target: n.fatTarget, unit: 'g', pct: Math.round(n.avgFat / n.fatTarget * 100) },
{ label: '碳水', actual: n.avgCarbs, target: n.carbsTarget, unit: 'g', pct: Math.round(n.avgCarbs / n.carbsTarget * 100) },
]
})
// 供应链流水线
const pipelineNodes = computed(() => [
{ label: '待收成', count: d.pipeline.pendingHarvest, color: '#52c41a' },
{ label: '待检测', count: d.pipeline.pendingTest, color: '#1890ff' },
{ label: '加工中', count: d.pipeline.processing, color: '#13c2c2' },
{ label: '烹制中', count: d.pipeline.cooking, color: '#faad14' },
{ label: '供餐中', count: d.pipeline.serving, color: '#722ed1' },
{ label: '已完成', count: d.pipeline.delivered, color: '#8c8c8c' },
])
// 热门菜品最大值
const maxDishOrders = computed(() => Math.max(...d.topDishes.map(t => t.orders), 1))
// 告警
const alertColumns = [
{ title: '级别', key: 'type', width: 100 },
{ title: '模块', key: 'module', width: 100 },
{ title: '内容', dataIndex: 'message', ellipsis: true },
{ title: '时间', dataIndex: 'time', width: 150 },
]
const filteredAlerts = computed(() =>
alertFilter.value ? d.alerts.filter(a => a.type === alertFilter.value) : d.alerts
)
</script>
<style scoped lang="scss">
.dashboard {
// KPI
.kpi-card {
text-align: center;
border-radius: 8px;
:deep(.ant-card-body) { padding: 16px 12px; }
}
.kpi-value {
font-size: 28px;
font-weight: 700;
line-height: 1.2;
}
.kpi-suffix { font-size: 14px; font-weight: 400; margin-left: 2px; }
.kpi-title { font-size: 13px; color: #999; margin-top: 4px; }
.kpi-trend {
margin-top: 6px;
font-size: 12px;
display: flex;
justify-content: center;
gap: 6px;
}
.trend-up { color: #52c41a; }
.trend-down { color: #f5222d; }
.trend-label { color: #bbb; }
// 食堂卡片
.canteen-card {
padding: 14px;
border: 1px solid #f0f0f0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover { border-color: #1890ff; box-shadow: 0 2px 8px rgba(24,144,255,0.1); }
}
.canteen-header { display: flex; justify-content: space-between; align-items: center; }
.canteen-name { font-size: 15px; font-weight: 600; }
.canteen-alias { font-size: 12px; color: #999; margin-top: 2px; }
.canteen-metrics { display: flex; justify-content: space-between; margin-top: 12px; }
.metric { text-align: center; }
.metric-val { display: block; font-size: 18px; font-weight: 700; color: #333; }
.metric-label { font-size: 11px; color: #999; }
.canteen-bar { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.bar-label { font-size: 11px; color: #999; width: 40px; flex-shrink: 0; }
.bar-track { flex: 1; height: 8px; background: #f0f0f0; border-radius: 4px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 4px; transition: width 0.6s; }
.bar-val { font-size: 12px; font-weight: 600; width: 35px; text-align: right; flex-shrink: 0; }
.canteen-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; font-size: 12px; color: #666; }
// 供应链
.pipeline-nodes {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
padding: 8px 0;
}
.pipe-node { text-align: center; z-index: 1; }
.pipe-icon {
width: 36px; height: 36px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; font-size: 14px; margin: 0 auto 4px;
}
.pipe-label { font-size: 11px; color: #666; }
.pipe-connector {
position: absolute; top: 25px; height: 2px; background: #e8e8e8;
width: calc((100% - 216px) / 5);
&:nth-child(7) { left: 36px; }
&:nth-child(8) { left: calc(36px + (100% - 216px) / 5 + 36px); }
&:nth-child(9) { left: calc(36px + ((100% - 216px) / 5 + 36px) * 2); }
&:nth-child(10) { left: calc(36px + ((100% - 216px) / 5 + 36px) * 3); }
&:nth-child(11) { left: calc(36px + ((100% - 216px) / 5 + 36px) * 4); }
}
.supply-stats { display: flex; flex-wrap: wrap; gap: 8px 0; }
.supply-item {
width: 50%; display: flex; justify-content: space-between;
padding: 4px 8px; font-size: 13px;
}
.supply-label { color: #999; }
.supply-val { font-weight: 600; }
// 营养
.nutrition-ring { text-align: center; padding-top: 8px; }
.ring-label { margin-top: 4px; font-size: 12px; color: #999; }
.nutrition-bars { display: flex; flex-direction: column; gap: 8px; padding: 4px 0; }
.nut-bar-row { display: flex; align-items: center; gap: 8px; }
.nut-label { width: 42px; font-size: 12px; color: #666; text-align: right; flex-shrink: 0; }
.nut-bar-track { flex: 1; height: 10px; background: #f0f0f0; border-radius: 5px; overflow: hidden; }
.nut-bar-fill { height: 100%; border-radius: 5px; transition: width 0.6s; }
.nut-val { font-size: 11px; color: #999; width: 85px; flex-shrink: 0; text-align: right; }
.nutrition-verdict { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.verdict-alert { font-size: 12px; color: #faad14; }
.nut-trend-label { display: flex; justify-content: space-between; margin-top: 4px; }
.nut-trend-tag { font-size: 12px; color: #722ed1; }
.nut-trend-target { font-size: 12px; color: #999; }
// 热门菜品
.top-dishes { display: flex; flex-direction: column; gap: 8px; }
.dish-row { display: flex; align-items: center; gap: 8px; }
.dish-rank {
width: 22px; height: 22px; border-radius: 50%; background: #f0f0f0;
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 600; color: #999; flex-shrink: 0;
&.rank-top { background: #faad14; color: #fff; }
}
.dish-name { width: 70px; font-size: 13px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dish-bar-track { flex: 1; height: 12px; background: #f0f0f0; border-radius: 6px; overflow: hidden; }
.dish-bar-fill { height: 100%; background: linear-gradient(90deg, #1890ff, #69c0ff); border-radius: 6px; transition: width 0.6s; }
.dish-orders { font-size: 12px; color: #333; font-weight: 600; width: 40px; text-align: right; flex-shrink: 0; }
.dish-rating { font-size: 12px; color: #faad14; width: 25px; text-align: right; flex-shrink: 0; }
}
</style>
@@ -0,0 +1,211 @@
<template>
<div class="page-container">
<a-page-header title="收成入库" />
<RelatedModules :links="relatedLinks" />
<!-- 统计卡片 -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="6">
<StatCard title="总批次" :value="data.length" color="#1890ff" />
</a-col>
<a-col :span="6">
<StatCard title="总数量" :value="totalQuantity" suffix="kg" color="#52c41a" />
</a-col>
<a-col :span="6">
<StatCard title="A级数量" :value="qualityCounts.A" color="#52c41a" />
</a-col>
<a-col :span="6">
<StatCard title="B/C级数量" :value="qualityCounts.B + qualityCounts.C" color="#faad14" />
</a-col>
</a-row>
<!-- 工具栏 -->
<a-card style="margin-bottom: 16px">
<a-row :gutter="16" align="middle">
<a-col :span="6">
<a-input v-model:value="searchText" placeholder="搜索批次号/作物" allow-clear />
</a-col>
<a-col :span="6">
<a-range-picker v-model:value="dateRange" style="width: 100%" />
</a-col>
<a-col :span="4">
<a-select v-model:value="statusFilter" placeholder="状态筛选" allow-clear style="width: 100%">
<a-select-option value="stored">已入库</a-select-option>
<a-select-option value="pending">待处理</a-select-option>
<a-select-option value="processing">进行中</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-button type="primary" @click="showModal">新增入库</a-button>
</a-col>
</a-row>
</a-card>
<!-- 数据表格 -->
<a-card>
<a-table
:columns="columns"
:data-source="filteredData"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'quality'">
<a-tag :color="record.quality === 'A' ? 'green' : record.quality === 'B' ? 'blue' : 'orange'">
{{ record.quality }}
</a-tag>
</template>
<template v-if="column.dataIndex === 'status'">
<a-tag :color="statusMap[record.status]?.color">
{{ statusMap[record.status]?.text }}
</a-tag>
</template>
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleStorage(record)">入库</a-button>
</template>
</template>
</a-table>
</a-card>
<!-- 入库弹窗 -->
<a-modal v-model:open="modalVisible" title="入库操作" @ok="handleSubmit" @cancel="resetForm">
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="入库仓">
<a-select v-model:value="form.warehouse" placeholder="请选择仓库">
<a-select-option value="1号仓">1号仓</a-select-option>
<a-select-option value="2号仓">2号仓</a-select-option>
<a-select-option value="3号冷库">3号冷库</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="检验员">
<a-input v-model:value="form.inspector" placeholder="请输入检验员" />
</a-form-item>
<a-form-item label="质量等级">
<a-radio-group v-model:value="form.quality">
<a-radio value="A">A级</a-radio>
<a-radio value="B">B级</a-radio>
<a-radio value="C">C级</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="备注">
<a-textarea v-model:value="form.remark" :rows="3" placeholder="请输入备注" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import type { Dayjs } from 'dayjs'
import { genHarvestData, statusMap, randomId } from '@/mock/data'
import StatCard from '@/components/common/StatCard.vue'
import RelatedModules from '@/components/common/RelatedModules.vue'
const relatedLinks = [
{ key: 'planting-monitor', label: '种养监控', color: 'green', arrow: '←' },
{ key: 'raw-storage', label: '毛菜入库', color: 'blue', arrow: '→' },
{ key: 'hygiene-test', label: '卫生检测', color: 'blue', arrow: '→' },
]
const data = ref(genHarvestData(15))
const searchText = ref('')
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
const statusFilter = ref<string | undefined>(undefined)
const modalVisible = ref(false)
const currentRecord = ref<any>(null)
const form = ref({
warehouse: undefined as string | undefined,
inspector: '',
quality: 'A',
remark: '',
})
const totalQuantity = computed(() => {
return data.value.reduce((sum, item) => sum + parseFloat(item.quantity), 0)
})
const qualityCounts = computed(() => {
const counts = { A: 0, B: 0, C: 0 }
data.value.forEach(item => {
if (item.quality in counts) counts[item.quality as keyof typeof counts]++
})
return counts
})
const filteredData = computed(() => {
return data.value.filter(item => {
const matchSearch = !searchText.value ||
item.batchNo.toLowerCase().includes(searchText.value.toLowerCase()) ||
item.crop.includes(searchText.value)
const matchStatus = !statusFilter.value || item.status === statusFilter.value
const matchDate = !dateRange.value ||
(item.harvestDate >= dateRange.value[0].format('YYYY-MM-DD') &&
item.harvestDate <= dateRange.value[1].format('YYYY-MM-DD'))
return matchSearch && matchStatus && matchDate
})
})
const columns = [
{ title: '批次号', dataIndex: 'batchNo', key: 'batchNo' },
{ title: '作物', dataIndex: 'crop', key: 'crop' },
{ title: '地块', dataIndex: 'land', key: 'land' },
{ title: '收成日期', dataIndex: 'harvestDate', key: 'harvestDate', sorter: (a: any, b: any) => a.harvestDate.localeCompare(b.harvestDate) },
{ title: '数量', dataIndex: 'quantity', key: 'quantity' },
{ title: '质量等级', dataIndex: 'quality', key: 'quality' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '检验员', dataIndex: 'inspector', key: 'inspector' },
{ title: '入库仓', dataIndex: 'warehouse', key: 'warehouse' },
{ title: '操作', dataIndex: 'action', key: 'action', width: 100 },
]
function showModal() {
currentRecord.value = null
resetForm()
modalVisible.value = true
}
function handleStorage(record: any) {
currentRecord.value = record
form.value.warehouse = record.warehouse
form.value.inspector = record.inspector
form.value.quality = record.quality
modalVisible.value = true
}
function resetForm() {
form.value = { warehouse: undefined, inspector: '', quality: 'A', remark: '' }
}
function handleSubmit() {
if (currentRecord.value) {
Object.assign(currentRecord.value, {
warehouse: form.value.warehouse,
inspector: form.value.inspector,
quality: form.value.quality,
remark: form.value.remark,
status: 'stored',
})
message.success('入库成功')
} else {
data.value.unshift({
id: randomId(),
batchNo: 'HV' + new Date().toISOString().slice(0, 10).replace(/-/g, '') + Math.floor(Math.random() * 900 + 100),
crop: '新作物',
land: 'A区稻田',
harvestDate: new Date().toISOString().slice(0, 10),
quantity: '0kg',
quality: form.value.quality,
status: 'stored',
inspector: form.value.inspector,
warehouse: form.value.warehouse || '1号仓',
remark: form.value.remark,
})
message.success('新增入库成功')
}
modalVisible.value = false
resetForm()
}
</script>
@@ -0,0 +1,187 @@
<template>
<div class="page-container">
<a-page-header title="土地环境监测" subtitle="种植地块土壤、温湿度等环境数据实时监测" />
<!-- 统计卡片 -->
<a-row :gutter="16" class="stat-row">
<a-col :span="6">
<StatCard title="监测地块" :value="totalLands" suffix="块" color="#1890ff">
<template #footer>覆盖全部种植区域</template>
</StatCard>
</a-col>
<a-col :span="6">
<StatCard title="平均温度" :value="avgTemp" suffix="°C" color="#faad14">
<template #footer>适宜区间 15-28°C</template>
</StatCard>
</a-col>
<a-col :span="6">
<StatCard title="平均湿度" :value="avgHumidity" suffix="%" color="#13c2c2">
<template #footer>适宜区间 45-75%</template>
</StatCard>
</a-col>
<a-col :span="6">
<StatCard title="预警地块" :value="alertCount" suffix="块" color="#f5222d">
<template #footer>需立即处理</template>
</StatCard>
</a-col>
</a-row>
<a-card :bordered="false" style="margin-top:16px">
<!-- 工具栏 -->
<div class="toolbar">
<a-space>
<a-input-search
v-model:value="searchText"
placeholder="搜索地块名称"
style="width: 220px"
allow-clear
/>
<a-select
v-model:value="statusFilter"
placeholder="状态筛选"
style="width: 140px"
allow-clear
>
<a-select-option value="normal">正常</a-select-option>
<a-select-option value="warning">预警</a-select-option>
<a-select-option value="error">异常</a-select-option>
</a-select>
<a-button type="primary" @click="handleRefresh">
<template #icon><span></span></template>
刷新数据
</a-button>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="filteredData"
:pagination="{ pageSize: 10 }"
row-key="id"
:custom-row="customRow"
bordered
>
<template #bodyCell="{ column, record }">
<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 === 'soilPH'">
<span :style="{ color: record.soilPH < 6 || record.soilPH > 7.5 ? '#f5222d' : '#52c41a' }">
{{ record.soilPH }}
</span>
</template>
</template>
</a-table>
</a-card>
<!-- 详情抽屉 -->
<a-drawer
v-model:open="drawerOpen"
title="地块详情"
width="520"
placement="right"
>
<template v-if="currentRecord">
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="地块名称">{{ currentRecord.name }}</a-descriptions-item>
<a-descriptions-item label="地块面积">{{ currentRecord.area }}</a-descriptions-item>
<a-descriptions-item label="土壤类型">{{ currentRecord.soilType }}</a-descriptions-item>
<a-descriptions-item label="pH 值">{{ currentRecord.soilPH }}</a-descriptions-item>
<a-descriptions-item label="土壤湿度">{{ currentRecord.humidity }}</a-descriptions-item>
<a-descriptions-item label="环境温度">{{ currentRecord.temperature }}</a-descriptions-item>
<a-descriptions-item label="有机质含量">{{ currentRecord.organicMatter }}</a-descriptions-item>
<a-descriptions-item label="种植作物">{{ currentRecord.crop }}</a-descriptions-item>
<a-descriptions-item label="当前状态">
<a-tag :color="statusMap[currentRecord.status]?.color">
{{ statusMap[currentRecord.status]?.text }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="上次检测时间">{{ currentRecord.lastCheck }}</a-descriptions-item>
</a-descriptions>
<a-divider> 7 天温度趋势</a-divider>
<ChartPanel
type="line"
:data="trendData"
:height="200"
/>
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import StatCard from '@/components/common/StatCard.vue'
import ChartPanel from '@/components/common/ChartPanel.vue'
import { genLandEnvData, statusMap, randomFloat } from '@/mock/data'
const dataSource = ref(genLandEnvData(15))
const searchText = ref('')
const statusFilter = ref<string>()
const drawerOpen = ref(false)
const currentRecord = ref<any>(null)
const columns = [
{ title: '地块名称', dataIndex: 'name', key: 'name', width: 110 },
{ title: '面积', dataIndex: 'area', key: 'area', width: 80 },
{ title: '土壤类型', dataIndex: 'soilType', key: 'soilType', width: 90 },
{ title: 'pH值', dataIndex: 'soilPH', key: 'soilPH', width: 80 },
{ title: '湿度', dataIndex: 'humidity', key: 'humidity', width: 80 },
{ title: '温度', dataIndex: 'temperature', key: 'temperature', width: 80 },
{ title: '有机质', dataIndex: 'organicMatter', key: 'organicMatter', width: 90 },
{ title: '种植作物', dataIndex: 'crop', key: 'crop', width: 100 },
{ title: '状态', key: 'status', width: 90 },
{ title: '上次检测', dataIndex: 'lastCheck', key: 'lastCheck', width: 120 },
]
const filteredData = computed(() => {
return dataSource.value.filter(item => {
const matchSearch = !searchText.value || item.name.includes(searchText.value)
const matchStatus = !statusFilter.value || item.status === statusFilter.value
return matchSearch && matchStatus
})
})
const totalLands = computed(() => dataSource.value.length)
const avgTemp = computed(() =>
(dataSource.value.reduce((s, d) => s + parseFloat(d.temperature), 0) / dataSource.value.length).toFixed(1)
)
const avgHumidity = computed(() =>
Math.round(dataSource.value.reduce((s, d) => s + parseFloat(d.humidity), 0) / dataSource.value.length)
)
const alertCount = computed(() => dataSource.value.filter(d => d.status !== 'normal').length)
const trendData = reactive(
['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map(d => ({
label: d,
value: randomFloat(15, 30),
}))
)
const customRow = (record: any) => ({
onClick: () => {
currentRecord.value = record
drawerOpen.value = true
},
style: { cursor: 'pointer' },
})
const handleRefresh = () => {
dataSource.value = genLandEnvData(15)
}
</script>
<style scoped lang="scss">
.page-container {
padding: 0;
}
.stat-row {
margin-top: 8px;
}
.toolbar {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
}
</style>
@@ -0,0 +1,219 @@
<template>
<div class="page-container">
<a-page-header title="种养监控" subtitle="实时监控作物生长状态与健康状况" />
<!-- 顶部过滤 -->
<div class="filter-bar">
<a-space>
<span style="color:#666">作物类型</span>
<a-select
v-model:value="cropFilter"
placeholder="全部作物"
style="width: 160px"
allow-clear
>
<a-select-option v-for="c in cropOptions" :key="c" :value="c">{{ c }}</a-select-option>
</a-select>
<a-button type="primary" @click="handleRefresh">刷新</a-button>
</a-space>
</div>
<!-- 卡片网格 -->
<a-row :gutter="[16, 16]" style="margin-top:16px">
<a-col v-for="item in filteredData" :key="item.id" :xs="24" :sm="12" :md="8" :lg="6">
<a-card hoverable class="monitor-card" @click="openDetail(item)">
<template #title>
<div class="card-title-row">
<span class="crop-name">{{ item.crop }}</span>
<a-badge v-if="item.pestAlert" color="red" text="虫害" />
</div>
</template>
<div class="card-body">
<div class="card-info-row">
<span class="label">地块</span>
<span class="value">{{ item.land }}</span>
</div>
<div class="stage-section">
<span class="label">生长阶段</span>
<a-steps :current="stageIndex(item.stage)" size="small" style="margin-top:4px">
<a-step v-for="s in stages" :key="s" :title="s" />
</a-steps>
</div>
<div class="health-section">
<div class="health-row">
<span class="label">健康评分</span>
<a-progress
type="circle"
:percent="item.healthScore"
:size="64"
:stroke-color="item.healthScore >= 80 ? '#52c41a' : item.healthScore >= 60 ? '#faad14' : '#f5222d'"
/>
</div>
</div>
<div class="card-info-row">
<span class="label">生长率</span>
<span class="value">{{ item.growthRate }}</span>
</div>
<div class="card-info-row">
<span class="label">水分状态</span>
<a-tag :color="statusMap[item.waterStatus]?.color">{{ statusMap[item.waterStatus]?.text }}</a-tag>
</div>
<div class="card-info-row last-update">
<span>更新: {{ item.lastUpdate }}</span>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 详情弹窗 -->
<a-modal
v-model:open="modalOpen"
title="种养监控详情"
width="600px"
:footer="null"
>
<template v-if="currentItem">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="作物" :span="1">{{ currentItem.crop }}</a-descriptions-item>
<a-descriptions-item label="地块" :span="1">{{ currentItem.land }}</a-descriptions-item>
<a-descriptions-item label="种植日期" :span="1">{{ currentItem.plantDate }}</a-descriptions-item>
<a-descriptions-item label="预计收获" :span="1">{{ currentItem.expectedHarvest }}</a-descriptions-item>
<a-descriptions-item label="生长阶段" :span="2">{{ currentItem.stage }}</a-descriptions-item>
<a-descriptions-item label="健康评分" :span="1">
<a-progress :percent="currentItem.healthScore" :size="'small'"
:stroke-color="currentItem.healthScore >= 80 ? '#52c41a' : currentItem.healthScore >= 60 ? '#faad14' : '#f5222d'"
/>
</a-descriptions-item>
<a-descriptions-item label="生长率" :span="1">{{ currentItem.growthRate }}</a-descriptions-item>
<a-descriptions-item label="虫害预警" :span="1">
<a-tag :color="currentItem.pestAlert ? 'red' : 'green'">{{ currentItem.pestAlert ? '有预警' : '无' }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="水分状态" :span="1">
<a-tag :color="statusMap[currentItem.waterStatus]?.color">{{ statusMap[currentItem.waterStatus]?.text }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="最近更新" :span="2">{{ currentItem.lastUpdate }}</a-descriptions-item>
</a-descriptions>
<a-divider>近 7 天健康评分趋势</a-divider>
<ChartPanel
type="line"
:data="healthTrend"
:height="180"
:colors="['#52c41a']"
/>
</template>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ChartPanel from '@/components/common/ChartPanel.vue'
import { genPlantingMonitorData, statusMap, randomNum } from '@/mock/data'
const stages = ['播种期', '发芽期', '生长期', '开花期', '结果期', '成熟期', '收获期']
const dataSource = ref(genPlantingMonitorData(12))
const cropFilter = ref<string>()
const modalOpen = ref(false)
const currentItem = ref<any>(null)
const cropOptions = computed(() => [...new Set(dataSource.value.map(d => d.crop))])
const filteredData = computed(() => {
if (!cropFilter.value) return dataSource.value
return dataSource.value.filter(d => d.crop === cropFilter.value)
})
const stageIndex = (stage: string) => {
const idx = stages.indexOf(stage)
return idx >= 0 ? idx : 0
}
const healthTrend = computed(() =>
['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map(d => ({
label: d,
value: randomNum(65, 100),
}))
)
const openDetail = (item: any) => {
currentItem.value = item
modalOpen.value = true
}
const handleRefresh = () => {
dataSource.value = genPlantingMonitorData(12)
}
</script>
<style scoped lang="scss">
.page-container {
padding: 0;
}
.filter-bar {
margin-top: 8px;
padding: 12px 16px;
background: #fff;
border-radius: 8px;
}
.monitor-card {
border-radius: 8px;
.card-title-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.crop-name {
font-weight: 600;
font-size: 15px;
}
.card-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.card-info-row {
display: flex;
justify-content: space-between;
align-items: center;
.label {
color: #999;
font-size: 13px;
}
.value {
font-weight: 500;
color: #333;
}
}
.stage-section {
.label {
color: #999;
font-size: 13px;
}
}
.health-section {
display: flex;
justify-content: center;
padding: 8px 0;
.health-row {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
.label {
color: #999;
font-size: 13px;
}
}
}
.last-update {
font-size: 12px;
color: #bbb;
justify-content: flex-end;
}
}
</style>
@@ -0,0 +1,203 @@
<template>
<div class="page-container">
<a-page-header title="采供管理" />
<RelatedModules :links="relatedLinks" />
<!-- 统计卡片 -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="6">
<StatCard title="总订单数" :value="data.length" color="#1890ff" />
</a-col>
<a-col :span="6">
<StatCard title="总金额" :value="totalAmount" suffix="元" color="#52c41a" />
</a-col>
<a-col :span="6">
<StatCard title="待处理" :value="pendingCount" color="#faad14" />
</a-col>
<a-col :span="6">
<StatCard title="本月订单" :value="thisMonthCount" color="#722ed1" />
</a-col>
</a-row>
<!-- 工具栏 -->
<a-card style="margin-bottom: 16px">
<a-row :gutter="16" align="middle">
<a-col :span="6">
<a-range-picker v-model:value="dateRange" style="width: 100%" />
</a-col>
<a-col :span="4">
<a-select v-model:value="statusFilter" placeholder="状态筛选" allow-clear style="width: 100%">
<a-select-option value="pending">待处理</a-select-option>
<a-select-option value="processing">进行中</a-select-option>
<a-select-option value="done">已完成</a-select-option>
<a-select-option value="approved">已审核</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-button type="primary" @click="showModal">新增采购</a-button>
</a-col>
</a-row>
</a-card>
<!-- 数据表格 -->
<a-card>
<a-table
:columns="columns"
:data-source="filteredData"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'status'">
<a-tag :color="statusMap[record.status]?.color">
{{ statusMap[record.status]?.text }}
</a-tag>
</template>
<template v-if="column.dataIndex === 'action'">
<a-popconfirm
title="确认审批通过?"
@confirm="handleApprove(record)"
ok-text="确认"
cancel-text="取消"
>
<a-button type="link" size="small" :disabled="record.status === 'approved'">审批</a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</a-card>
<!-- 新增采购弹窗 -->
<a-modal v-model:open="modalVisible" title="新增采购" @ok="handleSubmit" @cancel="resetForm">
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="物资名称">
<a-select v-model:value="form.material" placeholder="请选择物资">
<a-select-option v-for="m in materials" :key="m" :value="m">{{ m }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="供应商">
<a-select v-model:value="form.supplier" placeholder="请选择供应商">
<a-select-option v-for="s in suppliers" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="数量">
<a-input-group compact>
<a-input-number v-model:value="form.quantity" :min="1" style="width: 70%" placeholder="数量" />
<a-input v-model:value="form.unit" style="width: 30%" placeholder="单位" />
</a-input-group>
</a-form-item>
<a-form-item label="单价(元)">
<a-input-number v-model:value="form.unitPrice" :min="0" :precision="2" style="width: 100%" placeholder="请输入单价" />
</a-form-item>
<a-form-item label="到货日期">
<a-date-picker v-model:value="form.deliveryDate" style="width: 100%" />
</a-form-item>
<a-form-item label="备注">
<a-textarea v-model:value="form.remark" :rows="3" placeholder="请输入备注" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import type { Dayjs } from 'dayjs'
import { genProcurementData, statusMap, randomId } from '@/mock/data'
import StatCard from '@/components/common/StatCard.vue'
import RelatedModules from '@/components/common/RelatedModules.vue'
const relatedLinks = [
{ key: 'supplier-mgmt', label: '供商管理', color: 'green', arrow: '←' },
{ key: 'raw-storage', label: '毛菜入库', color: 'blue', arrow: '→' },
]
const data = ref(genProcurementData(12))
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
const statusFilter = ref<string | undefined>(undefined)
const modalVisible = ref(false)
const materials = ['有机大米', '土鸡蛋', '新鲜猪肉', '活鱼', '时令蔬菜', '有机牛奶', '散养鸡', '绿色面粉', '菜籽油', '食用盐']
const suppliers = ['绿源农业', '丰禾食品', '天然牧场', '鲜美水产', '有之家', '大地农庄', '春风蔬果', '碧水渔业']
const form = ref({
material: undefined as string | undefined,
supplier: undefined as string | undefined,
quantity: 100,
unit: 'kg',
unitPrice: 0,
deliveryDate: null as Dayjs | null,
remark: '',
})
const totalAmount = computed(() => {
return data.value.reduce((sum, item) => sum + parseFloat(item.totalPrice.replace('¥', '')), 0)
})
const pendingCount = computed(() => data.value.filter(item => item.status === 'pending').length)
const thisMonthCount = computed(() => {
const now = new Date()
const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
return data.value.filter(item => item.orderDate.startsWith(month)).length
})
const filteredData = computed(() => {
return data.value.filter(item => {
const matchStatus = !statusFilter.value || item.status === statusFilter.value
const matchDate = !dateRange.value ||
(item.orderDate >= dateRange.value[0].format('YYYY-MM-DD') &&
item.orderDate <= dateRange.value[1].format('YYYY-MM-DD'))
return matchStatus && matchDate
})
})
const columns = [
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo' },
{ title: '物资名称', dataIndex: 'material', key: 'material' },
{ title: '供应商', dataIndex: 'supplier', key: 'supplier' },
{ title: '数量', dataIndex: 'quantity', key: 'quantity' },
{ title: '单价', dataIndex: 'unitPrice', key: 'unitPrice' },
{ title: '总价', dataIndex: 'totalPrice', key: 'totalPrice' },
{ title: '下单日期', dataIndex: 'orderDate', key: 'orderDate', sorter: (a: any, b: any) => a.orderDate.localeCompare(b.orderDate) },
{ title: '到货日期', dataIndex: 'deliveryDate', key: 'deliveryDate' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '采购员', dataIndex: 'purchaser', key: 'purchaser' },
{ title: '操作', dataIndex: 'action', key: 'action', width: 100 },
]
function showModal() {
resetForm()
modalVisible.value = true
}
function resetForm() {
form.value = { material: undefined, supplier: undefined, quantity: 100, unit: 'kg', unitPrice: 0, deliveryDate: null, remark: '' }
}
function handleApprove(record: any) {
record.status = 'approved'
message.success('审批通过')
}
function handleSubmit() {
const total = form.value.quantity * form.value.unitPrice
data.value.unshift({
id: randomId(),
orderNo: 'PO' + new Date().toISOString().slice(0, 10).replace(/-/g, '') + Math.floor(Math.random() * 900 + 100),
material: form.value.material || '新物资',
supplier: form.value.supplier || '未指定',
quantity: form.value.quantity + form.value.unit,
unitPrice: '¥' + form.value.unitPrice.toFixed(1),
totalPrice: '¥' + total.toFixed(0),
orderDate: new Date().toISOString().slice(0, 10),
deliveryDate: form.value.deliveryDate?.format('YYYY-MM-DD') || '',
status: 'pending',
purchaser: '采购员A',
})
message.success('新增采购成功')
modalVisible.value = false
resetForm()
}
</script>
@@ -0,0 +1,207 @@
<template>
<div class="page-container">
<a-page-header title="供商管理" />
<!-- 工具栏 -->
<a-card style="margin-bottom: 16px">
<a-row :gutter="16" align="middle">
<a-col :span="6">
<a-input v-model:value="searchText" placeholder="搜索供应商名称" allow-clear />
</a-col>
<a-col :span="4">
<a-select v-model:value="categoryFilter" placeholder="类别筛选" allow-clear style="width: 100%">
<a-select-option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-button type="primary" @click="handleAdd">新增供应商</a-button>
</a-col>
</a-row>
</a-card>
<!-- 数据表格 -->
<a-card>
<a-table
:columns="columns"
:data-source="filteredData"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'category'">
<a-tag color="blue">{{ record.category }}</a-tag>
</template>
<template v-if="column.dataIndex === 'address'">
<a-tooltip :title="record.address">
<span class="ellipsis-text">{{ record.address }}</span>
</a-tooltip>
</template>
<template v-if="column.dataIndex === 'qualification'">
<a-tag :color="statusMap[record.qualification]?.color">
{{ statusMap[record.qualification]?.text }}
</a-tag>
</template>
<template v-if="column.dataIndex === 'rating'">
<a-rate :value="record.rating" disabled allow-half :style="{ fontSize: '14px' }" />
</template>
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button type="link" size="small" @click="handleDetail(record)">详情</a-button>
</template>
</template>
</a-table>
</a-card>
<!-- 新增/编辑弹窗 -->
<a-modal v-model:open="modalVisible" :title="isEdit ? '编辑供应商' : '新增供应商'" @ok="handleSubmit" @cancel="resetForm">
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="供应商名称">
<a-input v-model:value="form.name" placeholder="请输入名称" />
</a-form-item>
<a-form-item label="类别">
<a-select v-model:value="form.category" placeholder="请选择类别">
<a-select-option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="联系电话">
<a-input v-model:value="form.contact" placeholder="请输入联系电话" />
</a-form-item>
<a-form-item label="地址">
<a-textarea v-model:value="form.address" :rows="3" placeholder="请输入地址" />
</a-form-item>
</a-form>
</a-modal>
<!-- 详情抽屉 -->
<a-drawer v-model:open="drawerVisible" title="供应商详情" :width="480">
<a-descriptions v-if="currentRecord" bordered :column="1">
<a-descriptions-item label="供应商名称">{{ currentRecord.name }}</a-descriptions-item>
<a-descriptions-item label="类别">
<a-tag color="blue">{{ currentRecord.category }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ currentRecord.contact }}</a-descriptions-item>
<a-descriptions-item label="地址">{{ currentRecord.address }}</a-descriptions-item>
<a-descriptions-item label="资质状态">
<a-tag :color="statusMap[currentRecord.qualification]?.color">
{{ statusMap[currentRecord.qualification]?.text }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="评分">
<a-rate :value="currentRecord.rating" disabled allow-half />
</a-descriptions-item>
<a-descriptions-item label="合作年限">{{ currentRecord.cooperationYears }}</a-descriptions-item>
<a-descriptions-item label="累计订单">{{ currentRecord.totalOrders }}</a-descriptions-item>
<a-descriptions-item label="上次供货日期">{{ currentRecord.lastDelivery }}</a-descriptions-item>
</a-descriptions>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { genSupplierData, statusMap, randomId } from '@/mock/data'
const data = ref(genSupplierData(8))
const searchText = ref('')
const categoryFilter = ref<string | undefined>(undefined)
const modalVisible = ref(false)
const drawerVisible = ref(false)
const isEdit = ref(false)
const currentRecord = ref<any>(null)
const categories = ['蔬菜', '肉类', '水产', '粮油', '蛋奶', '水果']
const form = ref({
name: '',
category: undefined as string | undefined,
contact: '',
address: '',
})
const filteredData = computed(() => {
return data.value.filter(item => {
const matchSearch = !searchText.value || item.name.includes(searchText.value)
const matchCategory = !categoryFilter.value || item.category === categoryFilter.value
return matchSearch && matchCategory
})
})
const columns = [
{ title: '供应商名称', dataIndex: 'name', key: 'name' },
{ title: '类别', dataIndex: 'category', key: 'category' },
{ title: '联系电话', dataIndex: 'contact', key: 'contact' },
{ title: '地址', dataIndex: 'address', key: 'address', ellipsis: true },
{ title: '资质状态', dataIndex: 'qualification', key: 'qualification' },
{ title: '评分', dataIndex: 'rating', key: 'rating' },
{ title: '合作年限', dataIndex: 'cooperationYears', key: 'cooperationYears' },
{ title: '累计订单', dataIndex: 'totalOrders', key: 'totalOrders' },
{ title: '上次供货日期', dataIndex: 'lastDelivery', key: 'lastDelivery' },
{ title: '操作', dataIndex: 'action', key: 'action', width: 140 },
]
function handleAdd() {
isEdit.value = false
currentRecord.value = null
resetForm()
modalVisible.value = true
}
function handleEdit(record: any) {
isEdit.value = true
currentRecord.value = record
form.value = {
name: record.name,
category: record.category,
contact: record.contact,
address: record.address,
}
modalVisible.value = true
}
function handleDetail(record: any) {
currentRecord.value = record
drawerVisible.value = true
}
function resetForm() {
form.value = { name: '', category: undefined, contact: '', address: '' }
}
function handleSubmit() {
if (isEdit.value && currentRecord.value) {
Object.assign(currentRecord.value, {
name: form.value.name,
category: form.value.category,
contact: form.value.contact,
address: form.value.address,
})
message.success('编辑成功')
} else {
data.value.unshift({
id: randomId(),
name: form.value.name || '新供应商',
category: form.value.category || '蔬菜',
contact: form.value.contact,
address: form.value.address,
qualification: 'reviewing',
rating: 0,
cooperationYears: 0,
totalOrders: 0,
lastDelivery: new Date().toISOString().slice(0, 10),
})
message.success('新增成功')
}
modalVisible.value = false
resetForm()
}
</script>
<style scoped>
.ellipsis-text {
display: inline-block;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,93 @@
<template>
<div class="page-container">
<a-page-header title="净菜组配" subtitle="净菜组合配送管理" />
<a-card :bordered="false">
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="6">
<a-select v-model:value="filterStatus" placeholder="状态筛选" allowClear style="width: 100%">
<a-select-option value="pending">待组配</a-select-option>
<a-select-option value="processing">组配中</a-select-option>
<a-select-option value="done">已完成</a-select-option>
</a-select>
</a-col>
</a-row>
<a-table
:columns="columns"
:data-source="filteredData"
:pagination="{ pageSize: 8 }"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'dishes'">
<a-tag v-for="d in record.dishes" :key="d" color="blue" style="margin-bottom: 4px">{{ d }}</a-tag>
</template>
<template v-if="column.dataIndex === 'status'">
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
</template>
<template v-if="column.dataIndex === 'action'">
<a-button
v-if="record.status !== 'done'"
type="primary"
size="small"
@click="confirmAssemble(record)"
>确认组配</a-button>
<span v-else style="color: #999">已完成</span>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { statusMap, randomId, randomNum, randomDate, pick, pickMulti } from '@/mock/data'
const kitchenAreas = ['中餐A区', '中餐B区', '西餐区', '面点区', '凉菜区']
const vegs = ['白菜', '萝卜', '番茄', '黄瓜', '茄子', '青椒', '芹菜', '菠菜', '土豆', '胡萝卜']
interface AssembleItem {
id: string
assembleNo: string
orderNo: string
dishes: string[]
kitchen: string
status: string
operator: string
}
const tableData = ref<AssembleItem[]>(
Array.from({ length: 10 }, () => ({
id: randomId(),
assembleNo: 'ZP' + randomDate(7).replace(/-/g, '') + randomNum(100, 999),
orderNo: 'CO' + randomDate(7).replace(/-/g, '') + randomNum(100, 999),
dishes: pickMulti(vegs, randomNum(2, 5)),
kitchen: pick(kitchenAreas),
status: pick(['pending', 'processing', 'done']),
operator: pick(['操作员A', '操作员B', '操作员C']),
}))
)
const filterStatus = ref<string | undefined>()
const filteredData = computed(() =>
tableData.value.filter(d => !filterStatus.value || d.status === filterStatus.value)
)
const columns = [
{ title: '组配单号', dataIndex: 'assembleNo', width: 160 },
{ title: '关联订单', dataIndex: 'orderNo', width: 160 },
{ title: '菜品列表', dataIndex: 'dishes', width: 250 },
{ title: '配送厨房', dataIndex: 'kitchen', width: 110 },
{ title: '状态', dataIndex: 'status', width: 100 },
{ title: '操作员', dataIndex: 'operator', width: 100 },
{ title: '操作', dataIndex: 'action', width: 100 },
]
function confirmAssemble(record: AssembleItem) {
record.status = 'done'
}
</script>
@@ -0,0 +1,138 @@
<template>
<div class="page-container">
<a-page-header title="净菜进柜" subtitle="智能柜实时状态监控" />
<a-card :bordered="false">
<a-row :gutter="16">
<a-col :span="6" v-for="c in cabinets" :key="c.id" style="margin-bottom: 16px">
<a-card
hoverable
class="cabinet-card"
:class="`status-${c.status}`"
@click="openDrawer(c)"
>
<div class="cabinet-header">
<span class="cabinet-name">{{ c.name }}</span>
<a-badge :status="badgeStatus(c.status)" :text="statusMap[c.status]?.text" />
</div>
<div class="cabinet-info">
<div class="info-row"><span>当前物品</span><strong>{{ c.itemCount }} </strong></div>
<div class="info-row"><span>温度</span><strong>{{ c.temperature }}°C</strong></div>
<div class="info-row"><span>存放</span><strong>{{ c.fillLevel }}%</strong></div>
</div>
<a-progress
:percent="c.fillLevel"
:stroke-color="c.fillLevel > 85 ? '#f5222d' : c.fillLevel > 60 ? '#faad14' : '#52c41a'"
size="small"
/>
</a-card>
</a-col>
</a-row>
</a-card>
<a-drawer v-model:open="drawerVisible" :title="`${currentCabinet?.name} - 物品明细`" width="420">
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="柜号">{{ currentCabinet?.name }}</a-descriptions-item>
<a-descriptions-item label="温度">{{ currentCabinet?.temperature }}°C</a-descriptions-item>
<a-descriptions-item label="填充率">{{ currentCabinet?.fillLevel }}%</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusMap[currentCabinet?.status || 'normal']?.color">
{{ statusMap[currentCabinet?.status || 'normal']?.text }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
<a-divider>物品列表</a-divider>
<a-list :data-source="currentCabinet?.items || []" size="small">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.name" :description="`${item.qty} · 入柜时间 ${item.time}`" />
<template #actions>
<a-tag color="blue">{{ item.qty }}</a-tag>
</template>
</a-list-item>
</template>
</a-list>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { statusMap, randomId, randomNum, randomFloat, pick, pickMulti, randomTime } from '@/mock/data'
const vegs = ['白菜', '萝卜', '番茄', '黄瓜', '茄子', '青椒', '芹菜', '菠菜']
interface Cabinet {
id: string
name: string
itemCount: number
temperature: string
fillLevel: number
status: string
items: { name: string; qty: string; time: string }[]
}
const cabinets = ref<Cabinet[]>(
Array.from({ length: 8 }, (_, i) => {
const items = pickMulti(vegs, randomNum(2, 5)).map(v => ({
name: v,
qty: randomFloat(5, 30, 0) + 'kg',
time: randomTime(),
}))
const fillLevel = randomNum(20, 95)
return {
id: randomId(),
name: `${i + 1}号智能柜`,
itemCount: items.length,
temperature: randomFloat(2, 8).toString(),
fillLevel,
status: fillLevel > 85 ? 'error' : fillLevel > 60 ? 'warning' : 'normal',
items,
}
})
)
const drawerVisible = ref(false)
const currentCabinet = ref<Cabinet | null>(null)
function openDrawer(c: Cabinet) {
currentCabinet.value = c
drawerVisible.value = true
}
function badgeStatus(s: string) {
if (s === 'error') return 'error'
if (s === 'warning') return 'warning'
return 'success'
}
</script>
<style scoped lang="scss">
.cabinet-card {
border-radius: 8px;
border-top: 4px solid #52c41a;
&.status-warning { border-top-color: #faad14; }
&.status-error { border-top-color: #f5222d; }
}
.cabinet-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.cabinet-name {
font-size: 16px;
font-weight: 600;
}
.cabinet-info {
margin-bottom: 12px;
}
.info-row {
display: flex;
justify-content: space-between;
font-size: 13px;
color: #666;
padding: 4px 0;
strong { color: #333; }
}
</style>
@@ -0,0 +1,107 @@
<template>
<div class="page-container">
<a-page-header title="净菜订单" subtitle="厨房净菜需求订单管理" />
<RelatedModules :links="relatedLinks" />
<a-card :bordered="false">
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="4">
<a-select v-model:value="filterCanteen" placeholder="所属食堂" allowClear style="width: 100%">
<a-select-option v-for="c in canteenNames" :key="c" :value="c">{{ c }}</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-select v-model:value="filterKitchen" placeholder="厨房筛选" allowClear style="width: 100%">
<a-select-option v-for="k in kitchens" :key="k" :value="k">{{ k }}</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-select v-model:value="filterMeal" placeholder="餐次筛选" allowClear style="width: 100%">
<a-select-option value="早餐">早餐</a-select-option>
<a-select-option value="午餐">午餐</a-select-option>
<a-select-option value="晚餐">晚餐</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-select v-model:value="filterStatus" placeholder="状态筛选" allowClear style="width: 100%">
<a-select-option v-for="s in Object.keys(statusMap)" :key="s" :value="s">{{ statusMap[s].text }}</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-button type="primary">新建订单</a-button>
</a-col>
</a-row>
<a-table
:columns="columns"
:data-source="filteredData"
:pagination="{ pageSize: 8 }"
row-key="id"
size="middle"
>
<template #expandedRowRender="{ record }">
<a-table
:columns="subColumns"
:data-source="record.items"
:pagination="false"
size="small"
row-key="name"
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'itemsSummary'">
<a-tag> {{ record.items.length }} </a-tag>
</template>
<template v-if="column.dataIndex === 'status'">
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { genCleanOrderData, statusMap, canteenNames } from '@/mock/data'
import RelatedModules from '@/components/common/RelatedModules.vue'
const relatedLinks = [
{ key: 'clean-internal', label: '净菜内供', color: 'green', arrow: '←' },
{ key: 'recipe-plan', label: '食谱规划', color: 'green', arrow: '←' },
{ key: 'clean-cabinet', label: '净菜进柜', color: 'blue', arrow: '→' },
]
const tableData = ref(genCleanOrderData(15))
const filterCanteen = ref<string | undefined>()
const filterKitchen = ref<string | undefined>()
const filterMeal = ref<string | undefined>()
const filterStatus = ref<string | undefined>()
const kitchens = computed(() => [...new Set(tableData.value.map(d => d.kitchen))])
const filteredData = computed(() =>
tableData.value.filter(d =>
(!filterCanteen.value || d.canteen === filterCanteen.value) &&
(!filterKitchen.value || d.kitchen === filterKitchen.value) &&
(!filterMeal.value || d.mealType === filterMeal.value) &&
(!filterStatus.value || d.status === filterStatus.value)
)
)
const columns = [
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
{ title: '所属食堂', dataIndex: 'canteen', key: 'canteen', width: 100 },
{ title: '需求厨房', dataIndex: 'kitchen', width: 110 },
{ title: '餐次', dataIndex: 'mealType', width: 80 },
{ title: '明细', dataIndex: 'itemsSummary', width: 100 },
{ title: '下单日期', dataIndex: 'orderDate', width: 120 },
{ title: '需求日期', dataIndex: 'requiredDate', width: 120 },
{ title: '状态', dataIndex: 'status', width: 100 },
]
const subColumns = [
{ title: '菜品名称', dataIndex: 'name' },
{ title: '数量', dataIndex: 'qty' },
]
</script>
@@ -0,0 +1,129 @@
<template>
<div class="page-container">
<a-page-header title="按单烹制" />
<RelatedModules :links="relatedLinks" />
<!-- 统计行 -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="6">
<StatCard title="总订单" :value="data.length" color="#1890ff" />
</a-col>
<a-col :span="6">
<StatCard title="烹制中" :value="statusCounts.cooking" color="#faad14" />
</a-col>
<a-col :span="6">
<StatCard title="已完成" :value="statusCounts.done" color="#52c41a" />
</a-col>
<a-col :span="6">
<StatCard title="已上餐" :value="statusCounts.served" color="#999" />
</a-col>
</a-row>
<!-- 工具栏 -->
<a-card style="margin-bottom: 16px">
<a-row :gutter="16" align="middle">
<a-col :span="4">
<a-select v-model:value="filterCanteen" placeholder="所属食堂" allow-clear style="width: 100%">
<a-select-option v-for="c in canteenNames" :key="c" :value="c">{{ c }}</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-select v-model:value="statusFilter" placeholder="状态筛选" allow-clear style="width: 100%">
<a-select-option value="">全部</a-select-option>
<a-select-option value="pending">待烹制</a-select-option>
<a-select-option value="cooking">烹制中</a-select-option>
<a-select-option value="done">已完成</a-select-option>
<a-select-option value="served">已上餐</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-select v-model:value="mealFilter" placeholder="餐次筛选" allow-clear style="width: 100%">
<a-select-option value="早餐">早餐</a-select-option>
<a-select-option value="午餐">午餐</a-select-option>
<a-select-option value="晚餐">晚餐</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-button @click="refreshData">刷新</a-button>
</a-col>
</a-row>
</a-card>
<!-- 卡片网格 -->
<a-row :gutter="[16, 16]">
<a-col :span="8" v-for="item in filteredData" :key="item.id">
<a-card
:bordered="true"
:style="{ borderTop: '3px solid ' + borderColorMap[item.status] }"
>
<template #title>
<span>{{ item.dish }}</span>
<a-tag style="margin-left: 8px">{{ item.quantity }}</a-tag>
</template>
<p><strong>所属食堂</strong>{{ item.canteen }}</p>
<p><strong>厨房</strong>{{ item.kitchen }}</p>
<p><strong>厨师</strong>{{ item.chef }}</p>
<p><strong>温度</strong>{{ item.temperature }}</p>
<p><strong>餐次</strong>{{ item.mealType }}</p>
<a-steps :current="stepIndex(item.status)" size="small" style="margin-top: 12px">
<a-step title="待烹制" />
<a-step title="烹制中" />
<a-step title="已完成" />
<a-step title="已上餐" />
</a-steps>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { genCookByOrderData, statusMap, canteenNames } from '@/mock/data'
import StatCard from '@/components/common/StatCard.vue'
import RelatedModules from '@/components/common/RelatedModules.vue'
const relatedLinks = [
{ key: 'recipe-plan', label: '食谱规划', color: 'green', arrow: '←' },
{ key: 'clean-assemble', label: '净菜组配', color: 'green', arrow: '←' },
{ key: 'serving-monitor', label: '上餐监控', color: 'blue', arrow: '→' },
]
const data = ref(genCookByOrderData(10))
const statusFilter = ref<string | undefined>(undefined)
const mealFilter = ref<string | undefined>(undefined)
const filterCanteen = ref<string | undefined>(undefined)
const borderColorMap: Record<string, string> = {
pending: '#1890ff',
cooking: '#faad14',
done: '#52c41a',
served: '#999',
}
const statusCounts = computed(() => {
const counts = { pending: 0, cooking: 0, done: 0, served: 0 }
data.value.forEach(item => {
if (item.status in counts) counts[item.status as keyof typeof counts]++
})
return counts
})
const filteredData = computed(() => {
return data.value.filter(item => {
const matchCanteen = !filterCanteen.value || item.canteen === filterCanteen.value
const matchStatus = !statusFilter.value || item.status === statusFilter.value
const matchMeal = !mealFilter.value || item.mealType === mealFilter.value
return matchCanteen && matchStatus && matchMeal
})
})
function stepIndex(status: string): number {
const map: Record<string, number> = { pending: 0, cooking: 1, done: 2, served: 3 }
return map[status] ?? 0
}
function refreshData() {
data.value = genCookByOrderData(10)
}
</script>
@@ -0,0 +1,123 @@
<template>
<div class="page-container">
<a-page-header title="食安监控" subtitle="实时食品安全检查与监控" />
<!-- 统计卡片 -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="6">
<StatCard title="今日检查" :value="tableData.length" suffix="次" color="#1890ff" />
</a-col>
<a-col :span="6">
<StatCard title="合格率" :value="passRate" suffix="%" color="#52c41a" />
</a-col>
<a-col :span="6">
<StatCard title="发现问题" :value="issueCount" suffix="项" color="#f5222d" />
</a-col>
<a-col :span="6">
<StatCard title="平均评分" :value="avgScore" suffix="分" color="#faad14" />
</a-col>
</a-row>
<!-- 工具栏 -->
<a-card :bordered="false">
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="5">
<a-select v-model:value="filterCanteen" placeholder="所属食堂" allowClear style="width: 100%">
<a-select-option v-for="c in canteenNames" :key="c" :value="c">{{ c }}</a-select-option>
</a-select>
</a-col>
<a-col :span="5">
<a-select v-model:value="filterArea" placeholder="区域筛选" allowClear style="width: 100%">
<a-select-option v-for="a in areas" :key="a" :value="a">{{ a }}</a-select-option>
</a-select>
</a-col>
<a-col :span="5">
<a-select v-model:value="filterResult" placeholder="结果筛选" allowClear style="width: 100%">
<a-select-option value="passed">合格</a-select-option>
<a-select-option value="warning">预警</a-select-option>
<a-select-option value="failed">不合格</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-button type="primary" @click="refreshData">刷新数据</a-button>
</a-col>
</a-row>
<a-table
:columns="columns"
:data-source="filteredData"
:pagination="{ pageSize: 8, showTotal: (t: number) => `共 ${t} 条` }"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'result'">
<a-tag :color="statusMap[record.result]?.color">{{ statusMap[record.result]?.text }}</a-tag>
</template>
<template v-if="column.dataIndex === 'score'">
<a-progress
:percent="record.score"
:stroke-color="record.score > 90 ? '#52c41a' : record.score > 70 ? '#faad14' : '#f5222d'"
:size="'small'"
/>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import StatCard from '@/components/common/StatCard.vue'
import { genFoodSafetyData, statusMap, canteenNames } from '@/mock/data'
const tableData = ref(genFoodSafetyData(15))
const filterCanteen = ref<string | undefined>(undefined)
const filterArea = ref<string | undefined>(undefined)
const filterResult = ref<string | undefined>(undefined)
const areas = computed(() => [...new Set(tableData.value.map(d => d.area))])
const filteredData = computed(() =>
tableData.value.filter(d =>
(!filterCanteen.value || d.canteen === filterCanteen.value) &&
(!filterArea.value || d.area === filterArea.value) &&
(!filterResult.value || d.result === filterResult.value)
)
)
const passRate = computed(() => {
const passed = tableData.value.filter(d => d.result === 'passed').length
return tableData.value.length ? +((passed / tableData.value.length) * 100).toFixed(1) : 0
})
const issueCount = computed(() => tableData.value.filter(d => d.issues !== '无').length)
const avgScore = computed(() => {
const sum = tableData.value.reduce((s, d) => s + d.score, 0)
return tableData.value.length ? +(sum / tableData.value.length).toFixed(1) : 0
})
const columns = [
{ title: '所属食堂', dataIndex: 'canteen', key: 'canteen', width: 100 },
{ title: '区域', dataIndex: 'area', width: 100 },
{ title: '检查类型', dataIndex: 'type', width: 100 },
{ title: '检查时间', dataIndex: 'checkTime', width: 150 },
{ title: '结果', dataIndex: 'result', width: 80 },
{ title: '评分', dataIndex: 'score', width: 160 },
{ title: '检查员', dataIndex: 'inspector', width: 90 },
{ title: '问题描述', dataIndex: 'issues', ellipsis: true },
{ title: '监控编号', dataIndex: 'camera', width: 110 },
]
function refreshData() {
tableData.value = genFoodSafetyData(15)
}
</script>
<style scoped lang="scss">
.page-container {
padding: 0;
}
</style>
@@ -0,0 +1,76 @@
<template>
<div class="page-container">
<a-page-header title="生产统计" />
<!-- 顶部统计 -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="6">
<StatCard title="今日菜品数" :value="statsData.today.totalDishes" color="#1890ff" />
</a-col>
<a-col :span="6">
<StatCard title="今日出餐量" :value="statsData.today.totalServings" suffix="份" color="#52c41a" />
</a-col>
<a-col :span="6">
<StatCard title="食材用量" :value="statsData.today.materialsUsed" color="#722ed1" />
</a-col>
<a-col :span="6">
<StatCard title="浪费率" :value="statsData.today.wasteRate" color="#f5222d" />
</a-col>
</a-row>
<!-- 趋势图表 -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="12">
<ChartPanel title="本周出餐趋势" type="bar" :data="servingsChartData" :height="260" />
</a-col>
<a-col :span="12">
<ChartPanel title="本周浪费趋势" type="line" :data="wasteChartData" :height="260" />
</a-col>
</a-row>
<!-- 热门菜品排行 -->
<a-card title="热门菜品排行">
<a-table
:columns="topDishColumns"
:data-source="statsData.topDishes"
:pagination="{ pageSize: 10 }"
row-key="name"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'satisfaction'">
<a-rate :value="record.satisfaction" disabled allow-half :style="{ fontSize: '14px' }" />
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { reactive, computed } from 'vue'
import { genProductionStatsData } from '@/mock/data'
import StatCard from '@/components/common/StatCard.vue'
import ChartPanel from '@/components/common/ChartPanel.vue'
const statsData = reactive(genProductionStatsData())
const servingsChartData = computed(() =>
statsData.weeklyTrend.map(item => ({
label: item.day,
value: item.servings,
}))
)
const wasteChartData = computed(() =>
statsData.weeklyTrend.map(item => ({
label: item.day,
value: item.waste,
}))
)
const topDishColumns = [
{ title: '菜品', dataIndex: 'name', key: 'name' },
{ title: '出餐量', dataIndex: 'servings', key: 'servings', sorter: (a: any, b: any) => a.servings - b.servings },
{ title: '满意度', dataIndex: 'satisfaction', key: 'satisfaction' },
]
</script>
@@ -0,0 +1,110 @@
<template>
<div class="page-container">
<a-page-header title="食谱规划" subtitle="每周菜品与营养安排" />
<a-card :bordered="false">
<a-tabs v-model:activeKey="activeDay">
<a-tab-pane v-for="day in weekData" :key="day.day" :tab="day.day">
<a-row :gutter="16">
<a-col :span="8" v-for="meal in day.meals" :key="meal.meal">
<a-card
:title="meal.meal"
size="small"
:bordered="true"
:head-style="{ background: mealColor(meal.meal), color: '#fff', fontWeight: 600 }"
>
<div v-for="dish in meal.dishes" :key="dish.id" class="dish-item">
<div class="dish-header">
<span class="dish-name">{{ dish.name }}</span>
<span class="dish-cal">{{ dish.calories }}</span>
</div>
<div class="dish-tags">
<a-tag v-for="tag in dish.tags" :key="tag" :color="tagColor(tag)">{{ tag }}</a-tag>
</div>
</div>
<a-divider style="margin: 8px 0" />
<a-button type="link" size="small" @click="openEditModal(day.day, meal.meal)">编辑食谱</a-button>
</a-card>
</a-col>
</a-row>
</a-tab-pane>
</a-tabs>
</a-card>
<a-modal
v-model:open="editModalVisible"
:title="`编辑食谱 - ${editTarget.day} ${editTarget.meal}`"
@ok="editModalVisible = false"
width="600px"
>
<a-alert message="选择要添加到本餐的菜品" type="info" show-icon style="margin-bottom: 16px" />
<a-checkbox-group v-model:value="selectedDishes" style="width: 100%">
<a-row :gutter="[8, 8]">
<a-col :span="8" v-for="d in allDishes" :key="d">
<a-checkbox :value="d">{{ d }}</a-checkbox>
</a-col>
</a-row>
</a-checkbox-group>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { genRecipePlanData } from '@/mock/data'
const weekData = ref(genRecipePlanData())
const activeDay = ref('周一')
const editModalVisible = ref(false)
const editTarget = reactive({ day: '', meal: '' })
const selectedDishes = ref<string[]>([])
const allDishes = ['红烧肉', '清炒时蔬', '番茄鸡蛋', '糖醋里脊', '宫保鸡丁', '鱼香肉丝', '麻婆豆腐', '蒜蓉西兰花', '酸辣汤', '蛋炒饭', '紫菜蛋花汤', '水煮鱼', '回锅肉', '红烧茄子', '醋溜白菜', '干煸豆角', '凉拌黄瓜', '蒸鲈鱼', '炖牛腩', '小炒肉']
function mealColor(meal: string) {
if (meal === '早餐') return '#faad14'
if (meal === '午餐') return '#1890ff'
return '#722ed1'
}
function tagColor(tag: string) {
const map: Record<string, string> = { '高蛋白': 'red', '低脂': 'green', '高纤维': 'orange', '低糖': 'cyan', '补钙': 'blue', '富铁': 'purple' }
return map[tag] || 'default'
}
function openEditModal(day: string, meal: string) {
editTarget.day = day
editTarget.meal = meal
const dayData = weekData.value.find(d => d.day === day)
const mealData = dayData?.meals.find(m => m.meal === meal)
selectedDishes.value = mealData?.dishes.map(d => d.name) || []
editModalVisible.value = true
}
</script>
<style scoped lang="scss">
.dish-item {
padding: 8px 0;
border-bottom: 1px dashed #f0f0f0;
&:last-child { border-bottom: none; }
}
.dish-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.dish-name {
font-weight: 500;
font-size: 14px;
}
.dish-cal {
font-size: 12px;
color: #999;
}
.dish-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
</style>
@@ -0,0 +1,106 @@
<template>
<div class="page-container">
<a-page-header title="上餐监控" />
<RelatedModules :links="relatedLinks" />
<!-- 顶部统计 -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="8">
<StatCard title="总餐线" :value="data.length" color="#1890ff" />
</a-col>
<a-col :span="8">
<StatCard title="总出餐量" :value="totalServedAll" suffix="份" color="#52c41a" />
</a-col>
<a-col :span="8">
<StatCard title="平均温度" :value="avgTemperature" suffix="°C" color="#faad14" />
</a-col>
</a-row>
<!-- 筛选工具栏 -->
<a-card style="margin-bottom: 16px">
<a-row :gutter="16" align="middle">
<a-col :span="6">
<a-select v-model:value="filterCanteen" placeholder="所属食堂" allow-clear style="width: 100%">
<a-select-option v-for="c in canteenNames" :key="c" :value="c">{{ c }}</a-select-option>
</a-select>
</a-col>
</a-row>
</a-card>
<!-- 餐线卡片 -->
<a-row :gutter="[16, 16]">
<a-col :span="12" v-for="item in filteredData" :key="item.id">
<a-card>
<template #title>
<span>{{ item.mealLine }}</span>
<a-badge
:color="statusMap[item.status]?.color"
:text="statusMap[item.status]?.text"
style="margin-left: 12px"
/>
</template>
<a-descriptions :column="2" size="small">
<a-descriptions-item label="所属食堂">{{ item.canteen }}</a-descriptions-item>
<a-descriptions-item label="餐次">{{ item.mealType }}</a-descriptions-item>
<a-descriptions-item label="出餐时段">{{ item.servingStart.split(' ')[1] }} - {{ item.servingEnd.split(' ')[1] }}</a-descriptions-item>
<a-descriptions-item label="已出餐">{{ item.totalServed }}</a-descriptions-item>
<a-descriptions-item label="温度">{{ item.temperature }}</a-descriptions-item>
<a-descriptions-item label="值班员" :span="2">{{ item.monitor }}</a-descriptions-item>
</a-descriptions>
<div style="margin: 12px 0">
<span style="margin-right: 8px">菜品</span>
<a-tag v-for="dish in item.dishes" :key="dish" color="blue" style="margin-bottom: 4px">{{ dish }}</a-tag>
</div>
<a-progress
:percent="getServedPercent(item)"
:stroke-color="getServedPercent(item) > 80 ? '#52c41a' : '#1890ff'"
:format="() => item.totalServed + '份'"
/>
<a-alert
v-if="parseFloat(item.temperature) < 60"
type="warning"
message="温度偏低请注意保温"
show-icon
style="margin-top: 12px"
/>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { genServingMonitorData, statusMap, canteenNames } from '@/mock/data'
import StatCard from '@/components/common/StatCard.vue'
import RelatedModules from '@/components/common/RelatedModules.vue'
const relatedLinks = [
{ key: 'cook-by-order', label: '按单烹制', color: 'green', arrow: '←' },
{ key: 'surplus-disposal', label: '余量处置', color: 'blue', arrow: '→' },
{ key: 'consumption-mgmt', label: '消费管理', color: 'blue', arrow: '→' },
]
const data = ref(genServingMonitorData(8))
const filterCanteen = ref<string | undefined>(undefined)
const filteredData = computed(() =>
data.value.filter(item => !filterCanteen.value || item.canteen === filterCanteen.value)
)
const totalServedAll = computed(() => data.value.reduce((sum, item) => sum + item.totalServed, 0))
const avgTemperature = computed(() => {
if (data.value.length === 0) return 0
const sum = data.value.reduce((s, item) => s + parseFloat(item.temperature), 0)
return (sum / data.value.length).toFixed(1)
})
function getServedPercent(item: any): number {
const capacity = Math.max(item.totalServed, 500)
return Math.min(Math.round((item.totalServed / capacity) * 100), 100)
}
</script>
@@ -0,0 +1,146 @@
<template>
<div class="page-container">
<a-page-header title="余量处置" />
<!-- 图表与统计 -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="12">
<ChartPanel title="处置方式分布" type="pie" :data="disposalChartData" :height="240" />
</a-col>
<a-col :span="4">
<StatCard title="总余量" :value="totalSurplus" suffix="kg" color="#faad14" />
</a-col>
<a-col :span="4">
<StatCard title="处置批次" :value="data.length" color="#1890ff" />
</a-col>
<a-col :span="4">
<StatCard title="废弃率" :value="wasteRate" suffix="%" color="#f5222d" />
</a-col>
</a-row>
<!-- 工具栏 -->
<a-card style="margin-bottom: 16px">
<a-row :gutter="16" align="middle">
<a-col :span="6">
<a-range-picker v-model:value="dateRange" style="width: 100%" />
</a-col>
<a-col :span="4">
<a-select v-model:value="disposalFilter" placeholder="处置方式" allow-clear style="width: 100%">
<a-select-option value="员工餐">员工餐</a-select-option>
<a-select-option value="捐赠">捐赠</a-select-option>
<a-select-option value="堆肥">堆肥</a-select-option>
<a-select-option value="废弃">废弃</a-select-option>
</a-select>
</a-col>
</a-row>
</a-card>
<!-- 表格 -->
<a-card>
<a-table
:columns="columns"
:data-source="filteredData"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'disposal'">
<a-tag :color="disposalColorMap[record.disposal]">{{ record.disposal }}</a-tag>
</template>
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
</template>
</template>
</a-table>
</a-card>
<!-- 编辑弹窗 -->
<a-modal v-model:open="modalVisible" title="编辑处置方式" @ok="handleSubmit">
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="处置方式">
<a-select v-model:value="editDisposal" style="width: 100%">
<a-select-option value="员工餐">员工餐</a-select-option>
<a-select-option value="捐赠">捐赠</a-select-option>
<a-select-option value="堆肥">堆肥</a-select-option>
<a-select-option value="废弃">废弃</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import type { Dayjs } from 'dayjs'
import { genSurplusData, statusMap } from '@/mock/data'
import StatCard from '@/components/common/StatCard.vue'
import ChartPanel from '@/components/common/ChartPanel.vue'
const data = ref(genSurplusData(8))
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
const disposalFilter = ref<string | undefined>(undefined)
const modalVisible = ref(false)
const currentRecord = ref<any>(null)
const editDisposal = ref('')
const disposalColorMap: Record<string, string> = {
'员工餐': 'blue',
'捐赠': 'green',
'堆肥': 'orange',
'废弃': 'red',
}
const totalSurplus = computed(() => {
return data.value.reduce((sum, item) => sum + parseFloat(item.surplusQty), 0)
})
const wasteRate = computed(() => {
const wasteCount = data.value.filter(item => item.disposal === '废弃').length
return data.value.length > 0 ? ((wasteCount / data.value.length) * 100).toFixed(1) : '0'
})
const disposalChartData = computed(() => {
const countMap: Record<string, number> = {}
data.value.forEach(item => {
countMap[item.disposal] = (countMap[item.disposal] || 0) + 1
})
return Object.entries(countMap).map(([label, value]) => ({ label, value }))
})
const filteredData = computed(() => {
return data.value.filter(item => {
const matchDisposal = !disposalFilter.value || item.disposal === disposalFilter.value
const matchDate = !dateRange.value ||
(item.date >= dateRange.value[0].format('YYYY-MM-DD') &&
item.date <= dateRange.value[1].format('YYYY-MM-DD'))
return matchDisposal && matchDate
})
})
const columns = [
{ title: '日期', dataIndex: 'date', key: 'date', sorter: (a: any, b: any) => a.date.localeCompare(b.date) },
{ title: '餐次', dataIndex: 'mealType', key: 'mealType' },
{ title: '菜品', dataIndex: 'dish', key: 'dish' },
{ title: '余量', dataIndex: 'surplusQty', key: 'surplusQty' },
{ title: '处置方式', dataIndex: 'disposal', key: 'disposal' },
{ title: '处理人', dataIndex: 'handler', key: 'handler' },
{ title: '备注', dataIndex: 'remark', key: 'remark' },
{ title: '操作', dataIndex: 'action', key: 'action', width: 80 },
]
function handleEdit(record: any) {
currentRecord.value = record
editDisposal.value = record.disposal
modalVisible.value = true
}
function handleSubmit() {
if (currentRecord.value) {
currentRecord.value.disposal = editDisposal.value
message.success('修改成功')
}
modalVisible.value = false
}
</script>
@@ -0,0 +1,184 @@
<template>
<div class="page-container">
<a-page-header title="净菜外售" subtitle="净菜对外销售订单管理" />
<!-- 统计卡片 -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="8">
<a-card>
<a-statistic title="订单总数" :value="tableData.length" suffix="单" />
</a-card>
</a-col>
<a-col :span="8">
<a-card>
<a-statistic title="销售总额" :value="totalRevenue" prefix="¥" :value-style="{ color: '#52c41a' }" />
</a-card>
</a-col>
<a-col :span="8">
<a-card>
<a-statistic title="待配送" :value="pendingDelivery" :value-style="{ color: '#faad14' }" />
</a-card>
</a-col>
</a-row>
<a-card>
<!-- 工具栏 -->
<a-row :gutter="16" style="margin-bottom: 16px" align="middle">
<a-col :span="4">
<a-select v-model:value="statusFilter" style="width: 100%" placeholder="状态筛选">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending">待处理</a-select-option>
<a-select-option value="processing">进行中</a-select-option>
<a-select-option value="delivered">已配送</a-select-option>
<a-select-option value="done">已完成</a-select-option>
</a-select>
</a-col>
<a-col :span="5">
<a-input-search v-model:value="searchText" placeholder="搜索客户/订单号" allow-clear />
</a-col>
<a-col :span="15" style="text-align: right">
<a-button type="primary" @click="modalVisible = true">
<template #icon><PlusOutlined /></template>
新建订单
</a-button>
</a-col>
</a-row>
<a-table
:columns="columns"
:data-source="filteredData"
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` }"
row-key="id"
size="middle"
:scroll="{ x: 1200 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'status'">
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
</template>
<template v-if="column.dataIndex === 'address'">
<a-tooltip :title="record.address">
<span class="ellipsis-text">{{ record.address }}</span>
</a-tooltip>
</template>
</template>
</a-table>
</a-card>
<!-- 新建订单 Modal -->
<a-modal
v-model:open="modalVisible"
title="新建外售订单"
@ok="handleSubmit"
:width="560"
>
<a-form :model="formState" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="客户名称" required>
<a-select v-model:value="formState.customer" placeholder="请选择客户">
<a-select-option v-for="c in customerOptions" :key="c" :value="c">{{ c }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="品类明细" required>
<a-select v-model:value="formState.items" mode="multiple" placeholder="请选择品类">
<a-select-option v-for="m in materialOptions" :key="m" :value="m">{{ m }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="总重量(kg)" required>
<a-input-number v-model:value="formState.totalWeight" :min="1" style="width: 100%" />
</a-form-item>
<a-form-item label="总价(元)" required>
<a-input-number v-model:value="formState.totalPrice" :min="0" :precision="0" style="width: 100%" />
</a-form-item>
<a-form-item label="配送地址" required>
<a-input v-model:value="formState.address" placeholder="请输入配送地址" />
</a-form-item>
<a-form-item label="配送日期">
<a-date-picker v-model:value="formState.deliveryDate" style="width: 100%" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import { genCleanExternalData, statusMap } from '@/mock/data'
const tableData = ref(genCleanExternalData(20))
const statusFilter = ref('')
const searchText = ref('')
const modalVisible = ref(false)
const customerOptions = ['阳光餐厅', '幸福食堂', '社区超市', '学校食堂', '企业餐厅']
const materialOptions = ['白菜', '萝卜', '番茄', '黄瓜', '茄子', '青椒', '芹菜', '菠菜', '土豆', '西兰花']
const formState = reactive({
customer: undefined as string | undefined,
items: [] as string[],
totalWeight: 50,
totalPrice: 500,
address: '',
deliveryDate: null as any,
})
const totalRevenue = computed(() => {
return tableData.value.reduce((sum, r) => {
return sum + parseFloat(r.totalPrice.replace('¥', ''))
}, 0)
})
const pendingDelivery = computed(() => {
return tableData.value.filter(r => r.status === 'pending' || r.status === 'processing').length
})
const filteredData = computed(() => {
return tableData.value.filter(item => {
if (searchText.value) {
const kw = searchText.value.toLowerCase()
if (!item.customer.toLowerCase().includes(kw) && !item.orderNo.toLowerCase().includes(kw)) return false
}
if (statusFilter.value && item.status !== statusFilter.value) return false
return true
})
})
const columns = [
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
{ title: '客户', dataIndex: 'customer', width: 110 },
{ title: '品类明细', dataIndex: 'items', width: 180, ellipsis: true },
{ title: '总重量', dataIndex: 'totalWeight', width: 90 },
{ title: '总价', dataIndex: 'totalPrice', width: 100 },
{ title: '下单日期', dataIndex: 'orderDate', width: 110 },
{ title: '配送日期', dataIndex: 'deliveryDate', width: 110 },
{ title: '状态', dataIndex: 'status', width: 90 },
{ title: '配送地址', dataIndex: 'address', width: 160 },
]
function handleSubmit() {
if (!formState.customer || !formState.items.length || !formState.address) {
message.warning('请填写完整信息')
return
}
message.success('订单创建成功')
modalVisible.value = false
formState.customer = undefined
formState.items = []
formState.totalWeight = 50
formState.totalPrice = 500
formState.address = ''
formState.deliveryDate = null
}
</script>
<style scoped>
.ellipsis-text {
display: inline-block;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
</style>
@@ -0,0 +1,152 @@
<template>
<div class="page-container">
<a-page-header title="净菜内供" subtitle="内部部门净菜申领与配送管理" />
<RelatedModules :links="relatedLinks" />
<a-card>
<!-- 工具栏 -->
<a-row :gutter="16" style="margin-bottom: 16px" align="middle">
<a-col :span="4">
<a-select v-model:value="deptFilter" style="width: 100%" placeholder="需求部门">
<a-select-option value="">全部部门</a-select-option>
<a-select-option value="中餐厨房">中餐厨房</a-select-option>
<a-select-option value="西餐厨房">西餐厨房</a-select-option>
<a-select-option value="面点间">面点间</a-select-option>
<a-select-option value="凉菜间">凉菜间</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-select v-model:value="statusFilter" style="width: 100%" placeholder="状态筛选">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending">待处理</a-select-option>
<a-select-option value="processing">进行中</a-select-option>
<a-select-option value="delivered">已配送</a-select-option>
<a-select-option value="done">已完成</a-select-option>
</a-select>
</a-col>
<a-col :span="5">
<a-input-search v-model:value="searchText" placeholder="搜索食材/订单号" allow-clear />
</a-col>
<a-col :span="11" style="text-align: right">
<a-button type="primary" @click="modalVisible = true">
<template #icon><PlusOutlined /></template>
新增申请
</a-button>
</a-col>
</a-row>
<a-table
:columns="columns"
:data-source="filteredData"
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` }"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'status'">
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
</template>
</template>
</a-table>
</a-card>
<!-- 新增申请 Modal -->
<a-modal
v-model:open="modalVisible"
title="新增内供申请"
@ok="handleSubmit"
:width="520"
>
<a-form :model="formState" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="食材名称" required>
<a-select v-model:value="formState.material" placeholder="请选择食材">
<a-select-option v-for="m in materialOptions" :key="m" :value="m">{{ m }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="数量(kg)" required>
<a-input-number v-model:value="formState.quantity" :min="1" style="width: 100%" />
</a-form-item>
<a-form-item label="需求部门" required>
<a-select v-model:value="formState.dept" placeholder="请选择部门">
<a-select-option value="中餐厨房">中餐厨房</a-select-option>
<a-select-option value="西餐厨房">西餐厨房</a-select-option>
<a-select-option value="面点间">面点间</a-select-option>
<a-select-option value="凉菜间">凉菜间</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="期望配送日期">
<a-date-picker v-model:value="formState.deliveryDate" style="width: 100%" />
</a-form-item>
<a-form-item label="备注">
<a-textarea v-model:value="formState.remark" :rows="2" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import { genCleanInternalData, statusMap } from '@/mock/data'
import RelatedModules from '@/components/common/RelatedModules.vue'
const relatedLinks = [
{ key: 'clean-storage', label: '净菜入库', color: 'green', arrow: '←' },
{ key: 'clean-order', label: '净菜订单', color: 'blue', arrow: '→' },
]
const tableData = ref(genCleanInternalData(20))
const deptFilter = ref('')
const statusFilter = ref('')
const searchText = ref('')
const modalVisible = ref(false)
const materialOptions = ['白菜', '萝卜', '番茄', '黄瓜', '茄子', '青椒', '芹菜', '菠菜', '土豆', '西兰花']
const formState = reactive({
material: undefined as string | undefined,
quantity: 20,
dept: undefined as string | undefined,
deliveryDate: null as any,
remark: '',
})
const filteredData = computed(() => {
return tableData.value.filter(item => {
if (searchText.value) {
const kw = searchText.value.toLowerCase()
if (!item.material.toLowerCase().includes(kw) && !item.orderNo.toLowerCase().includes(kw)) return false
}
if (deptFilter.value && item.requestDept !== deptFilter.value) return false
if (statusFilter.value && item.status !== statusFilter.value) return false
return true
})
})
const columns = [
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
{ title: '食材', dataIndex: 'material', width: 90 },
{ title: '数量', dataIndex: 'quantity', width: 90 },
{ title: '需求部门', dataIndex: 'requestDept', width: 100 },
{ title: '申请日期', dataIndex: 'requestDate', width: 110 },
{ title: '配送日期', dataIndex: 'deliveryDate', width: 110 },
{ title: '状态', dataIndex: 'status', width: 90 },
{ title: '处理人', dataIndex: 'handler', width: 100 },
]
function handleSubmit() {
if (!formState.material || !formState.dept) {
message.warning('请填写完整信息')
return
}
message.success('内供申请已提交')
modalVisible.value = false
formState.material = undefined
formState.dept = undefined
formState.quantity = 20
formState.deliveryDate = null
formState.remark = ''
}
</script>
@@ -0,0 +1,130 @@
<template>
<div class="page-container">
<a-page-header title="净菜加工" subtitle="净菜加工流水线监控" />
<!-- 统计卡片 -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="6">
<a-card>
<a-statistic title="今日加工" :value="todayProcessed" suffix="批次" />
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic title="平均损耗率" :value="avgLossRate" suffix="%" :value-style="{ color: avgLossRate > 30 ? '#ff4d4f' : '#52c41a' }" />
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic title="加工中" :value="processingCount" :value-style="{ color: '#1890ff' }" />
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic title="已完成" :value="completedCount" :value-style="{ color: '#52c41a' }" />
</a-card>
</a-col>
</a-row>
<a-card>
<!-- 工具栏 -->
<a-row :gutter="16" style="margin-bottom: 16px" align="middle">
<a-col :span="4">
<a-select v-model:value="lineFilter" style="width: 100%" placeholder="加工线">
<a-select-option value="">全部加工线</a-select-option>
<a-select-option value="加工线1">加工线1</a-select-option>
<a-select-option value="加工线2">加工线2</a-select-option>
<a-select-option value="加工线3">加工线3</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-select v-model:value="statusFilter" style="width: 100%" placeholder="状态">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="processing">进行中</a-select-option>
<a-select-option value="done">已完成</a-select-option>
</a-select>
</a-col>
<a-col :span="5">
<a-input-search v-model:value="searchText" placeholder="搜索食材/批次号" allow-clear />
</a-col>
</a-row>
<a-table
:columns="columns"
:data-source="filteredData"
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (t: number) => `${t}` }"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'lossRate'">
<span :style="{ color: parseFloat(record.lossRate) > 30 ? '#ff4d4f' : '#52c41a', fontWeight: 600 }">
{{ record.lossRate }}
</span>
</template>
<template v-if="column.dataIndex === 'status'">
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { genCleanProcessData, statusMap } from '@/mock/data'
const tableData = ref(genCleanProcessData(25))
const lineFilter = ref('')
const statusFilter = ref('')
const searchText = ref('')
const todayProcessed = computed(() => {
const today = new Date().toISOString().slice(0, 10)
return tableData.value.filter(r => r.startTime.startsWith(today)).length
})
const avgLossRate = computed(() => {
if (!tableData.value.length) return 0
const sum = tableData.value.reduce((acc, r) => acc + parseFloat(r.lossRate), 0)
return +(sum / tableData.value.length).toFixed(1)
})
const processingCount = computed(() => tableData.value.filter(r => r.status === 'processing').length)
const completedCount = computed(() => tableData.value.filter(r => r.status === 'done').length)
const filteredData = computed(() => {
return tableData.value.filter(item => {
if (searchText.value) {
const kw = searchText.value.toLowerCase()
if (!item.material.toLowerCase().includes(kw) && !item.batchNo.toLowerCase().includes(kw)) return false
}
if (lineFilter.value && item.processLine !== lineFilter.value) return false
if (statusFilter.value && item.status !== statusFilter.value) return false
return true
})
})
const columns = [
{ title: '批次号', dataIndex: 'batchNo', width: 160 },
{ title: '食材', dataIndex: 'material', width: 90 },
{ title: '毛菜重量', dataIndex: 'rawWeight', width: 100 },
{ title: '净菜重量', dataIndex: 'netWeight', width: 100 },
{ title: '损耗率', dataIndex: 'lossRate', width: 90 },
{ title: '加工线', dataIndex: 'processLine', width: 90 },
{ title: '开始时间', dataIndex: 'startTime', width: 150 },
{ title: '结束时间', dataIndex: 'endTime', width: 150 },
{ title: '操作员', dataIndex: 'operator', width: 90 },
{ title: '状态', dataIndex: 'status', width: 90 },
]
</script>
<style scoped lang="scss">
.page-container {
:deep(.ant-statistic-title) {
font-size: 13px;
color: #666;
}
}
</style>
@@ -0,0 +1,94 @@
<template>
<div class="page-container">
<a-page-header title="净菜入库" subtitle="净菜成品入库与出库管理" />
<a-card>
<!-- 工具栏 -->
<a-row :gutter="16" style="margin-bottom: 16px" align="middle">
<a-col :span="6">
<a-input-search v-model:value="searchText" placeholder="搜索食材/批次号" allow-clear />
</a-col>
<a-col :span="4">
<a-select v-model:value="warehouseFilter" style="width: 100%" placeholder="仓位筛选">
<a-select-option value="">全部仓位</a-select-option>
<a-select-option value="净菜仓A">净菜仓A</a-select-option>
<a-select-option value="净菜仓B">净菜仓B</a-select-option>
<a-select-option value="净菜冷库">净菜冷库</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-select v-model:value="statusFilter" style="width: 100%" placeholder="状态筛选">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="stored">已入库</a-select-option>
<a-select-option value="delivered">已配送</a-select-option>
<a-select-option value="pending">待处理</a-select-option>
</a-select>
</a-col>
</a-row>
<a-table
:columns="columns"
:data-source="filteredData"
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` }"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'temperature'">
<span :style="{ color: parseFloat(record.temperature) > 5 ? '#ff4d4f' : '#52c41a', fontWeight: 600 }">
{{ record.temperature }}
</span>
</template>
<template v-if="column.dataIndex === 'status'">
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-popconfirm title="确认出库该批次?" @confirm="handleDeliver(record)">
<a-button type="link" size="small" :disabled="record.status === 'delivered'">出库</a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { genCleanStorageData, statusMap } from '@/mock/data'
const tableData = ref(genCleanStorageData(28))
const searchText = ref('')
const warehouseFilter = ref('')
const statusFilter = ref('')
const filteredData = computed(() => {
return tableData.value.filter(item => {
if (searchText.value) {
const kw = searchText.value.toLowerCase()
if (!item.material.toLowerCase().includes(kw) && !item.batchNo.toLowerCase().includes(kw)) return false
}
if (warehouseFilter.value && item.warehouse !== warehouseFilter.value) return false
if (statusFilter.value && item.status !== statusFilter.value) return false
return true
})
})
const columns = [
{ title: '批次号', dataIndex: 'batchNo', width: 160 },
{ title: '食材', dataIndex: 'material', width: 90 },
{ title: '数量', dataIndex: 'quantity', width: 90 },
{ title: '入库日期', dataIndex: 'storeDate', width: 110 },
{ title: '仓位', dataIndex: 'warehouse', width: 100 },
{ title: '温度', dataIndex: 'temperature', width: 80 },
{ title: '保质期', dataIndex: 'shelfLife', width: 80 },
{ title: '状态', dataIndex: 'status', width: 90 },
{ title: '操作', key: 'action', width: 90, fixed: 'right' as const },
]
function handleDeliver(record: any) {
record.status = 'delivered'
message.success(`批次 ${record.batchNo} 已出库`)
}
</script>
@@ -0,0 +1,155 @@
<template>
<div class="page-container">
<a-page-header title="卫生检测" subtitle="食材卫生检测记录与报告管理" />
<!-- 统计卡片 -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="6">
<a-card>
<a-statistic title="检测总数" :value="tableData.length" suffix="批次" />
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic title="合格率" :value="passRate" suffix="%" :value-style="{ color: '#52c41a' }" />
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic title="不合格数" :value="failedCount" :value-style="{ color: '#ff4d4f' }" />
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic title="今日检测" :value="todayCount" suffix="批次" />
</a-card>
</a-col>
</a-row>
<a-card>
<!-- 工具栏 -->
<a-row :gutter="16" style="margin-bottom: 16px" align="middle">
<a-col :span="6">
<a-range-picker v-model:value="dateRange" style="width: 100%" />
</a-col>
<a-col :span="4">
<a-select v-model:value="resultFilter" style="width: 100%" placeholder="检测结果">
<a-select-option value="">全部</a-select-option>
<a-select-option value="passed">合格</a-select-option>
<a-select-option value="failed">不合格</a-select-option>
</a-select>
</a-col>
<a-col :span="5">
<a-input-search v-model:value="searchText" placeholder="搜索食材/批次号" allow-clear />
</a-col>
</a-row>
<a-table
:columns="columns"
:data-source="filteredData"
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` }"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'result'">
<a-tag :color="statusMap[record.result]?.color">{{ statusMap[record.result]?.text }}</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-button type="link" size="small" @click="showReport(record)">查看报告</a-button>
</template>
</template>
</a-table>
</a-card>
<!-- 报告详情抽屉 -->
<a-drawer
v-model:open="drawerVisible"
title="检测报告详情"
:width="520"
>
<a-descriptions v-if="currentRecord" :column="1" bordered size="small">
<a-descriptions-item label="批次号">{{ currentRecord.batchNo }}</a-descriptions-item>
<a-descriptions-item label="食材名称">{{ currentRecord.material }}</a-descriptions-item>
<a-descriptions-item label="检测项目">{{ currentRecord.testItem }}</a-descriptions-item>
<a-descriptions-item label="检测日期">{{ currentRecord.testDate }}</a-descriptions-item>
<a-descriptions-item label="检测结果">
<a-tag :color="statusMap[currentRecord.result]?.color">{{ statusMap[currentRecord.result]?.text }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="检测值">{{ currentRecord.value }}</a-descriptions-item>
<a-descriptions-item label="标准值">{{ currentRecord.standard }}</a-descriptions-item>
<a-descriptions-item label="检测员">{{ currentRecord.inspector }}</a-descriptions-item>
<a-descriptions-item label="报告编号">{{ currentRecord.report }}</a-descriptions-item>
</a-descriptions>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Dayjs } from 'dayjs'
import { genHygieneTestData, statusMap } from '@/mock/data'
const tableData = ref(genHygieneTestData(30))
const searchText = ref('')
const resultFilter = ref('')
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
const drawerVisible = ref(false)
const currentRecord = ref<any>(null)
const passRate = computed(() => {
if (!tableData.value.length) return 0
const passed = tableData.value.filter(r => r.result === 'passed').length
return +((passed / tableData.value.length) * 100).toFixed(1)
})
const failedCount = computed(() => tableData.value.filter(r => r.result === 'failed').length)
const todayCount = computed(() => {
const today = new Date().toISOString().slice(0, 10)
return tableData.value.filter(r => r.testDate === today).length
})
const filteredData = computed(() => {
return tableData.value.filter(item => {
if (searchText.value) {
const kw = searchText.value.toLowerCase()
if (!item.material.toLowerCase().includes(kw) && !item.batchNo.toLowerCase().includes(kw)) return false
}
if (resultFilter.value && item.result !== resultFilter.value) return false
if (dateRange.value) {
const [start, end] = dateRange.value
const d = item.testDate
if (d < start.format('YYYY-MM-DD') || d > end.format('YYYY-MM-DD')) return false
}
return true
})
})
const columns = [
{ title: '批次号', dataIndex: 'batchNo', width: 160 },
{ title: '食材名', dataIndex: 'material', width: 100 },
{ title: '检测项目', dataIndex: 'testItem', width: 100 },
{ title: '检测日期', dataIndex: 'testDate', width: 110 },
{ title: '结果', dataIndex: 'result', width: 80 },
{ title: '检测值', dataIndex: 'value', width: 100 },
{ title: '标准值', dataIndex: 'standard', width: 100 },
{ title: '检测员', dataIndex: 'inspector', width: 90 },
{ title: '报告编号', dataIndex: 'report', width: 120 },
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
]
function showReport(record: any) {
currentRecord.value = record
drawerVisible.value = true
}
</script>
<style scoped lang="scss">
.page-container {
:deep(.ant-statistic-title) {
font-size: 13px;
color: #666;
}
}
</style>
@@ -0,0 +1,150 @@
<template>
<div class="page-container">
<a-page-header title="毛菜入库" subtitle="原始食材入库管理与登记" />
<RelatedModules :links="relatedLinks" />
<a-card>
<!-- 工具栏 -->
<a-row :gutter="16" style="margin-bottom: 16px" align="middle">
<a-col :span="6">
<a-input-search v-model:value="searchText" placeholder="搜索食材/批次号/供应商" allow-clear />
</a-col>
<a-col :span="4">
<a-select v-model:value="statusFilter" style="width: 100%" placeholder="状态筛选">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="stored">已入库</a-select-option>
<a-select-option value="processing">进行中</a-select-option>
<a-select-option value="pending">待处理</a-select-option>
</a-select>
</a-col>
<a-col :span="14" style="text-align: right">
<a-button type="primary" @click="modalVisible = true">
<template #icon><PlusOutlined /></template>
新增入库
</a-button>
</a-col>
</a-row>
<a-table
:columns="columns"
:data-source="filteredData"
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` }"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'status'">
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
</template>
</template>
</a-table>
</a-card>
<!-- 新增入库 Modal -->
<a-modal
v-model:open="modalVisible"
title="新增入库登记"
@ok="handleSubmit"
:width="560"
>
<a-form :model="formState" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="食材名称" required>
<a-select v-model:value="formState.material" placeholder="请选择食材">
<a-select-option v-for="m in materialOptions" :key="m" :value="m">{{ m }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="供应商" required>
<a-select v-model:value="formState.supplier" placeholder="请选择供应商">
<a-select-option v-for="s in supplierOptions" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="数量(kg)" required>
<a-input-number v-model:value="formState.quantity" :min="1" style="width: 100%" />
</a-form-item>
<a-form-item label="入库仓位" required>
<a-select v-model:value="formState.warehouse" placeholder="请选择仓位">
<a-select-option value="毛菜仓A">毛菜仓A</a-select-option>
<a-select-option value="毛菜仓B">毛菜仓B</a-select-option>
<a-select-option value="冷库C">冷库C</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="备注">
<a-textarea v-model:value="formState.remark" :rows="2" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import { genRawStorageData, statusMap } from '@/mock/data'
import RelatedModules from '@/components/common/RelatedModules.vue'
const relatedLinks = [
{ key: 'harvest-storage', label: '收成入库', color: 'green', arrow: '←' },
{ key: 'procurement-mgmt', label: '采供管理', color: 'green', arrow: '←' },
{ key: 'hygiene-test', label: '卫生检测', color: 'orange', arrow: '↔' },
{ key: 'clean-process', label: '净菜加工', color: 'blue', arrow: '→' },
]
const tableData = ref(genRawStorageData(28))
const searchText = ref('')
const statusFilter = ref('')
const modalVisible = ref(false)
const materialOptions = ['白菜', '萝卜', '番茄', '黄瓜', '茄子', '青椒', '猪里脊', '鸡胸肉']
const supplierOptions = ['绿源农业', '丰禾食品', '天然牧场', '鲜美水产', '有机之家']
const formState = reactive({
material: undefined as string | undefined,
supplier: undefined as string | undefined,
quantity: 100,
warehouse: undefined as string | undefined,
remark: '',
})
const filteredData = computed(() => {
return tableData.value.filter(item => {
if (searchText.value) {
const kw = searchText.value.toLowerCase()
if (
!item.material.toLowerCase().includes(kw) &&
!item.batchNo.toLowerCase().includes(kw) &&
!item.supplier.toLowerCase().includes(kw)
) return false
}
if (statusFilter.value && item.status !== statusFilter.value) return false
return true
})
})
const columns = [
{ title: '批次号', dataIndex: 'batchNo', width: 160 },
{ title: '食材名', dataIndex: 'material', width: 100 },
{ title: '供应商', dataIndex: 'supplier', width: 110 },
{ title: '数量', dataIndex: 'quantity', width: 90 },
{ title: '接收日期', dataIndex: 'receiveDate', width: 110 },
{ title: '仓位', dataIndex: 'warehouse', width: 100 },
{ title: '温度', dataIndex: 'temperature', width: 80 },
{ title: '保质期', dataIndex: 'shelfLife', width: 80 },
{ title: '状态', dataIndex: 'status', width: 90 },
{ title: '接收人', dataIndex: 'receiver', width: 100 },
]
function handleSubmit() {
if (!formState.material || !formState.supplier || !formState.warehouse) {
message.warning('请填写完整信息')
return
}
message.success('入库登记成功')
modalVisible.value = false
formState.material = undefined
formState.supplier = undefined
formState.warehouse = undefined
formState.quantity = 100
formState.remark = ''
}
</script>
+73
View File
@@ -0,0 +1,73 @@
<template>
<div class="login-page">
<div class="login-card">
<h1>智能餐养管理系统</h1>
<a-form :model="form" @finish="handleLogin">
<a-form-item name="username" :rules="[{ required: true, message: '请输入用户名' }]">
<a-input v-model:value="form.username" placeholder="用户名" size="large" />
</a-form-item>
<a-form-item name="password" :rules="[{ required: true, message: '请输入密码' }]">
<a-input-password v-model:value="form.password" placeholder="密码" size="large" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit" size="large" block :loading="loading">
登录
</a-button>
</a-form-item>
</a-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const form = reactive({
username: '',
password: '',
})
async function handleLogin() {
loading.value = true
try {
// TODO: 对接真实登录API
userStore.setToken('mock-token')
userStore.setUserInfo({ id: '1', name: '管理员', role: 'admin' })
message.success('登录成功')
router.push('/')
} finally {
loading.value = false
}
}
</script>
<style scoped lang="scss">
.login-page {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
padding: 40px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
h1 {
text-align: center;
margin-bottom: 32px;
color: #333;
}
}
</style>
@@ -0,0 +1,107 @@
<template>
<div class="page-container">
<a-page-header title="算法标准" sub-title="营养标准参数配置" />
<a-card>
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane v-for="g in groups" :key="g.key" :tab="g.label">
<a-form layout="vertical" :model="config[g.key]">
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="每日热量目标 (kcal)">
<a-slider v-model:value="config[g.key].calories" range :min="800" :max="3500" />
<div class="hint">{{ config[g.key].calories[0] }} - {{ config[g.key].calories[1] }} kcal</div>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="每日饮水量 (ml)">
<a-input-number v-model:value="config[g.key].water" :min="800" :max="3000" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left">三大营养素比例 (%)</a-divider>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="蛋白质">
<a-input-number v-model:value="config[g.key].protein" :min="5" :max="50" style="width: 100%" />
<a-slider v-model:value="config[g.key].protein" :min="5" :max="50" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="脂肪">
<a-input-number v-model:value="config[g.key].fat" :min="10" :max="50" style="width: 100%" />
<a-slider v-model:value="config[g.key].fat" :min="10" :max="50" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="碳水">
<a-input-number v-model:value="config[g.key].carbs" :min="30" :max="70" style="width: 100%" />
<a-slider v-model:value="config[g.key].carbs" :min="30" :max="70" />
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left">微量元素最低值</a-divider>
<a-row :gutter="24">
<a-col :span="6">
<a-form-item label="钙 (mg)">
<a-input-number v-model:value="config[g.key].calcium" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="铁 (mg)">
<a-input-number v-model:value="config[g.key].iron" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="维生素C (mg)">
<a-input-number v-model:value="config[g.key].vitC" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="膳食纤维 (g)">
<a-input-number v-model:value="config[g.key].fiber" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-tab-pane>
</a-tabs>
<div class="footer">
<a-button type="primary" @click="save">保存配置</a-button>
<a-button @click="reset">重置</a-button>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
const activeTab = ref('male')
const groups = [
{ key: 'male', label: '成人男性' },
{ key: 'female', label: '成人女性' },
{ key: 'elder', label: '老年人' },
{ key: 'child', label: '儿童' },
]
const make = (cal: [number, number], p: number, f: number, c: number) => ({
calories: cal, water: 1500, protein: p, fat: f, carbs: c,
calcium: 800, iron: 12, vitC: 100, fiber: 25,
})
const config = reactive<Record<string, any>>({
male: make([2200, 2600], 15, 30, 55),
female: make([1800, 2200], 15, 30, 55),
elder: make([1600, 2000], 18, 25, 57),
child: make([1400, 1800], 14, 32, 54),
})
const save = () => message.success('配置已保存')
const reset = () => message.info('已重置为默认值')
</script>
<style scoped lang="scss">
.hint { color: #999; font-size: 12px; }
.footer { margin-top: 16px; display: flex; gap: 12px; justify-content: flex-end; }
</style>
@@ -0,0 +1,75 @@
<template>
<div class="page-container">
<a-page-header title="消费管理" sub-title="餐厅消费明细" />
<RelatedModules :links="relatedLinks" />
<a-row :gutter="16" class="stats">
<a-col :span="6">
<a-card><a-statistic title="今日营收" :value="2386" prefix="¥" :precision="2" /></a-card>
</a-col>
<a-col :span="6">
<a-card><a-statistic title="今日订单" :value="156" /></a-card>
</a-col>
<a-col :span="6">
<a-card><a-statistic title="客单价" :value="15.3" prefix="¥" :precision="2" /></a-card>
</a-col>
<a-col :span="6">
<a-card><a-statistic title="主流支付" value="餐卡" /></a-card>
</a-col>
</a-row>
<a-card>
<div class="toolbar">
<a-range-picker v-model:value="dateRange" />
<a-select v-model:value="payFilter" style="width: 140px" placeholder="支付方式" 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>
</div>
<a-table :columns="columns" :data-source="filtered" :pagination="{ pageSize: 10 }" row-key="id">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'payMethod'">
<a-tag :color="payColor[record.payMethod]">{{ record.payMethod }}</a-tag>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { genConsumptionData } from '@/mock/data'
import RelatedModules from '@/components/common/RelatedModules.vue'
const relatedLinks = [
{ key: 'nutrition-order', label: '营养点餐', color: 'green', arrow: '←' },
{ key: 'nutrition-mgmt', label: '营养管理', color: 'blue', arrow: '→' },
]
const list = ref(genConsumptionData(30))
const dateRange = ref()
const payFilter = ref<string>()
const payColor: Record<string, string> = { '餐卡': 'blue', '微信': 'green', '支付宝': 'cyan' }
const columns = [
{ title: '订单号', dataIndex: 'orderNo' },
{ title: '用户', dataIndex: 'user' },
{ title: '餐次', dataIndex: 'mealType' },
{ title: '菜品', dataIndex: 'dishes', ellipsis: true },
{ title: '金额', dataIndex: 'totalPrice' },
{ title: '支付方式', key: 'payMethod' },
{ title: '日期', dataIndex: 'date' },
{ title: '时间', dataIndex: 'time' },
{ title: '餐线', dataIndex: 'mealLine' },
]
const filtered = computed(() =>
list.value.filter(r => !payFilter.value || r.payMethod === payFilter.value)
)
</script>
<style scoped lang="scss">
.stats { margin-bottom: 16px; }
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
</style>
@@ -0,0 +1,81 @@
<template>
<div class="page-container">
<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="categoryFilter" style="width: 140px" placeholder="分类" allow-clear>
<a-select-option v-for="c in categories" :key="c" :value="c">{{ c }}</a-select-option>
</a-select>
<a-button type="primary" @click="message.success('打开新增表单')">+ 新增餐品</a-button>
</div>
<div class="dish-grid">
<a-card v-for="d in filtered" :key="d.id" hoverable class="dish-card">
<template #cover>
<div class="dish-image" :style="{ background: colorOf(d.name) }">{{ d.name.slice(0, 1) }}</div>
</template>
<div class="dish-name">{{ d.name }}</div>
<div class="dish-meta">
<a-tag color="blue">{{ d.category }}</a-tag>
<span class="cal">{{ d.calories }}</span>
</div>
<div class="dish-row">
<span class="price">{{ d.price }}</span>
<a-rate :value="d.rating" disabled allow-half style="font-size: 12px" />
</div>
<div class="dish-row">
<span class="orders">销量: {{ d.orderCount }}</span>
<a-switch v-model:checked="d.available" size="small" />
</div>
</a-card>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { genDishInventoryData } from '@/mock/data'
const list = ref(genDishInventoryData(20))
const search = ref('')
const categoryFilter = ref<string>()
const categories = ['热菜', '凉菜', '汤品', '主食', '面点']
const colors = ['#fa8c16', '#52c41a', '#1890ff', '#eb2f96', '#722ed1', '#13c2c2']
const colorOf = (n: string) => colors[n.charCodeAt(0) % colors.length]
const filtered = computed(() =>
list.value.filter(d => {
const m = !search.value || d.name.includes(search.value)
const c = !categoryFilter.value || d.category === categoryFilter.value
return m && c
})
)
</script>
<style scoped lang="scss">
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
.dish-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.dish-card { :deep(.ant-card-body) { padding: 12px; } }
.dish-image {
height: 120px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 48px;
font-weight: bold;
}
.dish-name { font-size: 15px; font-weight: 600; margin-bottom: 8px; }
.dish-meta { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.dish-row { display: flex; justify-content: space-between; align-items: center; margin-top: 4px; }
.price { color: #f5222d; font-weight: 600; }
.orders { font-size: 12px; color: #999; }
.cal { font-size: 12px; color: #666; }
</style>
@@ -0,0 +1,62 @@
<template>
<div class="page-container">
<a-page-header title="味养创新" sub-title="风味与营养融合实验">
<template #extra>
<a-button type="primary" @click="message.success('打开新建实验表单')">+ 新建实验</a-button>
</template>
</a-page-header>
<div class="grid">
<a-card v-for="e in experiments" :key="e.id" hoverable class="exp-card">
<div class="exp-header">
<div class="exp-name">{{ e.name }}</div>
<a-tag :color="statusColor[e.status]">{{ e.status }}</a-tag>
</div>
<div class="desc">{{ e.description }}</div>
<div class="targets">
<span class="label">目标营养</span>
<a-tag v-for="t in e.targets" :key="t" color="cyan">{{ t }}</a-tag>
</div>
<a-divider style="margin: 12px 0" />
<div class="score-row">
<div>
<div class="score-label">口味评分</div>
<a-rate :value="e.tasteScore" disabled allow-half style="font-size: 14px" />
</div>
<div>
<div class="score-label">营养评分</div>
<a-rate :value="e.nutritionScore" disabled allow-half style="font-size: 14px" />
</div>
</div>
<div class="creator">创建人{{ e.creator }}</div>
</a-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { message } from 'ant-design-vue'
const statusColor: Record<string, string> = { '实验中': 'blue', '已验证': 'green', '待推广': 'orange' }
const experiments = ref([
{ id: 1, name: '低钠麻婆豆腐', description: '使用天然鲜味食材替代部分盐分', targets: ['低钠', '高钙'], tasteScore: 4.5, nutritionScore: 4.8, status: '已验证', creator: '营养师A' },
{ id: 2, name: '高纤维主食包', description: '加入燕麦麸皮提升膳食纤维含量', targets: ['膳食纤维', '复合碳水'], tasteScore: 4.0, nutritionScore: 4.5, status: '实验中', creator: '研发组' },
{ id: 3, name: '植物基红烧肉', description: '以豌豆蛋白模拟肉类风味与口感', targets: ['植物蛋白', '低脂'], tasteScore: 3.5, nutritionScore: 4.2, status: '实验中', creator: '研发组' },
{ id: 4, name: '富铁青菜汤', description: '搭配富铁食材提升铁吸收率', targets: ['铁', '维生素C'], tasteScore: 4.2, nutritionScore: 4.6, status: '待推广', creator: '营养师B' },
{ id: 5, name: '低GI米饭', description: '冷却后再加热降低升糖指数', targets: ['低GI', '抗性淀粉'], tasteScore: 4.0, nutritionScore: 4.4, status: '已验证', creator: '营养师A' },
{ id: 6, name: '功能性早餐粥', description: '添加益生菌与坚果碎', targets: ['益生菌', 'Omega-3'], tasteScore: 4.6, nutritionScore: 4.7, status: '待推广', creator: '研发组' },
])
</script>
<style scoped lang="scss">
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.exp-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.exp-name { font-size: 16px; font-weight: 600; }
.desc { color: #666; font-size: 13px; min-height: 36px; margin-bottom: 8px; }
.targets { margin-bottom: 8px; }
.label { font-size: 12px; color: #999; }
.score-row { display: flex; justify-content: space-between; }
.score-label { font-size: 12px; color: #999; margin-bottom: 4px; }
.creator { margin-top: 12px; font-size: 12px; color: #999; }
</style>
@@ -0,0 +1,62 @@
<template>
<div class="page-container">
<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="categoryFilter" style="width: 140px" placeholder="类别" allow-clear>
<a-select-option value="蔬菜">蔬菜</a-select-option>
<a-select-option value="肉类">肉类</a-select-option>
</a-select>
</div>
<a-table
:columns="columns"
:data-source="filtered"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'category'">
<a-tag :color="record.category === '蔬菜' ? 'green' : 'red'">{{ record.category }}</a-tag>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { genFoodCompositionData } from '@/mock/data'
const list = ref(genFoodCompositionData(20))
const search = ref('')
const categoryFilter = ref<string>()
const numSort = (key: string) => (a: any, b: any) => parseFloat(a[key]) - parseFloat(b[key])
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '类别', key: 'category' },
{ title: '热量', dataIndex: 'calories', key: 'calories', sorter: numSort('calories') },
{ title: '蛋白质', dataIndex: 'protein', key: 'protein', sorter: numSort('protein') },
{ title: '脂肪', dataIndex: 'fat', key: 'fat', sorter: numSort('fat') },
{ title: '碳水', dataIndex: 'carbs', key: 'carbs', sorter: numSort('carbs') },
{ title: '膳食纤维', dataIndex: 'fiber', key: 'fiber', sorter: numSort('fiber') },
{ title: '维生素C', dataIndex: 'vitaminC', key: 'vitaminC', sorter: numSort('vitaminC') },
{ title: '钙', dataIndex: 'calcium', key: 'calcium', sorter: numSort('calcium') },
{ title: '铁', dataIndex: 'iron', key: 'iron', sorter: numSort('iron') },
]
const filtered = computed(() =>
list.value.filter(r => {
const m = !search.value || r.name.includes(search.value)
const c = !categoryFilter.value || r.category === categoryFilter.value
return m && c
})
)
</script>
<style scoped lang="scss">
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
</style>
@@ -0,0 +1,63 @@
<template>
<div class="page-container">
<a-page-header title="食材效益" sub-title="食材成本与损耗分析" />
<a-row :gutter="16" class="stats">
<a-col :span="6">
<a-card><a-statistic title="本月采购总额" :value="156800" prefix="¥" /></a-card>
</a-col>
<a-col :span="6">
<a-card><a-statistic title="本月使用成本" :value="142300" prefix="¥" /></a-card>
</a-col>
<a-col :span="6">
<a-card><a-statistic title="平均损耗率" :value="6.8" suffix="%" /></a-card>
</a-col>
<a-col :span="6">
<a-card><a-statistic title="食材种类" :value="86" /></a-card>
</a-col>
</a-row>
<ChartPanel title="食材成本TOP" type="bar" :data="chartData" :height="260" />
<a-card style="margin-top: 16px">
<a-table :columns="columns" :data-source="list" :pagination="{ pageSize: 10 }" row-key="id" />
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ChartPanel from '@/components/common/ChartPanel.vue'
const materials = ['猪肉', '鸡肉', '大米', '青菜', '番茄', '土豆', '鸡蛋', '面粉']
const list = ref(materials.map((name, i) => {
const purchase = 200 + i * 30
const used = purchase * (0.88 + Math.random() * 0.08)
const loss = purchase - used
const cost = 15 + Math.random() * 25
return {
id: i,
name,
purchase: purchase.toFixed(0) + 'kg',
purchaseCost: '¥' + (purchase * cost).toFixed(0),
used: used.toFixed(1) + 'kg',
loss: loss.toFixed(1) + 'kg',
lossRate: ((loss / purchase) * 100).toFixed(1) + '%',
costPerKg: '¥' + cost.toFixed(2),
}
}))
const chartData = ref(list.value.map(m => ({ label: m.name, value: parseFloat(m.purchaseCost.slice(1)) })))
const columns = [
{ title: '食材', dataIndex: 'name' },
{ title: '采购量', dataIndex: 'purchase' },
{ title: '采购成本', dataIndex: 'purchaseCost' },
{ title: '使用量', dataIndex: 'used' },
{ title: '损耗量', dataIndex: 'loss' },
{ title: '损耗率', dataIndex: 'lossRate' },
{ title: '成本/kg', dataIndex: 'costPerKg' },
]
</script>
<style scoped lang="scss">
.stats { margin-bottom: 16px; }
</style>
@@ -0,0 +1,72 @@
<template>
<div class="page-container">
<a-page-header title="餐线管理" sub-title="餐线运营状况" />
<!-- 筛选工具栏 -->
<a-card style="margin-bottom: 16px">
<a-row :gutter="16" align="middle">
<a-col :span="6">
<a-select v-model:value="filterCanteen" placeholder="所属食堂" allow-clear style="width: 100%">
<a-select-option v-for="c in canteenNames" :key="c" :value="c">{{ c }}</a-select-option>
</a-select>
</a-col>
</a-row>
</a-card>
<div class="line-grid">
<a-card v-for="line in filteredList" :key="line.id" hoverable class="line-card">
<div class="line-header">
<div class="line-name">
<span class="dot" :class="line.status"></span>
{{ line.name }}
</div>
<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-statistic title="今日菜品" :value="line.todayDishes" />
</a-col>
<a-col :span="12">
<a-statistic title="今日服务" :value="line.todayServed" suffix="人" />
</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="负责人">{{ 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>
<div class="actions">
<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>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { genMealLineData, statusMap, canteenNames } from '@/mock/data'
const list = ref(genMealLineData())
const filterCanteen = ref<string | undefined>(undefined)
const filteredList = computed(() =>
list.value.filter(line => !filterCanteen.value || line.canteen === filterCanteen.value)
)
</script>
<style scoped lang="scss">
.line-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.line-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.line-name { font-size: 16px; font-weight: 600; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; background: #52c41a; }
.dot.warning { background: #faad14; }
.dot.error { background: #f5222d; }
.stats { margin-bottom: 12px; }
.desc { margin-bottom: 12px; }
.actions { display: flex; gap: 8px; justify-content: flex-end; }
</style>
@@ -0,0 +1,57 @@
<template>
<div class="page-container">
<a-page-header title="模型管理" sub-title="AI算法模型管理" />
<a-card>
<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>
</template>
<template v-else-if="column.key === 'accuracy'">
<a-progress :percent="record.accuracy" size="small" />
</template>
<template v-else-if="column.key === 'status'">
<a-badge :status="record.status === '在线' ? 'success' : 'default'" :text="record.status" />
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a @click="toggle(record)">{{ record.status === '在线' ? '禁用' : '启用' }}</a>
<a @click="message.success('已触发模型更新')">更新</a>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { message } from 'ant-design-vue'
const typeColor: Record<string, string> = { '推荐': 'blue', '营养分析': 'green', '预测': 'purple' }
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' },
])
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' },
]
const toggle = (r: any) => {
r.status = r.status === '在线' ? '离线' : '在线'
message.success('状态已切换')
}
</script>
@@ -0,0 +1,722 @@
<template>
<div class="nutrition-mgmt">
<a-page-header title="营养管理" sub-title="就餐人员营养数据分析与监控" />
<!-- 筛选工具栏 -->
<a-card :bordered="false" class="filter-bar">
<a-space size="middle" wrap>
<span class="filter-label">分析维度</span>
<a-radio-group v-model:value="analysisMode" button-style="solid">
<a-radio-button value="meal">按餐次</a-radio-button>
<a-radio-button value="daily">按日期</a-radio-button>
<a-radio-button value="weekly">按周汇总</a-radio-button>
<a-radio-button value="person">按人员</a-radio-button>
</a-radio-group>
<a-select v-if="analysisMode === 'meal'" v-model:value="filterMeal" style="width: 120px">
<a-select-option value="">全部餐次</a-select-option>
<a-select-option value="早餐">早餐</a-select-option>
<a-select-option value="午餐">午餐</a-select-option>
<a-select-option value="晚餐">晚餐</a-select-option>
</a-select>
<a-range-picker v-if="analysisMode === 'daily'" v-model:value="dateRange" />
<a-select v-if="analysisMode === 'person'" v-model:value="filterPerson" style="width: 150px" allow-clear placeholder="选择人员">
<a-select-option v-for="p in personList" :key="p" :value="p">{{ p }}</a-select-option>
</a-select>
<a-select v-model:value="filterDept" placeholder="部门" style="width: 130px" 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-option value="财务部">财务部</a-select-option>
<a-select-option value="行政部">行政部</a-select-option>
</a-select>
</a-space>
</a-card>
<!-- 营养概览统计 -->
<a-row :gutter="16" style="margin-top: 16px">
<a-col :span="4" v-for="stat in overviewStats" :key="stat.title">
<a-card :bordered="false" class="stat-mini">
<a-statistic
:title="stat.title"
:value="stat.value"
:suffix="stat.suffix"
:value-style="{ color: stat.color, fontSize: '22px' }"
/>
<div class="stat-sub">
<span :style="{ color: stat.trendUp ? '#52c41a' : '#f5222d' }">
{{ stat.trendUp ? '↑' : '↓' }} {{ stat.trend }}
</span>
<span class="stat-label">vs 推荐</span>
</div>
</a-card>
</a-col>
</a-row>
<!-- 图表区域 -->
<a-row :gutter="16" style="margin-top: 16px">
<!-- 宏量营养素结构 -->
<a-col :span="8">
<a-card title="宏量营养素结构" :bordered="false">
<div class="macro-structure">
<ChartPanel type="pie" :data="macroData" :height="220" :colors="['#1890ff', '#faad14', '#52c41a']" />
<div class="macro-verdict">
<a-tag :color="macroVerdict.color" style="font-size: 14px; padding: 4px 12px">
{{ macroVerdict.text }}
</a-tag>
<p class="verdict-detail">{{ macroVerdict.detail }}</p>
</div>
</div>
</a-card>
</a-col>
<!-- 营养素达标率 -->
<a-col :span="16">
<a-card title="营养素达标率(实际 vs 推荐)" :bordered="false">
<div class="nutrient-bars">
<div v-for="item in nutrientComparison" :key="item.name" class="nutrient-row">
<span class="nutrient-name">{{ item.name }}</span>
<div class="nutrient-bar-container">
<div class="nutrient-bar-bg">
<div
class="nutrient-bar-fill"
:style="{
width: Math.min(item.pct, 150) / 1.5 + '%',
background: item.pct >= 80 && item.pct <= 120 ? '#52c41a' : item.pct < 80 ? '#faad14' : '#f5222d'
}"
></div>
<div class="nutrient-bar-target" :style="{ left: '66.7%' }"></div>
</div>
</div>
<span class="nutrient-value">{{ item.actual }}{{ item.unit }}</span>
<span class="nutrient-recommend">/{{ item.recommend }}{{ item.unit }}</span>
<a-tag
:color="item.pct >= 80 && item.pct <= 120 ? 'green' : item.pct < 80 ? 'orange' : 'red'"
style="margin-left: 8px; min-width: 50px; text-align: center"
>{{ item.pct }}%</a-tag>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 按餐次分析 / 按日期趋势 -->
<a-row :gutter="16" style="margin-top: 16px">
<a-col :span="12">
<a-card :title="analysisMode === 'meal' ? '各餐次热量分布' : '近7日热量趋势'" :bordered="false">
<ChartPanel
:type="analysisMode === 'meal' ? 'bar' : 'line'"
:data="trendChartData"
:height="240"
:colors="analysisMode === 'meal' ? ['#1890ff', '#52c41a', '#faad14'] : ['#1890ff']"
/>
<div class="chart-tip">
<a-tag color="blue">推荐值</a-tag>
<span class="tip-text">{{ analysisMode === 'meal' ? '早餐400-500kcal / 午餐600-800kcal / 晚餐500-650kcal' : '每日推荐 1800-2200 kcal' }}</span>
</div>
</a-card>
</a-col>
<!-- 三餐营养对比 / 个人营养画像 -->
<a-col :span="12">
<a-card :title="analysisMode === 'person' ? '个人营养画像' : '三餐营养占比'" :bordered="false">
<template v-if="analysisMode === 'person' && filterPerson">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="姓名">{{ personProfile.name }}</a-descriptions-item>
<a-descriptions-item label="部门">{{ personProfile.dept }}</a-descriptions-item>
<a-descriptions-item label="日均热量">{{ personProfile.avgCalories }} kcal</a-descriptions-item>
<a-descriptions-item label="营养评分">
<a-progress :percent="personProfile.score" :size="20"
:stroke-color="personProfile.score >= 80 ? '#52c41a' : personProfile.score >= 60 ? '#faad14' : '#f5222d'" />
</a-descriptions-item>
<a-descriptions-item label="连续达标天数">{{ personProfile.consecutiveDays }} 天</a-descriptions-item>
<a-descriptions-item label="BMI">{{ personProfile.bmi }}</a-descriptions-item>
</a-descriptions>
<a-divider />
<ChartPanel type="progress" :data="personNutrientData" :height="200" />
</template>
<template v-else>
<div class="meal-ratio">
<div v-for="(meal, idx) in mealRatioData" :key="meal.label" class="meal-ratio-row">
<span class="meal-label">{{ meal.label }}</span>
<a-progress
:percent="meal.calPct"
:stroke-color="['#1890ff', '#52c41a', '#faad14'][idx]"
:format="() => meal.calories + 'kcal'"
style="flex: 1"
/>
<div class="meal-macros">
<a-tag color="blue">蛋白{{ meal.protein }}g</a-tag>
<a-tag color="orange">脂肪{{ meal.fat }}g</a-tag>
<a-tag color="green">碳水{{ meal.carbs }}g</a-tag>
</div>
</div>
</div>
<a-divider />
<div class="meal-verdict">
<a-alert
:type="mealBalance.type"
:message="mealBalance.title"
:description="mealBalance.desc"
show-icon
/>
</div>
</template>
</a-card>
</a-col>
</a-row>
<!-- 就餐人员营养明细 -->
<a-card title="就餐人员营养明细" :bordered="false" style="margin-top: 16px">
<a-table
:columns="detailColumns"
:data-source="filteredPersonData"
:pagination="{ pageSize: 10, showTotal: (t: number) => `${t}` }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'score'">
<a-progress
:percent="record.score"
:size="20"
:stroke-color="record.score >= 80 ? '#52c41a' : record.score >= 60 ? '#faad14' : '#f5222d'"
:format="(p: number) => p + ''"
/>
</template>
<template v-else-if="column.key === 'verdict'">
<a-tag :color="record.score >= 80 ? 'green' : record.score >= 60 ? 'orange' : 'red'">
{{ record.score >= 80 ? '达标' : record.score >= 60 ? '偏离' : '不达标' }}
</a-tag>
</template>
<template v-else-if="column.key === 'alerts'">
<a-tooltip v-if="record.alerts.length" :title="record.alerts.join('')">
<a-badge :count="record.alerts.length" :number-style="{ backgroundColor: '#faad14' }" />
</a-tooltip>
<span v-else style="color:#52c41a">无</span>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="viewPersonDetail(record)">详情</a-button>
</template>
</template>
</a-table>
</a-card>
<!-- 营养预警列表 -->
<a-card title="营养预警" :bordered="false" style="margin-top: 16px">
<a-table
:columns="alertColumns"
:data-source="nutritionAlerts"
:pagination="{ pageSize: 8 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'level'">
<a-tag :color="record.level === 'error' ? 'red' : record.level === 'warning' ? 'orange' : 'blue'">
{{ record.level === 'error' ? '异常' : record.level === 'warning' ? '预警' : '提示' }}
</a-tag>
</template>
<template v-else-if="column.key === 'type'">
<a-tag>{{ record.type }}</a-tag>
</template>
</template>
</a-table>
</a-card>
<!-- 个人详情抽屉 -->
<a-drawer v-model:open="drawerVisible" title="个人营养详情" placement="right" width="550">
<template v-if="drawerPerson">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="姓名">{{ drawerPerson.name }}</a-descriptions-item>
<a-descriptions-item label="部门">{{ drawerPerson.dept }}</a-descriptions-item>
<a-descriptions-item label="今日热量">{{ drawerPerson.calories }} kcal</a-descriptions-item>
<a-descriptions-item label="营养评分">
<a-progress :percent="drawerPerson.score" :size="20" />
</a-descriptions-item>
</a-descriptions>
<a-divider>各餐次营养明细</a-divider>
<a-table :columns="drawerMealColumns" :data-source="drawerMealData" :pagination="false" size="small" />
<a-divider>营养素详情</a-divider>
<ChartPanel type="progress" :data="drawerNutrientData" :height="260" />
<a-divider>建议</a-divider>
<a-alert
v-for="(tip, i) in drawerTips"
:key="i"
:type="tip.type"
:message="tip.msg"
style="margin-bottom: 8px"
show-icon
/>
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import ChartPanel from '@/components/common/ChartPanel.vue'
import { randomNum, randomFloat, pick } from '@/mock/data'
// ============ 筛选状态 ============
const analysisMode = ref<'meal' | 'daily' | 'weekly' | 'person'>('meal')
const filterMeal = ref('')
const filterDept = ref('')
const filterPerson = ref('')
const dateRange = ref<any>(null)
const drawerVisible = ref(false)
const drawerPerson = ref<any>(null)
const personList = ['张伟', '李芳', '王娜', '刘敏', '陈强', '杨磊', '黄艳', '赵勇', '周洋', '吴军']
const deptList = ['研发部', '市场部', '财务部', '行政部']
// ============ 营养标准 ============
const standards = {
calories: { min: 1800, max: 2200, recommend: 2000 },
protein: { min: 55, max: 85, recommend: 70, unit: 'g' },
fat: { min: 45, max: 75, recommend: 60, unit: 'g' },
carbs: { min: 250, max: 340, recommend: 300, unit: 'g' },
fiber: { min: 20, max: 35, recommend: 25, unit: 'g' },
calcium: { min: 700, max: 1000, recommend: 800, unit: 'mg' },
iron: { min: 10, max: 20, recommend: 15, unit: 'mg' },
vitC: { min: 80, max: 150, recommend: 100, unit: 'mg' },
sodium: { min: 1000, max: 2300, recommend: 1500, unit: 'mg' },
}
// ============ Mock 就餐数据 ============
function genPersonNutrition() {
return personList.map(name => {
const dept = pick(deptList)
const breakfastCal = randomNum(300, 600)
const lunchCal = randomNum(500, 900)
const dinnerCal = randomNum(400, 750)
const totalCal = breakfastCal + lunchCal + dinnerCal
const protein = randomFloat(40, 95, 0)
const fat = randomFloat(35, 85, 0)
const carbs = randomFloat(200, 380, 0)
const fiber = randomFloat(10, 35, 0)
const calcium = randomNum(400, 1100)
const iron = randomFloat(6, 22, 0)
const vitC = randomNum(50, 160)
const sodium = randomNum(800, 3000)
const alerts: string[] = []
if (totalCal > 2200) alerts.push('热量超标')
if (totalCal < 1800) alerts.push('热量不足')
if (protein < 55) alerts.push('蛋白质不足')
if (fat > 75) alerts.push('脂肪偏高')
if (fiber < 20) alerts.push('膳食纤维不足')
if (sodium > 2300) alerts.push('钠摄入超标')
if (vitC < 80) alerts.push('维C不足')
if (calcium < 700) alerts.push('钙摄入不足')
let score = 100
if (totalCal < 1800 || totalCal > 2200) score -= 15
if (protein < 55 || protein > 85) score -= 10
if (fat > 75) score -= 10
if (fiber < 20) score -= 10
if (sodium > 2300) score -= 10
if (calcium < 700) score -= 5
if (vitC < 80) score -= 5
score = Math.max(score, 30)
return {
id: name,
name,
dept,
calories: totalCal,
breakfastCal,
lunchCal,
dinnerCal,
protein: +protein,
fat: +fat,
carbs: +carbs,
fiber: +fiber,
calcium,
iron: +iron,
vitC,
sodium,
score,
alerts,
bmi: randomFloat(18.5, 28, 1),
consecutiveDays: randomNum(0, 14),
}
})
}
const allPersonData = ref(genPersonNutrition())
// ============ 概览统计 ============
const overviewStats = computed(() => {
const data = filteredPersonData.value
const n = data.length || 1
const avgCal = Math.round(data.reduce((s, d) => s + d.calories, 0) / n)
const avgProtein = Math.round(data.reduce((s, d) => s + d.protein, 0) / n)
const avgFat = Math.round(data.reduce((s, d) => s + d.fat, 0) / n)
const avgCarbs = Math.round(data.reduce((s, d) => s + d.carbs, 0) / n)
const avgFiber = Math.round(data.reduce((s, d) => s + d.fiber, 0) / n)
const avgScore = Math.round(data.reduce((s, d) => s + d.score, 0) / n)
return [
{ title: '日均热量', value: avgCal, suffix: 'kcal', color: avgCal >= 1800 && avgCal <= 2200 ? '#52c41a' : '#f5222d', trend: Math.abs(avgCal - 2000) + 'kcal', trendUp: avgCal <= 2200 },
{ title: '日均蛋白质', value: avgProtein, suffix: 'g', color: avgProtein >= 55 ? '#52c41a' : '#faad14', trend: (avgProtein - 70 > 0 ? '+' : '') + (avgProtein - 70) + 'g', trendUp: avgProtein >= 55 },
{ title: '日均脂肪', value: avgFat, suffix: 'g', color: avgFat <= 75 ? '#52c41a' : '#f5222d', trend: (avgFat - 60 > 0 ? '+' : '') + (avgFat - 60) + 'g', trendUp: avgFat <= 75 },
{ title: '日均碳水', value: avgCarbs, suffix: 'g', color: avgCarbs >= 250 && avgCarbs <= 340 ? '#52c41a' : '#faad14', trend: (avgCarbs - 300 > 0 ? '+' : '') + (avgCarbs - 300) + 'g', trendUp: avgCarbs <= 340 },
{ title: '膳食纤维', value: avgFiber, suffix: 'g', color: avgFiber >= 20 ? '#52c41a' : '#faad14', trend: (avgFiber - 25 > 0 ? '+' : '') + (avgFiber - 25) + 'g', trendUp: avgFiber >= 20 },
{ title: '营养评分', value: avgScore, suffix: '分', color: avgScore >= 80 ? '#52c41a' : avgScore >= 60 ? '#faad14' : '#f5222d', trend: avgScore >= 80 ? '良好' : '需改善', trendUp: avgScore >= 80 },
]
})
// ============ 宏量营养素饼图 ============
const macroData = computed(() => {
const data = filteredPersonData.value
const n = data.length || 1
const pCal = Math.round(data.reduce((s, d) => s + d.protein * 4, 0) / n)
const fCal = Math.round(data.reduce((s, d) => s + d.fat * 9, 0) / n)
const cCal = Math.round(data.reduce((s, d) => s + d.carbs * 4, 0) / n)
return [
{ label: '蛋白质', value: pCal },
{ label: '脂肪', value: fCal },
{ label: '碳水', value: cCal },
]
})
const macroVerdict = computed(() => {
const total = macroData.value.reduce((s, d) => s + d.value, 0) || 1
const pPct = macroData.value[0].value / total * 100
const fPct = macroData.value[1].value / total * 100
// 推荐:蛋白10-15%, 脂肪20-30%, 碳水50-65%
const ok = pPct >= 10 && pPct <= 20 && fPct >= 20 && fPct <= 35
return {
color: ok ? 'green' : 'orange',
text: ok ? '营养结构合理' : '营养结构偏离',
detail: `蛋白质${pPct.toFixed(0)}%(推荐10-15%)· 脂肪${fPct.toFixed(0)}%(推荐20-30%)· 碳水${(100 - pPct - fPct).toFixed(0)}%(推荐50-65%`,
}
})
// ============ 营养素达标率 ============
const nutrientComparison = computed(() => {
const data = filteredPersonData.value
const n = data.length || 1
const avg = (field: string) => Math.round(data.reduce((s: number, d: any) => s + d[field], 0) / n)
return [
{ name: '热量', actual: avg('calories'), recommend: 2000, unit: 'kcal', pct: Math.round(avg('calories') / 2000 * 100) },
{ name: '蛋白质', actual: avg('protein'), recommend: 70, unit: 'g', pct: Math.round(avg('protein') / 70 * 100) },
{ name: '脂肪', actual: avg('fat'), recommend: 60, unit: 'g', pct: Math.round(avg('fat') / 60 * 100) },
{ name: '碳水', actual: avg('carbs'), recommend: 300, unit: 'g', pct: Math.round(avg('carbs') / 300 * 100) },
{ name: '膳食纤维', actual: avg('fiber'), recommend: 25, unit: 'g', pct: Math.round(avg('fiber') / 25 * 100) },
{ name: '钙', actual: avg('calcium'), recommend: 800, unit: 'mg', pct: Math.round(avg('calcium') / 800 * 100) },
{ name: '铁', actual: avg('iron'), recommend: 15, unit: 'mg', pct: Math.round(avg('iron') / 15 * 100) },
{ name: '维生素C', actual: avg('vitC'), recommend: 100, unit: 'mg', pct: Math.round(avg('vitC') / 100 * 100) },
{ name: '钠', actual: avg('sodium'), recommend: 1500, unit: 'mg', pct: Math.round(avg('sodium') / 1500 * 100) },
]
})
// ============ 趋势图表 ============
const trendChartData = computed(() => {
if (analysisMode.value === 'meal') {
const data = filteredPersonData.value
const n = data.length || 1
return [
{ label: '早餐', value: Math.round(data.reduce((s, d) => s + d.breakfastCal, 0) / n) },
{ label: '午餐', value: Math.round(data.reduce((s, d) => s + d.lunchCal, 0) / n) },
{ label: '晚餐', value: Math.round(data.reduce((s, d) => s + d.dinnerCal, 0) / n) },
]
}
// 近7日趋势
return ['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map(d => ({
label: d,
value: randomNum(1700, 2300),
}))
})
// ============ 三餐营养占比 ============
const mealRatioData = computed(() => {
const data = filteredPersonData.value
const n = data.length || 1
const totalCal = Math.round(data.reduce((s, d) => s + d.calories, 0) / n) || 1
const bCal = Math.round(data.reduce((s, d) => s + d.breakfastCal, 0) / n)
const lCal = Math.round(data.reduce((s, d) => s + d.lunchCal, 0) / n)
const dCal = Math.round(data.reduce((s, d) => s + d.dinnerCal, 0) / n)
return [
{ label: '早餐', calories: bCal, calPct: Math.round(bCal / totalCal * 100), protein: randomNum(15, 30), fat: randomNum(10, 20), carbs: randomNum(40, 80) },
{ label: '午餐', calories: lCal, calPct: Math.round(lCal / totalCal * 100), protein: randomNum(25, 45), fat: randomNum(20, 35), carbs: randomNum(70, 130) },
{ label: '晚餐', calories: dCal, calPct: Math.round(dCal / totalCal * 100), protein: randomNum(20, 35), fat: randomNum(15, 30), carbs: randomNum(60, 110) },
]
})
const mealBalance = computed(() => {
const d = mealRatioData.value
const bPct = d[0].calPct
const lPct = d[1].calPct
// 推荐比例 早:午:晚 = 3:4:3
const ok = bPct >= 25 && bPct <= 35 && lPct >= 35 && lPct <= 45
return ok
? { type: 'success' as const, title: '三餐比例合理', desc: `早餐${bPct}% / 午餐${lPct}% / 晚餐${d[2].calPct}%,符合3:4:3推荐比例` }
: { type: 'warning' as const, title: '三餐比例待调整', desc: `早餐${bPct}% / 午餐${lPct}% / 晚餐${d[2].calPct}%,建议调整为3:4:3比例。${bPct < 25 ? '早餐占比偏低,应增加。' : ''}${lPct > 45 ? '午餐占比偏高,建议分散。' : ''}` }
})
// ============ 个人营养画像 ============
const personProfile = computed(() => {
const p = allPersonData.value.find(d => d.name === filterPerson.value)
return p || { name: '', dept: '', avgCalories: 0, score: 0, consecutiveDays: 0, bmi: 0 }
})
const personNutrientData = computed(() => {
const p = allPersonData.value.find(d => d.name === filterPerson.value)
if (!p) return []
return [
{ label: '蛋白质', value: p.protein, pct: Math.round(p.protein / 70 * 100) },
{ label: '脂肪', value: p.fat, pct: Math.round(p.fat / 60 * 100) },
{ label: '碳水', value: p.carbs, pct: Math.round(p.carbs / 300 * 100) },
{ label: '膳食纤维', value: p.fiber, pct: Math.round(p.fiber / 25 * 100) },
{ label: '钙', value: p.calcium, pct: Math.round(p.calcium / 800 * 100) },
]
})
// ============ 就餐人员明细表 ============
const detailColumns = [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 70 },
{ title: '部门', dataIndex: 'dept', key: 'dept', width: 80 },
{ title: '热量(kcal)', dataIndex: 'calories', key: 'calories', width: 90, sorter: (a: any, b: any) => a.calories - b.calories },
{ title: '蛋白质(g)', dataIndex: 'protein', key: 'protein', width: 85, sorter: (a: any, b: any) => a.protein - b.protein },
{ title: '脂肪(g)', dataIndex: 'fat', key: 'fat', width: 75, sorter: (a: any, b: any) => a.fat - b.fat },
{ title: '碳水(g)', dataIndex: 'carbs', key: 'carbs', width: 75, sorter: (a: any, b: any) => a.carbs - b.carbs },
{ title: '膳食纤维(g)', dataIndex: 'fiber', key: 'fiber', width: 90 },
{ title: '营养评分', key: 'score', width: 120, sorter: (a: any, b: any) => a.score - b.score },
{ title: '评定', key: 'verdict', width: 80 },
{ title: '预警', key: 'alerts', width: 60 },
{ title: '操作', key: 'action', width: 60 },
]
const filteredPersonData = computed(() => {
return allPersonData.value.filter(p => {
const matchDept = !filterDept.value || p.dept === filterDept.value
const matchPerson = !filterPerson.value || p.name === filterPerson.value
return matchDept && matchPerson
})
})
// ============ 营养预警 ============
const alertColumns = [
{ title: '级别', key: 'level', width: 70 },
{ title: '类型', key: 'type', width: 90 },
{ title: '人员', dataIndex: 'person', width: 70 },
{ title: '部门', dataIndex: 'dept', width: 80 },
{ title: '描述', dataIndex: 'message', ellipsis: true },
{ title: '时间', dataIndex: 'time', width: 150 },
]
const nutritionAlerts = computed(() => {
const alerts: any[] = []
allPersonData.value.forEach(p => {
p.alerts.forEach(a => {
alerts.push({
level: a.includes('超标') || a.includes('不足') && !a.includes('膳食') ? 'error' : 'warning',
type: a,
person: p.name,
dept: p.dept,
message: `${p.name} ${a}(当前值需关注)`,
time: '2026-04-0' + randomNum(5, 8) + ' ' + randomNum(8, 18) + ':' + String(randomNum(0, 59)).padStart(2, '0'),
})
})
})
return alerts.sort((a, b) => (a.level === 'error' ? 0 : 1) - (b.level === 'error' ? 0 : 1))
})
// ============ 个人详情抽屉 ============
const drawerMealColumns = [
{ title: '餐次', dataIndex: 'meal', width: 60 },
{ title: '热量(kcal)', dataIndex: 'calories', width: 90 },
{ title: '蛋白质(g)', dataIndex: 'protein', width: 80 },
{ title: '脂肪(g)', dataIndex: 'fat', width: 70 },
{ title: '碳水(g)', dataIndex: 'carbs', width: 70 },
]
const drawerMealData = computed(() => {
if (!drawerPerson.value) return []
const p = drawerPerson.value
return [
{ meal: '早餐', calories: p.breakfastCal, protein: randomNum(10, 25), fat: randomNum(8, 18), carbs: randomNum(30, 70) },
{ meal: '午餐', calories: p.lunchCal, protein: randomNum(20, 40), fat: randomNum(15, 30), carbs: randomNum(60, 120) },
{ meal: '晚餐', calories: p.dinnerCal, protein: randomNum(15, 30), fat: randomNum(10, 25), carbs: randomNum(50, 100) },
]
})
const drawerNutrientData = computed(() => {
if (!drawerPerson.value) return []
const p = drawerPerson.value
return [
{ label: '热量', value: p.calories, pct: Math.round(p.calories / 2000 * 100) },
{ label: '蛋白质', value: p.protein, pct: Math.round(p.protein / 70 * 100) },
{ label: '脂肪', value: p.fat, pct: Math.round(p.fat / 60 * 100) },
{ label: '碳水', value: p.carbs, pct: Math.round(p.carbs / 300 * 100) },
{ label: '膳食纤维', value: p.fiber, pct: Math.round(p.fiber / 25 * 100) },
{ label: '钙', value: p.calcium, pct: Math.round(p.calcium / 800 * 100) },
{ label: '铁', value: p.iron, pct: Math.round(p.iron / 15 * 100) },
{ label: '维C', value: p.vitC, pct: Math.round(p.vitC / 100 * 100) },
]
})
const drawerTips = computed(() => {
if (!drawerPerson.value) return []
const p = drawerPerson.value
const tips: { type: 'success' | 'warning' | 'error'; msg: string }[] = []
if (p.calories > 2200) tips.push({ type: 'warning', msg: '今日热量超标,建议减少高油脂食物摄入' })
if (p.calories < 1800) tips.push({ type: 'warning', msg: '今日热量不足,建议适当增加主食和蛋白质' })
if (p.protein < 55) tips.push({ type: 'warning', msg: '蛋白质摄入偏低,建议增加鱼肉蛋奶类食物' })
if (p.fat > 75) tips.push({ type: 'error', msg: '脂肪摄入偏高,建议选择清蒸水煮类菜品' })
if (p.fiber < 20) tips.push({ type: 'warning', msg: '膳食纤维不足,建议增加蔬菜和粗粮' })
if (p.sodium > 2300) tips.push({ type: 'error', msg: '钠摄入超标,建议选择少盐菜品' })
if (p.calcium < 700) tips.push({ type: 'warning', msg: '钙摄入不足,建议增加奶制品和豆类' })
if (tips.length === 0) tips.push({ type: 'success', msg: '营养摄入均衡,请继续保持!' })
return tips
})
function viewPersonDetail(record: any) {
drawerPerson.value = record
drawerVisible.value = true
}
</script>
<style scoped lang="scss">
.nutrition-mgmt {
.filter-bar {
.filter-label {
font-weight: 600;
color: #333;
}
}
.stat-mini {
:deep(.ant-card-body) { padding: 16px; }
}
.stat-sub {
margin-top: 4px;
font-size: 12px;
display: flex;
gap: 6px;
align-items: center;
.stat-label { color: #999; }
}
.nutrient-bars {
display: flex;
flex-direction: column;
gap: 10px;
padding: 8px 0;
}
.nutrient-row {
display: flex;
align-items: center;
gap: 8px;
}
.nutrient-name {
width: 70px;
font-size: 13px;
color: #666;
text-align: right;
flex-shrink: 0;
}
.nutrient-bar-container {
flex: 1;
min-width: 0;
}
.nutrient-bar-bg {
height: 14px;
background: #f0f0f0;
border-radius: 7px;
overflow: visible;
position: relative;
}
.nutrient-bar-fill {
height: 100%;
border-radius: 7px;
transition: width 0.6s ease;
}
.nutrient-bar-target {
position: absolute;
top: -2px;
width: 2px;
height: 18px;
background: #333;
opacity: 0.3;
}
.nutrient-value {
font-size: 13px;
font-weight: 600;
color: #333;
width: 55px;
text-align: right;
flex-shrink: 0;
}
.nutrient-recommend {
font-size: 12px;
color: #999;
width: 55px;
flex-shrink: 0;
}
.macro-structure {
text-align: center;
}
.macro-verdict {
margin-top: 12px;
}
.verdict-detail {
font-size: 12px;
color: #999;
margin-top: 6px;
}
.chart-tip {
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
.tip-text {
font-size: 12px;
color: #999;
}
}
.meal-ratio {
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px 0;
}
.meal-ratio-row {
display: flex;
align-items: center;
gap: 12px;
}
.meal-label {
width: 40px;
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
}
.meal-macros {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.meal-verdict {
margin-top: 8px;
}
}
</style>
@@ -0,0 +1,70 @@
<template>
<div class="page-container">
<a-page-header title="营养点餐" sub-title="基于营养目标的智能点餐订单" />
<RelatedModules :links="relatedLinks" />
<a-card>
<a-table
:columns="columns"
:data-source="list"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #expandedRowRender="{ record }">
<a-list size="small" :data-source="record.dishes">
<template #renderItem="{ item }">
<a-list-item>
<span>{{ item.name }}</span>
<span class="cal">{{ item.calories }}</span>
</a-list-item>
</template>
</a-list>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'score'">
<a-progress
type="circle"
:percent="record.nutritionScore"
:width="56"
:stroke-color="scoreColor(record.nutritionScore)"
/>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { genNutritionOrderData, statusMap } from '@/mock/data'
import RelatedModules from '@/components/common/RelatedModules.vue'
const relatedLinks = [
{ key: 'dish-inventory', label: '餐品总库', color: 'green', arrow: '←' },
{ key: 'recipe-mgmt', label: '食谱管理', color: 'green', arrow: '←' },
{ key: 'consumption-mgmt', label: '消费管理', color: 'blue', arrow: '→' },
{ key: 'pickup-mgmt', label: '取餐管理', color: 'blue', arrow: '→' },
]
const list = ref(genNutritionOrderData(15))
const columns = [
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo' },
{ title: '用户', dataIndex: 'user', key: 'user' },
{ title: '餐次', dataIndex: 'mealType', key: 'mealType' },
{ title: '目标热量', dataIndex: 'targetCalories', key: 'targetCalories' },
{ title: '实际热量', dataIndex: 'totalCalories', key: 'totalCalories' },
{ title: '营养评分', key: 'score', width: 100 },
{ title: '日期', dataIndex: 'date', key: 'date' },
{ title: '状态', key: 'status' },
]
const scoreColor = (s: number) => s >= 90 ? '#52c41a' : s >= 80 ? '#1890ff' : s >= 70 ? '#faad14' : '#f5222d'
</script>
<style scoped lang="scss">
.cal { color: #fa8c16; float: right; }
</style>
@@ -0,0 +1,90 @@
<template>
<div class="page-container">
<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="resultFilter" style="width: 140px" placeholder="检测结果" allow-clear>
<a-select-option value="passed">合格</a-select-option>
<a-select-option value="warning">预警</a-select-option>
<a-select-option value="failed">不合格</a-select-option>
</a-select>
<a-button type="primary" @click="reload">刷新</a-button>
</div>
<a-table
:columns="columns"
:data-source="filtered"
:pagination="{ pageSize: 10 }"
row-key="id"
:custom-row="rowClick"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'result'">
<a-tag :color="statusMap[record.result]?.color">{{ statusMap[record.result]?.text }}</a-tag>
</template>
</template>
</a-table>
</a-card>
<a-drawer v-model:open="drawerOpen" title="检测详情" width="480">
<a-descriptions v-if="current" :column="1" bordered size="small">
<a-descriptions-item label="菜品">{{ current.dish }}</a-descriptions-item>
<a-descriptions-item label="检测日期">{{ current.testDate }}</a-descriptions-item>
<a-descriptions-item label="热量">{{ current.calories }}</a-descriptions-item>
<a-descriptions-item label="蛋白质">{{ current.protein }}</a-descriptions-item>
<a-descriptions-item label="脂肪">{{ current.fat }}</a-descriptions-item>
<a-descriptions-item label="碳水化合物">{{ current.carbs }}</a-descriptions-item>
<a-descriptions-item label="膳食纤维">{{ current.fiber }}</a-descriptions-item>
<a-descriptions-item label="钠">{{ current.sodium }}</a-descriptions-item>
<a-descriptions-item label="检测结果">
<a-tag :color="statusMap[current.result]?.color">{{ statusMap[current.result]?.text }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="检测员">{{ current.tester }}</a-descriptions-item>
</a-descriptions>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { genNutritionTestData, statusMap } from '@/mock/data'
const list = ref(genNutritionTestData(30))
const search = ref('')
const resultFilter = ref<string>()
const drawerOpen = ref(false)
const current = ref<any>(null)
const columns = [
{ title: '菜品', dataIndex: 'dish', key: 'dish' },
{ title: '检测日期', dataIndex: 'testDate', key: 'testDate' },
{ title: '热量', dataIndex: 'calories', key: 'calories' },
{ title: '蛋白质', dataIndex: 'protein', key: 'protein' },
{ title: '脂肪', dataIndex: 'fat', key: 'fat' },
{ title: '碳水', dataIndex: 'carbs', key: 'carbs' },
{ title: '膳食纤维', dataIndex: 'fiber', key: 'fiber' },
{ title: '钠', dataIndex: 'sodium', key: 'sodium' },
{ title: '结果', key: 'result' },
{ title: '检测员', dataIndex: 'tester', key: 'tester' },
]
const filtered = computed(() =>
list.value.filter(r => {
const m = !search.value || r.dish.includes(search.value) || r.tester.includes(search.value)
const s = !resultFilter.value || r.result === resultFilter.value
return m && s
})
)
const rowClick = (record: any) => ({
onClick: () => {
current.value = record
drawerOpen.value = true
},
})
const reload = () => { list.value = genNutritionTestData(30) }
</script>
<style scoped lang="scss">
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
</style>
@@ -0,0 +1,73 @@
<template>
<div class="page-container">
<a-page-header title="取餐管理" sub-title="实时取餐订单监控">
<template #extra>
<a-button type="primary" @click="refresh">刷新</a-button>
</template>
</a-page-header>
<a-card>
<a-table :columns="columns" :data-source="orders" :pagination="{ pageSize: 10 }" row-key="id">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'pickupCode'">
<span class="code">{{ record.pickupCode }}</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusColor[record.status]">{{ record.status }}</a-tag>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { randomId, randomNum, randomTime, randomDate, pick, pickMulti } from '@/mock/data'
const statusColor: Record<string, string> = {
'待取餐': 'blue',
'已取餐': 'green',
'超时': 'red',
}
const dishes = ['宫保鸡丁', '麻婆豆腐', '番茄炒蛋', '青椒肉丝', '红烧肉', '清炒时蔬', '紫菜蛋花汤']
const users = ['张伟', '李芳', '王娜', '刘敏', '陈强', '杨洋']
const gen = () => Array.from({ length: 12 }, () => {
const status = pick(['待取餐', '待取餐', '已取餐', '已取餐', '超时'])
return {
id: randomId(),
pickupCode: 'P' + randomNum(1000, 9999),
user: pick(users),
mealType: pick(['早餐', '午餐', '晚餐']),
dishes: pickMulti(dishes, randomNum(1, 3)).join(', '),
window: randomNum(1, 6) + '号窗口',
status,
orderTime: randomDate(1) + ' ' + randomTime(),
pickupTime: status === '已取餐' ? randomDate(1) + ' ' + randomTime() : '-',
}
})
const orders = ref(gen())
const columns = [
{ title: '取餐码', key: 'pickupCode' },
{ title: '用户', dataIndex: 'user' },
{ title: '餐次', dataIndex: 'mealType' },
{ title: '菜品', dataIndex: 'dishes', ellipsis: true },
{ title: '取餐窗口', dataIndex: 'window' },
{ title: '状态', key: 'status' },
{ title: '下单时间', dataIndex: 'orderTime' },
{ title: '取餐时间', dataIndex: 'pickupTime' },
]
const refresh = () => {
orders.value = gen()
message.success('已刷新')
}
</script>
<style scoped lang="scss">
.code { font-family: monospace; font-weight: 600; color: #1890ff; font-size: 16px; }
</style>
@@ -0,0 +1,70 @@
<template>
<div class="page-container">
<a-page-header title="食谱管理" sub-title="周食谱创建与审核" />
<RelatedModules :links="relatedLinks" />
<a-card>
<div class="toolbar">
<a-input-search v-model:value="search" placeholder="搜索食谱名" style="width: 240px" allow-clear />
<a-button type="primary" @click="message.success('打开创建食谱表单')">+ 创建食谱</a-button>
</div>
<a-table :columns="columns" :data-source="filtered" :pagination="{ pageSize: 10 }" row-key="id">
<template #bodyCell="{ column, record }">
<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 === 'actions'">
<a-space>
<a @click="message.info('编辑 ' + record.name)">编辑</a>
<a @click="message.success('审核通过')">审核</a>
<a-popconfirm title="确认删除该食谱?" @confirm="del(record)">
<a class="danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { genRecipeMgmtData, statusMap } from '@/mock/data'
import RelatedModules from '@/components/common/RelatedModules.vue'
const relatedLinks = [
{ key: 'recipe-plan', label: '食谱规划', color: 'green', arrow: '←' },
{ key: 'nutrition-order', label: '营养点餐', color: 'blue', arrow: '→' },
{ key: 'dish-inventory', label: '餐品总库', color: 'orange', arrow: '↔' },
]
const list = ref(genRecipeMgmtData(12))
const search = ref('')
const columns = [
{ title: '食谱名称', dataIndex: 'name', key: 'name' },
{ title: '开始日期', dataIndex: 'startDate', key: 'startDate' },
{ title: '结束日期', dataIndex: 'endDate', key: 'endDate' },
{ title: '餐次数', dataIndex: 'mealCount', key: 'mealCount' },
{ title: '菜品数', dataIndex: 'dishCount', key: 'dishCount' },
{ title: '日均热量', dataIndex: 'avgCalories', key: 'avgCalories' },
{ title: '状态', key: 'status' },
{ title: '创建人', dataIndex: 'creator', key: 'creator' },
{ title: '操作', key: 'actions', width: 180 },
]
const filtered = computed(() =>
list.value.filter(r => !search.value || r.name.includes(search.value))
)
const del = (r: any) => {
list.value = list.value.filter(x => x.id !== r.id)
message.success('已删除')
}
</script>
<style scoped lang="scss">
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
.danger { color: #f5222d; }
</style>
@@ -0,0 +1,50 @@
<template>
<div class="page-container">
<a-page-header title="终端管理" sub-title="智能终端设备管理" />
<div class="grid">
<a-card v-for="d in devices" :key="d.id" hoverable class="dev-card">
<div class="dev-header">
<div>
<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' ? '在线' : '离线'" />
</div>
<a-descriptions :column="1" size="small" class="desc">
<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>
<div class="actions">
<a-button size="small" @click="message.info('重启 ' + d.name)">重启</a-button>
<a-button size="small" type="primary" @click="message.success('已发送更新指令')">更新</a-button>
</div>
</a-card>
</div>
</div>
</template>
<script setup lang="ts">
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' },
])
</script>
<style scoped lang="scss">
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.dev-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.dev-name { font-size: 16px; font-weight: 600; }
.dev-type { color: #999; font-size: 12px; }
.desc { margin-bottom: 12px; }
.actions { display: flex; gap: 8px; justify-content: flex-end; }
</style>
@@ -0,0 +1,83 @@
<template>
<div class="page-container">
<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>
</div>
<a-table :columns="columns" :data-source="filtered" :pagination="{ pageSize: 10 }" row-key="id">
<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'">
<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>
</template>
</a-table>
</a-card>
<a-modal v-model:open="modalOpen" title="新增用户" @ok="addUser">
<a-form :model="form" layout="vertical">
<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="部门">
<a-select v-model:value="form.department">
<a-select-option v-for="d in departments" :key="d" :value="d">{{ d }}</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<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: '' })
const departments = ['研发部', '市场部', '财务部', '人事部', '行政部', '销售部']
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' },
]
const filtered = computed(() =>
list.value.filter(u => {
const m = !search.value || u.name.includes(search.value) || u.phone.includes(search.value)
const d = !deptFilter.value || u.department === deptFilter.value
return m && d
})
)
const addUser = () => {
message.success('用户已添加')
modalOpen.value = false
}
</script>
<style scoped lang="scss">
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
</style>
@@ -0,0 +1,263 @@
<template>
<div class="canteen-mgmt">
<a-page-header title="食堂管理" />
<!-- 统计卡片 -->
<a-row :gutter="16" class="stat-row">
<a-col :span="6">
<StatCard title="食堂总数" :value="allCanteens.length" color="#1890ff" />
</a-col>
<a-col :span="6">
<StatCard title="营业中" :value="activeCount" color="#52c41a" />
</a-col>
<a-col :span="6">
<StatCard title="总容量" :value="totalCapacity" suffix="人" color="#faad14" />
</a-col>
<a-col :span="6">
<StatCard title="厨区总数" :value="totalKitchenAreas" color="#722ed1" />
</a-col>
</a-row>
<!-- 工具栏 -->
<a-card :bordered="false" style="margin-top: 16px">
<a-space>
<a-input-search v-model:value="searchText" placeholder="搜索食堂..." style="width: 200px" />
<a-select v-model:value="filterStatus" placeholder="状态" style="width: 130px" 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-option value="装修中">装修中</a-select-option>
</a-select>
<a-button type="primary" @click="showAddModal">新增食堂</a-button>
</a-space>
</a-card>
<!-- 表格 -->
<a-table
:columns="columns"
:data-source="filteredCanteens"
:pagination="{ pageSize: 10 }"
:bordered="false"
style="margin-top: 16px"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'kitchenAreas'">
<a-tag v-for="area in record.kitchenAreas" :key="area" color="cyan">{{ area }}</a-tag>
</template>
<template v-else-if="column.key === 'mealLines'">
<a-tag v-for="line in record.mealLines" :key="line" color="blue">{{ line }}</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === '营业中' ? 'green' : record.status === '暂停营业' ? 'orange' : 'red'">{{ record.status }}</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space size="small">
<a-button type="link" size="small" @click="editCanteen(record)">编辑</a-button>
<a-button type="link" size="small" @click="viewCanteen(record)">查看</a-button>
<a-popconfirm v-if="record.status === '营业中'" title="确认暂停营业?" @confirm="stopCanteen(record)">
<a-button type="link" danger size="small">停业</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<!-- 新增/编辑弹窗 -->
<a-modal v-model:visible="modalVisible" :title="editingId ? '编辑食堂' : '新增食堂'" @ok="saveCanteen" width="600px">
<a-form :model="form" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="食堂名称" required>
<a-input v-model:value="form.name" placeholder="请输入食堂名称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="别名">
<a-input v-model:value="form.alias" placeholder="请输入别名" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="地址" required>
<a-input v-model:value="form.address" placeholder="请输入地址" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="容量(人)" required>
<a-input-number v-model:value="form.capacity" :min="1" style="width: 100%" placeholder="请输入容量" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="负责人" required>
<a-input v-model:value="form.manager" placeholder="请输入负责人" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="联系电话">
<a-input v-model:value="form.phone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="厨房区域">
<a-select v-model:value="form.kitchenAreas" mode="multiple" placeholder="选择厨房区域">
<a-select-option value="中餐A区">中餐A区</a-select-option>
<a-select-option value="中餐B区">中餐B区</a-select-option>
<a-select-option value="西餐区">西餐区</a-select-option>
<a-select-option value="面点区">面点区</a-select-option>
<a-select-option value="凉菜区">凉菜区</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="餐线">
<a-select v-model:value="form.mealLines" mode="multiple" placeholder="选择餐线">
<a-select-option value="1号中餐线">1号中餐线</a-select-option>
<a-select-option value="2号中餐线">2号中餐线</a-select-option>
<a-select-option value="3号西餐线">3号西餐线</a-select-option>
<a-select-option value="VIP餐线">VIP餐线</a-select-option>
<a-select-option value="面点窗口">面点窗口</a-select-option>
<a-select-option value="小吃窗口">小吃窗口</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
<!-- 详情抽屉 -->
<a-drawer v-model:open="drawerVisible" title="食堂详情" placement="right" width="500">
<a-descriptions v-if="viewingCanteen" :column="1" bordered>
<a-descriptions-item label="食堂名称">{{ viewingCanteen.name }}</a-descriptions-item>
<a-descriptions-item label="别名">{{ viewingCanteen.alias }}</a-descriptions-item>
<a-descriptions-item label="地址">{{ viewingCanteen.address }}</a-descriptions-item>
<a-descriptions-item label="负责人">{{ viewingCanteen.manager }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ viewingCanteen.phone }}</a-descriptions-item>
<a-descriptions-item label="容量">{{ viewingCanteen.capacity }} </a-descriptions-item>
<a-descriptions-item label="厨房区域">
<a-tag v-for="area in viewingCanteen.kitchenAreas" :key="area" color="cyan">{{ area }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="餐线">
<a-tag v-for="line in viewingCanteen.mealLines" :key="line" color="blue">{{ line }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="viewingCanteen.status === '营业中' ? 'green' : 'orange'">{{ viewingCanteen.status }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="创建日期">{{ viewingCanteen.createDate }}</a-descriptions-item>
</a-descriptions>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { message } from 'ant-design-vue'
import StatCard from '@/components/common/StatCard.vue'
import { canteenList } from '@/mock/data'
const columns = [
{ title: '食堂名称', dataIndex: 'name', key: 'name', width: 110 },
{ title: '别名', dataIndex: 'alias', key: 'alias', width: 120 },
{ title: '地址', dataIndex: 'address', key: 'address', width: 100 },
{ title: '负责人', dataIndex: 'manager', key: 'manager', width: 80 },
{ title: '联系电话', dataIndex: 'phone', key: 'phone', width: 120 },
{ title: '容量(人)', dataIndex: 'capacity', key: 'capacity', width: 80 },
{ title: '厨区', key: 'kitchenAreas', width: 180 },
{ title: '餐线', key: 'mealLines', width: 180 },
{ title: '状态', key: 'status', width: 80 },
{ title: '操作', key: 'action', width: 140, fixed: 'right' },
]
const allCanteens = ref([...canteenList])
const searchText = ref('')
const filterStatus = ref('')
const modalVisible = ref(false)
const drawerVisible = ref(false)
const editingId = ref('')
const viewingCanteen = ref<any>(null)
const form = reactive({
name: '',
alias: '',
address: '',
manager: '',
phone: '',
capacity: 200,
kitchenAreas: [] as string[],
mealLines: [] as string[],
})
const activeCount = computed(() => allCanteens.value.filter(c => c.status === '营业中').length)
const totalCapacity = computed(() => allCanteens.value.reduce((s, c) => s + c.capacity, 0))
const totalKitchenAreas = computed(() => allCanteens.value.reduce((s, c) => s + c.kitchenAreas.length, 0))
const filteredCanteens = computed(() => {
return allCanteens.value.filter(c => {
const matchSearch = !searchText.value || c.name.includes(searchText.value) || c.alias.includes(searchText.value)
const matchStatus = !filterStatus.value || c.status === filterStatus.value
return matchSearch && matchStatus
})
})
function showAddModal() {
editingId.value = ''
form.name = ''
form.alias = ''
form.address = ''
form.manager = ''
form.phone = ''
form.capacity = 200
form.kitchenAreas = []
form.mealLines = []
modalVisible.value = true
}
function editCanteen(record: any) {
editingId.value = record.id
form.name = record.name
form.alias = record.alias
form.address = record.address
form.manager = record.manager
form.phone = record.phone
form.capacity = record.capacity
form.kitchenAreas = [...record.kitchenAreas]
form.mealLines = [...record.mealLines]
modalVisible.value = true
}
function viewCanteen(record: any) {
viewingCanteen.value = record
drawerVisible.value = true
}
function stopCanteen(record: any) {
record.status = '暂停营业'
message.success(`${record.name} 已暂停营业`)
}
function saveCanteen() {
if (!form.name || !form.address || !form.manager) {
message.error('请填写必填项')
return
}
if (editingId.value) {
const idx = allCanteens.value.findIndex(c => c.id === editingId.value)
if (idx > -1) {
allCanteens.value[idx] = { ...allCanteens.value[idx], ...form }
}
} else {
allCanteens.value.push({
id: 'c' + Date.now(),
...form,
status: '营业中',
createDate: new Date().toISOString().slice(0, 10),
})
}
message.success('食堂信息已保存')
modalVisible.value = false
}
</script>
<style scoped lang="scss">
.canteen-mgmt {
.stat-row { margin-bottom: 0; }
}
</style>
@@ -0,0 +1,299 @@
<template>
<div class="employee-mgmt">
<a-page-header title="员工管理" />
<!-- 统计卡片 -->
<a-row :gutter="16" class="stat-row">
<a-col :span="6">
<StatCard title="总员工数" :value="allEmployees.length" color="#1890ff" />
</a-col>
<a-col :span="6">
<StatCard title="在职员工" :value="activeEmployees.length" color="#52c41a" />
</a-col>
<a-col :span="6">
<StatCard title="本月入职" :value="thisMonthEmployees.length" color="#faad14" />
</a-col>
<a-col :span="6">
<StatCard title="离职率" :value="turnoverRate" suffix="%" color="#f5222d" />
</a-col>
</a-row>
<!-- 工具栏 -->
<a-card :bordered="false" style="margin-top: 16px">
<a-space>
<a-input-search
v-model:value="searchText"
placeholder="搜索员工..."
style="width: 200px"
/>
<a-select
v-model:value="filterDept"
placeholder="选择部门"
style="width: 150px"
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-option value="人事部">人事部</a-select-option>
<a-select-option value="种植管理中心">种植管理中心</a-select-option>
<a-select-option value="采购中心">采购中心</a-select-option>
<a-select-option value="检测中心">检测中心</a-select-option>
<a-select-option value="加工中心">加工中心</a-select-option>
<a-select-option value="中餐厨房">中餐厨房</a-select-option>
<a-select-option value="西餐厨房">西餐厨房</a-select-option>
<a-select-option value="面点间">面点间</a-select-option>
</a-select>
<a-select
v-model:value="filterStatus"
placeholder="选择状态"
style="width: 120px"
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-option value="离职">离职</a-select-option>
</a-select>
<a-button type="primary" @click="showAddModal">新增员工</a-button>
</a-space>
</a-card>
<!-- 员工表格 -->
<a-table
:columns="columns"
:data-source="filteredEmployees"
:pagination="{ pageSize: 10 }"
:bordered="false"
style="margin-top: 16px"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ record.status }}</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space size="small">
<a-button type="link" size="small" @click="editEmployee(record)">编辑</a-button>
<a-button type="link" size="small" @click="viewEmployee(record)">查看</a-button>
<a-popconfirm
v-if="record.status === '在职'"
title="确认离职?"
@confirm="removeEmployee(record.id)"
>
<a-button type="link" danger size="small">离职</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<!-- 新增/编辑员工弹窗 -->
<a-modal v-model:visible="modalVisible" :title="editingId ? '编辑员工' : '新增员工'" @ok="saveEmployee">
<a-form :model="form" layout="vertical">
<a-form-item label="姓名" required>
<a-input v-model:value="form.name" placeholder="请输入姓名" />
</a-form-item>
<a-form-item label="性别" required>
<a-radio-group v-model:value="form.gender">
<a-radio value="男"></a-radio>
<a-radio value="女"></a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="手机号" required>
<a-input v-model:value="form.phone" placeholder="请输入手机号" />
</a-form-item>
<a-form-item label="所属部门" required>
<a-select v-model:value="form.department" placeholder="选择部门">
<a-select-option value="行政部">行政部</a-select-option>
<a-select-option value="财务部">财务部</a-select-option>
<a-select-option value="人事部">人事部</a-select-option>
<a-select-option value="种植管理中心">种植管理中心</a-select-option>
<a-select-option value="采购中心">采购中心</a-select-option>
<a-select-option value="检测中心">检测中心</a-select-option>
<a-select-option value="加工中心">加工中心</a-select-option>
<a-select-option value="中餐厨房">中餐厨房</a-select-option>
<a-select-option value="西餐厨房">西餐厨房</a-select-option>
<a-select-option value="面点间">面点间</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="职位" required>
<a-input v-model:value="form.position" placeholder="请输入职位" />
</a-form-item>
<a-form-item label="入职日期" required>
<a-date-picker v-model:value="form.entryDate" style="width: 100%" />
</a-form-item>
</a-form>
</a-modal>
<!-- 查看详情抽屉 -->
<a-drawer v-model:open="drawerVisible" title="员工详情" placement="right">
<a-descriptions v-if="viewingEmployee" :column="1" bordered>
<a-descriptions-item label="姓名">{{ viewingEmployee.name }}</a-descriptions-item>
<a-descriptions-item label="别">{{ viewingEmployee.gender }}</a-descriptions-item>
<a-descriptions-item label="手机号">{{ viewingEmployee.phone }}</a-descriptions-item>
<a-descriptions-item label="部门">{{ viewingEmployee.department }}</a-descriptions-item>
<a-descriptions-item label="职位">{{ viewingEmployee.position }}</a-descriptions-item>
<a-descriptions-item label="入职日期">{{ viewingEmployee.entryDate }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusColor(viewingEmployee.status)">{{ viewingEmployee.status }}</a-tag>
</a-descriptions-item>
</a-descriptions>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import StatCard from '@/components/common/StatCard.vue'
const columns = [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{ title: '性别', dataIndex: 'gender', key: 'gender', width: 60 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 120 },
{ title: '部门', dataIndex: 'department', key: 'department', width: 120 },
{ title: '职位', dataIndex: 'position', key: 'position', width: 100 },
{ title: '入职日期', dataIndex: 'entryDate', key: 'entryDate', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
{ title: '操作', key: 'action', width: 150, fixed: 'right' },
]
const allEmployees = ref([
{ id: 'e1', name: '王芳', gender: '女', phone: '13800138001', department: '行政部', position: '部长', entryDate: '2020-02-01', status: '在职' },
{ id: 'e2', name: '李明', gender: '男', phone: '13800138002', department: '财务部', position: '部长', entryDate: '2020-02-01', status: '在职' },
{ id: 'e3', name: '张丽', gender: '女', phone: '13800138003', department: '人事部', position: '部长', entryDate: '2020-02-01', status: '在职' },
{ id: 'e4', name: '赵刚', gender: '男', phone: '13800138004', department: '种植管理中心', position: '主任', entryDate: '2020-03-15', status: '在职' },
{ id: 'e5', name: '孙涛', gender: '男', phone: '13800138005', department: '采购中心', position: '主任', entryDate: '2020-03-15', status: '在职' },
{ id: 'e6', name: '周红', gender: '女', phone: '13800138006', department: '检测中心', position: '主任', entryDate: '2020-04-15', status: '在职' },
{ id: 'e7', name: '吴军', gender: '男', phone: '13800138007', department: '工中心', position: '主任', entryDate: '2020-04-15', status: '在职' },
{ id: 'e8', name: '郑涛', gender: '男', phone: '13800138008', department: '中餐厨房', position: '主厨', entryDate: '2020-05-15', status: '在职' },
{ id: 'e9', name: '何静', gender: '女', phone: '13800138009', department: '西餐厨房', position: '主厨', entryDate: '2020-05-15', status: '在职' },
{ id: 'e10', name: '冯燕', gender: '女', phone: '13800138010', department: '面点间', position: '主管', entryDate: '2020-05-15', status: '在职' },
{ id: 'e11', name: '曾琳', gender: '女', phone: '13800138011', department: '营养研发中心', position: '营养师', entryDate: '2024-01-10', status: '试用期' },
{ id: 'e12', name: '黄超', gender: '男', phone: '13800138012', department: '餐线运营中心', position: '主管', entryDate: '2024-01-15', status: '试用期' },
{ id: 'e13', name: '刘敏', gender: '女', phone: '13800138013', department: '行政部', position: '主管', entryDate: '2023-06-01', status: '离职' },
])
const searchText = ref('')
const filterDept = ref('')
const filterStatus = ref('')
const modalVisible = ref(false)
const drawerVisible = ref(false)
const editingId = ref('')
const viewingEmployee = ref<any>(null)
const form = reactive({
name: '',
gender: '男',
phone: '',
department: '',
position: '',
entryDate: null,
})
const activeEmployees = computed(() => allEmployees.value.filter(e => e.status === '在职'))
const thisMonthEmployees = computed(() => {
const now = dayjs()
return allEmployees.value.filter(e => {
const entryMonth = dayjs(e.entryDate)
return entryMonth.isSame(now, 'month')
})
})
const turnoverRate = computed(() => {
const left = allEmployees.value.filter(e => e.status === '离职').length
return ((left / allEmployees.value.length) * 100).toFixed(1)
})
const filteredEmployees = computed(() => {
return allEmployees.value.filter(e => {
const matchSearch = !searchText.value || e.name.includes(searchText.value) || e.phone.includes(searchText.value)
const matchDept = !filterDept.value || e.department === filterDept.value
const matchStatus = !filterStatus.value || e.status === filterStatus.value
return matchSearch && matchDept && matchStatus
})
})
function statusColor(status: string) {
return status === '在职' ? 'green' : status === '试用期' ? 'orange' : 'red'
}
function showAddModal() {
editingId.value = ''
form.name = ''
form.gender = '男'
form.phone = ''
form.department = ''
form.position = ''
form.entryDate = null
modalVisible.value = true
}
function editEmployee(record: any) {
editingId.value = record.id
form.name = record.name
form.gender = record.gender
form.phone = record.phone
form.department = record.department
form.position = record.position
form.entryDate = dayjs(record.entryDate)
modalVisible.value = true
}
function viewEmployee(record: any) {
viewingEmployee.value = record
drawerVisible.value = true
}
function removeEmployee(id: string) {
const idx = allEmployees.value.findIndex(e => e.id === id)
if (idx > -1) {
allEmployees.value[idx].status = '离职'
message.success('员工已标记为离职')
}
}
function saveEmployee() {
if (!form.name || !form.phone || !form.department || !form.position) {
message.error('请填写必填项')
return
}
if (editingId.value) {
const idx = allEmployees.value.findIndex(e => e.id === editingId.value)
if (idx > -1) {
allEmployees.value[idx] = {
...allEmployees.value[idx],
name: form.name,
gender: form.gender,
phone: form.phone,
department: form.department,
position: form.position,
entryDate: form.entryDate?.format('YYYY-MM-DD'),
}
}
} else {
allEmployees.value.push({
id: 'e' + Date.now(),
name: form.name,
gender: form.gender,
phone: form.phone,
department: form.department,
position: form.position,
entryDate: form.entryDate?.format('YYYY-MM-DD'),
status: '试用期',
})
}
message.success('员工信息已保存')
modalVisible.value = false
}
</script>
<style scoped lang="scss">
.employee-mgmt {
.stat-row {
margin-bottom: 16px;
}
}
</style>
@@ -0,0 +1,266 @@
<template>
<div class="org-structure">
<a-page-header title="组织机构管理" />
<a-row :gutter="16">
<!-- 左侧组织树 -->
<a-col :span="8">
<a-card title="组织结构" :bordered="false">
<template #extra>
<a-button type="primary" size="small" @click="showAddOrgModal">新增部门</a-button>
</template>
<a-input-search
v-model:value="searchText"
placeholder="搜索部门..."
style="margin-bottom: 16px"
/>
<a-tree
v-model:selectedKeys="selectedOrgKeys"
:tree-data="orgTreeData"
:field-names="{ children: 'children', title: 'name', key: 'id' }"
@select="onOrgSelect"
>
<template #title="{ name, id }">
<span class="tree-node">
{{ name }}
<a-space size="small" style="margin-left: 8px">
<a-button type="link" size="small" @click.stop="editOrg(id)">编辑</a-button>
<a-popconfirm title="确认删除?" @confirm="deleteOrg(id)">
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
</a-space>
</span>
</template>
</a-tree>
</a-card>
</a-col>
<!-- 右侧部门详情 -->
<a-col :span="16">
<a-card v-if="selectedOrg" title="部门详情" :bordered="false">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="部门名称">{{ selectedOrg.name }}</a-descriptions-item>
<a-descriptions-item label="上级部门">{{ selectedOrg.parent || '无' }}</a-descriptions-item>
<a-descriptions-item label="部门负责人">{{ selectedOrg.manager }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ selectedOrg.phone }}</a-descriptions-item>
<a-descriptions-item label="员工数">{{ selectedOrg.employeeCount }}</a-descriptions-item>
<a-descriptions-item label="创建日期">{{ selectedOrg.createDate }}</a-descriptions-item>
</a-descriptions>
<a-divider />
<h3>部门员工</h3>
<a-table
:columns="employeeColumns"
:data-source="selectedOrgEmployees"
:pagination="{ pageSize: 5 }"
size="small"
:bordered="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === '在职' ? 'green' : 'red'">{{ record.status }}</a-tag>
</template>
</template>
</a-table>
</a-card>
<a-empty v-else description="请选择部门" />
</a-col>
</a-row>
<!-- 新增/编辑部门弹窗 -->
<a-modal v-model:visible="orgModalVisible" title="部门信息" @ok="saveOrg">
<a-form :model="orgForm" layout="vertical">
<a-form-item label="部门名称" required>
<a-input v-model:value="orgForm.name" placeholder="请输入部门名称" />
</a-form-item>
<a-form-item label="上级部门">
<a-select v-model:value="orgForm.parent" placeholder="选择上级部门">
<a-select-option value=""></a-select-option>
<a-select-option value="总部">总部</a-select-option>
<a-select-option value="绿色种采事业部">绿色种采事业部</a-select-option>
<a-select-option value="卫生供应事业部">卫生供应事业部</a-select-option>
<a-select-option value="健康生产事业部">健康生产事业部</a-select-option>
<a-select-option value="营养用餐事业部">营养用餐事业部</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="部门负责人">
<a-input v-model:value="orgForm.manager" placeholder="请输入负责人名称" />
</a-form-item>
<a-form-item label="联系电话">
<a-input v-model:value="orgForm.phone" placeholder="请输入联系电话" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { message } from 'ant-design-vue'
const orgTreeData = ref([
{
id: 'root',
name: '智能餐养集团',
parent: '',
manager: '董事长',
phone: '010-1234-5678',
employeeCount: 150,
createDate: '2020-01-01',
children: [
{
id: 'hq',
name: '总部',
parent: '智能餐养集团',
manager: '总经理',
phone: '010-1234-5679',
employeeCount: 25,
createDate: '2020-01-15',
children: [
{ id: 'admin', name: '行政部', parent: '总部', manager: '王芳', phone: '010-1234-5680', employeeCount: 8, createDate: '2020-02-01' },
{ id: 'finance', name: '财务部', parent: '总部', manager: '李明', phone: '010-1234-5681', employeeCount: 10, createDate: '2020-02-01' },
{ id: 'hr', name: '人事部', parent: '总部', manager: '张丽', phone: '010-1234-5682', employeeCount: 7, createDate: '2020-02-01' },
],
},
{
id: 'green',
name: '绿色种采事业部',
parent: '智能餐养集团',
manager: '事业部总经理',
phone: '010-1234-5683',
employeeCount: 35,
createDate: '2020-03-01',
children: [
{ id: 'plant', name: '种植管理中心', parent: '绿色种采事业部', manager: '赵刚', phone: '010-1234-5684', employeeCount: 18, createDate: '2020-03-15' },
{ id: 'procure', name: '采购中心', parent: '绿色种采事业部', manager: '孙涛', phone: '010-1234-5685', employeeCount: 17, createDate: '2020-03-15' },
],
},
{
id: 'hygiene',
name: '卫生供应事业部',
parent: '智能餐养集团',
manager: '事业部总经理',
phone: '010-1234-5686',
employeeCount: 30,
createDate: '2020-04-01',
children: [
{ id: 'test', name: '检测中心', parent: '卫生供应事业部', manager: '周红', phone: '010-1234-5687', employeeCount: 15, createDate: '2020-04-15' },
{ id: 'process', name: '加工中心', parent: '卫生供应事业部', manager: '吴军', phone: '010-1234-5688', employeeCount: 15, createDate: '2020-04-15' },
],
},
{
id: 'health',
name: '健康生产事业部',
parent: '智能餐养集团',
manager: '事业部总经理',
phone: '010-1234-5689',
employeeCount: 40,
createDate: '2020-05-01',
children: [
{ id: 'kitchen1', name: '中餐厨房', parent: '健康生产事业部', manager: '郑涛', phone: '010-1234-5690', employeeCount: 15, createDate: '2020-05-15' },
{ id: 'kitchen2', name: '西餐厨房', parent: '健康生产事业部', manager: '何静', phone: '010-1234-5691', employeeCount: 12, createDate: '2020-05-15' },
{ id: 'pastry', name: '面点间', parent: '健康生产事业部', manager: '冯燕', phone: '010-1234-5692', employeeCount: 13, createDate: '2020-05-15' },
],
},
{
id: 'nutrition',
name: '营养用餐事业部',
parent: '智能餐养集团',
manager: '事业部总经理',
phone: '010-1234-5693',
employeeCount: 20,
createDate: '2020-06-01',
children: [
{ id: 'research', name: '营养研发中心', parent: '营养用餐事业部', manager: '曾琳', phone: '010-1234-5694', employeeCount: 10, createDate: '2020-06-15' },
{ id: 'operation', name: '餐线运营中心', parent: '营养用餐事业部', manager: '黄超', phone: '010-1234-5695', employeeCount: 10, createDate: '2020-06-15' },
],
},
],
},
])
const selectedOrgKeys = ref<string[]>([])
const selectedOrg = ref<any>(null)
const searchText = ref('')
const orgModalVisible = ref(false)
const orgForm = reactive({ name: '', parent: '', manager: '', phone: '' })
const employeeColumns = [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 80 },
{ title: '职位', dataIndex: 'position', key: 'position', width: 80 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 60 },
]
const mockEmployees: Record<string, any[]> = {
admin: [
{ id: 'e1', name: '王芳', position: '部长', status: '在职' },
{ id: 'e2', name: '刘敏', position: '主管', status: '在职' },
{ id: 'e3', name: '陈娜', position: '专员', status: '在职' },
],
finance: [
{ id: 'e4', name: '李明', position: '部长', status: '在职' },
{ id: 'e5', name: '张伟', position: '会计', status: '在职' },
],
hr: [
{ id: 'e6', name: '张丽', position: '部长', status: '在职' },
{ id: 'e7', name: '周红', position: '专员', status: '在职' },
],
}
const selectedOrgEmployees = computed(() => {
if (!selectedOrg.value) return []
return mockEmployees[selectedOrg.value.id] || []
})
function onOrgSelect(keys: string[]) {
if (keys.length === 0) return
const key = keys[0]
const findOrg = (data: any[]): any => {
for (const item of data) {
if (item.id === key) return item
if (item.children) {
const found = findOrg(item.children)
if (found) return found
}
}
return null
}
selectedOrg.value = findOrg(orgTreeData.value)
}
function showAddOrgModal() {
orgForm.name = ''
orgForm.parent = ''
orgForm.manager = ''
orgForm.phone = ''
orgModalVisible.value = true
}
function editOrg(id: string) {
message.info('编辑功能开发中')
}
function deleteOrg(id: string) {
message.success('部门已删除')
}
function saveOrg() {
if (!orgForm.name) {
message.error('请输入部门名称')
return
}
message.success('部门已保存')
orgModalVisible.value = false
}
</script>
<style scoped lang="scss">
.org-structure {
.tree-node {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
}
</style>
@@ -0,0 +1,245 @@
<template>
<div class="role-mgmt">
<a-page-header title="用户角色管理" />
<a-row :gutter="16">
<!-- 左侧角色列表 -->
<a-col :span="12">
<a-card title="角色列表" :bordered="false">
<template #extra>
<a-button type="primary" size="small" @click="showAddRoleModal">新增角色</a-button>
</template>
<a-table
:columns="roleColumns"
:data-source="roles"
:pagination="false"
size="small"
:bordered="false"
@row-click="selectRole"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-switch v-model:checked="record.enabled" @change="toggleRoleStatus(record)" />
</template>
<template v-else-if="column.key === 'action'">
<a-space size="small">
<a-button type="link" size="small" @click.stop="editRole(record)">编辑</a-button>
<a-popconfirm
v-if="record.name !== '超级管理员'"
title="确认删除?"
@confirm="deleteRole(record.id)"
>
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</a-col>
<!-- 右侧权限配置 -->
<a-col :span="12">
<a-card v-if="selectedRole" title="权限配置" :bordered="false">
<p style="margin-bottom: 16px">
<strong>{{ selectedRole.name }}</strong> - {{ selectedRole.userCount }} 个用户
</p>
<a-tree
v-model:checkedKeys="selectedPermissions"
:tree-data="permissionTree"
:field-names="{ children: 'children', title: 'name', key: 'id' }"
checkable
@check="onPermissionChange"
/>
<a-button type="primary" style="margin-top: 16px" @click="savePermissions">保存权限</a-button>
</a-card>
<a-empty v-else description="请选择角色" />
</a-col>
</a-row>
<!-- 新增/编辑角色弹窗 -->
<a-modal v-model:visible="roleModalVisible" title="角色信息" @ok="saveRole">
<a-form :model="roleForm" layout="vertical">
<a-form-item label="角色名称" required>
<a-input v-model:value="roleForm.name" placeholder="请输入角色名称" />
</a-form-item>
<a-form-item label="角色描述">
<a-textarea v-model:value="roleForm.description" placeholder="请输入角色描述" :rows="3" />
</a-form-item>
<a-form-item label="权限">
<a-tree
v-model:checkedKeys="roleForm.permissions"
:tree-data="permissionTree"
:field-names="{ children: 'children', title: 'name', key: 'id' }"
checkable
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
const roleColumns = [
{ title: '角色名称', dataIndex: 'name', key: 'name', width: 120 },
{ title: '描述', dataIndex: 'description', key: 'description', width: 150, ellipsis: true },
{ title: '用户数', dataIndex: 'userCount', key: 'userCount', width: 60 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 60 },
{ title: '创建时间', dataIndex: 'createDate', key: 'createDate', width: 100 },
{ title: '操作', key: 'action', width: 100, fixed: 'right' },
]
const permissionTree = [
{
id: 'green',
name: '绿色种采',
children: [
{ id: 'green-view', name: '查看' },
{ id: 'green-edit', name: '编辑' },
{ id: 'green-delete', name: '删除' },
],
},
{
id: 'hygiene',
name: '卫生供应',
children: [
{ id: 'hygiene-view', name: '查看' },
{ id: 'hygiene-edit', name: '编辑' },
{ id: 'hygiene-delete', name: '删除' },
],
},
{
id: 'health',
name: '健康生产',
children: [
{ id: 'health-view', name: '查看' },
{ id: 'health-edit', name: '编辑' },
{ id: 'health-delete', name: '删除' },
],
},
{
id: 'nutrition',
name: '营养用餐',
children: [
{ id: 'nutrition-view', name: '查看' },
{ id: 'nutrition-edit', name: '编辑' },
{ id: 'nutrition-delete', name: '删除' },
],
},
{
id: 'system',
name: '系统管理',
children: [
{ id: 'system-view', name: '查看' },
{ id: 'system-edit', name: '编辑' },
{ id: 'system-delete', name: '删除' },
],
},
]
const roles = ref([
{ id: 'r1', name: '超级管理员', description: '拥有所有权限', userCount: 2, enabled: true, createDate: '2020-01-01', permissions: ['green', 'hygiene', 'health', 'nutrition', 'system'] },
{ id: 'r2', name: '系统管理员', description: '系统管理权限', userCount: 3, enabled: true, createDate: '2020-01-15', permissions: ['system'] },
{ id: 'r3', name: '种采管理员', description: '绿色种采权限', userCount: 5, enabled: true, createDate: '2020-02-01', permissions: ['green'] },
{ id: 'r4', name: '卫生管理员', description: '卫生供应权限', userCount: 4, enabled: true, createDate: '2020-02-15', permissions: ['hygiene'] },
{ id: 'r5', name: '生产管理员', description: '健康生产权限', userCount: 6, enabled: true, createDate: '2020-03-01', permissions: ['health'] },
{ id: 'r6', name: '营养管理员', description: '营养用餐权限', userCount: 5, enabled: true, createDate: '2020-03-15', permissions: ['nutrition'] },
{ id: 'r7', name: '普通员工', description: '基础查看权限', userCount: 50, enabled: true, createDate: '2020-04-01', permissions: ['green-view', 'hygiene-view', 'health-view', 'nutrition-view'] },
])
const selectedRole = ref<any>(null)
const selectedPermissions = ref<string[]>([])
const roleModalVisible = ref(false)
const editingId = ref('')
const roleForm = reactive({
name: '',
description: '',
permissions: [] as string[],
})
function selectRole(record: any) {
selectedRole.value = record
selectedPermissions.value = record.permissions || []
}
function showAddRoleModal() {
editingId.value = ''
roleForm.name = ''
roleForm.description = ''
roleForm.permissions = []
roleModalVisible.value = true
}
function editRole(record: any) {
editingId.value = record.id
roleForm.name = record.name
roleForm.description = record.description
roleForm.permissions = [...(record.permissions || [])]
roleModalVisible.value = true
}
function deleteRole(id: string) {
const idx = roles.value.findIndex(r => r.id === id)
if (idx > -1) {
roles.value.splice(idx, 1)
message.success('角色已删除')
}
}
function toggleRoleStatus(record: any) {
message.success(`角色已${record.enabled ? '启用' : '禁用'}`)
}
function onPermissionChange() {
// 权限树变化时的处理
}
function savePermissions() {
if (selectedRole.value) {
selectedRole.value.permissions = selectedPermissions.value
message.success('权限已保存')
}
}
function saveRole() {
if (!roleForm.name) {
message.error('请输入角色名称')
return
}
if (editingId.value) {
const idx = roles.value.findIndex(r => r.id === editingId.value)
if (idx > -1) {
roles.value[idx] = {
...roles.value[idx],
name: roleForm.name,
description: roleForm.description,
permissions: roleForm.permissions,
}
}
} else {
roles.value.push({
id: 'r' + Date.now(),
name: roleForm.name,
description: roleForm.description,
userCount: 0,
enabled: true,
createDate: new Date().toISOString().slice(0, 10),
permissions: roleForm.permissions,
})
}
message.success('角色已保存')
roleModalVisible.value = false
}
</script>
<style scoped lang="scss">
.role-mgmt {
:deep(.ant-table-row) {
cursor: pointer;
}
}
</style>
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+38
View File
@@ -0,0 +1,38 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
import { resolve } from 'path'
export default defineConfig({
plugins: [
vue(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
dts: 'src/auto-imports.d.ts',
}),
Components({
resolvers: [
AntDesignVueResolver({
importStyle: false,
}),
],
dts: 'src/components.d.ts',
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})
+14
View File
@@ -0,0 +1,14 @@
App({
globalData: {
userInfo: null,
baseUrl: 'https://api.example.com',
token: '',
},
onLaunch() {
const token = wx.getStorageSync('token')
if (token) {
this.globalData.token = token
}
},
})
+28
View File
@@ -0,0 +1,28 @@
{
"pages": [
"pages/eat/index",
"pages/ask/index",
"pages/cook/index",
"pages/profile/index"
],
"tabBar": {
"color": "#999",
"selectedColor": "#1890ff",
"backgroundColor": "#fff",
"borderStyle": "black",
"list": [
{ "pagePath": "pages/eat/index", "text": "吃" },
{ "pagePath": "pages/ask/index", "text": "问" },
{ "pagePath": "pages/cook/index", "text": "做" },
{ "pagePath": "pages/profile/index", "text": "我" }
]
},
"window": {
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#1890ff",
"navigationBarTitleText": "智能餐养",
"navigationBarTextStyle": "white"
},
"style": "v2",
"sitemapLocation": "sitemap.json"
}
+26
View File
@@ -0,0 +1,26 @@
page {
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, 'PingFang SC',
'Microsoft YaHei', Arial, sans-serif;
font-size: 28rpx;
color: #333;
}
.container {
padding: 24rpx;
}
.card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.section-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 16rpx;
color: #333;
}
+53
View File
@@ -0,0 +1,53 @@
let msgId = 0
const mockReplies = {
'今天吃什么好?': '根据您的健康档案,建议今天午餐选择:蒜蓉西兰花(95kcal,富含维生素C)+ 蒸鲈鱼(210kcal,高蛋白低脂)+ 杂粮饭(180kcal,高纤维)。总热量约485kcal,营养均衡。',
'如何减脂饮食?': '减脂饮食建议:\n1. 每日热量控制在1500-1800kcal\n2. 蛋白质占比30%(鸡胸肉、鱼虾)\n3. 减少精制碳水,多吃粗粮\n4. 蔬菜每餐不少于200g\n5. 少油少盐,清蒸水煮为主\n\n推荐今日菜品:清炒时蔬 + 蒸鲈鱼 + 凉拌黄瓜',
'蛋白质怎么补充?': '优质蛋白质来源推荐:\n- 鸡胸肉:每100g含蛋白质24g\n- 鱼类:每100g含蛋白质18-20g\n- 鸡蛋:每个约含6g蛋白质\n- 豆腐:每100g含蛋白质8g\n\n建议每日蛋白质摄入量:体重(kg)×1.2g。您可以在今天的菜单中选择宫保鸡丁或炖牛腩。',
'我的营养够吗?': '根据您近7天的用餐记录分析:\n- 热量:日均1850kcal(推荐2000kcal)略低\n- 蛋白质:日均65g(推荐72g)接近达标\n- 钙:日均680mg(推荐800mg)偏低\n- 维生素C:日均95mg(推荐100mg)达标\n\n建议增加奶制品和绿叶蔬菜摄入,补充钙质。',
}
const defaultReply = '感谢您的提问!根据您的个人健康数据和饮食偏好,我建议您关注以下几点:\n1. 保持每餐营养均衡\n2. 注意蛋白质和蔬菜的搭配\n3. 适量控制油盐摄入\n\n如需更详细的建议,请告诉我您的具体需求。'
Page({
data: {
messages: [
{ id: msgId++, role: 'assistant', content: '您好!我是AI营养顾问,可以为您解答饮食和营养相关问题。试试问我"今天吃什么好?"' },
],
inputValue: '',
scrollToId: '',
quickQuestions: ['今天吃什么好?', '如何减脂饮食?', '蛋白质怎么补充?', '我的营养够吗?'],
},
onInput(e) {
this.setData({ inputValue: e.detail.value })
},
askQuick(e) {
this.setData({ inputValue: e.currentTarget.dataset.q })
this.sendMessage()
},
sendMessage() {
const content = this.data.inputValue.trim()
if (!content) return
const userMsg = { id: msgId++, role: 'user', content }
const messages = [...this.data.messages, userMsg]
this.setData({
messages,
inputValue: '',
scrollToId: 'msg-' + userMsg.id,
})
// 模拟 AI 思考延迟
setTimeout(() => {
const reply = mockReplies[content] || defaultReply
const aiMsg = { id: msgId++, role: 'assistant', content: reply }
this.setData({
messages: [...this.data.messages, aiMsg],
scrollToId: 'msg-' + aiMsg.id,
})
}, 800)
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "问 - AI营养顾问"
}
+30
View File
@@ -0,0 +1,30 @@
<!--pages/ask/index.wxml-->
<view class="container">
<view class="section-title">AI 营养顾问</view>
<scroll-view class="chat-list" scroll-y scroll-into-view="{{scrollToId}}">
<view class="chat-item {{item.role}}" wx:for="{{messages}}" wx:key="id" id="msg-{{item.id}}">
<view class="bubble">
<text>{{item.content}}</text>
</view>
</view>
</scroll-view>
<view class="quick-questions">
<view class="quick-item" wx:for="{{quickQuestions}}" wx:key="*this" bindtap="askQuick" data-q="{{item}}">
{{item}}
</view>
</view>
<view class="input-bar">
<input
class="chat-input"
placeholder="输入您的饮食问题..."
value="{{inputValue}}"
bindinput="onInput"
confirm-type="send"
bindconfirm="sendMessage"
/>
<view class="send-btn" bindtap="sendMessage">发送</view>
</view>
</view>
+80
View File
@@ -0,0 +1,80 @@
.chat-list {
height: calc(100vh - 340rpx);
padding: 24rpx;
}
.chat-item {
margin-bottom: 24rpx;
display: flex;
}
.chat-item.user {
justify-content: flex-end;
}
.chat-item.assistant {
justify-content: flex-start;
}
.bubble {
max-width: 75%;
padding: 20rpx 28rpx;
border-radius: 16rpx;
font-size: 28rpx;
line-height: 1.6;
}
.user .bubble {
background: #1890ff;
color: #fff;
border-bottom-right-radius: 4rpx;
}
.assistant .bubble {
background: #fff;
color: #333;
border-bottom-left-radius: 4rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
.quick-questions {
display: flex;
flex-wrap: wrap;
padding: 0 24rpx;
gap: 16rpx;
margin-bottom: 16rpx;
}
.quick-item {
font-size: 24rpx;
color: #1890ff;
background: rgba(24, 144, 255, 0.08);
padding: 10rpx 20rpx;
border-radius: 24rpx;
border: 1rpx solid rgba(24, 144, 255, 0.2);
}
.input-bar {
display: flex;
padding: 16rpx 24rpx;
background: #fff;
align-items: center;
border-top: 1rpx solid #eee;
}
.chat-input {
flex: 1;
background: #f5f5f5;
border-radius: 32rpx;
padding: 16rpx 24rpx;
font-size: 28rpx;
}
.send-btn {
margin-left: 16rpx;
background: #1890ff;
color: #fff;
font-size: 28rpx;
padding: 14rpx 28rpx;
border-radius: 32rpx;
}
+61
View File
@@ -0,0 +1,61 @@
const mockFollowRecipes = [
{ id: 'f1', name: '红烧肉', image: '', description: '经典家常菜,肥而不腻', difficulty: '中等', duration: 45 },
{ id: 'f2', name: '蒜蓉西兰花', image: '', description: '低卡高纤维,健康首选', difficulty: '简单', duration: 10 },
{ id: 'f3', name: '番茄鸡蛋汤', image: '', description: '营养丰富,老少皆宜', difficulty: '简单', duration: 15 },
{ id: 'f4', name: '糖醋里脊', image: '', description: '酸甜可口,下饭神器', difficulty: '较难', duration: 30 },
{ id: 'f5', name: '蒸鲈鱼', image: '', description: '清淡鲜美,高蛋白低脂', difficulty: '中等', duration: 20 },
]
const mockMyRecipes = [
{ id: 'm1', name: '秘制卤鸡腿', description: '独家卤料配方,入味三分' },
{ id: 'm2', name: '凉拌三丝', description: '黄瓜丝+胡萝卜丝+粉丝,清爽开胃' },
]
const mockSharedRecipes = [
{ id: 's1', author: '美食达人小王', name: '低卡鸡胸肉沙拉', image: '', time: '2小时前', likes: 128, liked: false },
{ id: 's2', author: '营养师张姐', name: '五谷杂粮粥', image: '', time: '5小时前', likes: 86, liked: true },
{ id: 's3', author: '厨艺新星', name: '减脂版宫保鸡丁', image: '', time: '1天前', likes: 215, liked: false },
{ id: 's4', author: '健身小李', name: '高蛋白燕麦碗', image: '', time: '2天前', likes: 163, liked: true },
]
Page({
data: {
mode: 'follow',
followRecipes: mockFollowRecipes,
myRecipes: mockMyRecipes,
sharedRecipes: mockSharedRecipes,
},
switchMode(e) {
this.setData({ mode: e.currentTarget.dataset.mode })
},
viewRecipe(e) {
const id = e.currentTarget.dataset.id
const recipe = this.data.followRecipes.find(r => r.id === id)
wx.showModal({
title: recipe.name,
content: `难度: ${recipe.difficulty}\n时间: ${recipe.duration}分钟\n\n${recipe.description}\n\n步骤:\n1. 准备食材\n2. 处理食材\n3. 烹饪制作\n4. 装盘上桌`,
showCancel: false,
})
},
goCreate() {
wx.showToast({ title: '创作功能开发中', icon: 'none' })
},
toggleLike(e) {
const id = e.currentTarget.dataset.id
const list = this.data.sharedRecipes.map(item => {
if (item.id === id) {
return {
...item,
liked: !item.liked,
likes: item.liked ? item.likes - 1 : item.likes + 1,
}
}
return item
})
this.setData({ sharedRecipes: list })
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "做 - 烹饪与分享"
}
+61
View File
@@ -0,0 +1,61 @@
<!--pages/cook/index.wxml-->
<view class="container">
<view class="tab-bar">
<view class="tab-item {{mode === 'follow' ? 'active' : ''}}" bindtap="switchMode" data-mode="follow">照做</view>
<view class="tab-item {{mode === 'create' ? 'active' : ''}}" bindtap="switchMode" data-mode="create">创作</view>
<view class="tab-item {{mode === 'share' ? 'active' : ''}}" bindtap="switchMode" data-mode="share">分享</view>
</view>
<!-- 照做模式 -->
<block wx:if="{{mode === 'follow'}}">
<view class="card recipe-follow-item" wx:for="{{followRecipes}}" wx:key="id" bindtap="viewRecipe" data-id="{{item.id}}">
<view class="recipe-cover-placeholder" style="background:{{['#87e8de','#b7eb8f','#ffd591','#ffadd2','#d3adf7'][index%5]}}">
<text class="cover-text">{{item.name[0]}}{{item.name[1]}}</text>
</view>
<view class="recipe-detail">
<text class="recipe-name">{{item.name}}</text>
<text class="recipe-desc">{{item.description}}</text>
<view class="recipe-meta">
<text class="meta-tag">{{item.difficulty}}</text>
<text class="meta-time">{{item.duration}}分钟</text>
</view>
</view>
</view>
</block>
<!-- 创作模式 -->
<block wx:if="{{mode === 'create'}}">
<view class="card create-entry" bindtap="goCreate">
<text class="create-icon">+</text>
<text class="create-label">创建我的菜谱</text>
</view>
<view class="card" wx:for="{{myRecipes}}" wx:key="id">
<text class="recipe-name">{{item.name}}</text>
<text class="recipe-desc" style="margin-top:8rpx;">{{item.description}}</text>
</view>
</block>
<!-- 分享模式 -->
<block wx:if="{{mode === 'share'}}">
<view class="card" wx:for="{{sharedRecipes}}" wx:key="id">
<view class="share-header">
<view class="author-avatar" style="background:{{['#1890ff','#52c41a','#fa8c16','#722ed1'][index%4]}}">
<text>{{item.author[0]}}</text>
</view>
<view class="share-meta">
<text class="author-name">{{item.author}}</text>
<text class="share-time">{{item.time}}</text>
</view>
</view>
<text class="recipe-name" style="margin:16rpx 0 8rpx;">{{item.name}}</text>
<view class="share-footer">
<view class="like-btn {{item.liked ? 'liked' : ''}}" bindtap="toggleLike" data-id="{{item.id}}">
<text>{{item.liked ? '♥' : '♡'}} {{item.likes}}</text>
</view>
<view class="share-btn">
<text>转发</text>
</view>
</view>
</view>
</block>
</view>
+148
View File
@@ -0,0 +1,148 @@
.tab-bar {
display: flex;
background: #fff;
border-radius: 16rpx;
margin-bottom: 24rpx;
overflow: hidden;
}
.tab-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
font-size: 28rpx;
color: #666;
}
.tab-item.active {
color: #1890ff;
font-weight: bold;
border-bottom: 4rpx solid #1890ff;
}
.recipe-follow-item {
display: flex;
align-items: center;
}
.recipe-cover-placeholder {
width: 160rpx;
height: 120rpx;
border-radius: 12rpx;
margin-right: 20rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.cover-text {
font-size: 32rpx;
color: #fff;
font-weight: bold;
}
.recipe-detail {
flex: 1;
}
.recipe-name {
font-size: 32rpx;
font-weight: bold;
display: block;
}
.recipe-desc {
font-size: 26rpx;
color: #666;
display: block;
}
.recipe-meta {
margin-top: 12rpx;
display: flex;
gap: 16rpx;
align-items: center;
}
.meta-tag {
font-size: 22rpx;
color: #fa8c16;
background: rgba(250, 140, 22, 0.1);
padding: 4rpx 12rpx;
border-radius: 6rpx;
}
.meta-time {
font-size: 24rpx;
color: #999;
}
.create-entry {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200rpx;
}
.create-icon {
font-size: 64rpx;
color: #1890ff;
margin-bottom: 12rpx;
}
.create-label {
font-size: 30rpx;
color: #1890ff;
}
.share-header {
display: flex;
align-items: center;
gap: 16rpx;
}
.author-avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 28rpx;
font-weight: bold;
}
.share-meta {
display: flex;
flex-direction: column;
}
.author-name {
font-size: 28rpx;
font-weight: bold;
}
.share-time {
font-size: 22rpx;
color: #999;
}
.share-footer {
margin-top: 16rpx;
display: flex;
justify-content: flex-end;
gap: 32rpx;
}
.like-btn, .share-btn {
font-size: 26rpx;
color: #999;
padding: 8rpx 16rpx;
}
.like-btn.liked {
color: #ff4d4f;
}
+88
View File
@@ -0,0 +1,88 @@
// 静态 Mock 数据 - 无需后端
const mockRecipes = {
breakfast: [
{ id: 'b1', name: '杂粮粥', calories: 180, image: '', tags: ['低脂', '高纤维'] },
{ id: 'b2', name: '鸡蛋灌饼', calories: 320, image: '', tags: ['高蛋白'] },
{ id: 'b3', name: '豆浆油条', calories: 280, image: '', tags: ['经典'] },
{ id: 'b4', name: '水果沙拉', calories: 120, image: '', tags: ['低糖', '富维C'] },
{ id: 'b5', name: '蒸饺(6个)', calories: 350, image: '', tags: ['高蛋白'] },
],
lunch: [
{ id: 'l1', name: '红烧肉', calories: 520, image: '', tags: ['招牌', '高蛋白'] },
{ id: 'l2', name: '清炒时蔬', calories: 85, image: '', tags: ['低脂', '高纤维'] },
{ id: 'l3', name: '糖醋里脊', calories: 430, image: '', tags: ['热销'] },
{ id: 'l4', name: '番茄鸡蛋', calories: 180, image: '', tags: ['经典', '补钙'] },
{ id: 'l5', name: '蒜蓉西兰花', calories: 95, image: '', tags: ['低脂', '富维C'] },
{ id: 'l6', name: '宫保鸡丁', calories: 380, image: '', tags: ['高蛋白'] },
{ id: 'l7', name: '酸辣汤', calories: 110, image: '', tags: ['开胃'] },
],
dinner: [
{ id: 'd1', name: '蒸鲈鱼', calories: 210, image: '', tags: ['高蛋白', '低脂'] },
{ id: 'd2', name: '炖牛腩', calories: 450, image: '', tags: ['高蛋白', '补铁'] },
{ id: 'd3', name: '凉拌黄瓜', calories: 45, image: '', tags: ['低卡', '开胃'] },
{ id: 'd4', name: '紫菜蛋花汤', calories: 65, image: '', tags: ['补钙'] },
{ id: 'd5', name: '水煮鱼', calories: 380, image: '', tags: ['热销', '高蛋白'] },
],
}
const mockNutrition = {
breakfast: { calories: 750, protein: 28, fat: 22, carbs: 105 },
lunch: { calories: 1100, protein: 45, fat: 35, carbs: 140 },
dinner: { calories: 850, protein: 38, fat: 25, carbs: 110 },
}
Page({
data: {
currentTab: 'lunch',
recipes: [],
nutrition: {},
orderVisible: false,
selectedDish: null,
orderCount: 1,
},
onLoad() {
this.loadRecipes()
},
switchTab(e) {
const tab = e.currentTarget.dataset.tab
this.setData({ currentTab: tab })
this.loadRecipes()
},
loadRecipes() {
const tab = this.data.currentTab
this.setData({
recipes: mockRecipes[tab] || [],
nutrition: mockNutrition[tab] || {},
})
},
orderDish(e) {
const id = e.currentTarget.dataset.id
const dish = this.data.recipes.find(r => r.id === id)
this.setData({ orderVisible: true, selectedDish: dish, orderCount: 1 })
},
closeOrder() {
this.setData({ orderVisible: false, selectedDish: null })
},
changeCount(e) {
const delta = parseInt(e.currentTarget.dataset.delta)
let count = this.data.orderCount + delta
if (count < 1) count = 1
if (count > 10) count = 10
this.setData({ orderCount: count })
},
confirmOrder() {
const dish = this.data.selectedDish
wx.showToast({
title: `已点 ${dish.name} x${this.data.orderCount}`,
icon: 'success',
})
this.setData({ orderVisible: false })
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "吃 - 食谱与点餐"
}
+69
View File
@@ -0,0 +1,69 @@
<!--pages/eat/index.wxml-->
<view class="container">
<view class="section-title">今日食谱</view>
<view class="tab-bar">
<view class="tab-item {{currentTab === 'breakfast' ? 'active' : ''}}" bindtap="switchTab" data-tab="breakfast">早餐</view>
<view class="tab-item {{currentTab === 'lunch' ? 'active' : ''}}" bindtap="switchTab" data-tab="lunch">午餐</view>
<view class="tab-item {{currentTab === 'dinner' ? 'active' : ''}}" bindtap="switchTab" data-tab="dinner">晚餐</view>
</view>
<view class="recipe-list">
<view class="card recipe-item" wx:for="{{recipes}}" wx:key="id">
<view class="recipe-img-placeholder" style="background:{{item.id[0]==='b'?'#ffd591':item.id[0]==='l'?'#87e8de':'#b7eb8f'}}">
<text class="img-text">{{item.name[0]}}</text>
</view>
<view class="recipe-info">
<text class="recipe-name">{{item.name}}</text>
<text class="recipe-cal">{{item.calories}} kcal</text>
<view class="recipe-tags">
<text class="tag" wx:for="{{item.tags}}" wx:key="*this" wx:for-item="tag">{{tag}}</text>
</view>
</view>
<view class="order-btn" bindtap="orderDish" data-id="{{item.id}}">点餐</view>
</view>
</view>
<view class="section-title" style="margin-top: 24rpx;">营养概览</view>
<view class="card nutrition-overview">
<view class="nutrition-item">
<text class="label">热量</text>
<text class="value">{{nutrition.calories || '--'}} kcal</text>
</view>
<view class="nutrition-item">
<text class="label">蛋白质</text>
<text class="value">{{nutrition.protein || '--'}} g</text>
</view>
<view class="nutrition-item">
<text class="label">脂肪</text>
<text class="value">{{nutrition.fat || '--'}} g</text>
</view>
<view class="nutrition-item">
<text class="label">碳水</text>
<text class="value">{{nutrition.carbs || '--'}} g</text>
</view>
</view>
<!-- 点餐弹窗 -->
<view class="order-modal" wx:if="{{orderVisible}}">
<view class="order-mask" bindtap="closeOrder"></view>
<view class="order-content">
<view class="order-header">
<text class="order-title">{{selectedDish.name}}</text>
<text class="order-cal">{{selectedDish.calories}} kcal/份</text>
</view>
<view class="order-count-row">
<text>数量</text>
<view class="count-ctrl">
<view class="count-btn" bindtap="changeCount" data-delta="-1">-</view>
<text class="count-num">{{orderCount}}</text>
<view class="count-btn" bindtap="changeCount" data-delta="1">+</view>
</view>
</view>
<view class="order-total">
<text>总热量: {{selectedDish.calories * orderCount}} kcal</text>
</view>
<view class="order-confirm-btn" bindtap="confirmOrder">确认点餐</view>
</view>
</view>
</view>
+207
View File
@@ -0,0 +1,207 @@
.tab-bar {
display: flex;
background: #fff;
border-radius: 16rpx;
margin-bottom: 24rpx;
overflow: hidden;
}
.tab-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
font-size: 28rpx;
color: #666;
}
.tab-item.active {
color: #1890ff;
font-weight: bold;
border-bottom: 4rpx solid #1890ff;
}
.recipe-item {
display: flex;
align-items: center;
}
.recipe-img-placeholder {
width: 100rpx;
height: 100rpx;
border-radius: 12rpx;
margin-right: 20rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.img-text {
font-size: 36rpx;
color: #fff;
font-weight: bold;
}
.recipe-info {
flex: 1;
min-width: 0;
}
.recipe-name {
font-size: 30rpx;
font-weight: bold;
display: block;
}
.recipe-cal {
font-size: 24rpx;
color: #999;
margin-top: 6rpx;
display: block;
}
.recipe-tags {
margin-top: 8rpx;
display: flex;
flex-wrap: wrap;
gap: 8rpx;
}
.tag {
display: inline-block;
font-size: 22rpx;
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
padding: 4rpx 12rpx;
border-radius: 6rpx;
}
.order-btn {
background: #1890ff;
color: #fff;
font-size: 26rpx;
padding: 12rpx 24rpx;
border-radius: 8rpx;
flex-shrink: 0;
}
.nutrition-overview {
display: flex;
justify-content: space-around;
}
.nutrition-item {
text-align: center;
}
.nutrition-item .label {
font-size: 24rpx;
color: #999;
display: block;
}
.nutrition-item .value {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-top: 8rpx;
display: block;
}
/* 点餐弹窗 */
.order-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
}
.order-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
.order-content {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
padding: 40rpx;
}
.order-header {
margin-bottom: 32rpx;
}
.order-title {
font-size: 36rpx;
font-weight: bold;
display: block;
}
.order-cal {
font-size: 26rpx;
color: #999;
margin-top: 8rpx;
display: block;
}
.order-count-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-top: 1rpx solid #f0f0f0;
border-bottom: 1rpx solid #f0f0f0;
font-size: 30rpx;
}
.count-ctrl {
display: flex;
align-items: center;
gap: 24rpx;
}
.count-btn {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: #333;
}
.count-num {
font-size: 32rpx;
font-weight: bold;
min-width: 40rpx;
text-align: center;
}
.order-total {
padding: 20rpx 0;
font-size: 28rpx;
color: #666;
}
.order-confirm-btn {
background: #1890ff;
color: #fff;
text-align: center;
padding: 24rpx;
border-radius: 12rpx;
font-size: 32rpx;
font-weight: bold;
margin-top: 16rpx;
}
+55
View File
@@ -0,0 +1,55 @@
Page({
data: {
userInfo: null,
isLoggedIn: false,
balance: '¥ 568.50',
memberLevel: '黄金会员',
orderCount: 126,
healthScore: 85,
},
onLoad() {
// 模拟已登录状态
this.setData({
isLoggedIn: true,
userInfo: {
name: '张伟',
phone: '138****8888',
department: '研发部',
avatar: '',
},
})
},
login() {
// 模拟登录
this.setData({
isLoggedIn: true,
userInfo: {
name: '张伟',
phone: '138****8888',
department: '研发部',
avatar: '',
},
})
wx.showToast({ title: '登录成功', icon: 'success' })
},
logout() {
wx.showModal({
title: '提示',
content: '确认退出登录?',
success: (res) => {
if (res.confirm) {
this.setData({ isLoggedIn: false, userInfo: null })
wx.showToast({ title: '已退出', icon: 'none' })
}
},
})
},
goTo(e) {
const url = e.currentTarget.dataset.url
wx.showToast({ title: '功能开发中', icon: 'none' })
},
})
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "我的"
}
@@ -0,0 +1,98 @@
<!--pages/profile/index.wxml-->
<view class="container">
<!-- 用户信息卡片 -->
<view class="card user-card">
<view class="user-header" wx:if="{{isLoggedIn}}">
<view class="avatar-circle" style="background:#1890ff">
<text class="avatar-text">{{userInfo.name[0]}}</text>
</view>
<view class="user-meta">
<text class="nickname">{{userInfo.name}}</text>
<text class="user-dept">{{userInfo.department}} | {{userInfo.phone}}</text>
<view class="member-badge">
<text class="member-tag">{{memberLevel}}</text>
</view>
</view>
</view>
<view class="user-header" wx:else bindtap="login">
<view class="avatar-circle" style="background:#d9d9d9">
<text class="avatar-text">?</text>
</view>
<text class="login-tip">点击登录账号</text>
</view>
<!-- 数据概览 -->
<view class="user-stats" wx:if="{{isLoggedIn}}">
<view class="stat-item">
<text class="stat-value">{{balance}}</text>
<text class="stat-label">余额</text>
</view>
<view class="stat-item">
<text class="stat-value">{{orderCount}}</text>
<text class="stat-label">累计订单</text>
</view>
<view class="stat-item">
<text class="stat-value">{{healthScore}}</text>
<text class="stat-label">健康评分</text>
</view>
</view>
</view>
<!-- 功能菜单 -->
<view class="card menu-group">
<view class="menu-item" bindtap="goTo" data-url="health">
<view class="menu-icon" style="background:#52c41a">
<text class="icon-text">健</text>
</view>
<text class="menu-label">健康档案</text>
<text class="arrow">></text>
</view>
<view class="menu-item" bindtap="goTo" data-url="recharge">
<view class="menu-icon" style="background:#faad14">
<text class="icon-text">充</text>
</view>
<text class="menu-label">充值</text>
<text class="arrow">></text>
</view>
<view class="menu-item" bindtap="goTo" data-url="membership">
<view class="menu-icon" style="background:#722ed1">
<text class="icon-text">会</text>
</view>
<text class="menu-label">会员中心</text>
<text class="arrow">></text>
</view>
<view class="menu-item" bindtap="goTo" data-url="consumption">
<view class="menu-icon" style="background:#1890ff">
<text class="icon-text">消</text>
</view>
<text class="menu-label">消费记录</text>
<text class="arrow">></text>
</view>
</view>
<view class="card menu-group">
<view class="menu-item" bindtap="goTo" data-url="allergen">
<view class="menu-icon" style="background:#f5222d">
<text class="icon-text">敏</text>
</view>
<text class="menu-label">过敏原设置</text>
<text class="arrow">></text>
</view>
<view class="menu-item" bindtap="goTo" data-url="settings">
<view class="menu-icon" style="background:#8c8c8c">
<text class="icon-text">设</text>
</view>
<text class="menu-label">设置</text>
<text class="arrow">></text>
</view>
<view class="menu-item" bindtap="goTo" data-url="about">
<view class="menu-icon" style="background:#13c2c2">
<text class="icon-text">关</text>
</view>
<text class="menu-label">关于</text>
<text class="arrow">></text>
</view>
</view>
<view class="logout-btn" wx:if="{{isLoggedIn}}" bindtap="logout">退出登录</view>
</view>
+139
View File
@@ -0,0 +1,139 @@
.user-card {
padding: 32rpx;
}
.user-header {
display: flex;
align-items: center;
}
.avatar-circle {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
flex-shrink: 0;
}
.avatar-text {
font-size: 44rpx;
color: #fff;
font-weight: bold;
}
.user-meta {
display: flex;
flex-direction: column;
}
.nickname {
font-size: 36rpx;
font-weight: bold;
}
.user-dept {
font-size: 24rpx;
color: #999;
margin-top: 6rpx;
}
.member-badge {
margin-top: 8rpx;
}
.member-tag {
display: inline-block;
font-size: 22rpx;
color: #fa8c16;
border: 1rpx solid #fa8c16;
background: rgba(250, 140, 22, 0.08);
border-radius: 6rpx;
padding: 4rpx 16rpx;
}
.login-tip {
font-size: 28rpx;
color: #999;
}
.user-stats {
display: flex;
justify-content: space-around;
margin-top: 32rpx;
padding-top: 24rpx;
border-top: 1rpx solid #f0f0f0;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 34rpx;
font-weight: bold;
color: #333;
display: block;
}
.stat-label {
font-size: 22rpx;
color: #999;
margin-top: 6rpx;
display: block;
}
.menu-group {
padding: 0;
}
.menu-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-icon {
width: 56rpx;
height: 56rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
flex-shrink: 0;
}
.icon-text {
font-size: 26rpx;
color: #fff;
font-weight: bold;
}
.menu-label {
flex: 1;
font-size: 30rpx;
color: #333;
}
.arrow {
color: #ccc;
font-size: 28rpx;
}
.logout-btn {
text-align: center;
color: #f5222d;
font-size: 30rpx;
padding: 28rpx;
margin-top: 32rpx;
background: #fff;
border-radius: 16rpx;
}
+52
View File
@@ -0,0 +1,52 @@
{
"description": "项目配置文件",
"packOptions": {
"ignore": [],
"include": []
},
"setting": {
"bundle": false,
"userConfirmedBundleSwitch": false,
"urlCheck": true,
"scopeDataCheck": false,
"coverView": true,
"es6": true,
"postcss": true,
"compileHotReLoad": false,
"lazyloadPlaceholderEnable": false,
"preloadBackgroundData": false,
"minified": true,
"autoAudits": false,
"newFeature": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"useIsolateContext": true,
"nodeModules": false,
"enhance": true,
"useMultiFrameRuntime": true,
"showShadowRootInWxmlPanel": true,
"packNpmManually": false,
"enableEngineNative": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"showES6CompileOption": false,
"minifyWXML": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"compileWorklet": false,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true
},
"compileType": "miniprogram",
"condition": {},
"appid": "touristappid",
"simulatorPluginLibVersion": {},
"editorSetting": {}
}
@@ -0,0 +1,24 @@
{
"libVersion": "3.15.1",
"condition": {},
"projectname": "miniprogram",
"setting": {
"urlCheck": true,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"useApiHook": true,
"useApiHostProcess": true,
"showShadowRootInWxmlPanel": true,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"compileHotReLoad": true,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false,
"useIsolateContext": true
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"rules": [
{
"action": "allow",
"page": "*"
}
]
}
+33
View File
@@ -0,0 +1,33 @@
const app = getApp()
const request = (options) => {
return new Promise((resolve, reject) => {
wx.request({
url: app.globalData.baseUrl + options.url,
method: options.method || 'GET',
data: options.data || {},
header: {
'Content-Type': 'application/json',
Authorization: `Bearer ${app.globalData.token}`,
...options.header,
},
success(res) {
if (res.data.code === 0) {
resolve(res.data.data)
} else if (res.data.code === 401) {
wx.navigateTo({ url: '/pages/profile/index' })
reject(new Error('未登录'))
} else {
wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' })
reject(new Error(res.data.msg))
}
},
fail(err) {
wx.showToast({ title: '网络错误', icon: 'none' })
reject(err)
},
})
})
}
module.exports = { request }
+3237
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"name": "zhican",
"version": "1.0.0",
"private": true,
"description": "智能餐养系统 - 前端项目",
"workspaces": [
"admin"
],
"scripts": {
"admin:dev": "npm run dev -w admin",
"admin:build": "npm run build -w admin"
}
}