init: F9智慧缫丝系统 - 后端(server) + 主前端(web) + 打印设计器(openprint) 首次提交

This commit is contained in:
2026-08-15 15:37:25 +08:00
commit 7235265749
294 changed files with 59182 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
$login = Invoke-RestMethod -Uri "http://localhost:5210/api/auth/login" -Method Post -Body (@{ phone = "13800000000"; password = "123456" } | ConvertTo-Json) -ContentType "application/json"
$h = @{ Authorization = "Bearer $($login.data.token)" }
$tables = @("RawMaterial_Zhuangkou","RawMaterial_InStock","RawMaterial_Repack","RawMaterial_OutStock","RawMaterial_Stock","RawMaterial_Purchase","Process_Trial","Process_ProcessZhuangkou","Process_Sheet","ProXuan_Team","ProXuan_Daily","ProQian_Workshop","ProQian_Machine","ProQian_Schedule","ProHou_Daily","ProHou_SpoolLedger","Lims_Check","Lims_Denier","Lims_Moisture","Lims_GradeResult","Lims_Abnormal","Fims_Batch","Fims_Package","Fims_InStock","Fims_OutStock","Fims_Stock")
foreach ($t in $tables) {
try {
$m = Invoke-RestMethod -Uri "http://localhost:5210/api/meta/table/$t" -Headers $h -TimeoutSec 20
$code = ($m.data.fields | Where-Object { $_.isCode } | ForEach-Object { $_.name }) -join ','
$refs = ($m.data.fields | Where-Object { $_.refTable } | ForEach-Object { $_.name + '>' + $_.refTable }) -join ','
$enumCount = @($m.data.fields | Where-Object { $_.options }).Count
Write-Output "$t | code=[$code] refs=[$refs] enums=$enumCount"
} catch {
Write-Output "$t | ERROR"
}
}
+18
View File
@@ -0,0 +1,18 @@
$ErrorActionPreference = 'Stop'
$login = Invoke-RestMethod -Uri "http://localhost:5210/api/auth/login" -Method Post -Body (@{ phone = "13800000000"; password = "123456" } | ConvertTo-Json) -ContentType "application/json"
$h = @{ Authorization = "Bearer $($login.data.token)" }
Write-Output "=== GENCODE ==="
foreach ($t in @(@("RawMaterial_Zhuangkou","Code"), @("Process_ProcessZhuangkou","Code"), @("Fims_Batch","BatchNo"), @("Lims_Check","CheckNo"))) {
$r = Invoke-RestMethod -Uri "http://localhost:5210/api/meta/gencode/$($t[0])/$($t[1])" -Method Post -Headers $h
Write-Output " $($t[0]).$($t[1]) => $($r.data.value)"
}
Write-Output "=== REFS ==="
foreach ($tb in @("ProXuan_Team","ProQian_Machine","Fims_Batch","RawMaterial_Zhuangkou","BaseCommon_Partner","HRS_Employee")) {
try {
$ref = Invoke-RestMethod -Uri "http://localhost:5210/api/meta/refs/$tb" -Headers $h -TimeoutSec 15
Write-Output " $tb => $($ref.data.Count) items"
} catch {
Write-Output " $tb => ERROR"
}
}
Write-Output "DONE"
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🧵</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>F9 智慧缫丝系统 MES</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+2006
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "f9mes-web",
"private": true,
"version": "1.0.0",
"type": "module",
"description": "F9 智慧缫丝系统 MES - PC 管理端",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"echarts": "^5.6.0",
"element-plus": "^2.9.3",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.7"
}
}
+1
View File
@@ -0,0 +1 @@
{ "outputMode": "stdout" }
+6
View File
@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup>
</script>
+54
View File
@@ -0,0 +1,54 @@
import request from './request'
// ========== 认证 ==========
export const login = (data) => request.post('/auth/login', data)
export const getMe = () => request.get('/auth/me')
export const changePassword = (data) => request.post('/auth/change-password', data)
// ========== 天气 ==========
export const getWeather = (params) => request.get('/weather/now', { params })
// 天气码 → 中文描述(Open-Meteo WMO Weather interpretation codes
const WEATHER_CODES = {
0: '晴', 1: '晴', 2: '多云', 3: '阴', 45: '雾', 48: '雾凇',
51: '毛毛雨', 53: '小雨', 55: '中雨', 56: '冻雨', 57: '强冻雨',
61: '小雨', 63: '中雨', 65: '大雨', 66: '冻雨', 67: '强冻雨',
71: '小雪', 73: '中雪', 75: '大雪', 77: '雪粒',
80: '阵雨', 81: '强阵雨', 82: '暴雨', 85: '阵雪', 86: '强阵雪',
95: '雷暴', 96: '雷暴伴冰雹', 99: '强雷暴伴冰雹'
}
/** 天气接口原始数据 → 前端展示字段 */
export function mapWeather(data) {
if (!data) return null
return {
temp: Math.round(data.temperature ?? 0),
desc: WEATHER_CODES[data.weatherCode] || '未知',
wind: `风速 ${data.windSpeed ?? 0}m/s`,
humidity: data.humidity,
location: '缫丝厂区'
}
}
// ========== 表元数据 ==========
export const getTableMeta = (table) => request.get(`/meta/table/${table}`)
// 关联表下拉数据 [{value,label}]
export const getRefs = (table, params) => request.get(`/meta/refs/${table}`, { params })
// 业务编号自动生成(如 YLZ20260815-001
export const genCode = (table, field) => request.post(`/meta/gencode/${table}/${field}`)
// ========== 动态 CRUD ==========
export const crudPage = (table, data) => request.post(`/data/${table}/page`, data)
export const crudGet = (table, id) => request.get(`/data/${table}/${id}`)
export const crudAdd = (table, data) => request.post(`/data/${table}/add`, data)
export const crudUpdate = (table, data) => request.post(`/data/${table}/update`, data)
export const crudDelete = (table, id) => request.post(`/data/${table}/delete/${id}`)
export const crudDeleteRange = (table, ids) => request.post(`/data/${table}/deleteRange`, ids)
export const crudAll = (table) => request.get(`/data/${table}/all`)
// ========== 菜单/基础数据 ==========
export const getMenus = () => request.get('/basesys/menus')
// ========== 工作台 ==========
// 工作台聚合数据:统计卡 + 趋势 + 占比 + 待办 + 预警
export const getWorkBenchSummary = () => request.get('/workbench/summary')
+59
View File
@@ -0,0 +1,59 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import router from '@/router'
// 创建 axios 实例
const service = axios.create({
baseURL: '/api',
timeout: 30000
})
// 请求拦截器:附加 token
service.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器:统一处理业务码与错误
service.interceptors.response.use(
(response) => {
const res = response.data
// 非 ApiResult 结构(如文件流)直接返回
if (res === null || typeof res !== 'object' || !('code' in res)) {
return res
}
if (res.code !== 0) {
if (res.code === 401) {
const userStore = useUserStore()
userStore.logout()
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
} else {
ElMessage.error(res.message || '请求失败')
}
return Promise.reject(new Error(res.message || '请求失败'))
}
return res
},
(error) => {
const status = error.response?.status
if (status === 401) {
const userStore = useUserStore()
userStore.logout()
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
} else {
ElMessage.error(error.response?.data?.message || error.message || '网络错误')
}
return Promise.reject(error)
}
)
export default service
+547
View File
@@ -0,0 +1,547 @@
<template>
<div class="crud-page">
<!-- 搜索栏 -->
<el-card shadow="never" class="search-card">
<div class="flex-between">
<div class="flex" style="gap: 12px; flex-wrap: wrap">
<el-input
v-model="query.keyword"
placeholder="输入关键字搜索"
clearable
style="width: 220px"
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-for="f in searchSelects"
:key="'s' + f.name"
v-model="query.filters[f.name]"
:placeholder="f.label"
clearable
filterable
style="width: 180px"
@change="handleSearch"
>
<el-option
v-if="f.options && f.options.length"
v-for="opt in f.options"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
<el-option
v-else
v-for="opt in refCache[f.refTable]?.items || []"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button type="primary" @click="handleSearch">
<el-icon style="margin-right: 4px"><Search /></el-icon>查询
</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<div class="flex" style="gap: 10px">
<el-button type="success" @click="openDialog()">
<el-icon style="margin-right: 4px"><Plus /></el-icon>新增
</el-button>
<el-button type="danger" :disabled="!selection.length" @click="handleBatchDelete">
<el-icon style="margin-right: 4px"><Delete /></el-icon>批量删除
</el-button>
<el-button @click="loadData">
<el-icon style="margin-right: 4px"><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
</el-card>
<!-- 数据表格 -->
<el-card shadow="never" class="table-card">
<el-table
v-loading="loading"
:data="rows"
border
stripe
style="width: 100%"
@selection-change="(val) => (selection = val)"
>
<el-table-column type="selection" width="46" align="center" />
<el-table-column type="index" label="#" width="52" align="center" />
<el-table-column
v-for="col in tableCols"
:key="col.name"
:prop="col.name"
:label="col.label"
:min-width="colWidth(col)"
:show-overflow-tooltip="true"
align="left"
>
<template #default="{ row }">
<!-- 枚举:标签展示 -->
<el-tag v-if="col.options?.length" :type="tagType(row[col.name])" size="small">
{{ enumLabel(col, row[col.name]) }}
</el-tag>
<!-- 关联:显示名称 -->
<template v-else-if="col.refTable">
{{ refLabel(col, row[col.name]) }}
</template>
<!-- 进度:进度条展示 -->
<el-progress
v-else-if="isProgressCol(col)"
:percentage="Math.round(Number(row[col.name]) || 0)"
:status="Number(row[col.name]) >= 100 ? 'success' : undefined"
:stroke-width="10"
style="width: 130px"
/>
<!-- 布尔 -->
<template v-else-if="col.propType === 'Boolean'">
<el-tag :type="row[col.name] ? 'success' : 'info'" size="small">
{{ row[col.name] ? '是' : '否' }}
</el-tag>
</template>
<template v-else-if="isDateType(col)">
{{ formatDate(row[col.name]) }}
</template>
<template v-else-if="isNumberType(col)">
{{ formatNumber(row[col.name], col) }}
</template>
<template v-else>
{{ row[col.name] }}
</template>
</template>
</el-table-column>
<el-table-column label="操作" :width="detailRoute ? 200 : 160" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="detailRoute"
link
type="primary"
size="small"
@click="handleDetail(row)"
>详情</el-button>
<el-button link type="primary" size="small" @click="openDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.size"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="loadData"
@current-change="loadData"
/>
</div>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="640px"
destroy-on-close
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="130px">
<el-row :gutter="16">
<el-col v-for="f in formFields" :key="f.name" :span="f.span || 12">
<el-form-item :label="f.label" :prop="f.name">
<!-- 业务编号:只读展示(新增时自动生成) -->
<el-input v-if="f.isCode" v-model="form[f.name]" disabled placeholder="自动生成" />
<!-- 主键编辑时只读展示 -->
<el-input v-else-if="f.isIdentity && isEdit" v-model="form[f.name]" disabled />
<!-- 枚举:下拉选择 -->
<el-select
v-else-if="f.options?.length"
v-model="form[f.name]"
:placeholder="'请选择' + f.label"
style="width: 100%"
clearable
filterable
>
<el-option
v-for="opt in f.options"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<!-- 关联:远程下拉 -->
<el-select
v-else-if="f.refTable"
v-model="form[f.name]"
:placeholder="'请选择' + f.label"
style="width: 100%"
clearable
filterable
:loading="refLoading(f)"
>
<el-option
v-for="opt in refCache[refKey(f)]?.items || []"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<!-- 文本/长文本 -->
<el-input
v-else-if="isTextType(f)"
v-model="form[f.name]"
:placeholder="'请输入' + f.label"
:rows="controlType(f) === 'textarea' ? 3 : undefined"
:type="controlType(f) === 'textarea' ? 'textarea' : 'text'"
clearable
/>
<!-- 数字 -->
<el-input-number
v-else-if="isNumberType(f)"
v-model="form[f.name]"
:controls-position="'right'"
:precision="isDecimalType(f) ? 2 : 0"
:min="null"
style="width: 100%"
placeholder="请输入"
/>
<!-- 日期 -->
<el-date-picker
v-else-if="isDateType(f)"
v-model="form[f.name]"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择时间"
style="width: 100%"
/>
<!-- 布尔 -->
<el-switch v-else-if="f.propType === 'Boolean'" v-model="form[f.name]" />
<!-- 其他 -->
<el-input v-else v-model="form[f.name]" placeholder="请输入" clearable />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getTableMeta, crudPage, crudAdd, crudUpdate, crudDelete, crudDeleteRange,
getRefs, genCode
} from '@/api'
const props = defineProps({
/** 数据表名(未传时从路由 meta.tableName 读取) */
table: { type: String, default: '' },
/** 页面标题(未传时从路由 meta.title 读取) */
title: { type: String, default: '' }
})
const route = useRoute()
const router = useRouter()
const tableName = computed(() => props.table || route.meta.tableName || '')
const pageTitle = computed(() => props.title || route.meta.title || '')
/** 详情页路由(:id 占位,由菜单映射配置) */
const detailRoute = computed(() => route.meta.detailRoute || '')
// ================= 状态 =================
const loading = ref(false)
const saving = ref(false)
const rows = ref([])
const total = ref(0)
const selection = ref([])
const fields = ref([])
const query = reactive({ page: 1, size: 20, keyword: '', filters: {} })
const dialogVisible = ref(false)
const form = ref({})
const formRef = ref()
const isEdit = ref(false)
const rules = ref({})
/** 关联表缓存:refTable -> { items: [{value,label}], loading } */
const refCache = reactive({})
// ================= 计算属性 =================
const SYSTEM_FIELDS = ['Flag', 'AddTime', 'Adder', 'UpdateTime', 'Updater']
/** 表格列:排除系统字段与主键 */
const tableCols = computed(() =>
fields.value.filter((f) => !f.isSystem && !(f.isIdentity && f.name.toLowerCase() === 'id'))
)
/** 表单字段:排除系统字段与自增主键(主键由数据库维护) */
const formFields = computed(() =>
fields.value.filter((f) => !f.isSystem && !f.isIdentity)
)
/** 搜索区下拉:枚举/关联字段 */
const searchSelects = computed(() =>
tableCols.value.filter((f) => f.options?.length || f.refTable)
)
const dialogTitle = computed(() =>
isEdit.value ? `编辑${pageTitle.value}` : `新增${pageTitle.value}`
)
// ================= 控件类型 =================
const isTextType = (f) => f.propType === 'String'
const isNumberType = (f) =>
['Int16', 'Int32', 'Int64', 'Decimal', 'Double', 'Single', 'Byte', 'Long'].includes(f.propType)
/** 进度字段(名称为 Progress/进度)用进度条展示 */
const isProgressCol = (f) =>
['Progress', 'progress'].includes(f.name) && !f.isSystem
const isDecimalType = (f) => ['Decimal', 'Double', 'Single'].includes(f.propType)
const isDateType = (f) => f.propType === 'DateTime' || f.dbType?.includes('date')
/** 表单控件类型:code(编号) / select(枚举) / ref(关联) / switch / date / number / textarea / text */
const controlType = (f) => {
if (f.isCode) return 'code'
if (f.options?.length) return 'select'
if (f.refTable) return 'ref'
if (f.propType === 'Boolean') return 'switch'
if (isDateType(f)) return 'date'
if (isNumberType(f)) return 'number'
if (f.dbType && f.dbType.includes('text')) return 'textarea'
if (f.propType === 'String' && /(备注|说明|描述|内容|意见|原因|地址)/.test(f.label)) return 'textarea'
return 'text'
}
const colWidth = (f) => {
if (f.propType === 'DateTime') return 170
if (isNumberType(f)) return 110
if (f.dbType && f.dbType.includes('text')) return 200
if (f.options?.length || f.refTable) return 120
return 130
}
// ================= 格式化 =================
const formatDate = (v) => (v ? String(v).replace('T', ' ').slice(0, 19) : '')
const formatNumber = (v, col) => {
if (v === null || v === undefined) return ''
return isDecimalType(col) ? Number(v).toFixed(2) : v
}
const ENUM_TAG_TYPES = { 0: 'info', 1: 'success', 2: 'warning', 3: 'danger' }
const tagType = (v) => ENUM_TAG_TYPES[v] || 'info'
const enumLabel = (col, v) => col.options.find((o) => o.value === v)?.label ?? (v ?? '')
/** 关联字段显示名称 */
const refLabel = (col, v) => {
if (v === null || v === undefined || v === '') return ''
const cache = refCache[refKey(col)]
return cache?.map?.[v] ?? v
}
const refLoading = (f) => !!refCache[refKey(f)]?.loading
/** 关联下拉缓存 key:表名 + 过滤条件(同表多类型下拉区分缓存) */
const refKey = (f) => {
if (!f?.refTable) return ''
return f.refFilterField ? `${f.refTable}|${f.refFilterField}=${f.refFilterValue}` : f.refTable
}
// ================= 数据加载 =================
async function loadMeta() {
const res = await getTableMeta(tableName.value)
// 字段名统一转 camelCase,与数据接口返回的行 key 保持一致
fields.value = (res.data.fields || []).map((f) => ({
...f,
name: f.name.charAt(0).toLowerCase() + f.name.slice(1)
}))
// 预加载所有关联表下拉数据(按 表+过滤条件 去重)
const keys = [...new Set(fields.value.filter((f) => f.refTable).map(refKey))]
await Promise.all(keys.map(loadRefs))
document.title = `${pageTitle.value || res.data.title} - F9智慧缫丝系统`
}
/** 加载关联表下拉(带缓存;支持同表多类型过滤) */
async function loadRefs(key) {
if (!key || refCache[key]) return
const [table, filter] = key.split('|')
let filterField, filterValue
if (filter) {
const eq = filter.indexOf('=')
filterField = filter.slice(0, eq)
filterValue = filter.slice(eq + 1)
}
refCache[key] = { items: [], map: {}, loading: true }
try {
const res = await getRefs(table, { size: 500, filterField, filterValue })
if (res.code === 0 && Array.isArray(res.data)) {
const map = {}
res.data.forEach((i) => { map[i.value] = i.label })
refCache[key] = { items: res.data, map, loading: false }
} else {
refCache[key] = { items: [], map: {}, loading: false }
}
} catch (e) {
refCache[key] = { items: [], map: {}, loading: false }
}
}
async function loadData() {
loading.value = true
try {
const params = {
page: query.page,
size: query.size,
keyword: query.keyword || ''
}
const filters = Object.entries(query.filters).filter(([, v]) => v !== '' && v !== null && v !== undefined)
if (filters.length) params.where = Object.fromEntries(filters)
const res = await crudPage(tableName.value, params)
rows.value = res.data?.items || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
loadData()
}
function handleReset() {
query.keyword = ''
query.filters = {}
query.page = 1
loadData()
}
// ================= 新增/编辑 =================
async function openDialog(row) {
isEdit.value = !!row
form.value = {}
rules.value = {}
if (row) {
nextTick(() => {
Object.keys(row).forEach((k) => {
form.value[k] = row[k]
})
})
} else {
// 新增:自动生成业务编号
const codeField = formFields.value.find((f) => f.isCode)
if (codeField) {
try {
const res = await genCode(tableName.value, codeField.name)
if (res.code === 0) form.value[codeField.name] = res.data.value
} catch (e) {
/* 编号生成失败静默,用户可手动填写 */
}
}
}
dialogVisible.value = true
}
async function handleSave() {
saving.value = true
try {
const payload = { ...form.value }
// 清理系统字段,避免误传
SYSTEM_FIELDS.forEach((k) => delete payload[k])
if (!isEdit.value) delete payload.Id
// 清空未填写的空串,避免脏数据
formFields.value.forEach((f) => {
const v = payload[f.name]
if (v === '' || v === null || v === undefined) delete payload[f.name]
})
if (isEdit.value) {
await crudUpdate(tableName.value, payload)
ElMessage.success('修改成功')
} else {
await crudAdd(tableName.value, payload)
ElMessage.success('新增成功')
}
dialogVisible.value = false
loadData()
} catch (e) {
// 错误已由拦截器统一提示
} finally {
saving.value = false
}
}
// ================= 详情 =================
function handleDetail(row) {
if (!detailRoute.value) return
router.push(detailRoute.value.replace(':id', row.Id))
}
// ================= 删除 =================
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除「${pageTitle.value}」这条记录吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
})
await crudDelete(tableName.value, row.Id)
ElMessage.success('删除成功')
loadData()
}
async function handleBatchDelete() {
if (!selection.value.length) return
await ElMessageBox.confirm(
`确定删除选中的 ${selection.value.length} 条记录吗?`,
'批量删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
const ids = selection.value.map((r) => r.Id)
await crudDeleteRange(tableName.value, ids)
ElMessage.success('批量删除成功')
loadData()
}
// ================= 初始化 =================
onMounted(async () => {
await loadMeta()
await loadData()
})
</script>
<style scoped>
.crud-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.search-card {
border: none;
}
.search-card :deep(.el-card__body) {
padding: 14px 16px;
}
.table-card {
border: none;
}
.table-card :deep(.el-card__body) {
padding: 8px 12px 12px;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
</style>
+193
View File
@@ -0,0 +1,193 @@
<template>
<div class="tags-view">
<div class="tags-wrap">
<div
v-for="tab in visitedTabs"
:key="tab.path"
class="tag-item"
:class="{ active: tab.path === route.path }"
@click="goTab(tab)"
@contextmenu.prevent="openMenu($event, tab)"
>
<span class="dot" v-if="!tab.closable" />
<span class="tag-title">{{ tab.title }}</span>
<el-icon v-if="tab.closable" :size="12" class="tag-close" @click.stop="closeTab(tab)">
<Close />
</el-icon>
</div>
</div>
<!-- 右键菜单 -->
<div
v-show="menu.visible"
class="ctx-menu"
:style="{ left: menu.left + 'px', top: menu.top + 'px' }"
>
<div class="ctx-item" :class="{ disabled: !menu.tab || menu.tab.affix }" @click="closeCurrent">
关闭当前
</div>
<div class="ctx-item" @click="closeOthers">关闭其他</div>
<div class="ctx-item" @click="closeAll">关闭全部</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Close } from '@element-plus/icons-vue'
import { useTabsStore } from '@/stores/tabs'
const route = useRoute()
const router = useRouter()
const tabsStore = useTabsStore()
const visitedTabs = computed(() => tabsStore.tabs)
const menu = ref({ visible: false, left: 0, top: 0, tab: null })
/** 点击页签切换 */
const goTab = (tab) => {
if (tab.path === route.path) return
router.push(tab.fullPath || tab.path)
}
/** 关闭单个页签:若关闭的是当前激活页签,跳转到右侧/左侧相邻页签 */
const closeTab = (tab) => {
const index = tabsStore.tabs.findIndex((t) => t.path === tab.path)
if (index === -1 || tab.affix) return
tabsStore.removeTab(tab.path)
if (tab.path === route.path) {
const tabs = tabsStore.tabs
const next = tabs[index] || tabs[index - 1] || tabs[tabs.length - 1]
router.push(next ? next.fullPath : '/workbench')
}
}
const openMenu = (e, tab) => {
menu.value = { visible: true, left: e.clientX, top: e.clientY, tab }
}
const closeCurrent = () => {
const tab = menu.value.tab
menu.value.visible = false
if (tab) closeTab(tab)
}
const closeOthers = () => {
const tab = menu.value.tab
if (!tab) return
menu.value.visible = false
tabsStore.closeOthers(tab.path)
if (tab.path !== route.path) router.push(tab.fullPath || tab.path)
}
const closeAll = () => {
menu.value.visible = false
tabsStore.closeAll()
if (!tabsStore.tabs.some((t) => t.path === route.path)) {
const affix = tabsStore.tabs[0]
router.push(affix ? affix.fullPath : '/workbench')
}
}
// 点击页签栏以外区域关闭右键菜单
const closeMenu = () => {
menu.value.visible = false
}
onMounted(() => document.addEventListener('click', closeMenu))
onBeforeUnmount(() => document.removeEventListener('click', closeMenu))
</script>
<style scoped>
.tags-view {
height: 38px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
padding: 5px 10px 0;
box-sizing: border-box;
user-select: none;
}
.tags-wrap {
display: flex;
align-items: center;
gap: 6px;
height: 32px;
white-space: nowrap;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.tags-wrap::-webkit-scrollbar {
display: none;
}
.tag-item {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 10px;
font-size: 12px;
color: #495060;
background: #fff;
border: 1px solid #d8dce5;
border-radius: 3px;
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s;
}
.tag-item:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.tag-item.active {
color: #fff;
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.tag-item.active .dot {
background: #fff;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--el-color-primary);
flex-shrink: 0;
}
.tag-title {
line-height: 1;
}
.tag-close {
border-radius: 50%;
padding: 1px;
transition: background 0.2s;
}
.tag-close:hover {
background: rgba(0, 0, 0, 0.2);
color: #fff;
}
.ctx-menu {
position: fixed;
z-index: 3000;
min-width: 110px;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
}
.ctx-item {
padding: 7px 16px;
font-size: 13px;
color: #303133;
cursor: pointer;
}
.ctx-item:hover {
background: #f0f2f5;
color: var(--el-color-primary);
}
.ctx-item.disabled {
color: #c0c4cc;
cursor: not-allowed;
}
</style>
+141
View File
@@ -0,0 +1,141 @@
/**
* 菜单 component → 后端数据表名 映射表
* key 为后端菜单 Component(模块/页面),value 为 API 数据表名
* 未列入的 component 将按默认规则 "Module_PascalPage" 推断
*/
const TABLE_MAP = {
'BaseCommon/material': 'BaseCommon_Material',
'BaseCommon/spec': 'BaseCommon_SilkSpec',
'BaseCommon/grade': 'BaseCommon_GradeStandard',
'BaseCommon/partner': 'BaseCommon_Partner',
'BaseCommon/period': 'BaseCommon_AccountPeriod',
'BaseCommon/worktype': 'BaseCommon_WorkType',
'BaseCommon/dict': 'BaseCommon_Dict',
'RawMaterial/zhuangkou': 'RawMaterial_Zhuangkou',
'RawMaterial/instock': 'RawMaterial_InStock',
'RawMaterial/repack': 'RawMaterial_Repack',
'RawMaterial/outstock': 'RawMaterial_OutStock',
'RawMaterial/stock': 'RawMaterial_Stock',
'RawMaterial/purchase': 'RawMaterial_Purchase',
'Process/zhuangkou': 'Process_ProcessZhuangkou',
'Process/trial': 'Process_Trial',
'Process/index': 'Process_Index',
'Process/sheet': 'Process_Sheet',
'ProPlan/workorder': 'ProPlan_WorkOrder',
'ProPlan/change': 'ProPlan_Change',
'ProXuan/team': 'ProXuan_Team',
'ProXuan/daily': 'ProXuan_Daily',
'ProXuan/quality': 'ProXuan_Quality',
'ProXuan/tempstock': 'ProXuan_TempStock',
'ProQian/workshop': 'ProQian_Workshop',
'ProQian/machine': 'ProQian_Machine',
'ProQian/schedule': 'ProQian_Schedule',
'ProQian/shifttime': 'ProQian_ShiftTime',
'ProQian/cocoon': 'ProQian_CocoonBoiling',
'ProQian/thread': 'ProQian_ThreadRecord',
'ProHou/daily': 'ProHou_Daily',
'ProHou/spool': 'ProHou_SpoolLedger',
'ProHou/check': 'ProHou_SpoolCheck',
'ProHou/weigh': 'ProHou_WeighBig',
'Lims/spoolcheck': 'Lims_SpoolCheck',
'Lims/blackboard': 'Lims_BlackBoard',
'Lims/denier': 'Lims_Denier',
'Lims/moisture': 'Lims_Moisture',
'Lims/abnormal': 'Lims_Abnormal',
'Lims/grade': 'Lims_GradeResult',
'Fims/batch': 'Fims_Batch',
'Fims/package': 'Fims_Package',
'Fims/instock': 'Fims_InStock',
'Fims/outstock': 'Fims_OutStock',
'Fims/stock': 'Fims_Stock',
'Sams/customer': 'Sams_Customer',
'Sams/contract': 'Sams_Contract',
'Sams/dispatch': 'Sams_DispatchPlan',
'Sams/payment': 'Sams_Payment',
'Sams/invoice': 'Sams_Invoice',
'HRS/employee': 'HRS_Employee',
'HRS/change': 'HRS_EmployeeChange',
'HRS/attendance': 'HRS_Attendance',
'HRS/dorm': 'HRS_Dorm',
'HRS/canteen': 'HRS_CanteenRecord',
'FlexPay/config': 'FlexPay_Config',
'FlexPay/scheme': 'FlexPay_Scheme',
'FlexPay/reward': 'FlexPay_Reward',
'FlexPay/piece': 'FlexPay_PieceResult',
'FlexPay/salary': 'FlexPay_Salary',
'FlexPay/payroll': 'FlexPay_Payroll',
'Enms/meter': 'Enms_Meter',
'Enms/reading': 'Enms_Reading',
'Enms/rule': 'Enms_AllocateRule',
'Enms/result': 'Enms_AllocateResult',
'Qums/grain': 'Qums_GrainCount',
'Qums/task': 'Qums_CheckTask',
'WmsWu/stock': 'WmsWu_Stock',
'WmsWu/instock': 'WmsWu_InStock',
'WmsWu/outstock': 'WmsWu_OutStock',
'WmsWu/plan': 'WmsWu_PurchasePlan',
'WmsWu/apply': 'WmsWu_Apply',
'WmsWu/ledger': 'WmsWu_Ledger',
'WmsFu/stock': 'WmsFu_Stock',
'WmsFu/instock': 'WmsFu_InStock',
'WmsFu/outstock': 'WmsFu_OutStock',
'BizBoss/cost': 'BizBoss_CostAllocate',
'BizBoss/profit': 'BizBoss_Profit',
'RepCenter/report': 'RepCenter_Report',
'RepCenter/design': 'RepCenter_ReportDesign',
'BoardCenter/board': 'BoardCenter_Board',
'ApiCenter/internal': 'ApiCenter_InternalApi',
'ApiCenter/external': 'ApiCenter_ExternalApi',
'ApiCenter/sync': 'ApiCenter_SyncLog',
'BaseSys/menu': 'BaseSys_Menu',
'BaseSys/org': 'BaseSys_Org',
'BaseSys/role': 'BaseSys_Role',
'BaseSys/user': 'BaseSys_User'
}
/** 根据 component 推断表名 */
export function resolveTable(component) {
if (!component) return null
if (TABLE_MAP[component]) return TABLE_MAP[component]
// 默认规则:Module/page → Module_PascalPage
const [mod, page] = component.split('/')
if (!page) return null
const pascal = page.charAt(0).toUpperCase() + page.slice(1)
return `${mod}_${pascal}`
}
/** 特殊页面:不经过通用 CRUD(如工作台、看板) */
export const SPECIAL_PAGES = new Set(['WorkBench/index'])
/**
* 详情页映射:component → 详情页路由(:id 占位替换)
* 配置后,通用 CRUD 列表操作列会显示"详情"按钮
*/
export const DETAIL_MAP = {
'Process/zhuangkou': '/process/zhuangkou-detail'
}
/** 根据 component 解析详情页路由(无则返回 null) */
export function resolveDetail(component) {
return component ? DETAIL_MAP[component] || null : null
}
+304
View File
@@ -0,0 +1,304 @@
<template>
<el-container class="layout">
<!-- 侧边栏 -->
<el-aside :width="collapse ? '64px' : '220px'" class="aside">
<div class="logo" @click="$router.push('/workbench')">
<span class="logo-icon">🧵</span>
<span v-show="!collapse" class="logo-text">F9 智慧缫丝 MES</span>
</div>
<el-scrollbar class="menu-scroll">
<el-menu
:default-active="activeMenu"
:collapse="collapse"
:collapse-transition="false"
router
background-color="#001529"
text-color="rgba(255,255,255,0.68)"
active-text-color="#ffffff"
unique-opened
>
<template v-for="menu in menus" :key="menu.id">
<!-- 有子菜单 -->
<el-sub-menu v-if="menu.children && menu.children.length" :index="String(menu.id)">
<template #title>
<el-icon v-if="menu.icon"><component :is="menu.icon" /></el-icon>
<span>{{ menu.title }}</span>
</template>
<el-menu-item
v-for="child in menu.children"
:key="child.id"
:index="child.path"
>
<el-icon v-if="child.icon"><component :is="child.icon" /></el-icon>
<template #title>{{ child.title }}</template>
</el-menu-item>
</el-sub-menu>
<!-- 无子菜单直接显示 -->
<el-menu-item v-else :index="menu.path">
<el-icon v-if="menu.icon"><component :is="menu.icon" /></el-icon>
<template #title>{{ menu.title }}</template>
</el-menu-item>
</template>
</el-menu>
</el-scrollbar>
</el-aside>
<el-container class="main-wrap">
<!-- 顶栏 -->
<el-header class="navbar" height="52px">
<div class="flex-center" style="gap: 4px; cursor: pointer" @click="toggleCollapse">
<el-icon :size="20">
<Expand v-if="collapse" />
<Fold v-else />
</el-icon>
</div>
<el-breadcrumb separator="/" class="breadcrumb">
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
{{ item.title }}
</el-breadcrumb-item>
</el-breadcrumb>
<div class="flex-1"></div>
<!-- 天气 -->
<div class="weather flex-center" v-if="weather">
<el-icon><Sunny /></el-icon>
<span>{{ weather.temp }}°C</span>
<span class="weather-desc">{{ weather.desc }}</span>
</div>
<el-dropdown trigger="click" @command="handleCommand">
<div class="user-info flex-center">
<el-avatar :size="30" class="avatar">{{ avatarText }}</el-avatar>
<span class="user-name">{{ userStore.userName }}</span>
<el-icon><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="password">修改密码</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<!-- 多页签栏 -->
<TagsView />
<!-- 主内容key 绑定路由 path 区分不同菜单实例keep-alive 缓存打开过的页签状态 -->
<el-main class="content">
<keep-alive :max="20">
<router-view :key="route.path" />
</keep-alive>
</el-main>
</el-container>
</el-container>
<!-- 修改密码 -->
<el-dialog v-model="pwdVisible" title="修改密码" width="420px" destroy-on-close>
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="90px">
<el-form-item label="原密码" prop="oldPassword">
<el-input v-model="pwdForm.oldPassword" type="password" show-password placeholder="请输入原密码" />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="pwdForm.newPassword" type="password" show-password placeholder="6-20位" />
</el-form-item>
<el-form-item label="确认密码" prop="confirm">
<el-input v-model="pwdForm.confirm" type="password" show-password placeholder="再次输入新密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pwdVisible = false">取消</el-button>
<el-button type="primary" @click="submitPwd">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { changePassword, getWeather, mapWeather } from '@/api'
import TagsView from '@/components/TagsView.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const collapse = ref(false)
const pwdVisible = ref(false)
const pwdForm = ref({ oldPassword: '', newPassword: '', confirm: '' })
const pwdFormRef = ref()
const weather = ref(null)
const menus = computed(() => userStore.menus)
const activeMenu = computed(() => route.path)
const avatarText = computed(() => (userStore.userName || '管').slice(0, 1))
// 面包屑
const breadcrumbs = computed(() => {
const items = []
const path = route.path
for (const m of userStore.menus) {
if (m.path === path) {
items.push({ title: m.title, path: m.path })
break
}
if (m.children) {
const child = m.children.find((c) => c.path === path)
if (child) {
items.push({ title: m.title, path: m.path })
items.push({ title: child.title, path: child.path })
break
}
}
}
return items.length ? items : [{ title: route.meta.title || '', path }]
})
const toggleCollapse = () => (collapse.value = !collapse.value)
const handleCommand = (cmd) => {
if (cmd === 'logout') {
ElMessageBox.confirm('确定退出登录吗?', '提示', { type: 'warning' })
.then(() => {
userStore.logout()
router.push('/login')
})
.catch(() => {})
} else if (cmd === 'password') {
pwdForm.value = { oldPassword: '', newPassword: '', confirm: '' }
pwdVisible.value = true
}
}
const pwdRules = {
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度 6-20 位', trigger: 'blur' }
],
confirm: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{
validator: (r, v, cb) =>
v !== pwdForm.value.newPassword ? cb(new Error('两次输入的密码不一致')) : cb(),
trigger: 'blur'
}
]
}
async function submitPwd() {
await pwdFormRef.value.validate()
await changePassword({
oldPassword: pwdForm.value.oldPassword,
newPassword: pwdForm.value.newPassword
})
ElMessage.success('密码修改成功,请重新登录')
pwdVisible.value = false
userStore.logout()
router.push('/login')
}
// 天气
async function loadWeather() {
try {
const res = await getWeather({})
if (res.code === 0 && res.data) {
weather.value = mapWeather(res.data)
}
} catch (e) {
/* 天气失败静默 */
}
}
onMounted(loadWeather)
</script>
<style scoped>
.layout {
height: 100vh;
}
.aside {
background: #001529;
display: flex;
flex-direction: column;
transition: width 0.2s;
overflow: hidden;
}
.logo {
height: 52px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #fff;
font-weight: 600;
font-size: 15px;
cursor: pointer;
background: rgba(255, 255, 255, 0.04);
white-space: nowrap;
overflow: hidden;
}
.logo-icon {
font-size: 22px;
}
.logo-text {
letter-spacing: 1px;
}
.menu-scroll {
flex: 1;
}
.menu-scroll :deep(.el-menu) {
border-right: none;
}
.menu-scroll :deep(.el-menu-item.is-active) {
background: var(--el-color-primary);
}
.menu-scroll :deep(.el-menu-item:hover),
.menu-scroll :deep(.el-sub-menu__title:hover) {
background: rgba(255, 255, 255, 0.08);
}
.main-wrap {
min-width: 0;
}
.navbar {
background: #fff;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
gap: 12px;
}
.breadcrumb {
margin-left: 4px;
}
.weather {
gap: 4px;
font-size: 13px;
color: #606266;
padding: 0 12px;
border-left: 1px solid #e4e7ed;
}
.weather-desc {
color: #909399;
}
.user-info {
gap: 6px;
cursor: pointer;
padding: 0 4px;
outline: none;
}
.avatar {
background: var(--el-color-primary);
color: #fff;
font-size: 14px;
}
.user-name {
font-size: 14px;
color: #303133;
}
.content {
padding: 14px;
background: #f0f2f5;
overflow-y: auto;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router, { registerDynamicRoutes } from './router'
import { useUserStore } from './stores/user'
import './styles/index.css'
const pinia = createPinia()
// 刷新恢复:本地已有菜单缓存时先注册动态路由,避免首屏导航 404
const userStore = useUserStore(pinia)
if (userStore.isLogin && userStore.menus.length) {
registerDynamicRoutes(userStore.menus)
}
const app = createApp(App)
// 全局注册 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
+145
View File
@@ -0,0 +1,145 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useTabsStore } from '@/stores/tabs'
import { resolveTable, resolveDetail, SPECIAL_PAGES } from '@/config/table-map'
// 静态路由(与权限无关)
export const constantRoutes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录' }
},
{
path: '/',
name: 'root',
component: () => import('@/layout/index.vue'),
redirect: '/workbench',
children: [
{
path: '/workbench',
name: 'WorkBench',
component: () => import('@/views/workbench/index.vue'),
meta: { title: '工作台', affix: true }
},
{
path: '/404',
name: 'NotFound',
component: () => import('@/views/error/404.vue'),
meta: { title: '页面不存在' }
},
{
path: '/process/zhuangkou-detail/:id',
name: 'ProcessZhuangkouDetail',
component: () => import('@/views/process/zhuangkou-detail.vue'),
meta: { title: '工艺庄口详情', hidden: true }
}
]
},
{
path: '/:pathMatch(.*)*',
redirect: '/404',
meta: { hidden: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes: constantRoutes,
scrollBehavior: () => ({ top: 0 })
})
// 拍平菜单树,收集页面节点
export function flattenMenus(menus, arr = []) {
for (const m of menus || []) {
if (m.menuType === 1 && !SPECIAL_PAGES.has(m.component)) {
arr.push(m)
}
if (m.children?.length) flattenMenus(m.children, arr)
}
return arr
}
// 根据菜单动态注册业务路由
export function registerDynamicRoutes(menus) {
const pages = flattenMenus(menus)
for (const p of pages) {
if (router.hasRoute(p.name)) continue
const table = resolveTable(p.component)
router.addRoute('root', {
path: p.path,
name: p.path || p.component,
component: () => import('@/components/CrudPage.vue'),
meta: {
title: p.title || p.name,
tableName: table,
component: p.component,
detailRoute: resolveDetail(p.component)
}
})
}
}
let guardInited = false
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
if (to.path === '/login') {
// 退出登录/未登录跳转登录页时清空页签
useTabsStore().reset()
next()
return
}
if (!userStore.isLogin) {
next(`/login?redirect=${encodeURIComponent(to.fullPath)}`)
return
}
// 已登录:首次进入加载菜单并注册动态路由
if (!userStore.loaded) {
try {
await userStore.loadMenus()
registerDynamicRoutes(userStore.menus)
initAffixTabs()
guardInited = true
next({ ...to, replace: true })
} catch (e) {
userStore.logout()
next(`/login?redirect=${encodeURIComponent(to.fullPath)}`)
}
return
}
// 刷新后路由已注册但 guard 标记未恢复(页面刷新时 addRoute 丢失)
if (!guardInited) {
registerDynamicRoutes(userStore.menus)
initAffixTabs()
guardInited = true
next({ ...to, replace: true })
return
}
next()
})
/** 初始化固定页签(工作台) */
function initAffixTabs() {
useTabsStore().addTab({
path: '/workbench',
fullPath: '/workbench',
name: 'WorkBench',
meta: { title: '工作台', affix: true }
})
}
// 导航完成后:记录页签、同步浏览器标题(格式与 CrudPage 保持一致)
router.afterEach((to) => {
useTabsStore().addTab(to)
const title = to.meta?.title
document.title = title ? `${title} - F9智慧缫丝系统` : 'F9智慧缫丝系统'
})
export default router
+61
View File
@@ -0,0 +1,61 @@
import { defineStore } from 'pinia'
/**
* 多页签状态
* tab: { path, fullPath, title, name, affix, closable }
*/
export const useTabsStore = defineStore('tabs', {
state: () => ({
tabs: []
}),
getters: {
visitedTabs: (state) => state.tabs
},
actions: {
/** 添加页签(已存在时仅同步 fullPath);hidden 页面与无标题页面不进页签 */
addTab(route) {
if (route.meta?.hidden) return
const title = route.meta?.title
if (!title) return
const path = route.path
const existed = this.tabs.find((t) => t.path === path)
if (existed) {
if (route.fullPath && existed.fullPath !== route.fullPath) existed.fullPath = route.fullPath
return
}
this.tabs.push({
path,
fullPath: route.fullPath || path,
title,
name: route.name,
affix: !!route.meta?.affix,
closable: !route.meta?.affix
})
},
/** 移除页签,返回被移除的 tab(不存在或固定页签返回 null) */
removeTab(path) {
const index = this.tabs.findIndex((t) => t.path === path)
if (index === -1) return null
const [removed] = this.tabs.splice(index, 1)
return removed
},
/** 关闭其他页签(保留固定页签与当前页签) */
closeOthers(path) {
this.tabs = this.tabs.filter((t) => t.affix || t.path === path)
},
/** 关闭全部页签(仅保留固定页签) */
closeAll() {
this.tabs = this.tabs.filter((t) => t.affix)
},
/** 清空页签(退出登录时调用) */
reset() {
this.tabs = []
}
}
})
+52
View File
@@ -0,0 +1,52 @@
import { defineStore } from 'pinia'
import { login as apiLogin, getMe } from '@/api'
const TOKEN_KEY = 'f9mes_token'
const USER_KEY = 'f9mes_user'
const MENUS_KEY = 'f9mes_menus'
export const useUserStore = defineStore('user', {
state: () => ({
token: localStorage.getItem(TOKEN_KEY) || '',
userInfo: JSON.parse(localStorage.getItem(USER_KEY) || 'null'),
menus: JSON.parse(localStorage.getItem(MENUS_KEY) || '[]'),
loaded: false
}),
getters: {
isLogin: (state) => !!state.token,
userName: (state) =>
state.userInfo?.name || state.userInfo?.realName || state.userInfo?.userName || '管理员'
},
actions: {
async login(payload) {
const res = await apiLogin(payload)
this.token = res.data.token
this.userInfo = res.data.user
localStorage.setItem(TOKEN_KEY, this.token)
localStorage.setItem(USER_KEY, JSON.stringify(this.userInfo))
await this.loadMenus()
},
async loadMenus() {
const res = await getMe()
const data = res.data || {}
this.menus = data.menus || []
if (data.user) this.userInfo = data.user
localStorage.setItem(MENUS_KEY, JSON.stringify(this.menus))
if (data.user) localStorage.setItem(USER_KEY, JSON.stringify(this.userInfo))
this.loaded = true
},
logout() {
this.token = ''
this.userInfo = null
this.menus = []
this.loaded = false
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
localStorage.removeItem(MENUS_KEY)
}
}
})
+76
View File
@@ -0,0 +1,76 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', Arial, sans-serif;
font-size: 14px;
color: #303133;
background-color: #f0f2f5;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: #c0c4cc;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #909399;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* 通用工具类 */
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-1 {
flex: 1;
min-width: 0;
}
.text-right {
text-align: right;
}
.mt-10 {
margin-top: 10px;
}
.mb-10 {
margin-bottom: 10px;
}
/* 页面容器 */
.page-card {
background: #fff;
border-radius: 6px;
padding: 16px;
}
/* 表格内图片/标签 */
.avatar-cell img {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
vertical-align: middle;
}
+32
View File
@@ -0,0 +1,32 @@
<template>
<div class="not-found flex-center">
<div>
<div class="code">404</div>
<p class="desc">抱歉您访问的页面不存在或已被移除</p>
<el-button type="primary" @click="$router.push('/workbench')">返回工作台</el-button>
</div>
</div>
</template>
<script setup>
</script>
<style scoped>
.not-found {
height: 100%;
}
.code {
font-size: 90px;
font-weight: 700;
color: #409eff;
text-align: center;
line-height: 1;
text-shadow: 0 8px 24px rgba(64, 158, 255, 0.25);
}
.desc {
margin: 16px 0 24px;
color: #909399;
font-size: 15px;
text-align: center;
}
</style>
+120
View File
@@ -0,0 +1,120 @@
<template>
<div class="login-page">
<div class="login-bg"></div>
<div class="login-card">
<div class="login-header">
<div class="login-logo">🧵</div>
<h1 class="login-title">F9 智慧缫丝系统</h1>
<p class="login-sub">MES 生产执行管理系统</p>
</div>
<el-form ref="formRef" :model="form" :rules="rules" size="large" @keyup.enter="handleLogin">
<el-form-item prop="phone">
<el-input v-model="form.phone" placeholder="手机号" clearable>
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" show-password>
<template #prefix><el-icon><Lock /></el-icon></template>
</el-input>
</el-form-item>
<el-button type="primary" size="large" class="login-btn" :loading="loading" @click="handleLogin">
</el-button>
</el-form>
<p class="login-tip">默认账号13800000000 密码123456</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { registerDynamicRoutes } from '@/router'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const form = ref({ phone: '13800000000', password: '123456' })
const formRef = ref()
const loading = ref(false)
const rules = {
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
async function handleLogin() {
await formRef.value.validate()
loading.value = true
try {
await userStore.login({ ...form.value })
registerDynamicRoutes(userStore.menus)
ElMessage.success('登录成功')
router.push(route.query.redirect || '/workbench')
} catch (e) {
/* 错误已统一提示 */
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #0f2027 0%, #203a43 45%, #2c5364 100%);
}
.login-bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 20% 20%, rgba(64, 158, 255, 0.18), transparent 50%),
radial-gradient(ellipse at 80% 80%, rgba(64, 158, 255, 0.12), transparent 50%);
}
.login-card {
position: relative;
width: 400px;
padding: 42px 40px 28px;
background: rgba(255, 255, 255, 0.97);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-logo {
font-size: 44px;
line-height: 1;
}
.login-title {
margin: 10px 0 6px;
font-size: 22px;
color: #1f2d3d;
letter-spacing: 2px;
}
.login-sub {
font-size: 13px;
color: #909399;
}
.login-btn {
width: 100%;
letter-spacing: 6px;
margin-top: 6px;
}
.login-tip {
margin-top: 18px;
text-align: center;
font-size: 12px;
color: #c0c4cc;
}
</style>
+329
View File
@@ -0,0 +1,329 @@
<template>
<div class="detail-page">
<el-page-header @back="goBack" content="工艺庄口详情" />
<!-- 庄口档案 -->
<el-card shadow="never" class="info-card">
<el-descriptions :column="4" border>
<el-descriptions-item label="庄口编号">
<el-tag type="primary" size="small">{{ info.code }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="庄口名称">{{ info.name }}</el-descriptions-item>
<el-descriptions-item label="原料庄口">{{ zkLabel }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTagType(info.status)" size="small">
{{ enumText('status', info.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="生产进度">
<el-progress
:percentage="Number(info.progress || 0)"
:status="Number(info.progress || 0) >= 100 ? 'success' : undefined"
:stroke-width="14"
style="width: 160px"
/>
</el-descriptions-item>
<el-descriptions-item label="试产日期">{{ formatTime(info.trialDate) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(info.addTime) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ info.memo || '—' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 生产链路 -->
<el-card shadow="never" class="link-card">
<el-tabs v-model="activeTab">
<el-tab-pane v-for="sec in sections" :key="sec.key" :name="sec.key">
<template #label>
<span>{{ sec.title }}</span>
<el-badge v-if="sec.total > 0" :value="sec.total" class="tab-badge" />
</template>
<el-table v-loading="sec.loading" :data="sec.rows" border stripe size="small">
<el-table-column type="index" label="#" width="50" align="center" />
<el-table-column
v-for="col in sec.cols"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:width="col.width || 150"
:formatter="(row) => fmtCell(sec, col, row)"
show-overflow-tooltip
/>
<template #empty>
<el-empty description="暂无记录" :image-size="60" />
</template>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { crudPage, crudGet, getRefs, getTableMeta } from '@/api'
const route = useRoute()
const router = useRouter()
const id = Number(route.params.id)
const activeTab = ref('trial')
const info = ref({})
const metaCache = reactive({})
const refCache = reactive({})
// ================= 庄口档案 =================
const zkLabel = computed(() => {
const cache = refCache['RawMaterial_Zhuangkou']
return (cache?.map?.[info.value.zhuangkouId] ?? info.value.zhuangkouId) || '—'
})
// ================= 枚举解析 =================
function enumText(fieldName, v) {
if (v === null || v === undefined || v === '') return '—'
const field = metaCache['Process_ProcessZhuangkou']?.find((f) => f.name === fieldName)
if (field?.options?.length) {
return field.options.find((o) => o.value === v)?.label ?? v
}
return v
}
function statusTagType(v) {
const map = { 0: 'info', 1: 'success', 2: 'warning', 3: 'primary', 4: 'danger' }
return map[v] || 'info'
}
// ================= 分区配置 =================
const sections = ref([])
function buildSections() {
return [
{
key: 'trial', title: '试产记录', table: 'Process_Trial',
where: { processZhuangkouId: id },
cols: [
{ prop: 'billNo', label: '试产单号', width: 180 },
{ prop: 'trialType', label: '试产类型', enum: true },
{ prop: 'zhuangkouId', label: '原料庄口', refTable: 'RawMaterial_Zhuangkou' },
{ prop: 'trialMan', label: '试产人' },
{ prop: 'trialDate', label: '试产日期', width: 130 }
]
},
{
key: 'sheet', title: '工艺单', table: 'Process_Sheet',
where: { processZhuangkouId: id },
cols: [
{ prop: 'sheetNo', label: '工艺单号', width: 180 },
{ prop: 'name', label: '工艺名称' },
{ prop: 'version', label: '版本' },
{ prop: 'publishTime', label: '发布时间', width: 130 },
{ prop: 'status', label: '状态', enum: true }
]
},
{
key: 'plan', title: '生产计划', table: 'ProPlan_WorkOrder',
where: { processZhuangkouId: id },
cols: [
{ prop: 'orderNo', label: '工单号', width: 180 },
{ prop: 'targetProcess', label: '目标工序', enum: true },
{ prop: 'planInput', label: '计划投料(kg)' },
{ prop: 'actualInput', label: '实际投料(kg)' },
{ prop: 'planOutput', label: '计划产出(kg)' },
{ prop: 'actualOutput', label: '实际产出(kg)' },
{ prop: 'status', label: '状态', enum: true }
]
},
{
key: 'xuan', title: '选茧日报', table: 'ProXuan_Daily',
where: { zhuangkouId: info.value.zhuangkouId },
cols: [
{ prop: 'dailyDate', label: '日期', width: 130 },
{ prop: 'teamId', label: '班组', refTable: 'ProXuan_Team' },
{ prop: 'inputWeight', label: '领料量(kg)' },
{ prop: 'goodWeight', label: '上车茧(kg)' },
{ prop: 'wasteWeight', label: '下脚茧(kg)' }
]
},
{
key: 'qian', title: '前缫记录', table: 'ProQian_ThreadRecord',
where: { processZhuangkouId: id },
cols: [
{ prop: 'recordDate', label: '日期', width: 130 },
{ prop: 'machineId', label: '机台', refTable: 'ProQian_Machine' },
{ prop: 'shift', label: '班次', enum: true },
{ prop: 'threadCount', label: '下丝片数' },
{ prop: 'weight', label: '下丝量(kg)' }
]
},
{
key: 'hou', title: '后缫日报', table: 'ProHou_Daily',
where: { processZhuangkouId: id },
cols: [
{ prop: 'dailyDate', label: '日期', width: 130 },
{ prop: 'teamId', label: '班组', refTable: 'ProXuan_Team' },
{ prop: 'inputWeight', label: '上机丝量(kg)' },
{ prop: 'outputWeight', label: '返丝产量(kg)' },
{ prop: 'workerCount', label: '人数' }
]
},
{
key: 'check', title: '检验记录', table: 'Lims_BlackBoard',
where: { processZhuangkouId: id },
cols: [
{ prop: 'checkNo', label: '检验单号', width: 180 },
{ prop: 'checkDate', label: '检验日期', width: 130 },
{ prop: 'uniformity', label: '均匀度' },
{ prop: 'cleanliness', label: '洁净度' },
{ prop: 'grade', label: '等级' }
]
},
{
key: 'grade', title: '厂检定级', table: 'Lims_GradeResult',
where: { processZhuangkouId: id },
cols: [
{ prop: 'gradeNo', label: '判定单号', width: 180 },
{ prop: 'specId', label: '丝品位', refTable: 'BaseCommon_SilkSpec' },
{ prop: 'grade', label: '判定等级', width: 100 },
{ prop: 'grader', label: '判定人', width: 100 },
{ prop: 'gradeDate', label: '判定日期', width: 130 },
{ prop: 'status', label: '状态', enum: true }
]
},
{
key: 'fims', title: '成品批次', table: 'Fims_Batch',
where: { processZhuangkouId: id },
cols: [
{ prop: 'batchNo', label: '批次号', width: 180 },
{ prop: 'specId', label: '丝品位', refTable: 'BaseCommon_SilkSpec' },
{ prop: 'grade', label: '等级' },
{ prop: 'totalWeight', label: '总重(kg)' },
{ prop: 'packageCount', label: '件数' },
{ prop: 'status', label: '状态', enum: true }
]
},
{
key: 'stock', title: '成品库存', table: 'Fims_Stock',
where: { processZhuangkouId: id },
cols: [
{ prop: 'specId', label: '丝品位', refTable: 'BaseCommon_SilkSpec' },
{ prop: 'grade', label: '等级' },
{ prop: 'stockWeight', label: '结存(kg)' },
{ prop: 'packageCount', label: '件数' }
]
}
].filter((s) => !s.needZk || info.value.zhuangkouId)
}
// ================= 单元格格式化 =================
function fmtCell(sec, col, row) {
const v = row[col.prop]
if (v === null || v === undefined || v === '') return '—'
if (col.enum) {
const field = metaCache[sec.table]?.find((f) => f.name === col.prop)
if (field?.options?.length) {
const opt = field.options.find((o) => o.value === v)
if (opt) return opt.label
}
return v
}
if (col.refTable) {
const cache = refCache[col.refTable]
return cache?.map?.[v] ?? v
}
if (/Date/i.test(col.prop)) return formatTime(v)
if (typeof v === 'number') return Number.isInteger(v) ? v : v.toFixed(2)
return v
}
// ================= 数据加载 =================
const formatTime = (v) => (v ? String(v).replace('T', ' ').slice(0, 10) : '')
async function loadMeta(table) {
if (metaCache[table]) return
try {
const res = await getTableMeta(table)
metaCache[table] = (res.data?.fields || []).map((f) => ({
...f,
name: f.name.charAt(0).toLowerCase() + f.name.slice(1)
}))
} catch (e) {
metaCache[table] = []
}
}
async function loadRefs(refTable) {
if (!refTable || refCache[refTable]) return
refCache[refTable] = { items: [], map: {} }
try {
const res = await getRefs(refTable, { size: 200 })
const map = {}
;(res.data || []).forEach((i) => { map[i.value] = i.label })
refCache[refTable] = { items: res.data || [], map }
} catch (e) {
refCache[refTable] = { items: [], map: {} }
}
}
async function loadSection(sec) {
sec.loading = true
try {
const res = await crudPage(sec.table, {
page: 1, size: 5,
where: sec.where || {}
})
sec.rows = res.data?.items || []
sec.total = res.data?.total || 0
// 预取元数据与关联下拉
await loadMeta(sec.table)
const refTables = [...new Set((sec.cols || []).map((c) => c.refTable).filter(Boolean))]
await Promise.all(refTables.map(loadRefs))
} catch (e) {
sec.rows = []
sec.total = 0
} finally {
sec.loading = false
}
}
const goBack = () => router.back()
onMounted(async () => {
// 庄口档案
try {
const res = await getTableMeta('Process_ProcessZhuangkou')
metaCache['Process_ProcessZhuangkou'] = (res.data?.fields || []).map((f) => ({
...f,
name: f.name.charAt(0).toLowerCase() + f.name.slice(1)
}))
await loadRefs('RawMaterial_Zhuangkou')
const r = await crudGet('Process_ProcessZhuangkou', id)
info.value = r.data || {}
} catch (e) {
/* 档案加载失败静默 */
}
sections.value = buildSections()
sections.value.forEach((s) => { s.rows = []; s.total = 0; s.loading = false })
await Promise.all(sections.value.map(loadSection))
})
</script>
<style scoped>
.detail-page {
display: flex;
flex-direction: column;
gap: 14px;
}
.info-card {
border: none;
}
.link-card {
border: none;
}
.link-card :deep(.el-card__body) {
padding: 8px 12px 14px;
}
.tab-badge {
margin-left: 6px;
margin-top: -2px;
}
</style>
+488
View File
@@ -0,0 +1,488 @@
<template>
<div class="workbench">
<!-- 统计卡片 -->
<el-row :gutter="14" class="stat-row">
<el-col v-for="card in statCards" :key="card.title" :xs="12" :sm="12" :md="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-item">
<div class="stat-icon" :style="{ background: card.bg, color: card.color }">
<el-icon :size="26"><component :is="card.icon" /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ card.value }}</div>
<div class="stat-title">{{ card.title }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 图表区 -->
<el-row :gutter="14" class="chart-row">
<el-col :xs="24" :md="16">
<el-card shadow="never">
<template #header>
<div class="card-title"> 30 日产丝趋势kg</div>
</template>
<div ref="trendRef" class="chart trend-chart"></div>
</el-card>
</el-col>
<el-col :xs="24" :md="8">
<el-card shadow="never">
<template #header>
<div class="card-title">庄口产量占比近30天</div>
</template>
<div ref="pieRef" class="chart pie-chart"></div>
</el-card>
</el-col>
</el-row>
<!-- 待办 / 预警 -->
<el-row :gutter="14">
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header>
<div class="card-title">
待办任务
<el-badge v-if="todoCount" :value="todoCount" type="primary" class="head-badge" />
</div>
</template>
<div v-if="todos.length" class="todo-list">
<div
v-for="(t, i) in todos"
:key="i"
class="todo-item"
:class="{ clickable: t.url }"
@click="t.url && $router.push(t.url)"
>
<span class="todo-type" :class="t.type === '审批' ? 'type-flow' : 'type-order'">{{ t.type }}</span>
<span class="todo-title">{{ t.title }}</span>
<span class="todo-status" v-if="t.statusText">{{ t.statusText }}</span>
<span class="todo-time">{{ t.time }}</span>
</div>
</div>
<el-empty v-else description="暂无待办任务" :image-size="60" />
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header>
<div class="card-title">
风险预警
<el-badge v-if="warnings.length" :value="warnings.length" type="danger" class="head-badge" />
</div>
</template>
<div v-if="warnings.length" class="warn-list">
<div
v-for="w in warnings"
:key="w.id"
class="warn-item"
:class="{ clickable: w.url }"
@click="w.url && $router.push(w.url)"
>
<el-icon class="warn-icon" color="#f56c6c"><Warning /></el-icon>
<div class="warn-body">
<div class="warn-title">{{ w.title }}</div>
<div class="warn-content">{{ w.content }}<span class="warn-time">{{ w.time }}</span></div>
</div>
</div>
</div>
<el-empty v-else description="暂无预警" :image-size="60" />
</el-card>
</el-col>
</el-row>
<!-- 底部天气 + 快捷入口 -->
<el-row :gutter="14">
<el-col :xs="24" :md="10">
<el-card shadow="never">
<template #header>
<div class="card-title">今日天气</div>
</template>
<div v-if="weather" class="weather-box flex-center">
<span class="weather-icon">{{ weatherIcon }}</span>
<div>
<div class="weather-temp">{{ weather.temp }}°C</div>
<div class="weather-desc">{{ weather.desc }} {{ weather.wind }}</div>
<div class="weather-loc">{{ weather.location }}</div>
</div>
</div>
<el-empty v-else description="天气服务暂不可用" :image-size="60" />
</el-card>
</el-col>
<el-col :xs="24" :md="14">
<el-card shadow="never">
<template #header>
<div class="card-title">快捷入口</div>
</template>
<div class="quick-grid">
<div
v-for="item in quickMenus"
:key="item.path"
class="quick-item"
@click="$router.push(item.path)"
>
<el-icon :size="24" :color="item.color"><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import * as echarts from 'echarts'
import { useUserStore } from '@/stores/user'
import { getWeather, mapWeather, getWorkBenchSummary } from '@/api'
const userStore = useUserStore()
const trendRef = ref()
const pieRef = ref()
const weather = ref(null)
const todos = ref([])
const warnings = ref([])
const summary = ref({ cards: {}, trend: [], zhuangkou: [] })
let trendChart = null
let pieChart = null
const statCards = computed(() => {
const c = summary.value.cards || {}
const todoTotal = (c.workOrderPending || 0) + (c.flowTodo || 0)
const machineText = c.machineTotal ? `${c.machineRunning} / ${c.machineTotal}` : (c.machineRunning || 0)
return [
{ title: '今日产丝(kg)', value: fmtNum(c.todayOutput), icon: 'Goods', color: '#409eff', bg: 'rgba(64,158,255,0.12)' },
{ title: '在线机组(台)', value: machineText, icon: 'Cpu', color: '#67c23a', bg: 'rgba(103,194,58,0.12)' },
{ title: '待办任务(项)', value: todoTotal, icon: 'List', color: '#e6a23c', bg: 'rgba(230,162,60,0.12)' },
{ title: '库存预警(项)', value: c.warningCount || 0, icon: 'Warning', color: '#f56c6c', bg: 'rgba(245,108,108,0.12)' }
]
})
const todoCount = computed(() => todos.value.length)
// 快捷入口:从菜单取前 12 个页面
const quickMenus = computed(() => {
const pages = []
for (const m of userStore.menus) {
for (const c of m.children || []) {
pages.push({
title: c.title,
path: c.path,
icon: c.icon || 'Document',
color: ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399', '#9c27b0'][pages.length % 6]
})
if (pages.length >= 12) break
}
if (pages.length >= 12) break
}
return pages
})
const weatherIcon = computed(() => {
const d = weather.value?.desc || ''
if (d.includes('雨')) return '🌧️'
if (d.includes('雪')) return '❄️'
if (d.includes('云')) return '⛅'
if (d.includes('晴')) return '☀️'
return '🌤️'
})
function fmtNum(v) {
if (v === null || v === undefined) return '0'
return Number(v).toLocaleString('en-US', { maximumFractionDigits: 1 })
}
function initCharts() {
trendChart = echarts.init(trendRef.value)
pieChart = echarts.init(pieRef.value)
renderCharts()
}
function renderCharts() {
const trend = summary.value.trend || []
const zk = summary.value.zhuangkou || []
const allZero = trend.every((x) => !x.weight)
trendChart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 46, right: 16, top: 30, bottom: 26 },
xAxis: {
type: 'category',
data: trend.map((x) => x.date),
boundaryGap: false,
axisLine: { lineStyle: { color: '#dcdfe6' } }
},
yAxis: { type: 'value', name: 'kg', splitLine: { lineStyle: { color: '#f0f2f5' } } },
series: [
{
name: '产丝量',
type: 'line',
smooth: true,
symbol: 'none',
data: allZero ? [] : trend.map((x) => x.weight),
lineStyle: { width: 3, color: '#409eff' },
areaStyle: allZero
? undefined
: {
color: {
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(64,158,255,0.28)' },
{ offset: 1, color: 'rgba(64,158,255,0.02)' }
]
}
}
}
]
})
pieChart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c}kg ({d}%)' },
legend: { bottom: 0, textStyle: { fontSize: 11 } },
series: [
{
type: 'pie',
radius: ['42%', '68%'],
center: ['50%', '44%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
data: zk.length ? zk : [{ value: 1, name: '暂无数据' }]
}
]
})
}
function resizeCharts() {
trendChart?.resize()
pieChart?.resize()
}
async function loadSummary() {
try {
const res = await getWorkBenchSummary()
if (res.code === 0 && res.data) {
summary.value = res.data
todos.value = res.data.todos || []
warnings.value = res.data.warnings || []
await nextTick()
renderCharts()
}
} catch (e) {
/* 统计接口异常时保留空数据展示 */
}
}
async function loadWeather() {
try {
const res = await getWeather({})
if (res.code === 0 && res.data) weather.value = mapWeather(res.data)
} catch (e) {
/* 静默 */
}
}
onMounted(() => {
initCharts()
loadWeather()
loadSummary()
window.addEventListener('resize', resizeCharts)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeCharts)
trendChart?.dispose()
pieChart?.dispose()
})
</script>
<style scoped>
.workbench {
display: flex;
flex-direction: column;
gap: 14px;
}
.stat-card {
border: none;
}
.stat-item {
display: flex;
align-items: center;
gap: 14px;
}
.stat-icon {
width: 52px;
height: 52px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #303133;
line-height: 1.2;
}
.stat-title {
font-size: 13px;
color: #909399;
margin-top: 2px;
}
.card-title {
font-weight: 600;
color: #303133;
display: flex;
align-items: center;
gap: 8px;
}
.head-badge {
display: inline-flex;
}
.chart {
width: 100%;
}
.trend-chart {
height: 300px;
}
.pie-chart {
height: 300px;
}
.todo-list,
.warn-list {
display: flex;
flex-direction: column;
}
.todo-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 4px;
border-bottom: 1px solid #f0f2f5;
font-size: 13px;
}
.todo-item:last-child {
border-bottom: none;
}
.todo-item.clickable,
.warn-item.clickable {
cursor: pointer;
}
.todo-item.clickable:hover .todo-title,
.warn-item.clickable:hover .warn-title {
color: #409eff;
}
.todo-type {
flex-shrink: 0;
font-size: 12px;
padding: 1px 8px;
border-radius: 4px;
}
.type-order {
color: #409eff;
background: rgba(64, 158, 255, 0.1);
}
.type-flow {
color: #9c27b0;
background: rgba(156, 39, 176, 0.1);
}
.todo-title {
flex: 1;
color: #303133;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.todo-status {
flex-shrink: 0;
font-size: 12px;
color: #e6a23c;
}
.todo-time {
flex-shrink: 0;
font-size: 12px;
color: #909399;
}
.warn-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 4px;
border-bottom: 1px solid #f0f2f5;
}
.warn-item:last-child {
border-bottom: none;
}
.warn-icon {
margin-top: 2px;
flex-shrink: 0;
}
.warn-body {
flex: 1;
min-width: 0;
}
.warn-title {
font-size: 13px;
color: #303133;
font-weight: 600;
}
.warn-content {
font-size: 12px;
color: #909399;
margin-top: 2px;
display: flex;
justify-content: space-between;
gap: 8px;
}
.warn-time {
flex-shrink: 0;
}
.weather-box {
gap: 18px;
padding: 8px 0;
}
.weather-icon {
font-size: 52px;
}
.weather-temp {
font-size: 30px;
font-weight: 700;
color: #303133;
}
.weather-desc {
font-size: 13px;
color: #606266;
margin-top: 2px;
}
.weather-loc {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
padding: 4px 0;
}
.quick-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 10px 4px;
border-radius: 8px;
transition: background 0.2s;
font-size: 13px;
color: #606266;
}
.quick-item:hover {
background: #f5f7fa;
color: #409eff;
}
</style>
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5173,
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:5136',
changeOrigin: true
}
}
},
build: {
chunkSizeWarningLimit: 2000
}
})