init: F9智慧缫丝系统 - 后端(server) + 主前端(web) + 打印设计器(openprint) 首次提交
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
Generated
+2006
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "outputMode": "stdout" }
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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 = []
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user