云
17 小时以前 c2e8526f8f857be5e3a8c6f2b39c5efd424de33e
refactor(auth): 优化认证逻辑和界面组件结构

- 登出接口增加租户参数支持多租户场景
- 实现登录登出并发控制防止竞态条件
- 重构认证存储逻辑增加版本控制机制
- 更新认证页面布局为AuthScene组件
- 移除过时的MES生产工单状态枚举
- 调整多个表格列显示去除客户编码字段
- 在工艺路线表单中新增收率字段
- 完善质量检验相关数据表格结构
- 更新日期范围选择器工具函数引用
- 优化文档审核表单字段映射逻辑
已添加18个文件
已修改84个文件
2554 ■■■■■ 文件已修改
DESIGN.md 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/images/auth/herbal-courtyard.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/bi-realtime-table-qa.mjs 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/check-logout-browser.cjs 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/check-logout.cjs 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/login-theme-live-qa.cjs 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/one-click-schedule-qa.mjs 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/spare-management-qa.mjs 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/core/auth.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/dv/spare/index.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/md/client/index.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pd/document-audit/index.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/mps/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/route/process/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/task/index.ts 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/workorder/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/date-filter.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/indicatorresult/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/ipqc/index.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/iqc/index.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/ledger/index.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/oqc/index.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/rqc/index.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/batch/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/packages/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/productissue/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/productsales/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/returnsales/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/salesnotice/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/request.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wls/batch/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wls/packages/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wls/productissue/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wls/productsales/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wls/returnsales/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wls/salesnotice/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/bootstrap.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/engine/report-evaluator.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layouts/auth-scene.vue 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layouts/auth.vue 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main.ts 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/ui-kit/shadcn-ui/src/components/input-password/input-password.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/biz-mes-enum.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/common-ui/src/ui/authentication/login.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/hooks/src/use-design-tokens.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/request/src/request-client/preset-interceptors.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/request/src/request-client/request-client.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/preferences.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/auth.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/styles/herbal-theme.css 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/rangePickerProps.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/_core/authentication/login.vue 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/index.vue 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/modules/RealtimeTable.vue 164 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/warehouse/index.vue 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/data.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/repair/modules/form.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/spare-record/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/spare/index.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/spare/modules/action.vue 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/spare/modules/form.vue 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/spare/modules/records.vue 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/client/components/select.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/client/data.ts 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/modules/document-audit-form.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/modules/document-audit-list.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/card/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/card/modules/form.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/mps/data.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/mps/modules/form.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/data.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/data.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/index.vue 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/modules/auto-schedule.vue 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder-center/index.vue 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/data.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/data.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/modules/workbench.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/data.ts 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/indicatorresult/components/data.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/indicatorresult/components/form.vue 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/indicatorresult/components/list.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ipqc/data.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/iqc/data.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ledger/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/oqc/data.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/rqc/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/barcode/components/printer-label.vue 164 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/batch/data.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/itemreceipt/modules/line-list.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/outsourcereceipt/modules/line-list.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/packages/data.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productreceipt/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productreceipt/modules/line-list.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productsales/data.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnissue/modules/line-list.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnsales/data.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnsales/modules/line-list.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnvendor/modules/line-list.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/transfer/modules/line-list.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
DESIGN.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,72 @@
---
name: è¯å ‚主题
description: æœ±çº¢ã€æš–金与宣纸白构成的登录场景及应用主题
colors:
  primary: "#a51d24"
  primary-dark: "hsl(357 64% 73%)"
  paper: "hsl(40 50% 99%)"
  paper-deep: "hsl(37 33% 95%)"
  ink: "hsl(27 16% 17%)"
  border: "hsl(33 25% 86%)"
  dark-background: "hsl(26 16% 12%)"
  dark-card: "hsl(26 15% 14%)"
  dark-foreground: "hsl(35 30% 90%)"
  frame-gold: "#cda878"
typography:
  display:
    fontFamily: '"STKaiti", "KaiTi", "FangSong", serif'
    fontWeight: 700
  body:
    fontFamily: "var(--font-family, system-ui, sans-serif)"
rounded:
  control: "9px"
components:
  login-button:
    backgroundColor: "{colors.primary}"
    textColor: "#ffffff"
    rounded: "{rounded.control}"
---
# Design System: è¯å ‚主题
## Overview
**Creative North Star: "宣纸上的药堂"**
以已确认的朱红、暖金、宣纸白与药堂庭院参考为依据。登录场景用药材与建筑承载气氛,表单保持清晰;应用内部沿用现有组件与布局,通过全局语义色传递同一视觉身份。登录页的堂训、装饰框与庭院构图不推广到业务页面。
## Colors
朱红承担主要操作与选中态,暖金用于登录场景的细框和装饰;暖白与墨色承担日常阅读。`src/preferences.ts` å®šä¹‰é»˜è®¤ä¸»è‰²ï¼Œ`src/styles/herbal-theme.css` å®šä¹‰æ˜Žæš—中性色;主色色阶由偏好系统生成。
**The ç”¨æˆ·åå¥½ Rule.** é¦–次主题升级迁移到默认朱红,此后保留用户选择的主色与明暗模式。亮朱砂暗色主色覆盖只在当前主色等于默认朱红时启用。迁移细节与存储限制见[登录主题记录](../docs/design/login-herbal-theme.md)。
## Typography
正文继承应用字体变量与系统无衬线回退,保持表单和业务数据可读。公司名称、堂训采用本机楷体/仿宋回退,不下载字体;不同操作系统的字形会有差异。登录标题使用正文体系的粗体,避免把装饰字体用于输入内容。
## Layout
业务界面保留既有布局密度与组件结构。登录桌面采用左侧堂训、右侧表单,宽屏限制表单宽度;小于等于 900px æ”¹ä¸ºå•列表单并隐藏堂训,小于等于 540px æ”¶ç´§è¾¹è·ã€å…è®¸å“ç‰ŒåŒºåŸŸæ¢è¡Œã€‚语言和主题工具保留在页首。短桌面视口降低表单最小高度和内边距。
## Elevation & Depth
庭院图片位于登录内容下层;浅色表单用接近不透明的纸白底与柔和暖色阴影提供阅读层。暗色降低图片亮度与饱和度,表单改用深棕底。应用内部依赖现有卡片、浮层语义色,不追加场景装饰。
## Shapes
登录输入框与主按钮使用轻圆角;表单外壳使用更宽的圆角,暖金细线和四角纹样仅属于认证场景。框线与纹样为装饰,不承载交互。
## Components
认证布局继续渲染真实路由,保留现有 `AuthenticationLogin`、表单校验、加载态、验证码与认证 store è°ƒç”¨ã€‚输入支持账号/当前密码自动填充;可见焦点环明确定位键盘操作。密码切换为明文后仍保留显隐按钮的右侧空间(44px),避免文字与按钮重叠。暗色工具条采用深色底。标题旁使用无文字几何 SVG çº¹æ ·ã€‚
**The å…¬å¸èº«ä»½ Rule.** å…¬å¸åç§°ã€Logo、登录标题和描述仍读取现有配置。药堂背景与装饰属于主题,不能替代公司 Logo;未配置公司标志时隐藏框架示例 Logo。
## Do's and Don'ts
- ä½¿ç”¨è¯­ä¹‰è‰²ä¸ŽçŽ°æœ‰åå¥½ç³»ç»Ÿæ‰©å±•ä¸šåŠ¡é¡µé¢ï¼›ä¸šåŠ¡çŠ¶æ€è‰²ä¿ç•™åŽŸæœ‰å«ä¹‰ã€‚
- ä¿ç•™å…¬å¸åŠ¨æ€é…ç½®ã€çœŸå®žè®¤è¯é“¾è·¯ä¸Žç”¨æˆ·åŽç»­ä¸»é¢˜åå¥½ã€‚
- ä¸æŠŠèƒŒæ™¯ä¸­çš„药堂场景或几何装饰解释为企业认证、商标或产品资质。
- ä¸åœ¨ç”Ÿæˆçš„背景中绘制文字、Logo、表单或其他 UI。
- ä¸æŠŠ mock æˆªå›¾éªŒè¯æè¿°ä¸ºçœŸå®žåŽç«¯ç™»å½•成功。
public/images/auth/herbal-courtyard.png
scripts/bi-realtime-table-qa.mjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(path.join(root, 'package.json'));
const { parse, compileScript } = require('vue/compiler-sfc');
const ts = require('typescript');
const { chromium } = require('C:/Users/云/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const source = fs.readFileSync(path.join(root, 'src/views/bi/dashboard/modules/RealtimeTable.vue'), 'utf8');
const { descriptor } = parse(source);
const script = compileScript(descriptor, { id: 'qa', inlineTemplate: true });
const module = ts.transpileModule(script.content, {
  compilerOptions: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext },
}).outputText.replace(/from ['"]vue['"]/g, 'from "/vue.js"');
const html = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>BI æ»šåŠ¨éªŒè¯</title>
<style>body{background:#020817;color:#ebf0fc;font-family:Microsoft YaHei,sans-serif;margin:24px}h1{font-size:18px}main{max-width:1100px;margin:auto}.card{padding:16px;background:#0a1834;border:1px solid #153554;border-radius:8px} ${descriptor.styles.map(s => s.content).join('\n')}</style>
<main><h1>最近入库记录</h1><div class="card"><div id="app"></div></div></main>
<script type="module">
import {createApp, h, ref} from '/vue.js';import Table from '/table.js';
const rows=ref([]), height=ref(180), show=ref(true); const makeRows=n=>Array.from({length:n},(_,i)=>({'入库单号':'RK-'+String(i+1).padStart(4,'0'),'产品名称':'验证产品 '+(i+1),'仓库':'南通仓','数量':100+i}));
rows.value=makeRows(20); window.qa={setRows:n=>rows.value=makeRows(n), refresh:()=>rows.value=[...rows.value], setHeight:n=>height.value=n, hide:()=>show.value=false};
createApp({render:()=>show.value?h(Table,{rows:rows.value,maxHeight:height.value,speed:30}):null}).mount('#app');
</script></html>`;
const vueFile = path.join(path.dirname(require.resolve('vue/package.json')), 'dist/vue.esm-browser.js');
const server = http.createServer((request, response) => {
  response.setHeader('Content-Type', request.url === '/' ? 'text/html; charset=utf-8' : 'text/javascript; charset=utf-8');
  response.end(request.url === '/table.js' ? module : request.url === '/vue.js' ? fs.readFileSync(vueFile) : html);
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
let browser;
const errors = [];
const output = path.join(root, '../scripts/bi-realtime-scroll-20261014');
fs.mkdirSync(output, { recursive: true });
try {
  browser = await chromium.launch({ headless: true, executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe' });
  const page = await browser.newPage({ viewport: { width: 1440, height: 800 } });
  page.on('pageerror', error => errors.push(error.message));
  await page.goto(`http://127.0.0.1:${server.address().port}`);
  await page.waitForFunction(() => document.querySelector('.table-track')?.getAnimations().length === 1);
  const offset = () => page.locator('.table-track').evaluate(el => new DOMMatrix(getComputedStyle(el).transform).m42);
  const top = await page.locator('.header-table').evaluate(el => el.getBoundingClientRect().top);
  const first = await offset();
  await page.waitForTimeout(500);
  assert.ok((await offset()) < first - 8, 'Rows must move upwards');
  assert.equal(await page.locator('.header-table').evaluate(el => el.getBoundingClientRect().top), top, 'Header must stay fixed');
  await page.locator('.realtime-table').hover();
  const paused = await offset();
  await page.waitForTimeout(250);
  assert.ok(Math.abs((await offset()) - paused) < 1, 'Hover must pause');
  await page.mouse.move(0, 0);
  await page.waitForTimeout(200);
  assert.ok((await offset()) < paused - 3, 'Mouse leave must resume');
  const refreshOffset = await offset();
  await page.evaluate(() => window.qa.refresh());
  await page.waitForTimeout(100);
  assert.ok((await offset()) < refreshOffset, 'Refresh must preserve scroll position');
  await page.locator('.table-track').evaluate(el => {
    const animation = el.getAnimations()[0];
    animation.currentTime = Number(animation.effect.getTiming().duration) - 200;
  });
  await page.waitForTimeout(400);
  assert.ok((await offset()) > -20, 'Loop must wrap seamlessly');
  await page.locator('.realtime-table').focus();
  const focused = await offset();
  await page.waitForTimeout(100);
  assert.ok(Math.abs((await offset()) - focused) < 1, 'Focus must pause');
  await page.keyboard.press('ArrowDown');
  assert.ok((await offset()) < focused - 35, 'Keyboard must advance rows');
  await page.evaluate(() => document.activeElement.blur());
  await page.screenshot({ path: path.join(output, 'desktop.png'), fullPage: true });
  await page.setViewportSize({ width: 390, height: 844 });
  await page.waitForTimeout(150);
  assert.ok(await page.locator('.table-track').evaluate(el => el.getAnimations().length === 1), 'Resize must keep one animation');
  await page.screenshot({ path: path.join(output, 'mobile.png'), fullPage: true });
  await page.evaluate(() => window.qa.setRows(2));
  await page.waitForTimeout(150);
  assert.equal(await page.locator('.table-track').evaluate(el => el.getAnimations().length), 0, 'Short table must not scroll');
  assert.equal(await page.locator('table[aria-hidden="true"]').count(), 0, 'Short table must not duplicate rows');
  await page.evaluate(() => window.qa.setRows(30));
  await page.waitForFunction(() => document.querySelector('.table-track').getAnimations().length === 1);
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.waitForTimeout(150);
  assert.equal(await page.locator('.table-track').evaluate(el => el.getAnimations().length), 0, 'Reduced motion must disable autoplay');
  assert.equal(await page.locator('.table-viewport').evaluate(el => getComputedStyle(el).overflowY), 'auto', 'Reduced motion must allow manual scroll');
  await page.emulateMedia({ reducedMotion: 'no-preference' });
  await page.waitForFunction(() => document.querySelector('.table-track').getAnimations().length === 1);
  await page.evaluate(() => window.qa.setHeight(2000));
  await page.waitForTimeout(150);
  assert.equal(await page.locator('.table-track').evaluate(el => el.getAnimations().length), 0, 'Larger viewport must stop scrolling when rows fit');
  await page.evaluate(() => window.qa.hide());
  await page.waitForTimeout(100);
  assert.equal(await page.evaluate(() => document.getAnimations().length), 0, 'Unmount must clean up animations');
  assert.deepEqual(errors, []);
  const result = { passed: true, checks: ['continuous scroll', 'fixed header', 'hover pause/resume', 'refresh continuity', 'seamless loop', 'keyboard navigation', 'responsive resize', 'short data', 'reduced motion', 'unmount cleanup'], screenshots: ['desktop.png', 'mobile.png'] };
  fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2));
  console.log(JSON.stringify(result));
} finally {
  await browser?.close();
  server.close();
}
scripts/check-logout-browser.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
// Real user dropdown, confirmation and router; all APIs simulated.
const assert=require('node:assert/strict'),fs=require('fs'),path=require('path');
const {chromium}=require('C:/Users/云/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
(async()=>{
 const server=await(await import('vite')).createServer({root:path.resolve(__dirname,'..'),server:{host:'127.0.0.1',port:5183}});await server.listen();
 const browser=await chromium.launch({headless:true,executablePath:'C:/Program Files/Google/Chrome/Application/chrome.exe'});
 const page=await browser.newPage({viewport:{width:1440,height:950},reducedMotion:'reduce'});const errors=[];let logoutCalls=0;let releaseLogout;let logoutHeader;
 page.on('pageerror',e=>errors.push(e.message));
 await page.route('**/admin-api/**',async route=>{
   const url=route.request().url();
   if(url.includes('/system/auth/logout')){logoutCalls++;logoutHeader=route.request().headers().authorization;await new Promise(r=>releaseLogout=r);}
   const data=url.includes('company-config')?{companyName:'退出验证平台'}:url.includes('count')?0:url.includes('list')?[]:null;
   await route.fulfill({contentType:'application/json',body:JSON.stringify({code:0,data,msg:''})}).catch(()=>{});
 });
 try{
  await page.goto('http://127.0.0.1:5183/auth/login',{waitUntil:'domcontentloaded'});await page.getByRole('heading',{name:'欢迎回来'}).waitFor();
  await page.waitForFunction(()=>!document.querySelector('#__app-loading__')||getComputedStyle(document.querySelector('#__app-loading__')).opacity==='0');
  await page.evaluate(async()=>{
   const stores=await import('/src/packages/stores/src/index.ts'),{router}=await import('/src/router/index.ts'),{h}=await import('/node_modules/.vite/deps/vue.js');
   stores.useAccessStore().setAccessToken('qa-session-token');stores.useAccessStore().setRefreshToken('qa-refresh-token');stores.useAccessStore().setIsAccessChecked(true);
   stores.useUserStore().setUserInfo({userId:'1',username:'logout-qa',nickname:'退出验证用户',avatar:''});
   router.addRoute('Root',{name:'LogoutQa',path:'/qa-logout-session',meta:{title:'退出验证工作区'},component:{render(){return h('div','退出验证工作区')}}});
   await router.push('/qa-logout-session');
  });
  await page.locator('[id="__vben_main_content"]').getByText('退出验证工作区',{exact:true}).waitFor({timeout:60000});
  const avatars=page.locator('button[aria-haspopup="menu"]').filter({has:page.locator('.size-8')}).first();
  await avatars.click();
  await page.getByRole('menuitem',{name:/退出登录/}).click();
  const dialog=page.getByRole('dialog');await dialog.waitFor();
  const started=Date.now();await dialog.getByRole('button',{name:/^ç¡®\s*[认定]$/}).click();
  await page.waitForURL('**/auth/login',{timeout:5000});await page.getByRole('heading',{name:'欢迎回来'}).waitFor();
  const elapsed=Date.now()-started;
  const state=await page.evaluate(async()=>{const s=await import('/src/packages/stores/src/index.ts');return{access:s.useAccessStore().accessToken,refresh:s.useAccessStore().refreshToken,user:s.useUserStore().userInfo}});
  assert.equal(state.access,null);assert.equal(state.refresh,null);assert.equal(state.user,null);
  assert.equal(logoutCalls,1);assert.equal(logoutHeader,'Bearer qa-session-token');assert.equal(typeof releaseLogout,'function','Server logout must still be pending when login page appears');
  releaseLogout();await page.waitForTimeout(100);
  const result={passed:true,scope:'Real application user dropdown, confirmation, stores and routing; simulated authenticated session and pending backend, no real account login',checks:['click logout and confirm','navigate without refresh while server pending','access and refresh token cleared','user info cleared','server revoke uses original token'],elapsedMs:elapsed,errors};
  assert.deepEqual(errors,[]);
  const out=path.resolve(__dirname,'../../scripts/logout-fix-20261015');fs.mkdirSync(out,{recursive:true});fs.writeFileSync(path.join(out,'browser-result.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result));
 }catch(error){console.error(JSON.stringify({message:error.message,errors,body:(await page.locator('body').innerText()).slice(0,1800)}));throw error;}
 finally{releaseLogout?.();await browser.close();await server.close();}
})();
scripts/check-logout.cjs
@@ -62,7 +62,8 @@
    'vue-router': { useRouter: () => ({ currentRoute: vue.ref({ fullPath: '/analytics?tab=1' }), replace: async (to) => navigations.push(to) }) },
    '@vben/constants': { LOGIN_PATH: '/auth/login' }, '@vben/preferences': {},
    '@vben/stores': { ...setup, ...accessModule, ...userModule }, 'ant-design-vue': {}, '#/locales': {},
    '#/api': { logoutApi: async (token) => { apiCalls.push(token); if (apiFails) throw new Error('offline'); } },
    '#/store/audit-policy': { automaticApproval: vue.ref(false), loadAuditPolicy: async () => {} },
    '#/api': { logoutApi: async (token) => { apiCalls.push(token); if (apiFails === 'pending') return new Promise(() => {}); if (apiFails) throw new Error('offline'); } },
  }).useAuthStore();
  const errors = [];
  const originalError = console.error;
@@ -90,10 +91,74 @@
  assert.equal(rtc.getGroupCall(1), undefined);
  assert.equal(errors.length, resetFails ? 1 : 0);
}
async function sessionFixture(api) {
  const app=vue.createApp({});
  const setup=load('src/packages/stores/src/setup.ts',{'secure-ls':class{},'pinia-plugin-persistedstate':{createPersistedState:()=>()=>{}}});
  pinia.setActivePinia(await setup.initStores(app,{namespace:'logout-races'}));
  const accessModule=load('src/packages/stores/src/modules/access.ts'),userModule=load('src/packages/stores/src/modules/user.ts');
  const access=accessModule.useAccessStore(),user=userModule.useUserStore();const navigations=[];
  access.setAccessToken('old-access');access.setRefreshToken('old-refresh');
  const auth=load('src/store/auth.ts',{
    'vue-router':{useRouter:()=>({currentRoute:vue.ref({fullPath:'/private'}),replace:async to=>navigations.push(to),push:async to=>navigations.push(to)})},
    '@vben/constants':{LOGIN_PATH:'/auth/login'},'@vben/preferences':{},
    '@vben/stores':{...setup,...accessModule,...userModule},'ant-design-vue':{notification:{success(){throw Error('Stale success notification')}}},'#/locales':{$t:v=>v},
    '#/store/audit-policy':{automaticApproval:vue.ref(false),loadAuditPolicy:async()=>{}},
    '#/api':{logoutApi:async()=>{},...api},
  }).useAuthStore();
  return{access,user,auth,navigations};
}
function deferred(){let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b});return{promise,resolve,reject};}
async function pendingLoginCannotRestoreSession(){
  const pending=deferred();const f=await sessionFixture({loginApi:()=>pending.promise});
  const login=f.auth.authLogin('username',{});await f.auth.logout(false);
  pending.resolve({accessToken:'late-access',refreshToken:'late-refresh'});await login;
  assert.equal(f.access.accessToken,null);assert.equal(f.access.refreshToken,null);assert.equal(f.navigations.length,1);
}
async function pendingUserInfoCannotRestorePrivateData(){
  const pending=deferred();const f=await sessionFixture({getAuthPermissionInfoApi:()=>pending.promise});
  const info=f.auth.fetchUserInfo();const rejected=assert.rejects(info,/登录状态已变化/);
  await f.auth.logout(false);pending.resolve({user:{nickname:'old-user'},roles:['old-role'],menus:[],permissions:['old-permission']});await rejected;
  assert.equal(f.user.userInfo,null);assert.deepEqual(f.access.accessCodes,[]);
}
async function requestRaces(){
  const f=await sessionFixture({});const pending=deferred();let hooks;
  class Client{responses=[];requests=[];isRefreshing=false;refreshTokenQueue=[];addRequestInterceptor(x){this.requests.push(x)}addResponseInterceptor(x){this.responses.push(x)}}
  const presets=load('src/packages/effects/request/src/request-client/preset-interceptors.ts',{
    '../../../../locales/src':{$t:v=>v},'../../../../utils/src':{isFunction:v=>typeof v==='function'},axios:{isCancel:()=>false},
  });
  const request=load('src/api/request.ts',{
    '@vben/hooks':{isTenantEnable:()=>true,useAppConfig:()=>({apiURL:'/mock'})},
    '@vben/preferences':{preferences:{app:{enableRefreshToken:true,loginExpiredMode:'page'}}},
    '@vben/request':{RequestClient:Client,defaultResponseInterceptor:()=>({}),errorMessageResponseInterceptor:()=>({}),authenticateResponseInterceptor:options=>{hooks=options;return presets.authenticateResponseInterceptor(options)}},
    '@vben/stores':{useAccessStore:()=>f.access},'@vben/utils':{createApiEncrypt:()=>({})},
    'ant-design-vue':{message:{}},'#/store':{useAuthStore:()=>f.auth},'./core':{refreshTokenApi:()=>pending.promise},
  });
  const refresh=hooks.doRefreshToken();const rejected=assert.rejects(refresh,/登录状态已变化/);
  await f.auth.logout(false);pending.resolve({data:{data:{accessToken:'late-refreshed'}}});await rejected;assert.equal(f.access.accessToken,null);
  f.access.setAccessToken('new-account-token');f.access.setRefreshToken('new-account-refresh');
  await hooks.doReAuthenticate({config:{headers:{Authorization:'Bearer old-access'}}});assert.equal(f.access.accessToken,'new-account-token');
  const cfg={headers:{'tenant-id':7,'visit-tenant-id':9}};
  await request.baseRequestClient.requests[0].fulfilled(cfg);assert.equal(cfg.headers['tenant-id'],7);assert.equal(cfg.headers['visit-tenant-id'],9);
  const client={isRefreshing:false,refreshTokenQueue:[],request(){throw Error('Failed refresh must not replay queued requests')}};
  const failed=deferred();let reauthError;const authError={config:{url:'/private',headers:{Authorization:'Bearer old'}},response:{status:401}};
  const interceptor=presets.authenticateResponseInterceptor({client,doRefreshToken:()=>failed.promise,doReAuthenticate:async e=>{reauthError=e},enableRefreshToken:true,formatToken:t=>t});
  const first=interceptor.rejected(authError),firstRejected=assert.rejects(first,/refresh failed/);
  const second=interceptor.rejected({config:{url:'/other',headers:{}},response:{status:401}}),secondRejected=assert.rejects(second,/refresh failed/);
  const originalError=console.error;console.error=()=>{};
  try{failed.reject(new Error('refresh failed'));await Promise.all([firstRejected,secondRejected]);}finally{console.error=originalError;}
  assert.equal(reauthError,authError);assert.equal(client.isRefreshing,false);assert.equal(client.refreshTokenQueue.length,0);
}
(async () => {
  await scenario(false, false, false);
  await scenario(true, false, true);
  await scenario(false, true, false);
  await scenario(true, true, false);
  console.log('PASS: logout redirects and clears session with API success/failure and store reset failures; company and IM stores reset');
  await scenario('pending', false, false);
  await pendingLoginCannotRestoreSession();
  await pendingUserInfoCannotRestorePrivateData();
  await requestRaces();
  console.log('PASS: pending APIs do not block logout, stale login/user/refresh cannot restore sessions, failed refresh queue rejects; logout redirects and clears session with API success/failure and store reset failures; company and IM stores reset');
})();
scripts/login-theme-live-qa.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
// Actual application/router/forms, simulated APIs, no real authentication.
const assert=require('node:assert/strict');
const {chromium}=require('C:/Users/云/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs=require('fs'),path=require('path');
const output=path.resolve(__dirname,'../../scripts/login-theme-20261015');fs.mkdirSync(output,{recursive:true});
(async()=>{
 const server=await(await import('vite')).createServer({root:path.resolve(__dirname,'..'),server:{host:'127.0.0.1',port:5182}});await server.listen();
 const browser=await chromium.launch({headless:true,executablePath:'C:/Program Files/Google/Chrome/Application/chrome.exe'});
 const page=await browser.newPage({viewport:{width:1664,height:941},reducedMotion:'reduce'});
 const errors=[];let authPosts=0;page.on('pageerror',e=>errors.push(e.message));
 await page.route('**/admin-api/**',async route=>{
  const url=route.request().url();if(route.request().method()==='POST')authPosts++;
  const data=url.includes('company-config')?{companyName:'滋补品管理平台',loginTitle:'滋补品行业数字化管理平台',loginDescription:'传承中医药精华 Â· æœåŠ¡å¥åº·ç¾Žå¥½ç”Ÿæ´»'}:url.includes('tenant')?[]:null;
  await route.fulfill({contentType:'application/json',body:JSON.stringify({code:0,data,msg:''})});
 });
 try {
  await page.goto('http://127.0.0.1:5182/auth/login',{waitUntil:'domcontentloaded',timeout:60000});
  await page.getByRole('heading',{name:'欢迎回来'}).waitFor({timeout:60000});
  await page.waitForFunction(()=>!document.querySelector('#__app-loading__')||getComputedStyle(document.querySelector('#__app-loading__')).opacity==='0');
  await page.waitForFunction(()=>document.querySelector('.herbal-auth__background')?.complete);
  const user=page.getByRole('textbox',{name:'账号',exact:true}),password=page.locator('input[autocomplete="current-password"]');
  await user.fill('演示账户');await password.fill('not-a-real-password');await page.locator('.herbal-login__heading').click();await page.waitForTimeout(400);
  assert.equal(await user.getAttribute('autocomplete'),'username');assert.equal(await password.getAttribute('type'),'password');
  await page.getByRole('button',{name:'显示密码',exact:true}).focus();await page.keyboard.press('Space');assert.equal(await password.getAttribute('type'),'text');
  assert.equal(await password.evaluate(e=>getComputedStyle(e).paddingRight),'44px');
  await page.getByRole('button',{name:'隐藏密码',exact:true}).click();assert.equal(await password.getAttribute('type'),'password');
  const remember=page.locator('button[role="checkbox"]').first();await remember.click();assert.equal(await remember.getAttribute('aria-checked'),'true');
  await page.screenshot({path:path.join(output,'desktop-login.png'),fullPage:true});
  const tokens=await page.evaluate(()=>{const s=getComputedStyle(document.documentElement);return{brand:document.documentElement.dataset.brandTheme,primary:s.getPropertyValue('--primary').trim(),background:s.getPropertyValue('--background-deep').trim()}});
  assert.equal(tokens.brand,'herbal');assert.equal(tokens.background,'37 33% 95%');
  await page.locator('.herbal-auth__toolbar button').last().click();await page.waitForFunction(()=>document.documentElement.classList.contains('dark'));await page.waitForTimeout(350);
  assert.equal(await page.evaluate(()=>getComputedStyle(document.documentElement).getPropertyValue('--primary').trim()),'357 64% 73%');
  assert.equal(await page.locator('.herbal-panel').evaluate(e=>getComputedStyle(e).backgroundColor),'rgba(39, 31, 26, 0.97)');
  assert.equal(await page.locator('.herbal-auth__toolbar >div').evaluate(e=>getComputedStyle(e).backgroundColor),'rgb(48, 39, 31)');
  assert.equal(await page.evaluate(()=>getComputedStyle(document.documentElement).filter),'none','Dark styles must not apply a filter to the document');
  await page.screenshot({path:path.join(output,'desktop-dark.png'),fullPage:true});
  await page.locator('.herbal-auth__toolbar button').last().click();await page.waitForFunction(()=>!document.documentElement.classList.contains('dark'));
  await page.setViewportSize({width:390,height:844});await page.waitForTimeout(250);
  assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);
  await page.screenshot({path:path.join(output,'mobile-login.png'),fullPage:true});
  await user.fill('');await password.fill('');await page.getByRole('button',{name:'login',exact:true}).click();
  await page.getByText('请输入用户名',{exact:true}).first().waitFor();assert.equal(authPosts,0);
  assert.deepEqual(errors,[]);
  const result={passed:true,scope:'Real application/router/theme/login components; mocked backend and company APIs, demo account, no real authentication',checks:['dynamic company config','background loaded','autocomplete','keyboard password toggle','remember account','light theme variables','dark theme switching','mobile no overflow','required fields block backend'],tokens,screenshots:['desktop-login.png','desktop-dark.png','mobile-login.png']};
  fs.writeFileSync(path.join(output,'result.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result));
 } catch(error){console.error(JSON.stringify({message:error.message,errors}));throw error;}
 finally{await browser.close();await server.close();}
})();
scripts/one-click-schedule-qa.mjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,85 @@
// Component interaction check with simulated API data; never creates production tasks.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(path.join(root, 'package.json'));
const { parse, compileScript } = require('vue/compiler-sfc');
const ts = require('typescript');
const { chromium } = require('C:/Users/云/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const output = path.resolve(root, '../scripts/one-click-schedule-20261014');
fs.mkdirSync(output, { recursive: true });
const modal = `import {defineComponent,h,reactive,onMounted} from 'vue';
export function useVbenModal(options){const state=reactive({open:true,locked:false,confirmDisabled:true,...options});
const api={getData:()=>({workOrderIds:[1,2]}),setState:v=>Object.assign(state,v),lock:()=>state.locked=true,unlock:()=>state.locked=false,close:async()=>{if(options.onBeforeClose&&!options.onBeforeClose())return;state.open=false;await options.onOpenChange(false)}};
window.qaModal={state,api,options};onMounted(()=>options.onOpenChange(true));
const Modal=defineComponent({props:['title'],setup(props,{slots}){return()=>state.open?h('section',{class:'qa-modal'},[
h('header',props.title),h('div',{class:'qa-content'},slots.default?.()),h('footer',[
h('button',{class:'qa-close',disabled:state.locked,onClick:()=>api.close()},'取消'),
h('button',{class:'qa-confirm',disabled:state.locked||state.confirmDisabled,onClick:()=>options.onConfirm()},state.locked?'正在排产…':state.confirmText)])]):null}});return[Modal,api]}`;
const api = `export async function previewBatchSchedule(request){window.qa.previewCalls++;await new Promise(r=>setTimeout(r,120));
const blocked=window.qa.mode==='blocked',late=window.qa.mode==='late';return {canExecute:!blocked,previewHash:'test-hash',previewTime:'2026-10-14 15:00:00',warnings:late?['工单WO-001预计完成时间晚于交期']:[],
orders:[{workOrderId:1,workOrderCode:'WO-001',quantity:100,taskCount:blocked?0:2,requestDate:'2026-10-16 00:00:00',endTime:'2026-10-16 12:00:00',late,error:blocked?'WO-001:工序成型没有可用工作站及有效设备产能,请先维护':undefined},{workOrderId:2,workOrderCode:'WO-002',quantity:200,taskCount:blocked?0:2,requestDate:'2026-10-19 00:00:00',endTime:'2026-10-19 13:00:00',late:false,error:blocked?'WO-002:工序包装缺少设备产能,请先维护':undefined}],
tasks:blocked?[]:[{workOrderId:1,workOrderCode:'WO-001',processId:1,processName:'成型',workstationName:'成型工作站A',machineryNames:'成型设备A01',quantity:100,hourlyCapacity:50,duration:2,startTime:'2026-10-16 09:00:00',endTime:'2026-10-16 11:00:00'},{workOrderId:1,workOrderCode:'WO-001',processId:2,processName:'包装',workstationName:'包装工作站B',machineryNames:'包装设备B01',quantity:100,hourlyCapacity:100,duration:1,startTime:'2026-10-16 11:00:00',endTime:'2026-10-16 12:00:00'},{workOrderId:2,workOrderCode:'WO-002',processId:1,processName:'成型',workstationName:'成型工作站A',machineryNames:'成型设备A01',quantity:200,hourlyCapacity:100,duration:2,startTime:'2026-10-19 09:00:00',endTime:'2026-10-19 11:00:00'},{workOrderId:2,workOrderCode:'WO-002',processId:2,processName:'包装',workstationName:'包装工作站B',machineryNames:'包装设备B01',quantity:200,hourlyCapacity:100,duration:2,startTime:'2026-10-19 11:00:00',endTime:'2026-10-19 13:00:00'}]};}
export async function executeBatchSchedule(request){window.qa.executeCalls++;window.qa.lastRequest=request;await new Promise(r=>setTimeout(r,200));if(window.qa.mode==='executeError')throw new Error('排产数据已变化,请重新生成预览后确认');return{taskIds:[1,2,3,4]}}`;
const html = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>一键排产交互验证</title>
<style>body{margin:0;background:#f4f6f8;font-family:Microsoft YaHei,sans-serif;color:#172033}.qa-modal{width:calc(100% - 32px);max-width:1200px;margin:20px auto;background:white;border-radius:8px}.qa-modal header{padding:18px 24px;font-size:18px;font-weight:600;border-bottom:1px solid #e8ebee}.qa-content{padding:24px}.qa-modal footer{display:flex;justify-content:flex-end;gap:12px;padding:16px 24px;border-top:1px solid #e8ebee}.qa-modal button{padding:8px 16px;border:1px solid #d9d9d9;border-radius:6px;background:white;cursor:pointer}.qa-modal .qa-confirm{background:#1677ff;color:white;border-color:#1677ff}.qa-modal button:disabled{color:#8c8c8c;background:#f5f5f5;border-color:#d9d9d9;cursor:default}.space-y-5>*+*{margin-top:20px}.flex{display:flex}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-end{align-items:flex-end}.gap-3{gap:12px}.gap-2{gap:8px}.font-medium{font-weight:500}.text-sm{font-size:14px}.text-muted-foreground{color:#657083}.text-destructive{color:#cf1322}.mb-2{margin-bottom:8px}.mt-2{margin-top:8px}h3{font-size:15px;margin-top:0}p{margin:4px 0}@media(max-width:600px){.qa-content{padding:16px}.qa-modal header{padding:16px}.ant-descriptions{overflow-x:auto}}</style>
<div id="app"></div><script type="module" src="/qa-entry.js"></script></html>`;
const entry = `import{createApp,h}from'vue';import AutoSchedule from'/src/views/mes/pro/task/modules/auto-schedule.vue';window.qa={mode:new URLSearchParams(location.search).get('mode')||'valid',previewCalls:0,executeCalls:0,success:0};createApp({render:()=>h(AutoSchedule,{onSuccess:()=>window.qa.success++})}).mount('#app')`;
const server = await createServer({ root, configFile: false, appType: 'custom', cacheDir: 'node_modules/.cache/one-click-schedule-qa', server: { host: '127.0.0.1', port: 0 },
  plugins: [{ name: 'schedule-qa', enforce: 'pre',
    resolveId(id) { if (id === '@vben/common-ui') return '\0qa-modal'; if (id === '#/api/mes/pro/task') return '\0qa-api'; if (id === '/qa-entry.js') return '\0qa-entry'; },
    load(id) { if (id === '\0qa-modal') return modal; if (id === '\0qa-api') return api; if (id === '\0qa-entry') return entry; },
    transform(code, id) { if (!id.endsWith('.vue')) return;
      const { descriptor } = parse(code); const script = compileScript(descriptor, { id: 'schedule-qa', inlineTemplate: true });
      return { code: ts.transpileModule(script.content, { compilerOptions: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext } }).outputText, map: null };
    },
    configureServer(server) { server.middlewares.use((request,response,next)=>{if(request.url?.startsWith('/?')||request.url==='/'){response.setHeader('Content-Type','text/html;charset=utf-8');response.end(html)}else next()}); },
  }], optimizeDeps: { entries: [], noDiscovery: true, include: ['vue', 'ant-design-vue', 'dayjs'] } });
await server.listen();
let browser;
const errors=[];
try {
  browser=await chromium.launch({headless:true,executablePath:'C:/Program Files/Google/Chrome/Application/chrome.exe'});
  const page=await browser.newPage({viewport:{width:1440,height:1000}});
  page.on('pageerror',error=>errors.push(error.message));
  const url=`http://127.0.0.1:${server.httpServer.address().port}`;
  await page.goto(url);await page.getByText('工单校验',{exact:true}).waitFor();
  assert.equal(await page.locator('.qa-confirm').isEnabled(),true);
  assert.equal(await page.evaluate(()=>window.qa.previewCalls),1,'Opening modal must generate exactly one preview');
  await page.screenshot({path:path.join(output,'desktop-preview.png'),fullPage:true});
  await page.locator('.ant-picker').click();
  await page.locator('td[title="2026-10-20"]').click();
  await page.locator('.ant-picker-ok button').click();
  await page.waitForTimeout(150);
  assert.equal(await page.locator('.qa-confirm').isEnabled(),false,'Changed earliest time must invalidate preview');
  await page.getByText('生成排产预览',{exact:true}).click();await page.getByText('工单校验',{exact:true}).waitFor();
  await page.locator('.qa-confirm').click();
  assert.equal(await page.locator('.qa-close').isEnabled(),false,'Cancel must be disabled while executing');
  await page.waitForFunction(()=>window.qa.success===1);
  assert.equal(await page.evaluate(()=>window.qa.executeCalls),1,'Confirm must execute only once');
  await page.goto(url+'/?mode=blocked');await page.getByText('工单校验',{exact:true}).waitFor();
  assert.equal(await page.locator('.qa-confirm').isEnabled(),false,'Configuration errors must block submission');
  await page.setViewportSize({width:390,height:844});await page.screenshot({path:path.join(output,'mobile-blocked.png'),fullPage:true});
  await page.goto(url+'/?mode=late');await page.getByText('工单校验',{exact:true}).waitFor();
  assert.equal(await page.locator('.qa-confirm').isEnabled(),false,'Late risk requires acknowledgement');
  await page.getByText('我已核对并接受以上交期延期风险',{exact:true}).click();
  assert.equal(await page.locator('.qa-confirm').isEnabled(),true);
  await page.locator('.qa-confirm').click();await page.waitForFunction(()=>window.qa.success===1);
  assert.equal(await page.evaluate(()=>window.qa.lastRequest.allowLate),true);
  await page.goto(url+'/?mode=executeError');await page.getByText('工单校验',{exact:true}).waitFor();
  await page.locator('.qa-confirm').click();await page.getByText('排产数据已变化,请重新生成预览后确认',{exact:true}).waitFor();
  assert.equal(await page.locator('.qa-confirm').isEnabled(),false,'Failed execution must discard old preview');
  assert.equal(await page.locator('.qa-close').isEnabled(),true,'Failure must unlock modal');
  assert.deepEqual(errors,[]);
  const result={passed:true,scope:'Actual scheduling component, simulated APIs and modal adapter; no production writes',checks:['initial preview','time change invalidates preview','single submit','execution locks cancel','missing config blocks','late acknowledgement','server rejection unlocks and invalidates'],screenshots:['desktop-preview.png','mobile-blocked.png']};
  fs.writeFileSync(path.join(output,'frontend-result.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result));
} catch (error) {
  console.error(JSON.stringify({message:error.message,browserErrors:errors}));
  throw error;
} finally { await browser?.close();await server.close(); }
scripts/spare-management-qa.mjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,66 @@
// Actual Vue components with simulated API data; no production stock writes.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {createRequire} from 'node:module';
import {fileURLToPath} from 'node:url';
import {createServer} from 'vite';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
const require=createRequire(path.join(root,'package.json'));
const {parse,compileScript}=require('vue/compiler-sfc'),ts=require('typescript');
const {chromium}=require('C:/Users/云/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const output=path.resolve(root,'../scripts/spare-management-20261014');fs.mkdirSync(output,{recursive:true});
const modal=`import {defineComponent,h,reactive,onMounted} from 'vue';
export const Page=defineComponent({setup(p,{slots}){return()=>h('main',slots.default?.())}});
export function useVbenModal(options){
 if(options.connectedComponent||!options.onOpenChange)return[defineComponent({setup(){return()=>null}}),{setData(){return this},open(){}}];
 const state=reactive({open:true,locked:false,...options});const api={getData:()=>window.qa.data,setState:v=>Object.assign(state,v),lock:()=>state.locked=true,unlock:()=>state.locked=false,close:async()=>{if(options.onBeforeClose&&!options.onBeforeClose())return;state.open=false;await options.onOpenChange(false)}};
 window.qaModal={state,api,options};onMounted(()=>options.onOpenChange(true));
 return[defineComponent({props:['title'],setup(props,{slots}){return()=>state.open?h('section',{class:'qa-modal'},[h('header',props.title),h('div',slots.default?.()),h('footer',[h('button',{class:'qa-close',disabled:state.locked,onClick:()=>api.close()},'取消'),h('button',{class:'qa-confirm',disabled:state.locked,onClick:()=>options.onConfirm().catch(()=>{})},'确认')])]):null}}),api];}`;
const api=`export const movementLabels={1:'初始库存',2:'入库',3:'维修领用',4:'退回'};
export async function getSparePage(){return {list:window.qa.parts,total:window.qa.parts.length}}
export async function getSpare(){return window.qa.parts[0]}
export async function getSpareMovements(){return {list:window.qa.movements,total:window.qa.movements.length}}
export async function spareAction(type,data){window.qa.calls++;window.qa.request={type,...data};await new Promise(r=>setTimeout(r,250));if(window.qa.fail)throw Error('server rejection');return 1}
export async function createSpare(data){window.qa.saved=data;return 3}
export async function updateSpare(data){window.qa.saved=data}
export async function deleteSpare(){}
`;
const entry=`import {createApp,h} from 'vue';import 'ant-design-vue/dist/reset.css';
import Ledger from '/src/views/mes/dv/spare/index.vue';import Records from '/src/views/mes/dv/spare/modules/records.vue';import Action from '/src/views/mes/dv/spare/modules/action.vue';import Form from '/src/views/mes/dv/spare/modules/form.vue';
const mode=new URLSearchParams(location.search).get('mode')||'ledger';
const parts=[{id:1,code:'SP-001',name:'深沟球轴承',specification:'6205-2RS',unit:'ä»¶',location:'备件库 A-01',stockQuantity:10,safetyQuantity:15,status:0},{id:2,code:'SP-002',name:'传动皮带',specification:'B-1250',unit:'条',location:'备件库 B-02',stockQuantity:25,safetyQuantity:5,status:0}];
const movement={id:1,sparePartId:1,sparePartCode:'SP-001',sparePartName:'深沟球轴承',specification:'6205-2RS',unit:'ä»¶',type:3,quantity:4,returnedQuantity:1,returnableQuantity:3,repairCode:'WX-20261014-001',repairId:1,machineryName:'数控加工中心',receiverName:'维修员甲',remark:'更换主轴轴承',createTime:'2026-10-14 15:30:00'};
window.qa={parts,movements:[movement],calls:0,data:mode==='return'?{type:'return',original:movement}:mode==='form'?{}:{type:mode==='receive'?'receive':'issue',part:parts[0],repairId:1,repairCode:'WX-20261014-001'}};
const C=mode==='ledger'?Ledger:mode==='records'?Records:mode==='form'?Form:Action;
const app=createApp({render:()=>mode==='records'?h('main',[h(C)]):h(C)});app.directive('access',{});app.mount('#app');`;
const html=`<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>备件组件验证 Â· æ¨¡æ‹Ÿæ•°æ®</title><style>body{margin:0;background:#f5f5f5;color:#1f1f1f;font:14px system-ui}main{padding:24px;background:white;margin:20px}.qa-modal{background:white;max-width:600px;margin:24px auto;padding:24px}header{font-size:20px;font-weight:600;margin-bottom:20px}footer{display:flex;gap:12px;justify-content:flex-end;padding-top:16px}footer button{padding:8px 20px}footer button:disabled{opacity:.5}@media(max-width:640px){main{margin:8px;padding:12px}.qa-modal{margin:8px;padding:16px}} .flex{display:flex}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-3{gap:12px}.gap-y-3{row-gap:12px}.mb-4{margin-bottom:16px}.mb-3{margin-bottom:12px}.mt-4{margin-top:16px}.w-full{width:100%}.w-28{width:112px}.w-36{width:144px}.text-lg{font-size:18px}.text-base{font-size:16px}.font-semibold{font-weight:600}.grid{display:grid}.gap-x-4{column-gap:16px}.ml-2{margin-left:8px}@media(min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:col-span-2{grid-column:span 2}}</style><div id="app"></div><script type="module" src="/qa-entry.js"></script></html>`;
const server=await createServer({root,configFile:false,appType:'custom',cacheDir:'node_modules/.cache/spare-management-qa',server:{host:'127.0.0.1',port:0},plugins:[{name:'spare-qa',enforce:'pre',
resolveId(id){if(id==='@vben/common-ui')return '\0qa-modal';if(id==='#/api/mes/dv/spare')return '\0qa-api';if(id==='#/api/mes/dv/repair')return '\0qa-repair';if(id==='#/api/system/user')return '\0qa-user';if(id==='/qa-entry.js')return '\0qa-entry'},
load(id){if(id==='\0qa-modal')return modal;if(id==='\0qa-api')return api;if(id==='\0qa-repair')return 'export async function getRepairPage(){return {list:[{id:1,code:"WX-001",status:0,machineryName:"设备A"}]}}';if(id==='\0qa-user')return 'export async function getSimpleUserList(){return [{id:1,nickname:"维修员甲"}]}';if(id==='\0qa-entry')return entry},
transform(code,id){if(!id.endsWith('.vue'))return;const {descriptor}=parse(code);const script=compileScript(descriptor,{id,inlineTemplate:true});return{code:ts.transpileModule(script.content,{compilerOptions:{target:ts.ScriptTarget.ESNext,module:ts.ModuleKind.ESNext}}).outputText,map:null}},
configureServer(s){s.middlewares.use((req,res,next)=>{if(req.url==='/'||req.url?.startsWith('/?')){res.setHeader('Content-Type','text/html;charset=utf-8');res.end(html)}else next()})}
}],optimizeDeps:{entries:[],noDiscovery:true,include:['vue','ant-design-vue']}});
await server.listen();let browser;const errors=[];
try{
 browser=await chromium.launch({headless:true,executablePath:'C:/Program Files/Google/Chrome/Application/chrome.exe'});const page=await browser.newPage({viewport:{width:1440,height:950}});
 page.on('pageerror',e=>errors.push(e.message));const url='http://127.0.0.1:'+server.httpServer.address().port;
 await page.goto(url);await page.getByText('深沟球轴承',{exact:true}).waitFor();await page.screenshot({path:path.join(output,'desktop-ledger.png'),fullPage:true});
 await page.goto(url+'/?mode=records');await page.getByText('更换主轴轴承',{exact:true}).waitFor();await page.setViewportSize({width:390,height:844});await page.screenshot({path:path.join(output,'mobile-records.png'),fullPage:true});
 await page.goto(url+'/?mode=issue');await page.locator('.qa-confirm').waitFor();await page.locator('.qa-confirm').click();await page.waitForTimeout(100);assert.equal(await page.evaluate(()=>window.qa.calls),0,'Missing receiver and reason blocks issue');
 await page.locator('#form_item_receiverUserId').click();await page.locator('.ant-select-item-option-content').filter({hasText:'维修员甲'}).click();
 await page.locator('textarea').fill('更换主轴轴承');await page.locator('.ant-input-number-input').fill('11');await page.locator('.qa-confirm').click();await page.getByText('超过当前可用库存',{exact:true}).waitFor();assert.equal(await page.evaluate(()=>window.qa.calls),0);
 await page.locator('.ant-input-number-input').fill('2');await page.setViewportSize({width:1440,height:950});await page.screenshot({path:path.join(output,'desktop-issue.png'),fullPage:true});
 await page.locator('.qa-confirm').click();await page.waitForFunction(()=>window.qa.calls===1);assert.equal(await page.locator('.qa-close').isEnabled(),false);await page.waitForFunction(()=>!window.qaModal.state.open);assert.equal(await page.evaluate(()=>window.qa.calls),1);
 await page.goto(url+'/?mode=return');await page.locator('.qa-confirm').waitFor();await page.locator('textarea').fill('未使用退回');await page.locator('.ant-input-number-input').fill('4');await page.locator('.qa-confirm').click();await page.getByText('超过可退回数量',{exact:true}).waitFor();assert.equal(await page.evaluate(()=>window.qa.calls),0);
 await page.locator('.ant-input-number-input').fill('2');await page.evaluate(()=>window.qa.fail=true);await page.locator('.qa-confirm').click();await page.waitForFunction(()=>window.qa.calls===1&&!window.qaModal.state.locked);assert.equal(await page.locator('.qa-close').isEnabled(),true);
 const oldKey=await page.evaluate(()=>window.qa.request.requestKey);await page.evaluate(()=>window.qa.fail=false);await page.locator('.qa-confirm').click();await page.waitForFunction(()=>!window.qaModal.state.open);assert.equal(await page.evaluate(()=>window.qa.request.requestKey),oldKey,'Failed submission retry uses same idempotency key');
 await page.goto(url+'/?mode=form');await page.locator('.qa-confirm').waitFor();await page.locator('.qa-confirm').click();await page.waitForTimeout(80);assert.equal(await page.evaluate(()=>window.qa.saved),undefined,'Required fields block empty part creation');
 await page.goto(url+'/?mode=issue');await page.locator('.qa-confirm').waitFor();await page.locator('#form_item_receiverUserId').click();await page.locator('.ant-select-item-option-content').filter({hasText:'维修员甲'}).click();await page.locator('textarea').fill('更换主轴轴承');await page.locator('.ant-input-number-input').fill('2');await page.locator('header').click();await page.waitForTimeout(300);await page.screenshot({path:path.join(output,'desktop-issue.png'),fullPage:true});
 assert.deepEqual(errors,[]);const result={passed:true,scope:'Actual Vue/Ant Design components with mock APIs and modal adapter; no production writes',checks:['ledger display and shortage','records mobile display','required issue fields','issue stock limit','single submit and cancel lock','return limit','failure unlock','retry idempotency key','required part fields'],screenshots:['desktop-ledger.png','mobile-records.png','desktop-issue.png']};fs.writeFileSync(path.join(output,'frontend-result.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result));
}finally{await browser?.close();await server.close()}
src/api/core/auth.ts
@@ -79,13 +79,14 @@
}
/** é€€å‡ºç™»å½• */
export async function logoutApi(accessToken: string) {
export async function logoutApi(accessToken: string, tenant?: { tenantId: number | null; visitTenantId: number | null }) {
  return baseRequestClient.post(
    '/system/auth/logout',
    {},
    {
      headers: {
        Authorization: `Bearer ${accessToken}`,
        ...(tenant ? { 'tenant-id': tenant.tenantId, 'visit-tenant-id': tenant.visitTenantId } : {}),
      },
    },
  );
src/api/mes/dv/spare/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export interface SparePart {
  id: number; code: string; name: string; specification?: string; unit: string;
  location?: string; stockQuantity: number; safetyQuantity: number; status: number; remark?: string;
}
export interface SpareMovement {
  id: number; sparePartId: number; sparePartCode: string; sparePartName: string;
  specification?: string; unit: string; type: number; quantity: number;
  beforeQuantity: number; afterQuantity: number; repairId?: number; repairCode?: string;
  machineryName?: string; receiverName?: string; receiverUserId?: number; operatorUserId: number;
  originalId?: number; returnedQuantity: number; returnableQuantity: number; remark: string; createTime: string;
}
export interface SpareAction {
  requestKey: string; sparePartId?: number; repairId?: number; originalId?: number;
  receiverUserId?: number; quantity: number; remark: string;
}
export type SpareSave = Omit<SparePart, 'id' | 'stockQuantity'> & { id?: number; initialQuantity: number };
export const getSparePage = (params: PageParam & Record<string, unknown>) =>
  requestClient.get<PageResult<SparePart>>('/mes/dv/spare/page', { params });
export const getSpare = (id: number) => requestClient.get<SparePart>('/mes/dv/spare/get', { params: { id } });
export const createSpare = (data: SpareSave) => requestClient.post<number>('/mes/dv/spare/create', data);
export const updateSpare = (data: SpareSave) => requestClient.put('/mes/dv/spare/update', data);
export const deleteSpare = (id: number) => requestClient.delete('/mes/dv/spare/delete', { params: { id } });
export const getSpareMovements = (params: PageParam & Record<string, unknown>) =>
  requestClient.get<PageResult<SpareMovement>>('/mes/dv/spare/movement-page', { params });
export const spareAction = (type: 'issue' | 'receive' | 'return', data: SpareAction) =>
  requestClient.post<number>(`/mes/dv/spare/${type}`, data);
export const movementLabels: Record<number, string> = { 1: '初始库存', 2: '入库', 3: '维修领用', 4: '退回' };
src/api/mes/md/client/index.ts
@@ -6,7 +6,6 @@
  /** MES å®¢æˆ· */
  export interface Client {
    id?: number; // å®¢æˆ·ç¼–号
    code?: string; // å®¢æˆ·ç¼–码
    name?: string; // å®¢æˆ·åç§°
    nickname?: string; // å®¢æˆ·ç®€ç§°
    englishName?: string; // å®¢æˆ·è‹±æ–‡åç§°
@@ -31,9 +30,9 @@
  /** å®¢æˆ·å¯¼å…¥ç»“æžœ */
  export interface ClientImportRespVO {
    createCodes?: string[]; // æ–°å¢žæˆåŠŸçš„å®¢æˆ·ç¼–ç 
    updateCodes?: string[]; // æ›´æ–°æˆåŠŸçš„å®¢æˆ·ç¼–ç 
    failureCodes?: Record<string, string>; // å¯¼å…¥å¤±è´¥çš„客户编码及原因
    createCustomerNames?: string[]; // æ–°å¢žæˆåŠŸçš„å®¢æˆ·åç§°
    updateCustomerNames?: string[]; // æ›´æ–°æˆåŠŸçš„å®¢æˆ·åç§°
    failureCustomerNames?: Record<string, string>; // å¯¼å…¥å¤±è´¥çš„客户名称及原因
  }
}
src/api/mes/pd/document-audit/index.ts
@@ -14,6 +14,10 @@
    documentName?: string;
    /** èµ„料版本(冗余) */
    documentVersion?: string;
    /** èµ„料文件名(取资料第一个附件) */
    fileName?: string;
    /** èµ„料文件地址(取资料第一个附件) */
    fileUrl?: string;
    /** å®¡æ ¸äººï¼ˆç”¨æˆ·ID) */
    reviewer?: number;
    /** å®¡æ ¸äººåç§° */
src/api/mes/pro/mps/index.ts
@@ -19,7 +19,6 @@
    quantity?: number; // è®¡åˆ’生产数量
    quantityProduced?: number; // å·²ç”Ÿäº§æ•°é‡
    clientId?: number; // å®¢æˆ·ID
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    requestDate?: number; // éœ€æ±‚日期
    issueDate?: number; // ä¸‹å‘日期
src/api/mes/pro/route/process/index.ts
@@ -16,6 +16,8 @@
    routeId?: number;
    processId?: number;
    paramTemplateId?: number;
    /** å·¥åºæ”¶çŽ‡ï¼ˆäº§å‡º/投入,0~1) */
    yieldRate?: number;
    processCode?: string;
    processName?: string;
    sort?: number;
src/api/mes/pro/task/index.ts
@@ -176,6 +176,55 @@
}
/** æŸ¥è¯¢å¾…报工台账(分页) */
export interface BatchScheduleRequest {
  workOrderIds: number[];
  startTime: string;
  previewHash?: string;
  previewTime?: string;
  allowLate?: boolean;
}
export interface BatchSchedulePreview {
  canExecute: boolean;
  previewHash: string;
  previewTime: string;
  warnings: string[];
  orders: {
    workOrderId: number;
    workOrderCode?: string;
    workOrderName?: string;
    quantity?: number;
    taskCount: number;
    requestDate?: string;
    endTime?: string;
    late: boolean;
    error?: string;
  }[];
  tasks: {
    workOrderId: number;
    workOrderCode: string;
    processId: number;
    processName: string;
    workstationId: number;
    workstationName: string;
    machineryNames: string;
    quantity: number;
    hourlyCapacity: number;
    duration: number;
    startTime: string;
    endTime: string;
  }[];
}
export function previewBatchSchedule(data: BatchScheduleRequest) {
  return requestClient.post<BatchSchedulePreview>('/mes/pro/task/auto-schedule/preview', data);
}
export function executeBatchSchedule(data: BatchScheduleRequest) {
  return requestClient.post<{ taskIds: number[] }>('/mes/pro/task/auto-schedule', data);
}
/** æŸ¥è¯¢å¾…报工台账(分页) */
export function getPendingFeedbackPage(
  params: MesProTaskApi.PendingFeedbackPageParams,
) {
src/api/mes/pro/workorder/index.ts
@@ -22,7 +22,6 @@
    quantityChanged?: number; // è°ƒæ•´æ•°é‡
    quantityScheduled?: number; // å·²æŽ’产数量
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    vendorId?: number; // ä¾›åº”商编号
    vendorName?: string; // ä¾›åº”商名称
@@ -35,7 +34,6 @@
    cancelDate?: number; // å–消时间
    status?: number; // è®¢å•状态
    scheduleStatus?: number; // æŽ’产状态
    productionStatus?: number; // ç”Ÿäº§çŠ¶æ€
    processProgressList?: ProcessProgress[]; // å·¥åºç”Ÿäº§è¿›åº¦
    completionProgress?: number; // å®Œæˆè¿›åº¦
    remark?: string; // å¤‡æ³¨
src/api/mes/qc/date-filter.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
import dayjs from 'dayjs';
/** æ—¥æœŸæŸ¥è¯¢åŒ…含结束日期当天的全部质检记录,分页与导出使用相同口径。 */
export function normalizeQcDateFilter<T extends object>(params: T): T {
  const normalized: Record<string, string[]> = {};
  for (const field of ['inspectDate', 'receiveDate'] as const) {
    const range = (params as { inspectDate?: string[]; receiveDate?: string[] })[field];
    if (!Array.isArray(range) || range.length !== 2) continue;
    const start = dayjs(range[0]);
    const end = dayjs(range[1]);
    if (!start.isValid() || !end.isValid()) continue;
    normalized[field] = [
      start.startOf('day').format('YYYY-MM-DD HH:mm:ss'),
      end.endOf('day').format('YYYY-MM-DD HH:mm:ss'),
    ];
  }
  return Object.keys(normalized).length > 0 ? { ...params, ...normalized } : params;
}
src/api/mes/qc/indicatorresult/index.ts
@@ -24,7 +24,6 @@
    qcId?: number; // å…³è”质检单 ID
    qcType?: number; // è´¨æ£€ç±»åž‹
    itemId?: number; // äº§å“ç‰©æ–™ ID
    sn?: string; // ç‰©èµ„ SN
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
    items?: IndicatorResultDetail[]; // æ£€éªŒç»“果明细列表
src/api/mes/qc/ipqc/index.ts
@@ -2,6 +2,8 @@
import { requestClient } from '#/api/request';
import { normalizeQcDateFilter } from '../date-filter';
export namespace MesQcIpqcApi {
  /** MES è¿‡ç¨‹æ£€éªŒå• */
  export interface Ipqc {
@@ -58,7 +60,7 @@
/** æŸ¥è¯¢è¿‡ç¨‹æ£€éªŒå•分页 */
export function getIpqcPage(params: PageParam) {
  return requestClient.get<PageResult<MesQcIpqcApi.Ipqc>>('/mes/qc/ipqc/page', {
    params,
    params: normalizeQcDateFilter(params),
  });
}
@@ -89,7 +91,9 @@
/** å¯¼å‡ºè¿‡ç¨‹æ£€éªŒå• */
export function exportIpqc(params: any) {
  return requestClient.download('/mes/qc/ipqc/export-excel', { params });
  return requestClient.download('/mes/qc/ipqc/export-excel', {
    params: normalizeQcDateFilter(params),
  });
}
/** ä¸‹è½½è¿‡ç¨‹æ£€éªŒå•导入模板 */
src/api/mes/qc/iqc/index.ts
@@ -2,6 +2,8 @@
import { requestClient } from '#/api/request';
import { normalizeQcDateFilter } from '../date-filter';
export namespace MesQcIqcApi {
  /** MES æ¥æ–™æ£€éªŒå• */
  export interface Iqc {
@@ -53,7 +55,7 @@
/** æŸ¥è¯¢æ¥æ–™æ£€éªŒå•分页 */
export function getIqcPage(params: PageParam) {
  return requestClient.get<PageResult<MesQcIqcApi.Iqc>>('/mes/qc/iqc/page', {
    params,
    params: normalizeQcDateFilter(params),
  });
}
@@ -84,7 +86,9 @@
/** å¯¼å‡ºæ¥æ–™æ£€éªŒå• */
export function exportIqc(params: any) {
  return requestClient.download('/mes/qc/iqc/export-excel', { params });
  return requestClient.download('/mes/qc/iqc/export-excel', {
    params: normalizeQcDateFilter(params),
  });
}
/** ä¸‹è½½æ¥æ–™æ£€éªŒå•导入模板 */
src/api/mes/qc/ledger/index.ts
@@ -2,6 +2,8 @@
import { requestClient } from '#/api/request';
import { normalizeQcDateFilter } from '../date-filter';
export namespace MesQcInspectionLedgerApi {
  /** MES æ£€æµ‹ç»“果台账(一行一个检测项结果;检验单未录入检测项时该组字段为空) */
  export interface Ledger {
@@ -36,13 +38,13 @@
export function getInspectionLedgerPage(params: PageParam) {
  return requestClient.get<PageResult<MesQcInspectionLedgerApi.Ledger>>(
    '/mes/qc/inspection-ledger/page',
    { params },
    { params: normalizeQcDateFilter(params) },
  );
}
/** å¯¼å‡ºæ£€æµ‹ç»“果台账 */
export function exportInspectionLedger(params: Record<string, unknown>) {
  return requestClient.download('/mes/qc/inspection-ledger/export-excel', {
    params,
    params: normalizeQcDateFilter(params),
  });
}
src/api/mes/qc/oqc/index.ts
@@ -2,6 +2,8 @@
import { requestClient } from '#/api/request';
import { normalizeQcDateFilter } from '../date-filter';
export namespace MesQcOqcApi {
  /** MES å‡ºè´§æ£€éªŒå• */
  export interface Oqc {
@@ -53,7 +55,7 @@
/** æŸ¥è¯¢å‡ºè´§æ£€éªŒå•分页 */
export function getOqcPage(params: PageParam) {
  return requestClient.get<PageResult<MesQcOqcApi.Oqc>>('/mes/qc/oqc/page', {
    params,
    params: normalizeQcDateFilter(params),
  });
}
@@ -84,7 +86,9 @@
/** å¯¼å‡ºå‡ºè´§æ£€éªŒå• */
export function exportOqc(params: any) {
  return requestClient.download('/mes/qc/oqc/export-excel', { params });
  return requestClient.download('/mes/qc/oqc/export-excel', {
    params: normalizeQcDateFilter(params),
  });
}
/** ä¸‹è½½å‡ºè´§æ£€éªŒå•导入模板 */
src/api/mes/qc/rqc/index.ts
@@ -2,6 +2,8 @@
import { requestClient } from '#/api/request';
import { normalizeQcDateFilter } from '../date-filter';
export namespace MesQcRqcApi {
  /** MES é€€è´§æ£€éªŒå• */
  export interface Rqc {
@@ -43,7 +45,7 @@
/** æŸ¥è¯¢é€€è´§æ£€éªŒå•分页 */
export function getRqcPage(params: PageParam) {
  return requestClient.get<PageResult<MesQcRqcApi.Rqc>>('/mes/qc/rqc/page', {
    params,
    params: normalizeQcDateFilter(params),
  });
}
@@ -74,5 +76,7 @@
/** å¯¼å‡ºé€€è´§æ£€éªŒå• */
export function exportRqc(params: any) {
  return requestClient.download('/mes/qc/rqc/export-excel', { params });
  return requestClient.download('/mes/qc/rqc/export-excel', {
    params: normalizeQcDateFilter(params),
  });
}
src/api/mes/wm/batch/index.ts
@@ -19,7 +19,6 @@
    vendorCode?: string; // ä¾›åº”商编码
    vendorName?: string; // ä¾›åº”商名称
    clientId?: number; // å®¢æˆ· ID
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    purchaseOrderCode?: string; // é‡‡è´­è®¢å•编号
    salesOrderCode?: string; // é”€å”®è®¢å•编号
@@ -80,7 +79,6 @@
    salesDate?: Date; // å‡ºåº“日期
    salesOrderCode?: string; // é”€å”®è®¢å•编号
    clientId?: number; // å®¢æˆ· ID
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    oqcCode?: string; // OQC æ£€éªŒå•号
    checkResult?: number; // OQC æ£€éªŒç»“论(字典 mes_qc_check_result)
src/api/mes/wm/packages/index.ts
@@ -12,7 +12,6 @@
    salesOrderCode?: string; // é”€å”®è®¢å•编号
    invoiceCode?: string; // å‘票编号
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    clientNickname?: string; // å®¢æˆ·ç®€ç§°
    length?: number; // ç®±é•¿åº¦
src/api/mes/wm/productissue/index.ts
@@ -16,7 +16,6 @@
    workstationName?: string; // å·¥ä½œç«™åç§°
    workOrderId?: number; // ç”Ÿäº§è®¢å•编号
    workOrderCode?: string; // ç”Ÿäº§è®¢å•编码
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    requiredTime?: number; // éœ€æ±‚æ—¶é—´
    status?: number; // å•据状态
src/api/mes/wm/productsales/index.ts
@@ -11,7 +11,6 @@
    noticeId?: number; // å‘货通知单编号
    noticeCode?: string; // å‘货通知单编码
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    salesOrderCode?: string; // é”€å”®è®¢å•编号
    saleOrderId?: number; // é”€å”®è®¢å•ID
src/api/mes/wm/returnsales/index.ts
@@ -10,7 +10,6 @@
    name?: string; // é€€è´§å•名称
    salesOrderCode?: string; // é”€å”®è®¢å•号
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    returnDate?: number; // é€€è´§æ—¥æœŸ
    returnReason?: string; // é€€è´§åŽŸå› 
src/api/mes/wm/salesnotice/index.ts
@@ -11,7 +11,6 @@
    saleOrderId?: number; // é”€å”®è®¢å•编号
    saleOrderCode?: string; // é”€å”®è®¢å•编号
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    salesDate?: number; // å‘货日期
    recipientName?: string; // æ”¶è´§äºº
src/api/request.ts
@@ -33,10 +33,13 @@
  /**
   * é‡æ–°è®¤è¯é€»è¾‘
   */
  async function doReAuthenticate() {
  async function doReAuthenticate(error?: { config?: { headers?: Record<string, unknown> } }) {
    console.warn('Access token or refresh token is invalid or expired. ');
    const accessStore = useAccessStore();
    const authStore = useAuthStore();
    const requestToken = error?.config?.headers?.Authorization;
    // å·²é€€å‡ºæˆ–已切换账号时,旧请求的 401 ä¸å¾—清除新会话。
    if (requestToken && requestToken !== formatToken(accessStore.accessToken)) return;
    accessStore.setAccessToken(null);
    if (
      preferences.app.loginExpiredMode === 'modal' &&
@@ -58,6 +61,9 @@
      throw new Error('Refresh token is null!');
    }
    const resp = await refreshTokenApi(refreshToken);
    if (accessStore.refreshToken !== refreshToken) {
      throw new Error('登录状态已变化,忽略旧会话的令牌刷新结果');
    }
    const newToken = resp?.data?.data?.accessToken;
    // add by èŠ‹è‰¿ï¼šè¿™é‡Œä¸€å®šè¦æŠ›å‡º resp.data,从而触发 authenticateResponseInterceptor ä¸­ï¼Œåˆ·æ–°ä»¤ç‰Œå¤±è´¥ï¼ï¼ï¼
    if (!newToken) {
@@ -220,13 +226,13 @@
  fulfilled: (config) => {
    const accessStore = useAccessStore();
    // æ·»åŠ ç§Ÿæˆ·ç¼–å·
    config.headers['tenant-id'] = tenantEnable
      ? accessStore.tenantId
      : undefined;
    if (!('tenant-id' in config.headers)) {
      config.headers['tenant-id'] = tenantEnable ? accessStore.tenantId : undefined;
    }
    // åªæœ‰ç™»å½•时,才设置 visit-tenant-id è®¿é—®ç§Ÿæˆ·
    config.headers['visit-tenant-id'] = tenantEnable
      ? accessStore.visitTenantId
      : undefined;
    if (!('visit-tenant-id' in config.headers)) {
      config.headers['visit-tenant-id'] = tenantEnable ? accessStore.visitTenantId : undefined;
    }
    return config;
  },
});
src/api/wls/batch/index.ts
@@ -19,7 +19,6 @@
    vendorCode?: string; // ä¾›åº”商编码
    vendorName?: string; // ä¾›åº”商名称
    clientId?: number; // å®¢æˆ· ID
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    purchaseOrderCode?: string; // é‡‡è´­è®¢å•编号
    salesOrderCode?: string; // é”€å”®è®¢å•编号
src/api/wls/packages/index.ts
@@ -12,7 +12,6 @@
    salesOrderCode?: string; // é”€å”®è®¢å•编号
    invoiceCode?: string; // å‘票编号
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    clientNickname?: string; // å®¢æˆ·ç®€ç§°
    length?: number; // ç®±é•¿åº¦
src/api/wls/productissue/index.ts
@@ -13,7 +13,6 @@
    workstationName?: string; // å·¥ä½œç«™åç§°
    workOrderId?: number; // ç”Ÿäº§è®¢å•编号
    workOrderCode?: string; // ç”Ÿäº§è®¢å•编码
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    requiredTime?: number; // éœ€æ±‚æ—¶é—´
    status?: number; // å•据状态
src/api/wls/productsales/index.ts
@@ -11,7 +11,6 @@
    noticeId?: number; // å‘货通知单编号
    noticeCode?: string; // å‘货通知单编码
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    salesOrderCode?: string; // é”€å”®è®¢å•编号
    saleOrderId?: number; // é”€å”®è®¢å•ID
src/api/wls/returnsales/index.ts
@@ -10,7 +10,6 @@
    name?: string; // é€€è´§å•名称
    salesOrderCode?: string; // é”€å”®è®¢å•号
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    returnDate?: number; // é€€è´§æ—¥æœŸ
    returnReason?: string; // é€€è´§åŽŸå› 
src/api/wls/salesnotice/index.ts
@@ -10,7 +10,6 @@
    name?: string; // é€šçŸ¥å•名称
    salesOrderCode?: string; // é”€å”®è®¢å•编号
    clientId?: number; // å®¢æˆ·ç¼–号
    clientCode?: string; // å®¢æˆ·ç¼–码
    clientName?: string; // å®¢æˆ·åç§°
    salesDate?: number; // å‘货日期
    recipientName?: string; // æ”¶è´§äºº
src/bootstrap.ts
@@ -7,6 +7,7 @@
import { initStores } from './packages/stores/src';
import './packages/styles/src';
import './packages/styles/src/antd/index.css';
import './styles/herbal-theme.css';
import { useTitle } from '@vueuse/core';
src/components/quality/engine/report-evaluator.ts
@@ -197,6 +197,17 @@
      return;
    }
    const hasLimit = item.upperLimit != null || item.lowerLimit != null;
    if (
      hasLimit &&
      (!String(item.actualValue ?? '').trim() || !Number.isFinite(toNumber(item.actualValue)))
    ) {
      applyResult(item, undefined);
      errors.push(
        `第 ${index + 1} é¡¹ã€Œ${item.itemName ?? ''}」缺少有效数值结果,待补测或确认原始记录`,
      );
      return;
    }
    const byLimit = evaluateByLimit(item);
    if (byLimit !== undefined) {
      applyResult(item, byLimit ? QUALITY_RESULT.PASS : QUALITY_RESULT.FAIL);
@@ -274,4 +285,4 @@
  const node = parseRule(expression);
  ruleCache.set(expression, node);
  return node;
}
}
src/layouts/auth-scene.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,78 @@
<script setup lang="ts">
withDefaults(defineProps<{ appName?: string; logo?: string; platformTitle?: string; platformDescription?: string }>(), {
  appName: '', logo: '', platformTitle: '滋补品行业数字化管理平台', platformDescription: '传承中医药精华 Â· æœåŠ¡å¥åº·ç¾Žå¥½ç”Ÿæ´»',
});
</script>
<template>
  <div class="herbal-auth">
    <img class="herbal-auth__background" src="/images/auth/herbal-courtyard.png" alt="" fetchpriority="high" decoding="async" aria-hidden="true" />
    <div class="herbal-auth__veil" aria-hidden="true"></div>
    <header class="herbal-auth__header">
      <div class="herbal-brand">
        <img v-if="logo" :src="logo" :alt="appName || '公司标志'" class="herbal-brand__logo" />
        <span v-if="appName" class="herbal-brand__name">{{ appName }}</span>
        <div class="herbal-brand__platform">
          <p class="herbal-brand__title">{{ platformTitle }}</p>
          <p class="herbal-brand__description">{{ platformDescription }}</p>
        </div>
      </div>
      <div class="herbal-auth__toolbar"><slot name="toolbar"></slot></div>
    </header>
    <main class="herbal-auth__main">
      <section class="herbal-motto" aria-labelledby="herbal-motto-title">
        <h2 id="herbal-motto-title"><span></span>堂训<span></span></h2>
        <p>品味虽贵,必不敢减物力</p>
        <p>炮制虽繁,必不敢省人工</p>
        <div class="herbal-motto__rule" aria-hidden="true"><span></span><svg viewBox="0 0 60 24"><path d="M3 12h10c0-6 8-9 12-5 0-7 10-7 10 0 5-4 12-1 12 5h10M13 12c0 8 12 11 17 3 5 8 17 5 17-3M24 12c0-7 12-7 12 0s-12 7-12 0Z" /></svg><span></span></div>
      </section>
      <section class="herbal-panel" aria-label="账户登录">
        <div class="herbal-panel__frame" aria-hidden="true">
          <svg v-for="corner in ['tl','tr','bl','br']" :key="corner" :class="'herbal-corner herbal-corner--'+corner" viewBox="0 0 44 44"><path d="M2 42V12A10 10 0 0 1 12 2h30M9 42V9h33M2 21h19V2M9 28h19V9M2 35h33V2" /></svg>
        </div>
        <div class="herbal-panel__content"><slot></slot></div>
      </section>
    </main>
  </div>
</template>
<style scoped>
.herbal-auth{position:relative;isolation:isolate;min-height:100svh;overflow-x:clip;background:#f6eddf;color:#29251f;padding:clamp(24px,3.2vw,52px) clamp(24px,5vw,88px) 42px;font-family:var(--font-family,system-ui,sans-serif)}
.herbal-auth__background,.herbal-auth__veil{position:absolute;inset:0;width:100%;height:100%;z-index:-2;object-fit:cover;object-position:center}
.herbal-auth__veil{z-index:-1;background:linear-gradient(90deg,transparent 48%,rgba(248,239,226,.14))}
.herbal-auth__header{display:flex;align-items:center;justify-content:space-between;gap:24px;position:relative;z-index:2}
.herbal-brand{display:flex;align-items:center;gap:18px;min-width:0}
.herbal-brand__logo{width:70px;height:70px;object-fit:contain;flex-shrink:0}
.herbal-brand__name{font-family:"STKaiti","KaiTi","FangSong",serif;font-weight:700;font-size:clamp(24px,2.3vw,38px);white-space:nowrap}
.herbal-brand__platform{border-left:1px solid #8e7e6a;padding-left:24px;min-width:0}
.herbal-brand__title{font-size:clamp(17px,1.5vw,24px);letter-spacing:.04em;margin:0 0 7px;line-height:1.4}
.herbal-brand__description{font-size:13px;color:#71685b;letter-spacing:.16em;margin:0;line-height:1.5}
.herbal-auth__toolbar{flex-shrink:0;position:relative;min-width:80px}
.herbal-auth__toolbar :deep(>div){position:static!important;background:rgba(255,253,248,.75);border:1px solid #e5d8c5;border-radius:30px;padding:4px 10px}
.herbal-auth__main{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(400px,.85fr);gap:clamp(36px,5vw,88px);align-items:start;min-height:calc(100svh - 176px);padding-top:34px}
.herbal-motto{align-self:start;margin-top:12px;padding:0 0 24px;text-align:center}
.herbal-motto h2{display:flex;align-items:center;justify-content:center;gap:34px;font-family:"STKaiti","KaiTi","FangSong",serif;font-size:clamp(40px,4.3vw,72px);font-weight:700;line-height:1.2;letter-spacing:.2em;color:#8b1419;margin:0 0 12px}
.herbal-motto h2 span{height:1px;background:#ae7650;width:90px}
.herbal-motto p{font-family:"STKaiti","KaiTi","FangSong",serif;font-size:clamp(26px,3.15vw,54px);font-weight:700;line-height:1.65;letter-spacing:.015em;color:#863310;margin:0;white-space:nowrap}
.herbal-motto__rule{display:flex;align-items:center;justify-content:center;gap:22px;margin-top:22px;color:#a46628}
.herbal-motto__rule span{width:110px;height:1px;background:currentColor}
.herbal-motto__rule svg{width:55px;height:26px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.herbal-panel{position:relative;width:100%;max-width:584px;justify-self:end;min-height:690px;border-radius:20px;background:rgba(255,253,249,.96);box-shadow:0 22px 55px rgba(91,49,19,.13);padding:clamp(76px,12.8vh,120px) clamp(34px,4.6vw,78px) 64px;display:flex;align-items:flex-start}
.herbal-panel__frame{position:absolute;inset:16px;border:1px solid #cda878;border-radius:12px;pointer-events:none}
.herbal-corner{position:absolute;width:38px;height:38px;fill:none;stroke:#cda878;stroke-width:1.3;background:#fffdf9}
.herbal-corner--tl{top:-1px;left:-1px;border-top-left-radius:12px}.herbal-corner--tr{top:-1px;right:-1px;transform:rotate(90deg);border-top-left-radius:12px}.herbal-corner--bl{bottom:-1px;left:-1px;transform:rotate(-90deg);border-top-left-radius:12px}.herbal-corner--br{bottom:-1px;right:-1px;transform:rotate(180deg);border-top-left-radius:12px}
.herbal-panel__content{width:100%;position:relative;min-width:0}
:global(.dark .herbal-auth){background:#211b17;color:#f4eadd}
:global(.dark .herbal-auth__background){filter:brightness(.4) saturate(.7)}
:global(.dark .herbal-auth__veil){background:rgba(24,19,15,.28)}
:global(.dark .herbal-brand__description){color:#d4c3ac}
:global(.dark .herbal-motto h2){color:#f0bab3}
:global(.dark .herbal-motto p){color:#edcfa5}
:global(.dark .herbal-panel){background:rgba(39,31,26,.97)}
:global(.dark .herbal-corner){background:#271f1a}
:global(.dark .herbal-auth__toolbar >div){background:#30271f;border-color:#8d7053;color:#f4eadd}
@media(min-width:1800px){.herbal-auth__main{grid-template-columns:minmax(0,1fr) 584px}.herbal-motto{max-width:900px;justify-self:center;width:100%}}
@media(max-width:1199px){.herbal-auth{padding:28px}.herbal-brand{gap:12px}.herbal-brand__logo{width:52px;height:52px}.herbal-brand__platform{padding-left:16px}.herbal-brand__description{letter-spacing:.06em}.herbal-auth__main{gap:26px;grid-template-columns:minmax(0,1fr) minmax(360px,.9fr)}.herbal-motto p{font-size:clamp(25px,3vw,36px)}.herbal-panel{padding:50px 36px;min-height:600px}}
@media(max-width:900px){.herbal-auth{padding:24px;}.herbal-brand__name{font-size:24px}.herbal-brand__description{display:none}.herbal-brand__title{font-size:17px}.herbal-auth__main{grid-template-columns:1fr;min-height:calc(100svh - 112px);padding:32px 0;}.herbal-motto{display:none}.herbal-panel{max-width:520px;min-height:560px;justify-self:center}.herbal-auth__background{object-position:35% center}.herbal-auth__veil{background:rgba(248,239,226,.48)}}
@media(max-width:540px){.herbal-auth{padding:20px 16px 24px}.herbal-auth__header{align-items:flex-start;gap:10px}.herbal-brand{gap:10px;flex-wrap:wrap;max-width:calc(100% - 86px)}.herbal-brand__logo{width:42px;height:42px}.herbal-brand__name{font-size:22px}.herbal-brand__platform{border:0;padding:0;flex-basis:100%}.herbal-brand__title{font-size:14px;letter-spacing:.02em;margin:0}.herbal-auth__toolbar{min-width:76px}.herbal-panel{padding:50px 28px;min-height:510px;border-radius:16px}.herbal-panel__frame{inset:12px}.herbal-auth__main{padding-top:28px}.herbal-corner{width:30px;height:30px}}
@media(max-height:760px) and (min-width:901px){.herbal-panel{min-height:530px;padding-top:48px;padding-bottom:48px}.herbal-auth__main{padding-top:22px}.herbal-motto{margin-top:20px}}
</style>
src/layouts/auth.vue
@@ -1,15 +1,16 @@
<script lang="ts" setup>
import { computed, onMounted } from 'vue';
import { AuthPageLayout } from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { preferences, usePreferences } from '@vben/preferences';
import { $t } from '#/locales';
import { useCompanyConfigStore } from '#/store/company-config';
import AuthenticationToolbar from '../packages/effects/layouts/src/authentication/toolbar.vue';
import AuthScene from './auth-scene.vue';
declare const __APP_LOGO__: string;
const companyConfigStore = useCompanyConfigStore();
const { isDark } = usePreferences();
onMounted(() => {
  // ç™»å½•页展示 loginTitle:启动加载走的是纯函数(不填 store),进入登录页时拉取一次
@@ -18,22 +19,27 @@
const appName = computed(() => preferences.copyright.companyName);
// ç™»å½•页 logo ä¼˜å…ˆå–数据库配置(preferences.logo.source),未配置时回退编译期默认 __APP_LOGO__
const logo = computed(() => preferences.logo.source || __APP_LOGO__);
const logoDark = computed(() => preferences.logo.sourceDark);
// ç™»å½•页标题/描述使用 i18n å›ºå®šæ–‡æ¡ˆï¼Œä¸éšå…¬å¸é…ç½®å˜åŒ–
const pageTitle = computed(() => $t('authentication.pageTitle'));
const pageDescription = computed(() => $t('authentication.pageDesc'));
const logo = computed(() => {
  const source = (isDark.value && preferences.logo.sourceDark) || preferences.logo.source || __APP_LOGO__;
  // æœªé…ç½®å…¬å¸æ ‡å¿—时不展示框架演示 Logo。
  return source?.includes('@vbenjs/static-source') ? '' : source;
});
const pageTitle = computed(() => companyConfigStore.loginTitle || '滋补品行业数字化管理平台');
const pageDescription = computed(() => companyConfigStore.loginDescription || '传承中医药精华 Â· æœåŠ¡å¥åº·ç¾Žå¥½ç”Ÿæ´»');
</script>
<template>
  <AuthPageLayout
  <AuthScene
    :app-name="appName"
    :logo="logo"
    :logo-dark="logoDark"
    :page-description="pageDescription"
    :page-title="pageTitle"
    :platform-description="pageDescription"
    :platform-title="pageTitle"
  >
    <!-- è‡ªå®šä¹‰å·¥å…·æ  -->
    <!-- <template #toolbar></template> -->
  </AuthPageLayout>
    <template #toolbar><AuthenticationToolbar :toolbar-list="['language', 'theme']" /></template>
    <RouterView v-slot="{ Component, route }">
      <KeepAlive :include="['Login']">
        <component :is="Component" :key="route.fullPath" />
      </KeepAlive>
    </RouterView>
  </AuthScene>
</template>
src/main.ts
@@ -1,4 +1,5 @@
import { initPreferences, preferencesManager } from './packages/preferences/src';
import { watch } from 'vue';
import { initPreferences, preferences, preferencesManager } from './packages/preferences/src';
import { unmountGlobalLoading } from './packages/utils/src';
import { overridesPreferences, preferencesExtension } from './preferences';
@@ -24,6 +25,28 @@
    overrides: overridesPreferences,
  });
  // é¦–次升级同步品牌主色,避免旧的蓝色偏好遮盖本次主题更新。
  // å‡çº§åŽç”¨æˆ·åœ¨åå¥½è®¾ç½®ä¸­é€‰æ‹©çš„颜色和明暗模式仍会保留。
  document.documentElement.dataset.brandTheme = 'herbal';
  const brandThemeKey = `${namespace}-herbal-theme-v1`;
  try {
    if (!localStorage.getItem(brandThemeKey)) {
      preferencesManager.updatePreferences({
        theme: { builtinType: 'custom', colorPrimary: '#a51d24' },
      });
      localStorage.setItem(brandThemeKey, '1');
    }
  } catch {
    preferencesManager.updatePreferences({
      theme: { builtinType: 'custom', colorPrimary: '#a51d24' },
    });
  }
  watch(
    () => preferences.theme.colorPrimary,
    (color) => { document.documentElement.dataset.herbalPalette = String(color.toLowerCase() === '#a51d24'); },
    { immediate: true, flush: 'sync' },
  );
  // å¼ºåˆ¶è¦†ç›–公司 logo å’Œ favicon,绕过 localStorage ç¼“å­˜
  if (__APP_LOGO__) {
    preferencesManager.updatePreferences({ logo: { source: __APP_LOGO__ } });
src/packages/@core/ui-kit/shadcn-ui/src/components/input-password/input-password.vue
@@ -42,16 +42,21 @@
        <slot name="strengthText"> </slot>
      </p>
    </template>
    <div
    <button
      type="button"
      :aria-label="show ? '隐藏密码' : '显示密码'"
      :aria-pressed="show"
      :disabled="!!$attrs.disabled"
      :class="{
        'top-3': !!passwordStrength,
        'top-1/2 -translate-y-1/2 items-center': !passwordStrength,
      }"
      class="text-foreground/60 hover:text-foreground absolute inset-y-0 right-0 flex cursor-pointer pr-3 text-lg leading-5"
      @click="show = !show"
      @keydown.enter.stop
    >
      <Eye v-if="show" class="size-4" />
      <EyeOff v-else class="size-4" />
    </div>
    </button>
  </div>
</template>
src/packages/constants/src/biz-mes-enum.ts
@@ -147,7 +147,6 @@
  DV_MACHINERY_CODE: 'DV_MACHINERY_CODE',
  DV_REPAIR_CODE: 'DV_REPAIR_CODE',
  DV_SUBJECT_CODE: 'DV_SUBJECT_CODE',
  MD_CLIENT_CODE: 'MD_CLIENT_CODE',
  MD_ITEM_TYPE_CODE: 'MD_ITEM_TYPE_CODE',
  MD_ITEM_CODE: 'MD_ITEM_CODE',
  MD_VENDOR_CODE: 'MD_VENDOR_CODE',
@@ -168,6 +167,7 @@
  QC_INDICATOR_CODE: 'QC_INDICATOR_CODE',
  QC_INDICATOR_RESULT_CODE: 'QC_INDICATOR_RESULT_CODE',
  QC_IQC_CODE: 'QC_IQC_CODE',
  QC_IQC_VENDOR_BATCH_CODE: 'QC_IQC_VENDOR_BATCH_CODE',
  QC_IPQC_CODE: 'QC_IPQC_CODE',
  QC_OQC_CODE: 'QC_OQC_CODE',
  QC_RQC_CODE: 'QC_RQC_CODE',
@@ -242,13 +242,6 @@
  CONFIRMED: 1, // å·²ç¡®è®¤
  FINISHED: 2, // å·²å®Œæˆ
  CANCELED: 3, // å·²å–消
} as const;
/** MES ç”Ÿäº§å·¥å•生产状态枚举(对齐后端 MesProWorkOrderProductionStatusEnum) */
export const MesProWorkOrderProductionStatusEnum = {
  WAITING: 0, // å¾…生产
  PRODUCING: 1, // ç”Ÿäº§ä¸­
  FINISHED: 2, // ç”Ÿäº§å®Œæˆ
} as const;
/** MES å·¥å•类型枚举 */
src/packages/effects/common-ui/src/ui/authentication/login.vue
@@ -68,6 +68,7 @@
const rememberMe = ref(!!localUsername);
async function handleSubmit() {
  if (props.loading) return;
  const { valid } = await formApi.validate();
  const values = await formApi.getValues();
  if (valid) {
src/packages/effects/hooks/src/use-design-tokens.ts
@@ -24,6 +24,7 @@
    colorPrimary: '',
    colorSuccess: '',
    colorTextBase: '',
    colorTextLightSolid: '',
    colorWarning: '',
    zIndexPopupBase: 2000, // è°ƒæ•´åŸºç¡€å¼¹å±‚层级,避免下拉等组件被弹窗或者最大化状态下的表格遮挡
  });
@@ -48,7 +49,7 @@
      tokens.colorTextBase = getCssVariableValue('--foreground');
      getCssVariableValue('--primary-foreground');
      tokens.colorTextLightSolid = getCssVariableValue('--primary-foreground');
      tokens.colorBorderSecondary = tokens.colorBorder =
        getCssVariableValue('--border');
src/packages/effects/request/src/request-client/preset-interceptors.ts
@@ -52,7 +52,7 @@
  formatToken,
}: {
  client: RequestClient;
  doReAuthenticate: () => Promise<void>;
  doReAuthenticate: (error?: any) => Promise<void>;
  doRefreshToken: () => Promise<string>;
  enableRefreshToken: boolean;
  formatToken: (token: string) => null | string;
@@ -67,13 +67,14 @@
      // åˆ¤æ–­æ˜¯å¦å¯ç”¨äº† refreshToken åŠŸèƒ½
      // å¦‚果没有启用或者已经是重试请求了,直接跳转到重新登录
      if (!enableRefreshToken || config.__isRetryRequest) {
        await doReAuthenticate();
        await doReAuthenticate(error);
        throw error;
      }
      // å¦‚果正在刷新 token,则将请求加入队列,等待刷新完成
      if (client.isRefreshing) {
        return new Promise((resolve) => {
          client.refreshTokenQueue.push((newToken: string) => {
        return new Promise((resolve, reject) => {
          client.refreshTokenQueue.push((newToken: string, refreshError?: unknown) => {
            if (refreshError) { reject(refreshError); return; }
            config.headers.Authorization = formatToken(newToken);
            resolve(client.request(config.url, { ...config }));
          });
@@ -96,10 +97,10 @@
        return client.request(error.config.url, { ...error.config });
      } catch (refreshError) {
        // å¦‚果刷新 token å¤±è´¥ï¼Œå¤„理错误(如强制登出或跳转登录页面)
        client.refreshTokenQueue.forEach((callback) => callback(''));
        client.refreshTokenQueue.forEach((callback) => callback('', refreshError));
        client.refreshTokenQueue = [];
        console.error('Refresh token failed, please login again.');
        await doReAuthenticate();
        await doReAuthenticate(error);
        throw refreshError;
      } finally {
src/packages/effects/request/src/request-client/request-client.ts
@@ -47,7 +47,7 @@
  public isRefreshing = false;
  public postSSE: SSE['postSSE'];
  // åˆ·æ–°token队列
  public refreshTokenQueue: ((token: string) => void)[] = [];
  public refreshTokenQueue: ((token: string, error?: unknown) => void)[] = [];
  public requestSSE: SSE['requestSSE'];
  public upload: FileUploader['upload'];
src/preferences.ts
@@ -20,6 +20,10 @@
 */
export const overridesPreferences = defineOverridesPreferences({
  // overrides
  theme: {
    builtinType: 'custom',
    colorPrimary: '#a51d24',
  },
  app: {
    /** åŽç«¯è·¯ç”±æ¨¡å¼ */
    accessMode: 'backend',
src/store/auth.ts
@@ -29,6 +29,8 @@
  const router = useRouter();
  const loginLoading = ref(false);
  let sessionVersion = 0;
  let logoutTask: Promise<void> | null = null;
  /**
   * å¼‚步处理登录操作
@@ -44,6 +46,7 @@
  ) {
    // å¼‚步处理用户登录操作并获取 accessToken
    let userInfo: null | UserInfo = null;
    const version = ++sessionVersion;
    try {
      let loginResult: AuthApi.LoginResult;
      loginLoading.value = true;
@@ -65,6 +68,7 @@
        }
      }
      const { accessToken, refreshToken } = loginResult;
      if (version !== sessionVersion) return { userInfo: null };
      // å¦‚果成功获取到 accessToken
      if (accessToken) {
@@ -78,6 +82,7 @@
        //   // getAccessCodesApi(),
        // ]);
        const fetchUserInfoResult = await fetchUserInfo();
        if (version !== sessionVersion) return { userInfo: null };
        userInfo = fetchUserInfoResult.user;
@@ -98,8 +103,11 @@
          });
        }
      }
    } catch (error) {
      if (version === sessionVersion) throw error;
      return { userInfo: null };
    } finally {
      loginLoading.value = false;
      if (version === sessionVersion) loginLoading.value = false;
    }
    return {
@@ -107,16 +115,28 @@
    };
  }
  async function logout(redirect: boolean = true) {
  function logout(redirect: boolean = true): Promise<void> {
    if (logoutTask) return logoutTask;
    const accessToken = accessStore.accessToken;
    const tenant = { tenantId: accessStore.tenantId, visitTenantId: accessStore.visitTenantId };
    const fullPath = router.currentRoute.value.fullPath;
    ++sessionVersion;
    // æœåŠ¡å™¨æ³¨é”€ç‹¬ç«‹æ‰§è¡Œï¼Œæœ¬åœ°æ¸…ç†ä¸Žè·³è½¬ä¸ç­‰å¾…ç½‘ç»œã€‚
    // ä½¿ç”¨é€€å‡ºå‰çš„ token å’Œç§Ÿæˆ·å¿«ç…§ï¼Œé¿å…æ¸…理后丢失注销上下文。
    if (accessToken) void logoutApi(accessToken, tenant).catch(() => {});
    logoutTask = clearSessionAndNavigate(redirect, fullPath).finally(() => { logoutTask = null; });
    return logoutTask;
  }
  async function clearSessionAndNavigate(redirect: boolean, fullPath: string) {
    try {
      const accessToken = accessStore.accessToken as string;
      if (accessToken) {
        await logoutApi(accessToken);
      }
    } catch {
      // ä¸åšä»»ä½•处理
      resetAllStores();
    } catch (error) {
      console.error('Failed to reset business stores during logout', error);
    }
    resetAllStores();
    // è®¤è¯æ¸…理不能依赖业务 store çš„重置结果。
    accessStore.$reset();
    userStore.$reset();
    accessStore.setLoginExpired(false);
    // å›žç™»å½•页带上当前路由地址
@@ -124,18 +144,26 @@
      path: LOGIN_PATH,
      query: redirect
        ? {
            redirect: encodeURIComponent(router.currentRoute.value.fullPath),
            redirect: encodeURIComponent(fullPath),
          }
        : {},
    });
  }
  async function fetchUserInfo() {
    const version = sessionVersion;
    const accessToken = accessStore.accessToken;
    // åŠ è½½
    // eslint-disable-next-line no-useless-assignment
    let authPermissionInfo: AuthPermissionInfo | null = null;
    authPermissionInfo = await getAuthPermissionInfoApi();
    if (version !== sessionVersion || accessToken !== accessStore.accessToken) {
      throw new Error('登录状态已变化,忽略旧会话的用户信息');
    }
    await loadAuditPolicy();
    if (version !== sessionVersion || accessToken !== accessStore.accessToken) {
      throw new Error('登录状态已变化,忽略旧会话的用户信息');
    }
    // userStore
    userStore.setUserInfo(authPermissionInfo.user);
    userStore.setUserRoles(authPermissionInfo.roles);
src/styles/herbal-theme.css
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,59 @@
/* å“ç‰Œä¸­æ€§è‰²ä¸Žä¸»è‰²åˆ†ç¦»ï¼šä¸»è‰²ä»é€šè¿‡ Vben preferences ç”Ÿæˆå…¨éƒ¨è‰²é˜¶ã€‚ */
html[data-brand-theme='herbal']:not(.dark) {
  --background: 40 50% 99%;
  --background-deep: 37 33% 95%;
  --foreground: 27 16% 17%;
  --card: 40 50% 99%;
  --card-foreground: 27 16% 17%;
  --popover: 40 50% 99%;
  --popover-foreground: 27 16% 17%;
  --muted: 35 25% 94%;
  --muted-foreground: 28 10% 40%;
  --secondary: 35 25% 94%;
  --secondary-foreground: 27 16% 17%;
  --accent: 33 35% 93%;
  --accent-dark: 33 28% 90%;
  --accent-darker: 33 24% 87%;
  --accent-lighter: 38 45% 97%;
  --accent-hover: 33 28% 90%;
  --accent-foreground: 27 16% 17%;
  --border: 33 25% 86%;
  --input: 33 25% 86%;
  --sidebar: 40 50% 99%;
  --sidebar-deep: 37 35% 97%;
  --menu: var(--sidebar);
  --header: 40 50% 99%;
}
html[data-brand-theme='herbal'].dark {
  --background: 26 16% 12%;
  --background-deep: 25 18% 9%;
  --foreground: 35 30% 90%;
  --card: 26 15% 14%;
  --card-foreground: 35 30% 90%;
  --popover: 26 15% 14%;
  --popover-foreground: 35 30% 90%;
  --muted: 26 13% 19%;
  --muted-foreground: 33 15% 68%;
  --border: 27 15% 26%;
  --input: 27 15% 26%;
  --sidebar: 26 16% 12%;
  --sidebar-deep: 25 18% 10%;
  --menu: var(--sidebar);
  --header: 26 16% 12%;
}
/* æš—色下使用明亮朱砂色,保证链接与选中态可读;按钮搭配深色文字。 */
html[data-brand-theme='herbal'][data-herbal-palette='true'].dark {
  --primary: 357 64% 73% !important;
  --primary-500: 357 64% 73% !important;
  --primary-600: 357 69% 77% !important;
  --primary-700: 357 70% 83% !important;
  --primary-foreground: 27 25% 12%;
}
/* æ”¯æŒåŠæ·±è‰²å¯¼èˆªåå¥½ï¼Œä¸è®©æµ…色品牌底覆盖该偏好。 */
html[data-brand-theme='herbal']:not(.dark) .dark {
  --sidebar: 26 16% 12%;
  --sidebar-deep: 25 18% 10%;
  --menu: var(--sidebar);
  --header: 26 16% 12%;
}
src/utils/rangePickerProps.ts
@@ -66,3 +66,13 @@
    },
  };
}
/** æŒ‰ä¸šåŠ¡æ—¥æœŸç­›é€‰ï¼ŒæŽ¥å£ä»æŽ¥æ”¶å®Œæ•´æ—¶é—´å­—ç¬¦ä¸²ã€‚ */
export function getDateRangePickerDefaultProps() {
  return {
    ...getRangePickerDefaultProps(),
    format: 'YYYY-MM-DD',
    placeholder: ['开始日期', '结束日期'],
    showTime: false,
  };
}
src/views/_core/authentication/login.vue
@@ -97,6 +97,8 @@
      component: 'VbenInput',
      componentProps: {
        placeholder: $t('authentication.usernameTip'),
        autocomplete: 'username',
        'aria-label': $t('authentication.username'),
      },
      fieldName: 'username',
      label: $t('authentication.username'),
@@ -109,6 +111,8 @@
      component: 'VbenInputPassword',
      componentProps: {
        placeholder: $t('authentication.passwordTip'),
        autocomplete: 'current-password',
        'aria-label': $t('authentication.password'),
      },
      fieldName: 'password',
      label: $t('authentication.password'),
@@ -122,7 +126,7 @@
</script>
<template>
  <div>
  <div class="herbal-login">
    <AuthenticationLogin
      ref="loginRef"
      :form-schema="formSchema"
@@ -134,7 +138,14 @@
      :show-forget-password="false"
      :show-doc-link="false"
      @submit="handleLogin"
    />
    >
      <template #title>
        <div class="herbal-login__heading">
          <h1>{{ $t('authentication.welcomeBack') }}<svg class="herbal-login__seal" viewBox="0 0 20 30" aria-hidden="true"><rect x="1" y="1" width="18" height="28" rx="2"/><path d="M5 5h10v8H5V5Zm0 12h10v8H5v-8Zm5-12v8m0 4v8"/></svg></h1>
          <p>{{ $t('authentication.loginSubtitle') }}</p>
        </div>
      </template>
    </AuthenticationLogin>
    <Verification
      ref="verifyRef"
      v-if="captchaEnable"
@@ -147,3 +158,24 @@
    />
  </div>
</template>
<style scoped>
.herbal-login__heading{margin-bottom:32px}
.herbal-login__heading h1{font-size:clamp(28px,2.4vw,36px);font-weight:700;line-height:1.4;letter-spacing:-.02em;margin:0 0 12px;color:#29251f;display:flex;align-items:center;gap:14px}
.herbal-login__heading p{font-size:15px;line-height:1.7;color:#756d63;margin:0}
.herbal-login__seal{width:20px;height:30px;flex-shrink:0;color:#a51d24;fill:none;stroke:currentColor;stroke-width:1.2}
.herbal-login :deep(input:not([type='checkbox'])){min-height:56px;border-radius:9px;border:1px solid #ddd6cc;background:rgba(255,255,255,.82);padding:12px 16px;font-size:16px;color:#30281f}
.herbal-login :deep(input::placeholder){color:#7a7167;opacity:1}
.herbal-login :deep(input:focus-visible){outline:2px solid #a51d24;outline-offset:3px}
.herbal-login :deep(input[autocomplete='current-password']){padding-right:44px}
.herbal-login :deep(button[aria-label='login']){min-height:58px;margin-top:8px;border-radius:9px;background:#a51d24;color:#fff;font-size:19px;font-weight:600;letter-spacing:.12em;box-shadow:0 5px 12px rgba(137,24,30,.12)}
.herbal-login :deep(button[aria-label='login']:hover){background:#87171d}
.herbal-login :deep(button[aria-label='login']:focus-visible){outline:2px solid #a51d24;outline-offset:4px}
.herbal-login :deep(button[aria-label='login']:disabled){opacity:.65;cursor:wait}
:global(.dark .herbal-login__heading h1){color:#f5eee4}
:global(.dark .herbal-login__heading p){color:#d1c1ae}
:global(.dark .herbal-login__seal){color:#eea5a4}
:global(.dark .herbal-login input:not([type='checkbox'])){background:#302720;border-color:#6f5b48;color:#f5eee4}
:global(.dark .herbal-login input::placeholder){color:#c2b29e}
@media(max-width:540px){.herbal-login__heading h1{font-size:28px}.herbal-login__heading p{font-size:14px}.herbal-login :deep(input:not([type='checkbox'])){min-height:52px}}
</style>
src/views/bi/dashboard/index.vue
@@ -12,6 +12,7 @@
import ChartCard from './modules/ChartCard.vue';
import ClockWidget from './modules/ClockWidget.vue';
import RealtimeTable from './modules/RealtimeTable.vue';
import { DASHBOARD_META, getDashboardCode, getDefaultCharts } from './data';
defineOptions({ name: 'BiDashboard' });
@@ -24,26 +25,38 @@
const loading = ref(true);
const lastUpdateTime = ref('');
let dataRefreshTimer: ReturnType<typeof setInterval> | undefined;
let requestSequence = 0;
let fetching = false;
let disposed = false;
async function loadData(background = false) {
  if (!meta.value) return;
  if (!meta.value || disposed || (background && fetching)) return;
  const sequence = ++requestSequence;
  const code = meta.value.code;
  fetching = true;
  if (!background && dataRefreshTimer) clearInterval(dataRefreshTimer);
  try {
    if (!background) loading.value = true;
    const data = await getDashboardData(meta.value.code);
    const data = await getDashboardData(code);
    if (disposed || sequence !== requestSequence) return;
    charts.value = data.length > 0 ? data : getDefaultCharts();
    lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN', { hour12: false });
  } catch {
    if (!background) charts.value = getDefaultCharts();
    if (!background && !disposed && sequence === requestSequence) charts.value = getDefaultCharts();
  } finally {
    loading.value = false;
    await nextTick();
    resizeAllCharts();
    if (!background) {
      const intervals = charts.value.map((chart) => chart.refreshInterval).filter((interval) => interval > 0);
      if (intervals.length) dataRefreshTimer = setInterval(() => {
        if (!loading.value && document.visibilityState === 'visible') loadData(true);
      }, Math.max(15, Math.min(...intervals)) * 1000);
    if (!disposed && sequence === requestSequence) {
      fetching = false;
      loading.value = false;
      await nextTick();
      if (!disposed && sequence === requestSequence) {
        resizeAllCharts();
        if (!background) {
          const intervals = charts.value.map((chart) => chart.refreshInterval).filter((interval) => interval > 0);
          if (intervals.length) dataRefreshTimer = setInterval(() => {
            if (!loading.value && document.visibilityState === 'visible') loadData(true);
          }, Math.max(15, Math.min(...intervals)) * 1000);
        }
      }
    }
  }
}
@@ -161,11 +174,6 @@
  return val;
}
// å¤§å±è¡¨æ ¼ã€Œå®žæ—¶æ»šåŠ¨ã€ï¼šæ•°æ®å¤åˆ¶ä¸€ä»½ï¼Œé…åˆ CSS marquee åšæ— ç¼çºµå‘滚动
function duplicateRows(rows?: Record<string, unknown>[]): Record<string, unknown>[] {
  return rows ? [...rows, ...rows] : [];
}
// ======== çœ‹æ¿ä¸»é¢˜è‰² ========
const dashboardAccentColors: Record<string, string> = {
  'purchase-sales': '#6366f1',
@@ -205,6 +213,8 @@
  loadData();
});
onBeforeUnmount(() => {
  disposed = true;
  requestSequence++;
  if (dataRefreshTimer) clearInterval(dataRefreshTimer);
  document.removeEventListener('fullscreenchange', onFullscreenChange);
  resizeObserver?.disconnect();
@@ -311,30 +321,11 @@
            <span class="table-header-title">{{ chart.name }}</span>
            <span class="table-badge">实时滚动</span>
          </div>
          <div v-if="chart.data && chart.data.length > 0" class="table-wrap">
            <table class="data-table">
              <thead>
                <tr>
                  <th
                    v-for="key in Object.keys(chart.data[0] || {})"
                    :key="key"
                  >{{ key }}</th>
                </tr>
              </thead>
              <tbody class="marquee-tbody">
                <tr
                  v-for="(row, ri) in duplicateRows(chart.data)"
                  :key="ri"
                  :style="{ animationDelay: `${ri * 50}ms` }"
                >
                  <td
                    v-for="key in Object.keys(chart.data[0] || {})"
                    :key="key"
                  >{{ getCellValue(row, key) }}</td>
                </tr>
              </tbody>
            </table>
          </div>
          <RealtimeTable
            v-if="chart.data && chart.data.length > 0"
            :rows="chart.data"
            :format-cell="getCellValue"
          />
          <Empty v-else description="暂无数据" />
        </div>
      </template>
@@ -612,71 +603,6 @@
  padding: 2px 8px; border-radius: 10px;
  background: rgba(0,255,136,0.08); color: var(--green);
  border: 1px solid rgba(0,255,136,0.15);
}
.table-wrap {
  overflow-x: auto;
  overflow-y: hidden;
  max-height: 320px;
  border-radius: 8px;
  background: rgba(4, 14, 36, 0.55);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  border: 1px solid rgba(0, 229, 255, 0.08);
}
.data-table {
  width: 100%; border-collapse: collapse; font-size: 12px;
}
.data-table thead {
  position: sticky;
  top: 0;
  z-index: 2;
}
.data-table thead th {
  background: rgba(6, 18, 42, 0.8);
  color: var(--text-secondary);
  font-weight: 600; font-size: 10px;
  text-transform: uppercase; letter-spacing: 0.8px;
  padding: 10px 14px; text-align: left;
  border-bottom: 1px solid rgba(0, 229, 255, 0.12);
  white-space: nowrap;
}
.data-table tbody td {
  padding: 9px 14px; color: var(--text-primary);
  border-bottom: 1px solid rgba(0, 229, 255, 0.04);
  white-space: nowrap;
}
.data-table tbody tr {
  transition: all 0.25s;
  opacity: 0;
  animation: row-fade-in 0.45s ease forwards;
}
.data-table tbody tr:hover {
  background: rgba(0, 229, 255, 0.06);
  box-shadow: inset 0 0 20px rgba(0, 229, 255, 0.03);
}
@keyframes row-fade-in {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: translateY(0); }
}
/* å¤§å±è¡¨æ ¼ã€Œå®žæ—¶æ»šåŠ¨ã€ï¼štbody å¤åˆ¶ä¸€ä»½åŽ translateY(-50%) æ— ç¼å¾ªçޝ */
.marquee-tbody {
  animation: table-marquee 15s linear infinite;
}
.marquee-tbody:hover {
  animation-play-state: paused;
}
@keyframes table-marquee {
  from { transform: translateY(0); }
  to { transform: translateY(-50%); }
}
/* ======== ç©ºçŠ¶æ€ ======== */
src/views/bi/dashboard/modules/RealtimeTable.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
const props = withDefaults(defineProps<{
  rows: Record<string, unknown>[];
  formatCell?: (row: Record<string, unknown>, column: string) => unknown;
  maxHeight?: number;
  speed?: number;
}>(), { maxHeight: 260, speed: 24 });
const columns = computed(() => Object.keys(props.rows[0] ?? {}));
const viewport = ref<HTMLElement>();
const track = ref<HTMLElement>();
const original = ref<HTMLElement>();
const scrolling = ref(false);
const hovered = ref(false);
const focused = ref(false);
const reducedMotion = ref(false);
let animation: Animation | undefined;
let observer: ResizeObserver | undefined;
let motionQuery: MediaQueryList | undefined;
let frame = 0;
let distance = 0;
let duration = 0;
let active = true;
let disposed = false;
let measuring = false;
function syncPlayback() {
  if (!animation) return;
  if (hovered.value || focused.value || document.hidden || !active) animation.pause();
  else animation.play();
}
async function measure() {
  if (disposed || measuring || !viewport.value || !original.value || !track.value) return;
  measuring = true;
  try {
    const height = original.value.offsetHeight;
    const shouldScroll = !reducedMotion.value && height > viewport.value.clientHeight + 1;
    const nextDuration = height / Math.max(1, props.speed) * 1000;
    if (shouldScroll && animation && height === distance && duration === nextDuration) return;
    const offset = distance && duration
      ? (Number(animation?.currentTime ?? 0) % duration) / duration * distance : 0;
    animation?.cancel();
    animation = undefined;
    scrolling.value = shouldScroll;
    distance = height;
    duration = nextDuration;
    await nextTick();
    if (disposed || !shouldScroll || !track.value) return;
    // Animate a normal block, not tbody. A second identical table fills the loop seam.
    animation = track.value.animate([
      { transform: 'translateY(0)' },
      { transform: `translateY(-${height}px)` },
    ], { duration, iterations: Infinity, easing: 'linear' });
    animation.currentTime = (offset % height) / height * duration;
    syncPlayback();
  } finally {
    measuring = false;
  }
}
function scheduleMeasure() {
  cancelAnimationFrame(frame);
  frame = requestAnimationFrame(() => { void measure(); });
}
function onMotionChange() {
  reducedMotion.value = motionQuery?.matches ?? false;
  scheduleMeasure();
}
function onKeydown(event: KeyboardEvent) {
  if (!animation || !scrolling.value) return;
  const step = event.key === 'ArrowDown' ? 40 : event.key === 'ArrowUp' ? -40 : 0;
  if (!step) return;
  event.preventDefault();
  const nextTime = Number(animation.currentTime ?? 0) + step / Math.max(1, props.speed) * 1000;
  animation.currentTime = ((nextTime % duration) + duration) % duration;
}
watch(() => [props.rows, props.maxHeight, props.speed], scheduleMeasure, { flush: 'post' });
watch([hovered, focused], syncPlayback);
onMounted(() => {
  motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
  motionQuery.addEventListener('change', onMotionChange);
  onMotionChange();
  observer = new ResizeObserver(scheduleMeasure);
  if (viewport.value) observer.observe(viewport.value);
  if (original.value) observer.observe(original.value);
  document.addEventListener('visibilitychange', syncPlayback);
});
onActivated(() => { active = true; scheduleMeasure(); syncPlayback(); });
onDeactivated(() => { active = false; syncPlayback(); });
onBeforeUnmount(() => {
  disposed = true;
  cancelAnimationFrame(frame);
  animation?.cancel();
  observer?.disconnect();
  motionQuery?.removeEventListener('change', onMotionChange);
  document.removeEventListener('visibilitychange', syncPlayback);
});
</script>
<template>
  <div
    class="realtime-table"
    role="region"
    aria-label="实时数据表,聚焦或悬停暂停滚动,方向键逐行查看"
    tabindex="0"
    :style="{ '--table-height': `${maxHeight}px`, '--table-width': `${columns.length * 120}px` }"
    @mouseenter="hovered = true"
    @mouseleave="hovered = false"
    @focusin="focused = true"
    @focusout="focused = false"
    @keydown="onKeydown"
  >
    <div class="table-content">
      <table class="header-table">
        <thead><tr><th v-for="column in columns" :key="column" scope="col">{{ column }}</th></tr></thead>
      </table>
      <div ref="viewport" class="table-viewport" :class="{ 'is-scrolling': scrolling }">
        <div ref="track" class="table-track" :class="{ 'is-scrolling': scrolling }">
          <div ref="original">
            <table>
              <thead class="sr-only"><tr><th v-for="column in columns" :key="column" scope="col">{{ column }}</th></tr></thead>
              <tbody>
                <tr v-for="(row, index) in rows" :key="index">
                  <td v-for="column in columns" :key="column" :title="String(formatCell ? formatCell(row, column) ?? '' : row[column] ?? '')">
                    {{ formatCell ? formatCell(row, column) : row[column] }}
                  </td>
                </tr>
              </tbody>
            </table>
          </div>
          <table v-if="scrolling" aria-hidden="true">
            <tbody>
              <tr v-for="(row, index) in rows" :key="index">
                <td v-for="column in columns" :key="column">
                  {{ formatCell ? formatCell(row, column) : row[column] }}
                </td>
              </tr>
            </tbody>
          </table>
        </div>
      </div>
    </div>
  </div>
</template>
<style scoped>
.realtime-table { overflow-x: auto; border: 1px solid rgba(0, 229, 255, 0.08); border-radius: 8px; background: rgba(4, 14, 36, 0.55); }
.realtime-table:focus-visible { outline: 2px solid var(--cyan, #00e5ff); outline-offset: 2px; }
.table-content { min-width: var(--table-width); }
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 12px; }
th { padding: 10px 14px; background: rgb(6, 18, 42); color: var(--text-secondary, #b9c4dc); font-size: 10px; font-weight: 600; text-align: left; border-bottom: 1px solid rgba(0, 229, 255, 0.12); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
td { padding: 9px 14px; color: var(--text-primary, #ebf0fc); border-bottom: 1px solid rgba(0, 229, 255, 0.04); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
tbody tr:hover { background: rgba(0, 229, 255, 0.06); }
.table-viewport { max-height: var(--table-height); overflow-y: auto; scrollbar-width: thin; }
.table-viewport.is-scrolling { overflow-y: hidden; }
.table-track.is-scrolling { will-change: transform; }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); }
</style>
src/views/bi/warehouse/index.vue
@@ -17,6 +17,7 @@
} from '#/api/bi/warehouseCoordinate';
import ClockWidget from '../dashboard/modules/ClockWidget.vue';
import RealtimeTable from '../dashboard/modules/RealtimeTable.vue';
import {
  getChinaMapOptions,
  getWarehouseAreaLineOptions,
@@ -30,19 +31,29 @@
const charts = ref<BiDashboardApi.ChartItem[]>([]);
const loading = ref(true);
const lastUpdateTime = ref('');
let fetching = false;
let disposed = false;
async function loadData() {
async function loadData(background = false) {
  if (fetching || disposed) return false;
  fetching = true;
  try {
    loading.value = true;
    if (!background) loading.value = true;
    const data = await getDashboardData('warehouse');
    if (disposed) return false;
    charts.value = data.length > 0 ? data : [];
    lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN', { hour12: false });
    return true;
  } catch {
    charts.value = [];
    if (!background && !disposed) charts.value = [];
    return false;
  } finally {
    loading.value = false;
    await nextTick();
    resizeAllCharts();
    fetching = false;
    if (!disposed) {
      loading.value = false;
      await nextTick();
      if (!disposed) resizeAllCharts();
    }
  }
}
@@ -233,10 +244,7 @@
// ======== è¡¨æ ¼æ•°æ® ========
const tableData = computed(() => tableChart.value?.data || []);
const tableColumns = computed(() => {
  if (tableData.value.length === 0) return [];
  return Object.keys(tableData.value[0] || {});
});
// ======== åº“存预警 ========
const warningList = computed(() => {
@@ -428,7 +436,8 @@
function setupTimer() {
  clearTimer();
  refreshTimer = setInterval(async () => {
    await loadData();
    if (document.hidden || editMode.value || disposed) return;
    if (!(await loadData(true))) return;
    await nextTick();
    await renderAllCharts();
    if (editMode.value) await nextTick().then(() => attachMapListeners());
@@ -497,13 +506,16 @@
  resizeObserver = new ResizeObserver(() => resizeAllCharts());
  if (dashboardRef.value) resizeObserver.observe(dashboardRef.value);
  await loadCoordinates();
  if (disposed) return;
  await loadData();
  if (disposed) return;
  await nextTick();
  await renderAllCharts();
  setupTimer();
});
onBeforeUnmount(() => {
  disposed = true;
  document.removeEventListener('fullscreenchange', onFullscreenChange);
  resizeObserver?.disconnect();
  clearTimer();
@@ -720,26 +732,11 @@
          æœ€è¿‘入库记录
          <span class="wh-table-badge">实时滚动</span>
        </div>
        <div v-if="tableData.length > 0" class="wh-table-wrap">
          <table class="wh-table">
            <thead>
              <tr>
                <th v-for="col in tableColumns" :key="col">{{ col }}</th>
              </tr>
            </thead>
            <tbody>
              <tr
                v-for="(row, ri) in tableData"
                :key="ri"
                :style="{ animationDelay: `${ri * 50}ms` }"
              >
                <td v-for="col in tableColumns" :key="col">
                  {{ getCellValue(row, col) }}
                </td>
              </tr>
            </tbody>
          </table>
        </div>
        <RealtimeTable
          v-if="tableData.length > 0"
          :rows="tableData"
          :format-cell="getCellValue"
        />
        <Empty v-else description="暂无数据" />
      </div>
    </template>
@@ -1187,50 +1184,6 @@
  padding: 2px 8px; border-radius: 10px;
  background: rgba(0,255,136,0.08); color: var(--green);
  border: 1px solid rgba(0,255,136,0.15);
}
.wh-table-wrap {
  overflow-x: auto; border-radius: 8px;
  background: rgba(4, 14, 36, 0.55);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  border: 1px solid rgba(0, 229, 255, 0.08);
}
.wh-table {
  width: 100%; border-collapse: collapse; font-size: 12px;
}
.wh-table thead th {
  background: rgba(6, 18, 42, 0.8);
  color: var(--text-secondary);
  font-weight: 600; font-size: 10px;
  text-transform: uppercase; letter-spacing: 0.8px;
  padding: 10px 14px; text-align: left;
  border-bottom: 1px solid rgba(0,229,255,0.12);
  white-space: nowrap;
}
.wh-table tbody td {
  padding: 9px 14px; color: var(--text-primary);
  border-bottom: 1px solid rgba(0, 229, 255, 0.04);
  white-space: nowrap;
}
.wh-table tbody tr {
  transition: all 0.25s;
  opacity: 0;
  animation: row-fade-in 0.45s ease forwards;
}
.wh-table tbody tr:hover {
  background: rgba(0, 229, 255, 0.06);
  box-shadow: inset 0 0 20px rgba(0, 229, 255, 0.03);
}
@keyframes row-fade-in {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: translateY(0); }
}
/* ======== å…¨å± ======== */
src/views/crm/invoice/data.ts
@@ -194,6 +194,15 @@
      },
    },
    {
      fieldName: 'invoiceNo',
      label: '发票号码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入发票号码,支持模糊查询',
        allowClear: true,
      },
    },
    {
      fieldName: 'customerId',
      label: '客户',
      component: markRaw(CrmCustomerSelect),
src/views/mes/dv/repair/modules/form.vue
@@ -4,6 +4,7 @@
import type { MesDvRepairApi } from '#/api/mes/dv/repair';
import { computed, ref } from 'vue';
import { useAccess } from '@vben/access';
import { useVbenModal } from '@vben/common-ui';
import { MesDvRepairResultEnum, MesDvRepairStatusEnum } from '@vben/constants';
@@ -23,6 +24,8 @@
import { useFormSchema } from '../data';
import LineList from './line-list.vue';
import SpareRecords from '../../spare/modules/records.vue';
const { hasAccessByCodes } = useAccess();
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
@@ -210,6 +213,13 @@
      :disabled="isLineReadonly"
      :repair-id="formData.id"
    />
    <SpareRecords
      v-if="formData?.id && hasAccessByCodes(['mes:dv-spare:record'])"
      :key="formData.id"
      :repair-id="formData.id"
      :repair-code="formData.code"
      :can-issue="[0, 1].includes(formData.status ?? -1)"
    />
    <template #prepend-footer>
      <div class="flex flex-auto items-center gap-2">
        <Popconfirm
src/views/mes/dv/spare-record/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,6 @@
<script setup lang="ts">
import { Page } from '@vben/common-ui';
import Records from '../spare/modules/records.vue';
</script>
<template><Page><Records /></Page></template>
src/views/mes/dv/spare/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { SparePart } from '#/api/mes/dv/spare';
import { onMounted, reactive, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Alert, Button, Checkbox, Form, Input, Popconfirm, Select, Space, Table, Tag } from 'ant-design-vue';
import { deleteSpare, getSparePage } from '#/api/mes/dv/spare';
import SpareForm from './modules/form.vue';
import Action from './modules/action.vue';
import Records from './modules/records.vue';
const rows=ref<SparePart[]>([]),loading=ref(false),error=ref('');
const page=ref(1),size=ref(10),total=ref(0),selected=ref<SparePart>();
const filters=reactive<{code?:string;name?:string;status?:number;shortageOnly:boolean}>({shortageOnly:false});
const [FormModal,formApi]=useVbenModal({connectedComponent:SpareForm,destroyOnClose:true});
const [ActionModal,actionApi]=useVbenModal({connectedComponent:Action,destroyOnClose:true});
const [RecordsModal,recordsApi]=useVbenModal({showConfirmButton:false});
let seq=0;
const columns=[{title:'备件编码',dataIndex:'code',width:140},{title:'备件名称',dataIndex:'name',width:180},
 {title:'规格型号',dataIndex:'specification',width:160},{title:'单位',dataIndex:'unit',width:80},
 {title:'可用库存',dataIndex:'stockQuantity',key:'stock',width:130},{title:'安全库存',dataIndex:'safetyQuantity',width:110},
 {title:'存放位置',dataIndex:'location',width:150},{title:'状态',dataIndex:'status',key:'status',width:90},
 {title:'操作',key:'action',width:290,fixed:'right' as const}];
async function load(reset=false) {
 if(reset)page.value=1;const current=++seq;loading.value=true;error.value='';
 try{const result=await getSparePage({pageNo:page.value,pageSize:size.value,...filters});if(current===seq){rows.value=result.list;total.value=result.total;}}
 catch{if(current===seq){rows.value=[];total.value=0;error.value='备件加载失败,请点击查询重试';}}
 finally{if(current===seq)loading.value=false;}
}
function reset(){Object.assign(filters,{code:undefined,name:undefined,status:undefined,shortageOnly:false});void load(true);}
function changePage(p:{current?:number;pageSize?:number}){page.value=p.current||1;size.value=p.pageSize||10;void load();}
function showForm(part?:SparePart){formApi.setData({id:part?.id}).open();}
function action(type:'issue'|'receive',part:SparePart){actionApi.setData({type,part}).open();}
function history(part:SparePart){selected.value=part;recordsApi.open();}
async function remove(part:SparePart){await deleteSpare(part.id);await load(true);}
onMounted(()=>load());
</script>
<template>
 <Page>
  <FormModal @success="load(true)" /><ActionModal @success="load(true)" />
  <RecordsModal :title="selected ? selected.name+' Â· åº“存与领用记录' : '库存与领用记录'" class="w-full lg:w-4/5"><Records v-if="selected" :key="selected.id" :spare-part-id="selected.id" /></RecordsModal>
  <div class="mb-4 flex flex-wrap items-center justify-between gap-3"><h2 class="text-lg font-semibold">备件台账</h2><Button v-access:code="['mes:dv-spare:create']" type="primary" @click="showForm()">新增备件</Button></div>
  <Form layout="inline" class="mb-4 flex flex-wrap gap-y-3">
   <Form.Item label="备件编码"><Input v-model:value="filters.code" placeholder="完整备件编码" allow-clear @press-enter="load(true)" /></Form.Item>
   <Form.Item label="备件名称"><Input v-model:value="filters.name" placeholder="输入备件名称" allow-clear @press-enter="load(true)" /></Form.Item>
   <Form.Item label="状态"><Select v-model:value="filters.status" class="w-28" allow-clear :options="[{label:'启用',value:0},{label:'停用',value:1}]" /></Form.Item>
   <Form.Item><Checkbox v-model:checked="filters.shortageOnly">仅看低于安全库存</Checkbox></Form.Item>
   <Form.Item><Space><Button type="primary" :loading="loading" @click="load(true)">查询</Button><Button @click="reset">重置</Button></Space></Form.Item>
  </Form>
  <Alert v-if="error" class="mb-3" type="error" :message="error" />
  <Table :columns="columns" :data-source="rows" :loading="loading" row-key="id" :scroll="{x:1330}" :pagination="{current:page,pageSize:size,total,showSizeChanger:true}" @change="changePage">
   <template #bodyCell="{column,record}">
    <template v-if="column.key==='stock'"><span>{{ record.stockQuantity }}</span><Tag v-if="Number(record.stockQuantity)<Number(record.safetyQuantity)" color="warning" class="ml-2">不足</Tag></template>
    <template v-else-if="column.key==='status'"><Tag :color="record.status===0?'success':'default'">{{ record.status===0?'启用':'停用' }}</Tag></template>
    <template v-else-if="column.key==='action'"><Space :size="0" wrap>
     <Button v-access:code="['mes:dv-spare:update']" type="link" @click="showForm(record as SparePart)">编辑</Button>
     <Button v-access:code="['mes:dv-spare:receive']" type="link" :disabled="record.status!==0" @click="action('receive',record as SparePart)">入库</Button>
     <Button v-access:code="['mes:dv-spare:issue']" type="link" :disabled="record.status!==0 || Number(record.stockQuantity)<=0" @click="action('issue',record as SparePart)">领用</Button>
     <Button v-access:code="['mes:dv-spare:record']" type="link" @click="history(record as SparePart)">记录</Button>
     <Popconfirm title="确认删除备件?仅无库存且无流水的备件可删除。" @confirm="remove(record as SparePart)"><Button v-access:code="['mes:dv-spare:delete']" type="link" danger>删除</Button></Popconfirm>
    </Space></template>
   </template>
  </Table>
 </Page>
</template>
src/views/mes/dv/spare/modules/action.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,95 @@
<script setup lang="ts">
import type { SpareMovement, SparePart, SpareAction } from '#/api/mes/dv/spare';
import type { FormInstance } from 'ant-design-vue';
import { computed, reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Form, Input, InputNumber, Select } from 'ant-design-vue';
import { getSparePage, spareAction } from '#/api/mes/dv/spare';
import { getRepairPage } from '#/api/mes/dv/repair';
import { getSimpleUserList } from '#/api/system/user';
const emit = defineEmits(['success']);
const formRef = ref<FormInstance>();
const type = ref<'issue'|'receive'|'return'>('issue');
const saving = ref(false);
const loadError = ref('');
const original = ref<SpareMovement>();
const parts = ref<SparePart[]>([]);
const repairs = ref<{label:string;value:number}[]>([]);
const users = ref<{label:string;value:number}[]>([]);
const model = reactive<SpareAction>({ requestKey: '', quantity: 1, remark: '' });
const fixedPart = ref(false), fixedRepair = ref(false);
let partSeq = 0, repairSeq = 0, openSeq = 0;
const title = computed(() => ({issue:'维修领用备件',receive:'备件入库',return:'退回未使用备件'})[type.value]);
const chosenPart = computed(() => parts.value.find(p => p.id === model.sparePartId));
const maxQuantity = computed(() => type.value === 'return' ? Number(original.value?.returnableQuantity ?? 0) : type.value === 'issue' ? Number(chosenPart.value?.stockQuantity ?? 0) : undefined);
const required = [{required:true,message:'请填写此项'}];
async function searchParts(name='') {
  const seq = ++partSeq;
  try { const result = await getSparePage({pageNo:1,pageSize:50,status:0,name}); if(seq === partSeq) parts.value=result.list; }
  catch { if(seq===partSeq) loadError.value='备件加载失败,请重新打开弹窗后重试'; }
}
async function searchRepairs(code='') {
  const seq=++repairSeq;
  try { const result=await getRepairPage({pageNo:1,pageSize:50,code} as Parameters<typeof getRepairPage>[0]);
    if(seq===repairSeq) repairs.value=result.list.filter(r=>r.status===0||r.status===1).map(r=>({value:r.id!,label:`${r.code} Â· ${r.machineryName || r.name || ''}`}));
  } catch { if(seq===repairSeq) loadError.value='维修单加载失败,请重新打开弹窗后重试'; }
}
const [Modal, modalApi] = useVbenModal({
  onBeforeClose:()=>!saving.value,
  async onOpenChange(open) {
    const seq=++openSeq;
    if(!open) { partSeq++; repairSeq++; return; }
    const data=modalApi.getData<{type:'issue'|'receive'|'return';part?:SparePart;repairId?:number;repairCode?:string;original?:SpareMovement}>();
    type.value=data.type; original.value=data.original; loadError.value='';
    fixedPart.value=!!data.part||data.type==='return'; fixedRepair.value=!!data.repairId;
    Object.assign(model,{requestKey:crypto.randomUUID(),sparePartId:data.part?.id,repairId:data.repairId,originalId:data.original?.id,receiverUserId:undefined,quantity:1,remark:''});
    parts.value=data.part?[data.part]:[]; repairs.value=[]; users.value=[]; formRef.value?.clearValidate();
    if(data.repairId) repairs.value=[{value:data.repairId,label:data.repairCode || String(data.repairId)}];
    modalApi.lock();
    try {
      const jobs:Promise<unknown>[]=[];
      if(data.type!=='return'&&!data.part) jobs.push(searchParts());
      if(data.type==='issue'&&!data.repairId) jobs.push(searchRepairs());
      if(data.type==='issue') jobs.push(getSimpleUserList().then(list=>{if(seq===openSeq)users.value=list.filter(u=>u.id!==undefined).map(u=>({value:u.id!,label:u.nickname}));}));
      await Promise.all(jobs);
    } catch { if(seq===openSeq) loadError.value='基础数据加载失败,请重新打开弹窗后重试'; }
    finally { if(seq===openSeq) modalApi.unlock(); }
  },
  async onConfirm() {
    if(saving.value||loadError.value)return;
    await formRef.value?.validate();
    saving.value=true;modalApi.lock();
    try { await spareAction(type.value,{...model}); emit('success');saving.value=false;await modalApi.close(); }
    finally { saving.value=false;modalApi.unlock(); }
  },
});
const quantityRules = [{required:true,message:'请输入数量'}, {validator:async (_:unknown,value:number)=>{
  if(!Number.isFinite(value)||value<=0)throw new Error('数量必须大于0');
  if(maxQuantity.value!==undefined && value>maxQuantity.value) throw new Error(type.value==='return'?'超过可退回数量':'超过当前可用库存');
}}];
</script>
<template>
  <Modal :title="title" class="w-full sm:w-[600px]">
    <Alert v-if="loadError" :message="loadError" type="error" class="mb-4" />
    <Alert v-else class="mb-4" type="info" :message="type === 'issue' ? '领用立即扣减库存,并关联当前维修单。请核对备件、领用人和数量。' : type === 'return' ? '仅退回未使用的备件,累计退回不能超过原领用数量。' : '入库增加库存并保留记录,请填写补货来源或单据编号。'" />
    <Form ref="formRef" :model="model" layout="vertical">
      <Form.Item v-if="type === 'return'" label="原领用记录">
        {{ original?.repairCode }} Â· {{ original?.sparePartName }} Â· å¯é€€å›ž {{ original?.returnableQuantity }} {{ original?.unit }}
      </Form.Item>
      <Form.Item v-else label="备件" name="sparePartId" :rules="required">
        <Select v-model:value="model.sparePartId" show-search :disabled="fixedPart || saving" :filter-option="false" placeholder="输入备件名称查找" :options="parts.map(p=>({value:p.id,label:p.code+' Â· '+p.name+' Â· åº“å­˜ '+p.stockQuantity+' '+p.unit}))" @search="searchParts" />
      </Form.Item>
      <Form.Item v-if="type === 'issue'" label="关联维修单" name="repairId" :rules="required">
        <Select v-model:value="model.repairId" :disabled="fixedRepair || saving" show-search :filter-option="false" :options="repairs" placeholder="输入维修单编号查找(草稿或维修中)" @search="searchRepairs" />
      </Form.Item>
      <Form.Item v-if="type === 'issue'" label="领用人" name="receiverUserId" :rules="required">
        <Select v-model:value="model.receiverUserId" show-search option-filter-prop="label" :options="users" placeholder="请选择领用人" :disabled="saving" />
      </Form.Item>
      <Form.Item :label="'数量'+(chosenPart ? '('+chosenPart.unit+')' : '')" name="quantity" :rules="quantityRules">
        <InputNumber v-model:value="model.quantity" :min="0.001" :precision="3" class="w-full" :disabled="saving" />
      </Form.Item>
      <Form.Item label="操作说明" name="remark" :rules="required"><Input.TextArea v-model:value="model.remark" :maxlength="500" :rows="3" :disabled="saving" placeholder="填写领用用途、入库来源或退回原因" /></Form.Item>
    </Form>
  </Modal>
</template>
src/views/mes/dv/spare/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { SpareSave } from '#/api/mes/dv/spare';
import type { FormInstance } from 'ant-design-vue';
import { reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Form, Input, InputNumber, Select } from 'ant-design-vue';
import { createSpare, getSpare, updateSpare } from '#/api/mes/dv/spare';
const emit = defineEmits(['success']);
const formRef = ref<FormInstance>();
const saving = ref(false);
const model = reactive<SpareSave>({ code: '', name: '', unit: 'ä»¶', initialQuantity: 0, safetyQuantity: 0, status: 0 });
const required = [{ required: true, message: '请填写此项' }];
const [Modal, modalApi] = useVbenModal({
  onBeforeClose: () => !saving.value,
  async onOpenChange(open) {
    if (!open) return;
    Object.assign(model, { id: undefined, code: '', name: '', specification: '', unit: 'ä»¶', location: '', initialQuantity: 0, safetyQuantity: 0, status: 0, remark: '' });
    formRef.value?.clearValidate();
    const { id } = modalApi.getData<{ id?: number }>();
    if (id) { modalApi.lock(); try { Object.assign(model, await getSpare(id)); } finally { modalApi.unlock(); } }
  },
  async onConfirm() {
    if (saving.value) return;
    await formRef.value?.validate();
    saving.value = true; modalApi.lock();
    try { await (model.id ? updateSpare(model) : createSpare(model)); emit('success'); saving.value = false; await modalApi.close(); }
    finally { saving.value = false; modalApi.unlock(); }
  },
});
</script>
<template>
  <Modal :title="model.id ? '编辑备件' : '新增备件'" class="w-full sm:w-[640px]">
    <Alert class="mb-4" type="info" :message="model.id ? '库存通过入库、领用和退回变更。已有流水后不可修改计量单位。' : '初始库存登记后会保留流水,后续补货请使用入库。'" />
    <Form ref="formRef" :model="model" layout="vertical" class="grid grid-cols-1 gap-x-4 sm:grid-cols-2">
      <Form.Item label="备件编码" name="code" :rules="required"><Input v-model:value="model.code" :maxlength="64" /></Form.Item>
      <Form.Item label="备件名称" name="name" :rules="required"><Input v-model:value="model.name" :maxlength="128" /></Form.Item>
      <Form.Item label="规格型号" name="specification"><Input v-model:value="model.specification" :maxlength="255" /></Form.Item>
      <Form.Item label="计量单位" name="unit" :rules="required"><Input v-model:value="model.unit" :maxlength="32" /></Form.Item>
      <Form.Item label="存放位置" name="location"><Input v-model:value="model.location" :maxlength="128" /></Form.Item>
      <Form.Item label="安全库存" name="safetyQuantity" :rules="required"><InputNumber v-model:value="model.safetyQuantity" :min="0" :precision="3" class="w-full" /></Form.Item>
      <Form.Item v-if="!model.id" label="初始库存" name="initialQuantity" :rules="required"><InputNumber v-model:value="model.initialQuantity" :min="0" :precision="3" class="w-full" /></Form.Item>
      <Form.Item label="状态" name="status"><Select v-model:value="model.status" :options="[{label:'启用',value:0},{label:'停用',value:1}]" /></Form.Item>
      <Form.Item label="备注" name="remark" class="sm:col-span-2"><Input.TextArea v-model:value="model.remark" :maxlength="500" :rows="3" /></Form.Item>
    </Form>
  </Modal>
</template>
src/views/mes/dv/spare/modules/records.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,65 @@
<script setup lang="ts">
import type { SpareMovement } from '#/api/mes/dv/spare';
import { onMounted, reactive, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Button, Form, Input, Select, Space, Table } from 'ant-design-vue';
import { getSpareMovements, movementLabels } from '#/api/mes/dv/spare';
import Action from './action.vue';
const props=defineProps<{repairId?:number;sparePartId?:number;canIssue?:boolean;repairCode?:string}>();
const rows=ref<SpareMovement[]>([]),loading=ref(false),error=ref('');
const page=ref(1),size=ref(10),total=ref(0);
const filters=reactive<{repairCode?:string;type?:number}>({type:3});
let seq=0;
const [ActionModal,actionApi]=useVbenModal({connectedComponent:Action,destroyOnClose:true});
const columns=[
 {title:'操作时间',dataIndex:'createTime',key:'createTime',width:175},
 {title:'类型',dataIndex:'type',key:'type',width:95},
 {title:'备件编码',dataIndex:'sparePartCode',width:135},
 {title:'备件名称',dataIndex:'sparePartName',width:150},
 {title:'规格型号',dataIndex:'specification',width:130},
 {title:'数量 / å•位',key:'quantity',width:110},
 {title:'已退回',dataIndex:'returnedQuantity',width:90},
 {title:'可退回',dataIndex:'returnableQuantity',width:90},
 {title:'维修单号',dataIndex:'repairCode',width:145},
 {title:'设备',dataIndex:'machineryName',width:150},
 {title:'领用人',dataIndex:'receiverName',width:110},
 {title:'操作说明',dataIndex:'remark',width:220},
 {title:'操作',key:'action',width:90,fixed:'right' as const},
];
async function load(reset=false) {
 if(reset)page.value=1;
 const current=++seq;loading.value=true;error.value='';
 try {const result=await getSpareMovements({pageNo:page.value,pageSize:size.value,...filters,repairId:props.repairId,sparePartId:props.sparePartId});
 if(current===seq){rows.value=result.list;total.value=result.total;}}
 catch {if(current===seq){rows.value=[];total.value=0;error.value='记录加载失败,请点击查询重试';}}
 finally {if(current===seq)loading.value=false;}
}
function changePage(p:{current?:number;pageSize?:number}){page.value=p.current||1;size.value=p.pageSize||10;void load();}
function issue(){actionApi.setData({type:'issue',repairId:props.repairId,repairCode:props.repairCode}).open();}
function returnPart(record:SpareMovement){actionApi.setData({type:'return',original:record}).open();}
watch(()=>[props.repairId,props.sparePartId],()=>load(true));
onMounted(()=>load());
defineExpose({refresh:()=>load(true)});
</script>
<template>
 <div class="mt-4">
  <ActionModal @success="load(true)" />
  <div class="mb-3 flex flex-wrap items-center justify-between gap-3"><h3 class="text-base font-semibold">备件领用记录</h3>
   <Button v-if="canIssue" v-access:code="['mes:dv-spare:issue']" type="primary" @click="issue">领用备件</Button>
  </div>
  <Form layout="inline" class="mb-4 flex flex-wrap gap-y-3">
   <Form.Item v-if="!repairId" label="维修单号"><Input v-model:value="filters.repairCode" allow-clear placeholder="完整维修单号" @press-enter="load(true)" /></Form.Item>
   <Form.Item label="记录类型"><Select v-model:value="filters.type" allow-clear class="w-36" :options="Object.entries(movementLabels).map(([value,label])=>({label,value:Number(value)}))" /></Form.Item>
   <Form.Item><Space><Button :loading="loading" @click="load(true)">查询</Button><Button @click="filters.repairCode=undefined;filters.type=3;load(true)">重置</Button></Space></Form.Item>
  </Form>
  <Alert v-if="error" type="error" :message="error" class="mb-3" />
  <Table :columns="columns" :data-source="rows" :loading="loading" row-key="id" :scroll="{x:1790}" :pagination="{current:page,pageSize:size,total,showSizeChanger:true}" @change="changePage">
   <template #bodyCell="{column,record}">
    <template v-if="column.key==='type'">{{ movementLabels[record.type] }}</template>
    <template v-else-if="column.key==='quantity'">{{ record.quantity }} {{ record.unit }}</template>
    <template v-else-if="column.key==='action'"><Button v-if="record.type===3 && Number(record.returnableQuantity)>0" v-access:code="['mes:dv-spare:return']" type="link" @click="returnPart(record as SpareMovement)">退回</Button><span v-else>—</span></template>
   </template>
  </Table>
 </div>
</template>
src/views/mes/md/client/components/select.vue
@@ -113,7 +113,6 @@
    <Tooltip :mouse-enter-delay="0.5" :open="selectedItem ? undefined : false">
      <template #title>
        <div v-if="selectedItem" class="leading-6">
          <div>编码:{{ selectedItem.code || '-' }}</div>
          <div>名称:{{ selectedItem.name || '-' }}</div>
          <div>简称:{{ selectedItem.nickname || '-' }}</div>
          <div>电话:{{ selectedItem.telephone || '-' }}</div>
src/views/mes/md/client/data.ts
@@ -8,15 +8,6 @@
export function useClientSelectGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: '客户编码',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入客户编码',
      },
    },
    {
      fieldName: 'name',
      label: '客户名称',
      component: 'Input',
@@ -52,11 +43,6 @@
): VxeTableGridOptions<MesMdClientApi.Client>['columns'] {
  return [
    { type: multiple ? 'checkbox' : 'radio', width: 50 },
    {
      field: 'code',
      title: '客户编码',
      minWidth: 160,
    },
    {
      field: 'name',
      title: '客户名称',
src/views/mes/pd/project/modules/document-audit-form.vue
@@ -23,7 +23,8 @@
    { fieldName: 'documentVersion', label: '资料版本', component: 'Input', componentProps: { disabled: true } },
    { fieldName: 'reviewerNickname', label: '审核人', component: 'Input', componentProps: { disabled: true } },
    {
      fieldName: 'auditStatus',
      // å­—段名不能用 auditStatus:自动审批模式下会被 adapter/form çš„ isAuditField æ•´æ¡è¿‡æ»¤æŽ‰ï¼Œå¯¼è‡´å®¡æ ¸äººæ— æ³•选择
      fieldName: 'auditResult',
      label: '审核结果',
      component: 'Select',
      componentProps: {
@@ -47,7 +48,7 @@
    modalApi.lock();
    try {
      const raw = await formApi.getValues();
      await updatePdDocumentAudit({ id: raw.id, auditStatus: raw.auditStatus, remark: raw.remark, auditTime: Date.now() });
      await updatePdDocumentAudit({ id: raw.id, auditStatus: raw.auditResult, remark: raw.remark, auditTime: Date.now() });
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
      await modalApi.close();
@@ -59,9 +60,8 @@
    if (!isOpen) return;
    const data = modalApi.getData<MesPdDocumentAuditApi.DocumentAudit>();
    modalApi.setState({ title: '资料审核' });
    const formData = { ...data };
    delete formData.auditStatus;
    await formApi.setValues(formData);
    // æ¯æ¬¡æ‰“开都清空审核结果,避免复用上一行已选的值
    await formApi.setValues({ ...data, auditResult: undefined });
  },
});
</script>
src/views/mes/pd/project/modules/document-audit-list.vue
@@ -49,6 +49,7 @@
      { field: 'auditNo', title: '审核单号', minWidth: 140 },
      { field: 'documentName', title: '资料名称', minWidth: 160 },
      { field: 'documentVersion', title: '资料版本', minWidth: 90 },
      { field: 'fileUrl', title: '文件', minWidth: 140, slots: { default: 'file' } },
      { field: 'reviewerNickname', title: '审核人', minWidth: 100 },
      { field: 'auditStatus', title: '审核状态', minWidth: 100, cellRender: { name: 'CellDict', props: { type: DICT_TYPE.MES_PD_DOCUMENT_AUDIT_STATUS } } },
      { field: 'auditTime', title: '审核时间', minWidth: 160, formatter: 'formatDateTime' },
@@ -78,6 +79,12 @@
<template>
  <AuditFormModal @success="handleRefresh" />
  <Grid table-title="资料审核">
    <template #file="{ row }">
      <a v-if="row.fileUrl" :href="row.fileUrl" target="_blank" class="text-primary">
        {{ row.fileName || '查看文件' }}
      </a>
      <span v-else class="text-gray-400">-</span>
    </template>
    <template #actions="{ row }">
      <TableAction
        :actions="[
src/views/mes/pro/card/index.vue
@@ -3,7 +3,7 @@
import type { MesProCardApi } from '#/api/mes/pro/card';
import { Page, useVbenModal } from '@vben/common-ui';
import { MesProCardStatusEnum } from '@vben/constants';
import { BarcodeBizTypeEnum, MesProCardStatusEnum } from '@vben/constants';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message } from 'ant-design-vue';
@@ -185,7 +185,7 @@
        <PrinterLabel
          :biz-code="row.code"
          :biz-id="row.id"
          biz-type="PROCARD"
          :biz-type="BarcodeBizTypeEnum.PROCARD"
        />
      </template>
    </Grid>
src/views/mes/pro/card/modules/form.vue
@@ -19,7 +19,10 @@
  updateCard,
} from '#/api/mes/pro/card';
import { $t } from '#/locales';
import { BarcodeDetail } from '#/views/wls/barcode/components';
import {
  BarcodeDetail,
  PrinterLabel,
} from '#/views/wls/barcode/components';
import { useFormSchema } from '../data';
import ProcessList from './process-list.vue';
@@ -76,6 +79,7 @@
    formData.value.id,
    BarcodeBizTypeEnum.PROCARD,
    formData.value.code,
    formData.value.itemName || formData.value.code,
  );
}
@@ -207,6 +211,14 @@
        >
          æŸ¥çœ‹æ¡ç 
        </Button>
        <PrinterLabel
          v-if="formType === 'detail' && formData?.id"
          :biz-id="formData.id"
          :biz-type="BarcodeBizTypeEnum.PROCARD"
          :biz-code="formData.code"
          :biz-name="formData.itemName || formData.code"
          title="流转卡"
        />
      </div>
    </template>
  </Modal>
src/views/mes/pro/mps/data.ts
@@ -250,11 +250,6 @@
      formatter: 'formatAmount3',
    },
    {
      field: 'clientCode',
      title: '客户编码',
      width: 120,
    },
    {
      field: 'clientName',
      title: '客户名称',
      minWidth: 120,
src/views/mes/pro/mps/modules/form.vue
@@ -144,9 +144,6 @@
        <Descriptions.Item label="已生产数量">
          {{ formData.quantityProduced }}
        </Descriptions.Item>
        <Descriptions.Item label="客户编码">
          {{ formData.clientCode }}
        </Descriptions.Item>
        <Descriptions.Item label="客户名称">
          {{ formData.clientName }}
        </Descriptions.Item>
src/views/mes/pro/route/data.ts
@@ -230,6 +230,19 @@
      },
    },
    {
      fieldName: 'yieldRate',
      label: '收率',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0.0001,
        max: 1,
        step: 0.01,
        precision: 4,
        placeholder: '产出/投入,如 0.98',
      },
    },
    {
      fieldName: 'linkType',
      label: '与下道工序关系',
      component: 'Select',
src/views/mes/pro/task/data.ts
@@ -86,6 +86,7 @@
/** å¾…排产订单列表的字段 */
export function useGridColumns(): VxeTableGridOptions<MesProWorkOrderApi.WorkOrder>['columns'] {
  return [
    { type: 'checkbox', width: 48, fixed: 'left' },
    {
      field: 'code',
      title: '订单编码',
@@ -143,15 +144,6 @@
      },
    },
    {
      field: 'productionStatus',
      title: '生产状态',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ORDER_PRODUCTION_STATUS },
      },
    },
    {
      field: 'quantity',
      title: '订单数量',
      width: 100,
@@ -173,11 +165,6 @@
      slots: { default: 'progressPercent' },
    },
    {
      field: 'clientCode',
      title: '客户编码',
      width: 120,
    },
    {
      field: 'clientName',
      title: '客户名称',
      width: 120,
@@ -191,7 +178,7 @@
    {
      title: '操作',
      width: 160,
      width: 240,
      fixed: 'right',
      slots: { default: 'actions' },
    },
src/views/mes/pro/task/index.vue
@@ -7,12 +7,11 @@
import { Page, useVbenModal } from '@vben/common-ui';
import {
  MesProWorkOrderProductionStatusEnum,
  MesProWorkOrderStatusEnum,
  MesProWorkOrderTypeEnum,
} from '@vben/constants';
import { Button, Card, Progress, Tabs } from 'ant-design-vue';
import { Button, Card, Progress, Tabs, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getGanttTaskList } from '#/api/mes/pro/task';
@@ -22,6 +21,7 @@
import { GanttChart } from './components';
import { useGridColumns, useGridFormSchema } from './data';
import ScheduleForm from './modules/schedule-form.vue';
import AutoSchedule from './modules/auto-schedule.vue';
const router = useRouter();
const ganttTasks = ref<any[]>([]); // ç”˜ç‰¹å›¾é¢„览数据
@@ -35,9 +35,31 @@
  destroyOnClose: true,
});
const [AutoScheduleModal, autoScheduleModalApi] = useVbenModal({
  connectedComponent: AutoSchedule,
  destroyOnClose: true,
});
function canAutoSchedule(row: MesProWorkOrderApi.WorkOrder) {
  return row.status === MesProWorkOrderStatusEnum.CONFIRMED
    && row.type === MesProWorkOrderTypeEnum.SELF
    && !isCompletedWorkOrder(row)
    && (row.quantity ?? 0) > 0
    && (row.quantityScheduled ?? 0) < (row.quantity ?? 0);
}
function handleAutoSchedule(row?: MesProWorkOrderApi.WorkOrder) {
  const rows: MesProWorkOrderApi.WorkOrder[] = row ? [row] : gridApi.grid.getCheckboxRecords();
  if (!rows.length) return message.warning('请先勾选待排产工单');
  if (rows.length > 30) return message.warning('一次最多选择30个工单,请分批排产');
  if (rows.some((order) => !canAutoSchedule(order))) return message.warning('请选择已确认且仍需排产的自行生产工单');
  autoScheduleModalApi.setData({ workOrderIds: rows.map((order) => order.id!) }).open();
}
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.grid.clearCheckboxRow();
  gridApi.query();
  loadGanttPreview();
}
@@ -72,12 +94,9 @@
  router.push({ name: 'MesProTaskGanttEdit' });
}
/** åˆ¤æ–­è®¢å•是否为“已完成类型”:订单状态=已完成 æˆ– ç”Ÿäº§çŠ¶æ€=生产完成 */
/** åˆ¤æ–­è®¢å•是否为“已完成类型”:订单状态=已完成 */
function isCompletedWorkOrder(row: MesProWorkOrderApi.WorkOrder): boolean {
  return (
    row.status === MesProWorkOrderStatusEnum.FINISHED ||
    row.productionStatus === MesProWorkOrderProductionStatusEnum.FINISHED
  );
  return row.status === MesProWorkOrderStatusEnum.FINISHED;
}
/**
@@ -103,6 +122,11 @@
  gridOptions: {
    columns: useGridColumns(),
    keepSource: true,
    checkboxConfig: {
      checkStrictly: true,
      showHeader: true,
      checkMethod: ({ row }: { row: MesProWorkOrderApi.WorkOrder }) => canAutoSchedule(row),
    },
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
@@ -154,6 +178,7 @@
<template>
  <Page auto-content-height>
    <ScheduleModal @success="handleRefresh" />
    <AutoScheduleModal @success="handleRefresh" />
    <!-- æŽ’产甘特图预览 -->
    <Card title="排产甘特图" class="mb-4">
      <template #extra>
@@ -177,8 +202,14 @@
        <TableAction
          :actions="[
            {
              label: '甘特图编辑',
              label: '一键排产',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pro-task:create'],
              onClick: () => handleAutoSchedule(),
            },
            {
              label: '甘特图编辑',
              icon: ACTION_ICON.EDIT,
              onClick: handleGanttEdit,
            },
@@ -200,6 +231,13 @@
        <TableAction
          :actions="[
            {
              label: '一键排产',
              type: 'link',
              auth: ['mes:pro-task:create'],
              ifShow: canAutoSchedule(row),
              onClick: () => handleAutoSchedule(row),
            },
            {
              label: '排产维护',
              type: 'link',
              auth: ['mes:pro-task:create'],
src/views/mes/pro/task/modules/auto-schedule.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,180 @@
<script setup lang="ts">
import type { BatchSchedulePreview, BatchScheduleRequest } from '#/api/mes/pro/task';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Button, Checkbox, DatePicker, Descriptions, Table, Tag, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { executeBatchSchedule, previewBatchSchedule } from '#/api/mes/pro/task';
const emit = defineEmits(['success']);
const ids = ref<number[]>([]);
function defaultStartTime() {
  let value = dayjs().add(5, 'minute').startOf('minute');
  if (value.hour() >= 18) value = value.add(1, 'day').hour(9).minute(0).second(0);
  else if (value.hour() < 9) value = value.hour(9).minute(0).second(0);
  while (value.day() === 0 || value.day() === 6) value = value.add(1, 'day').hour(9).minute(0).second(0);
  return value;
}
const startTime = ref(defaultStartTime());
const preview = ref<BatchSchedulePreview>();
const loading = ref(false);
const executing = ref(false);
const acceptLate = ref(false);
const errorText = ref('');
let sequence = 0;
let open = false;
const canConfirm = computed(() => !!preview.value?.canExecute && !loading.value && !executing.value
  && (!preview.value.warnings.length || acceptLate.value));
const [Modal, modalApi] = useVbenModal({
  confirmText: '确认排产',
  confirmDisabled: true,
  onBeforeClose: () => !executing.value,
  onConfirm: execute,
  async onOpenChange(isOpen) {
    open = isOpen;
    sequence++;
    if (!isOpen) {
      preview.value = undefined;
      ids.value = [];
      loading.value = false;
      errorText.value = '';
      return;
    }
    ids.value = modalApi.getData<{ workOrderIds: number[] }>().workOrderIds;
    startTime.value = defaultStartTime();
    await calculate();
  },
});
watch(canConfirm, (value) => modalApi.setState({ confirmDisabled: !value }));
watch(startTime, () => {
  sequence++;
  preview.value = undefined;
  acceptLate.value = false;
  loading.value = false;
  errorText.value = '';
}, { flush: 'sync' });
function request(): BatchScheduleRequest {
  return { workOrderIds: [...ids.value], startTime: startTime.value.format('YYYY-MM-DD HH:mm:ss') };
}
async function calculate() {
  if (executing.value || !startTime.value || !ids.value.length) return;
  const current = ++sequence;
  preview.value = undefined;
  acceptLate.value = false;
  errorText.value = '';
  loading.value = true;
  try {
    const result = await previewBatchSchedule(request());
    if (open && current === sequence) preview.value = result;
  } catch (error) {
    if (open && current === sequence) errorText.value = error instanceof Error ? error.message : '预览失败,请稍后重试';
  } finally {
    if (current === sequence) loading.value = false;
  }
}
async function execute() {
  if (!canConfirm.value || !preview.value) return;
  executing.value = true;
  errorText.value = '';
  modalApi.lock();
  try {
    const result = await executeBatchSchedule({ ...request(), previewHash: preview.value.previewHash,
      previewTime: preview.value.previewTime, allowLate: acceptLate.value });
    message.success(`排产成功:${ids.value.length} ä¸ªå·¥å•,生成 ${result.taskIds.length} ä¸ªä»»åŠ¡`);
    executing.value = false;
    await modalApi.close();
    emit('success');
  } catch (error) {
    errorText.value = error instanceof Error ? error.message : '执行结果未确认,请刷新列表并重新预览';
    // è¯·æ±‚失败或结果未确认后必须重算;旧预览不能直接再次提交。
    preview.value = undefined;
    acceptLate.value = false;
    emit('success'); // åˆ·æ–°å·¥å•和甘特图,核对超时请求是否已经提交。
  } finally {
    executing.value = false;
    modalApi.unlock();
  }
}
const orderColumns = [
  { title: '工单编码', dataIndex: 'workOrderCode', width: 170 },
  { title: '生产数量', dataIndex: 'quantity', width: 100 },
  { title: '新增任务', dataIndex: 'taskCount', width: 90 },
  { title: '需求日期', dataIndex: 'requestDate', width: 120 },
  { title: '预计完成', dataIndex: 'endTime', width: 175 },
  { title: '校验结果', dataIndex: 'error', width: 300 },
];
const taskColumns = [
  { title: '工单', dataIndex: 'workOrderCode', width: 160 },
  { title: '工序', dataIndex: 'processName', width: 130 },
  { title: '工作站', dataIndex: 'workstationName', width: 150 },
  { title: '设备', dataIndex: 'machineryNames', width: 180 },
  { title: '本次数量', dataIndex: 'quantity', width: 100 },
  { title: '小时产能', dataIndex: 'hourlyCapacity', width: 100 },
  { title: '工时(h)', dataIndex: 'duration', width: 85 },
  { title: '开工时间', dataIndex: 'startTime', width: 180 },
  { title: '完成时间', dataIndex: 'endTime', width: 180 },
];
</script>
<template>
  <Modal title="一键排产" class="w-[min(96vw,1200px)]">
    <div class="space-y-5 px-1">
      <Alert type="info" show-icon message="先校验与预览,再确认排产"
        description="按交期优先及工序顺序安排工作站与设备;每天09:00–18:00,排除周末和已配置节假日,非工作时间自动顺延。已部分排产的工单只补齐剩余数量,最多30个工单、200个新任务。" />
      <div class="flex flex-wrap items-end gap-3">
        <label class="flex flex-col gap-2">
          <span class="font-medium">最早开工时间</span>
          <DatePicker v-model:value="startTime" show-time :allow-clear="false" :disabled="executing"
            format="YYYY-MM-DD HH:mm:ss" :disabled-date="(date) => date.endOf('day').isBefore(dayjs())" />
        </label>
        <Button :loading="loading" :disabled="executing" @click="calculate">
          {{ preview ? '重新计算预览' : '生成排产预览' }}
        </Button>
        <span class="text-sm text-muted-foreground">已选择 {{ ids.length }} ä¸ªå·¥å•</span>
      </div>
      <Alert v-if="errorText" type="error" show-icon :message="errorText" />
      <template v-if="preview">
        <Descriptions :column="{ xs: 1, sm: 3 }" size="small" bordered>
          <Descriptions.Item label="工单数">{{ preview.orders.length }}</Descriptions.Item>
          <Descriptions.Item label="新增任务数">{{ preview.tasks.length }}</Descriptions.Item>
          <Descriptions.Item label="预览有效期">15分钟,提交时重新校验</Descriptions.Item>
        </Descriptions>
        <Alert v-if="!preview.canExecute" type="error" show-icon message="部分工单未通过校验,本批次不能提交"
          description="请根据下方校验结果修正工单、设备或已有任务的配置,再重新计算预览。" />
        <div>
          <h3 class="mb-2 font-medium">工单校验</h3>
          <Table :columns="orderColumns" :data-source="preview.orders" row-key="workOrderId"
            :pagination="false" size="small" :scroll="{ x: 1055 }">
            <template #bodyCell="{ column, record, text }">
              <template v-if="column.dataIndex === 'error'">
                <span v-if="record.error" class="text-destructive">{{ record.error }}</span>
                <Tag v-else :color="record.late ? 'orange' : 'green'">{{ record.late ? '有延期风险' : '通过' }}</Tag>
              </template>
              <template v-else-if="column.dataIndex === 'requestDate'">{{ text ? String(text).slice(0, 10) : '未指定' }}</template>
            </template>
          </Table>
        </div>
        <div v-if="preview.tasks.length">
          <h3 class="mb-2 font-medium">任务安排</h3>
          <Table :columns="taskColumns" :data-source="preview.tasks"
            :row-key="(row) => `${row.workOrderId}-${row.processId}`" :pagination="{ pageSize: 10, showSizeChanger: false }"
            size="small" :scroll="{ x: 1365 }" />
        </div>
        <Alert v-if="preview.warnings.length" type="warning" show-icon message="请核对交期风险">
          <template #description>
            <p v-for="warning in preview.warnings" :key="warning">{{ warning }}</p>
            <Checkbox v-model:checked="acceptLate" :disabled="executing" class="mt-2">
              æˆ‘已核对并接受以上交期延期风险
            </Checkbox>
          </template>
        </Alert>
      </template>
    </div>
  </Modal>
</template>
src/views/mes/pro/workorder-center/index.vue
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProWorkOrderApi, TaskInfo } from '#/api/mes/pro/workorder';
import type { MesProWorkOrderApi, TaskInfo, WorkOrderProgress } from '#/api/mes/pro/workorder';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -16,19 +16,24 @@
  getWorkOrderProgress,
  checkAndAutoFinishWorkOrder,
} from '#/api/mes/pro/workorder';
import { previewAutoGenerate, autoGenerateTasks } from '#/api/mes/pro/task';
import ArchiveDetail from '#/views/mes/pd/archive/modules/detail.vue';
import { useGridColumns, useGridFormSchema, useTaskGridColumns } from './data';
import QuickFeedbackForm from './modules/quick-feedback.vue';
import SchedulePreviewModal from './modules/schedule-preview.vue';
import AutoSchedule from '../task/modules/auto-schedule.vue';
defineOptions({ name: 'WorkorderCenter' });
const archiveDetailRef = ref<InstanceType<typeof ArchiveDetail>>();
const archiveVisible = ref(false);
const archiveProductCode = ref('');
function handleArchive(row: MesProWorkOrderApi.WorkOrder) {
  archiveProductCode.value = row.productCode ?? '';
  archiveVisible.value = true;
}
const router = useRouter();
const progressData = ref<MesProWorkOrderApi.WorkOrderProgress | null>(null);
const progressData = ref<WorkOrderProgress | null>(null);
const activeProcessKeys = ref<string[]>([]);
const expandedRowId = ref<number | null>(null);
@@ -38,7 +43,7 @@
});
const [SchedulePreviewModalWrap, schedulePreviewModalApi] = useVbenModal({
  connectedComponent: SchedulePreviewModal,
  connectedComponent: AutoSchedule,
  destroyOnClose: true,
});
@@ -107,27 +112,9 @@
    .open();
}
/** ä¸€é”®æŽ’产预览 */
async function handleSchedulePreview(row: MesProWorkOrderApi.WorkOrder) {
  try {
    const preview = await previewAutoGenerate({ workOrderId: row.id! });
    schedulePreviewModalApi
      .setData({ workOrder: row, preview })
      .open();
  } catch {
    message.error('获取排产预览失败');
  }
}
/** æ‰§è¡Œä¸€é”®æŽ’产 */
async function handleScheduleExecute(row: MesProWorkOrderApi.WorkOrder) {
  try {
    const result = await autoGenerateTasks({ workOrderId: row.id! });
    message.success(`排产成功,生成 ${result.taskIds?.length || 0} ä¸ªä»»åŠ¡`);
    handleRefresh();
  } catch {
    message.error('排产失败');
  }
/** ä¸Žç”Ÿäº§æŽ’产共用校验、预览和执行流程。 */
function handleSchedulePreview(row: MesProWorkOrderApi.WorkOrder) {
  schedulePreviewModalApi.setData({ workOrderIds: [row.id!] }).open();
}
const [Grid, gridApi] = useVbenVxeGrid({
@@ -193,26 +180,16 @@
        <TableAction
          :actions="[
            {
              label: '排产预览',
              type: 'link',
              auth: ['mes:pro-task:create'],
              ifShow: row.quantityScheduled < row.quantity,
              onClick: handleSchedulePreview.bind(null, row),
            },
            {
              label: '一键排产',
              type: 'link',
              auth: ['mes:pro-task:create'],
              ifShow: row.quantityScheduled < row.quantity,
              popConfirm: {
                title: '确认一键排产?将按照工艺路线自动生成任务。',
                confirm: handleScheduleExecute.bind(null, row),
              },
              ifShow: (row.quantityScheduled ?? 0) < (row.quantity ?? 0),
              onClick: handleSchedulePreview.bind(null, row),
            },
            {
              label: '归档',
              type: 'link',
              onClick: () => archiveDetailRef.value?.open(row.productCode),
              onClick: () => handleArchive(row),
            },
          ]"
        />
@@ -316,6 +293,6 @@
        </Collapse.Panel>
      </Collapse>
    </Card>
    <ArchiveDetail ref="archiveDetailRef" />
    <ArchiveDetail :visible="archiveVisible" :product-code="archiveProductCode" @update:visible="archiveVisible = $event" />
  </Page>
</template>
src/views/mes/pro/workorder/data.ts
@@ -455,11 +455,6 @@
      width: 100,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      width: 120,
    },
    {
      field: 'clientName',
      title: '客户名称',
      width: 120,
@@ -477,15 +472,6 @@
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.MES_PRO_WORK_ORDER_STATUS },
      },
    },
    {
      field: 'productionStatus',
      title: '生产状态',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ORDER_PRODUCTION_STATUS },
      },
    },
    {
@@ -622,11 +608,6 @@
      field: 'quantity',
      title: '订单数量',
      width: 100,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      width: 120,
    },
    {
      field: 'clientName',
src/views/mes/pro/workorder/index.vue
@@ -68,7 +68,7 @@
  /** å®Œæˆè®¢å•前校验 */
  function handleCheckFinish(row: MesProWorkOrderApi.WorkOrder) {
    if (row.productionStatus !== 2) {
    if (row.status !== MesProWorkOrderStatusEnum.FINISHED) {
      Modal.confirm({
        title: '操作确认',
        content: '该订单未完成,是否确认要继续完成?',
src/views/mes/process-design/route/data.ts
@@ -202,6 +202,21 @@
      rules: "selectRequired",
    },
    {
      fieldName: "yieldRate",
      label: "工序收率",
      component: "InputNumber",
      help: "产出量 Ã· æŠ•入量。填写 0.98 è¡¨ç¤º 98%,1 è¡¨ç¤ºæ— æŸè€—;未填写按 100% å‚与排产计算。",
      componentProps: {
        class: "!w-full",
        min: 0.0001,
        max: 1,
        step: 0.01,
        precision: 4,
        placeholder: "如 0.98(98%)",
      },
      rules: z.number().min(0.0001).max(1).nullish(),
    },
    {
      fieldName: "linkType",
      label: "与下道工序关系",
      component: "Select",
src/views/mes/process-design/route/modules/workbench.vue
@@ -406,6 +406,9 @@
              </div>
              <div class="mt-1 flex flex-wrap gap-x-5 gap-y-1 text-sm text-gray-500">
                <span>序号:{{ selectedProcess.sort }}</span>
                <span>
                  å·¥åºæ”¶çŽ‡ï¼š{{ selectedProcess.yieldRate == null ? '未设置(按 100%)' : `${Number((selectedProcess.yieldRate * 100).toFixed(2))}%` }}
                </span>
                <span>准备时间:{{ selectedProcess.prepareTime ?? 0 }} åˆ†</span>
                <span>等待时间:{{ selectedProcess.waitTime ?? 0 }} åˆ†</span>
                <span v-if="selectedProcess.linkType != null">
@@ -542,4 +545,4 @@
      </div>
    </div>
  </div>
</template>
</template>
src/views/mes/qc/batchtrace/data.ts
@@ -56,15 +56,6 @@
        placeholder: '请输入销售订单编号',
      },
    },
    {
      fieldName: 'purchaseOrderCode',
      label: '采购订单编号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入采购订单编号',
      },
    },
  ];
}
@@ -97,19 +88,9 @@
      width: 80,
    },
    {
      field: 'vendorCode',
      title: '供应商编码',
      minWidth: 140,
    },
    {
      field: 'vendorName',
      title: '供应商名称',
      minWidth: 160,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      minWidth: 140,
    },
    {
      field: 'clientName',
@@ -119,11 +100,6 @@
    {
      field: 'salesOrderCode',
      title: '销售订单编号',
      minWidth: 160,
    },
    {
      field: 'purchaseOrderCode',
      title: '采购订单编号',
      minWidth: 160,
    },
    {
@@ -192,24 +168,12 @@
      span: 3,
    },
    {
      field: 'purchaseOrderCode',
      label: '采购订单编号',
    },
    {
      field: 'vendorCode',
      label: '供应商编码',
    },
    {
      field: 'vendorName',
      label: '供应商名称',
    },
    {
      field: 'salesOrderCode',
      label: '销售订单编号',
    },
    {
      field: 'clientCode',
      label: '客户编码',
    },
    {
      field: 'clientName',
@@ -243,16 +207,6 @@
      title: '入库日期',
      minWidth: 160,
      formatter: 'formatDate',
    },
    {
      field: 'purchaseOrderCode',
      title: '采购订单编号',
      minWidth: 160,
    },
    {
      field: 'vendorCode',
      title: '供应商编码',
      minWidth: 140,
    },
    {
      field: 'vendorName',
src/views/mes/qc/indicatorresult/components/data.ts
@@ -11,11 +11,12 @@
import { generateAutoCode } from '#/api/mes/md/autocode/record';
/** è¡¨å•类型 */
export type FormType = 'create' | 'update';
export type FormType = 'create' | 'update' | 'detail';
/** æ–°å¢ž/修改检测结果的表单 */
export function useQcIndicatorResultFormSchema(
  formApi?: VbenFormApi,
  readonly = false,
): VbenFormSchema[] {
  return [
    {
@@ -34,7 +35,7 @@
        placeholder: '请输入样品编号',
      },
      rules: 'required',
      suffix: () =>
      suffix: readonly ? undefined : () =>
        h(
          Button,
          {
@@ -48,14 +49,6 @@
          },
          { default: () => '生成' },
        ),
    },
    {
      fieldName: 'sn',
      label: '物资 SN',
      component: 'Input',
      componentProps: {
        placeholder: '请输入物资 SN',
      },
    },
    {
      fieldName: 'remark',
@@ -79,18 +72,13 @@
      width: 200,
    },
    {
      field: 'sn',
      title: '物资SN',
      minWidth: 200,
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 200,
    },
    {
      title: '操作',
      width: 150,
      width: 240,
      fixed: 'right',
      slots: { default: 'actions' },
    },
src/views/mes/qc/indicatorresult/components/form.vue
@@ -40,11 +40,14 @@
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const isDetail = computed(() => formType.value === 'detail');
const items = ref<MesQcIndicatorResultApi.IndicatorResultDetail[]>([]);
const ctxData = ref<CtxData>();
const getTitle = computed(() =>
  formType.value === 'update'
  isDetail.value
    ? '检测结果详情'
    : formType.value === 'update'
    ? $t('ui.actionTitle.edit', ['检验结果'])
    : $t('ui.actionTitle.create', ['检验结果']),
);
@@ -73,9 +76,21 @@
    : [];
}
function getDisplayValue(item: MesQcIndicatorResultApi.IndicatorResultDetail) {
  if (item.value === undefined || item.value === null || item.value === '') {
    return '未填写';
  }
  if (item.valueType === MesQcResultValueType.DICT) {
    return getValueOptions(item.valueSpecification).find(
      (option) => option.value === String(item.value),
    )?.label ?? item.value;
  }
  return item.value;
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (!ctxData.value) {
    if (!ctxData.value || isDetail.value) {
      return;
    }
    const { valid } = await formApi.validate();
@@ -130,11 +145,18 @@
      items.value = [];
      return;
    }
    formApi.setState({ schema: useQcIndicatorResultFormSchema(formApi) });
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<CtxData>();
    ctxData.value = data;
    formType.value = data.formType;
    formApi.setState({
      schema: useQcIndicatorResultFormSchema(formApi, isDetail.value),
    });
    formApi.setDisabled(isDetail.value);
    modalApi.setState({
      showConfirmButton: !isDetail.value,
      cancelText: isDetail.value ? '关闭' : $t('common.cancel'),
    });
    modalApi.lock();
    try {
      const detail = await getIndicatorResultDetail(
@@ -148,7 +170,9 @@
        valueNumber:
          (item.valueType === MesQcResultValueType.FLOAT ||
            item.valueType === MesQcResultValueType.INTEGER) &&
          item.value !== null
          item.value != null &&
          String(item.value).trim() !== '' &&
          Number.isFinite(Number(item.value))
            ? Number(item.value)
            : undefined,
      }));
@@ -157,7 +181,6 @@
        id: detail.id,
        code: detail.code,
        remark: detail.remark,
        sn: detail.sn,
      });
    } finally {
      modalApi.unlock();
@@ -181,8 +204,14 @@
          <AForm.Item
            :label="`检测项${index + 1}:${item.indicatorName ?? ''}`"
          >
            <div v-if="isDetail" class="whitespace-pre-wrap break-words py-1">
              {{ getDisplayValue(item) }}
              <div v-if="item.remark" class="mt-1 text-sm text-muted-foreground">
                å¤‡æ³¨ï¼š{{ item.remark }}
              </div>
            </div>
            <InputNumber
              v-if="
              v-else-if="
                item.valueType === MesQcResultValueType.FLOAT ||
                item.valueType === MesQcResultValueType.INTEGER
              "
src/views/mes/qc/indicatorresult/components/list.vue
@@ -55,6 +55,18 @@
    .open();
}
/** æŸ¥çœ‹æ£€æµ‹ç»“果,质检完成后仍可打开只读详情。 */
function handleDetail(row: MesQcIndicatorResultApi.IndicatorResult) {
  formModalApi
    .setData({
      formType: 'detail',
      id: row.id,
      qcId: props.qcId,
      qcType: props.qcType,
    })
    .open();
}
/** åˆ é™¤æ£€æµ‹ç»“æžœ */
async function handleDelete(row: MesQcIndicatorResultApi.IndicatorResult) {
  const hideLoading = message.loading({
@@ -120,6 +132,12 @@
        <TableAction
          :actions="[
            {
              label: '查看详情',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
src/views/mes/qc/ipqc/data.ts
@@ -17,7 +17,7 @@
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getTemplateItemOptionList } from '#/api/mes/qc/template/item';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
import { getDateRangePickerDefaultProps } from '#/utils';
import { MdItemSelect } from '#/views/mes/md/item/components';
import { MdWorkstationSelect } from '#/views/mes/md/workstation/components';
import { ProTaskSelect } from '#/views/mes/pro/task/components';
@@ -437,11 +437,25 @@
      },
    },
    {
      fieldName: 'inspectorUserId',
      label: '检测人员',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        api: getSimpleUserList,
        labelField: 'nickname',
        placeholder: '请选择检测人员',
        showSearch: true,
        optionFilterProp: 'label',
        valueField: 'id',
      },
    },
    {
      fieldName: 'inspectDate',
      label: '检测日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        ...getDateRangePickerDefaultProps(),
      },
    },
  ];
src/views/mes/qc/iqc/data.ts
@@ -13,7 +13,7 @@
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getTemplateItemOptionList } from '#/api/mes/qc/template/item';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
import { getDateRangePickerDefaultProps } from '#/utils';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
@@ -178,6 +178,21 @@
      componentProps: {
        placeholder: '请输入供应商批次号',
      },
      suffix: () =>
        h(
          Button,
          {
            disabled: formType === 'detail',
            type: 'default',
            onClick: async () => {
              const code = await generateAutoCode(
                MesAutoCodeRuleCode.QC_IQC_VENDOR_BATCH_CODE,
              );
              await formApi?.setFieldValue('vendorBatch', code);
            },
          },
          { default: () => '生成' },
        ),
    },
    {
      fieldName: 'receivedQuantity',
@@ -335,7 +350,7 @@
      label: '来料日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        ...getDateRangePickerDefaultProps(),
      },
    },
    {
@@ -343,7 +358,7 @@
      label: '检测日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        ...getDateRangePickerDefaultProps(),
      },
    },
    {
src/views/mes/qc/ledger/data.ts
@@ -8,7 +8,7 @@
import { getDictOptions } from '@vben/hooks';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
import { getDateRangePickerDefaultProps } from '#/utils';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
/** åˆ—表的搜索表单 */
@@ -29,7 +29,7 @@
      label: '检测日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        ...getDateRangePickerDefaultProps(),
      },
    },
    {
src/views/mes/qc/oqc/data.ts
@@ -13,7 +13,7 @@
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getTemplateItemOptionList } from '#/api/mes/qc/template/item';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
import { getDateRangePickerDefaultProps } from '#/utils';
import CrmCustomerSelect from '#/components/crm-customer-select.vue';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
@@ -371,11 +371,25 @@
      },
    },
    {
      fieldName: 'inspectorUserId',
      label: '检测人员',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        api: getSimpleUserList,
        labelField: 'nickname',
        placeholder: '请选择检测人员',
        showSearch: true,
        optionFilterProp: 'label',
        valueField: 'id',
      },
    },
    {
      fieldName: 'inspectDate',
      label: '检测日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        ...getDateRangePickerDefaultProps(),
      },
    },
  ];
src/views/mes/qc/rqc/data.ts
@@ -17,7 +17,7 @@
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getTemplateItemOptionList } from '#/api/mes/qc/template/item';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
import { getDateRangePickerDefaultProps } from '#/utils';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
/** è¡¨å•类型 */
@@ -364,7 +364,7 @@
      label: '检测日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        ...getDateRangePickerDefaultProps(),
      },
    },
  ];
src/views/wls/barcode/components/printer-label.vue
@@ -1,30 +1,184 @@
<script lang="ts" setup>
import type { MesWmBarcodeApi } from '#/api/mes/wm/barcode';
import { nextTick, ref } from 'vue';
import { Button, message } from 'ant-design-vue';
import { createBarcode, getBarcodeByBusiness } from '#/api/mes/wm/barcode';
import WlsBarcode from './barcode.vue';
defineOptions({ name: 'WlsPrinterLabel' });
withDefaults(
/** æ ‡ç­¾ä¸Šé™„加展示的业务字段(可选,默认不展示) */
interface LabelField {
  label: string;
  value?: number | string;
}
const props = withDefaults(
  defineProps<{
    bizCode?: string;
    bizId?: number;
    bizType?: string;
    /** ä¸šåŠ¡åç§°ï¼Œä½œä¸ºæ ‡ç­¾ä¸»ä¿¡æ¯å±•ç¤ºåœ¨æ¡ç ä¸‹æ–¹ */
    bizName?: string;
    bizType?: number;
    /** æ ‡ç­¾æ ‡é¢˜ */
    title?: string;
    /** æŒ‰é’®æ–‡æ¡ˆ */
    labelText?: string;
    /** é™„加字段,一般不需要传 */
    fields?: LabelField[];
  }>(),
  {
    bizCode: undefined,
    bizId: undefined,
    bizName: undefined,
    bizType: undefined,
    title: '',
    labelText: '标签打印',
    fields: () => [],
  },
);
function handlePrint() {
  message.warning('标签打印功能暂未实现,敬请期待');
const barcodeRef = ref<InstanceType<typeof WlsBarcode>>();
const barcode = ref<MesWmBarcodeApi.Barcode>();
const renderKey = ref(0);
const printing = ref(false);
/** æ‰“印窗口:需在用户点击时同步打开,否则会被浏览器拦截 */
const printWindow = ref<null | Window>(null);
function escapeHtml(value: string) {
  return value
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;');
}
/** æž„建标签 HTML:标题 + æ¡ç å›¾ + åç§°ï¼ˆå±…中),附加字段可选 */
function buildLabelHtml(base64: string) {
  const nameHtml = props.bizName
    ? `<div class="label-name">${escapeHtml(props.bizName)}</div>`
    : '';
  const infoHtml = props.fields
    .filter((item) => item.value !== undefined && item.value !== '')
    .map(
      (item) =>
        `<p><span class="info-label">${escapeHtml(item.label)}</span>${escapeHtml(String(item.value))}</p>`,
    )
    .join('');
  return `<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>${escapeHtml(props.title || '打印标签')}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:Arial,"Microsoft YaHei",sans-serif;display:flex;justify-content:center;padding:12px}
.label{width:320px;padding:8px;text-align:center}
.label-title{font-size:13px;color:#333;margin-bottom:6px}
.label-barcode{width:200px;margin:0 auto}
.label-barcode img{display:block;width:100%}
.label-name{margin-top:8px;font-size:16px;font-weight:700;line-height:1.4;word-break:break-all}
.label-info{margin-top:8px;font-size:12px;line-height:1.6;text-align:left;color:#333}
.label-info p{margin:2px 0}
.info-label{color:#666;margin-right:4px}
.info-label::after{content:":"}
@page{margin:5mm}
@media print{body{padding:0}.label{width:auto;max-width:100%}}
</style>
</head><body><div class="label">
${props.title ? `<div class="label-title">${escapeHtml(props.title)}</div>` : ''}
<div class="label-barcode"><img src="${base64}" alt="条码" /></div>
${nameHtml}
${infoHtml ? `<div class="label-info">${infoHtml}</div>` : ''}
</div></body></html>`;
}
/** æ¡ç å›¾æ¸²æŸ“完成,把标签内容写入已打开的打印窗口并调起打印 */
function handleBarcodeDone() {
  const win = printWindow.value;
  const base64 = barcodeRef.value?.getImageBase64();
  printWindow.value = null;
  if (!win || win.closed) {
    return;
  }
  if (!base64) {
    message.warning('条码渲染失败,无法打印标签');
    win.close();
    return;
  }
  win.document.open();
  win.document.write(buildLabelHtml(base64));
  win.document.close();
  setTimeout(() => win.print(), 500);
}
/** æ‰“印标签:无条码时先生成,再渲染条码图后打印 */
async function handlePrint() {
  if (!props.bizType || !props.bizId) {
    message.warning('缺少业务类型或业务编号,无法打印标签');
    return;
  }
  if (printing.value) {
    return;
  }
  // åœ¨ç”¨æˆ·æ‰‹åŠ¿å†…åŒæ­¥æ‰“å¼€çª—å£ï¼Œé¿å…å¼¹çª—è¢«æ‹¦æˆª
  const win = window.open('', '_blank');
  if (!win) {
    message.error('无法打开打印窗口,请检查浏览器设置');
    return;
  }
  win.document.write(
    '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="font-family:Arial;padding:20px">正在生成标签,请稍候…</body></html>',
  );
  printWindow.value = win;
  printing.value = true;
  try {
    let data = await getBarcodeByBusiness(props.bizType, props.bizId);
    if (!data?.content) {
      await createBarcode({
        bizCode: props.bizCode || '',
        bizId: props.bizId,
        bizName: props.bizName || '',
        bizType: props.bizType,
      });
      data = await getBarcodeByBusiness(props.bizType, props.bizId);
    }
    if (!data?.content) {
      message.warning('条码生成失败,无法打印标签');
      win.close();
      printWindow.value = null;
      return;
    }
    barcode.value = data;
    renderKey.value += 1;
    await nextTick();
  } catch {
    message.error('打印标签失败,请重试');
    win.close();
    printWindow.value = null;
  } finally {
    printing.value = false;
  }
}
</script>
<template>
  <Button type="link" @click="handlePrint">
  <Button :loading="printing" @click="handlePrint">
    {{ labelText }}
  </Button>
  <!-- éšè—çš„æ¡ç æ¸²æŸ“容器,渲染完成后取出图片写入打印窗口 -->
  <div
    v-if="barcode?.content"
    class="pointer-events-none fixed top-0 -left-[9999px] h-0 w-0 overflow-hidden"
  >
    <WlsBarcode
      :key="renderKey"
      ref="barcodeRef"
      :content="barcode.content"
      :format="barcode.format"
      :height="150"
      :width="280"
      @done="handleBarcodeDone"
    />
  </div>
</template>
src/views/wls/batch/data.ts
@@ -208,11 +208,6 @@
      width: 120,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      width: 110,
    },
    {
      field: 'clientName',
      title: '客户名称',
      width: 110,
src/views/wls/itemreceipt/modules/line-list.vue
@@ -8,6 +8,7 @@
import { computed, reactive } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { BarcodeBizTypeEnum } from '@vben/constants';
import { message } from 'ant-design-vue';
@@ -232,7 +233,7 @@
            v-if="isStock"
            :biz-code="row.batchCode"
            :biz-id="row.batchId"
            biz-type="ITEM_BATCH"
            :biz-type="BarcodeBizTypeEnum.BATCH"
          />
        </div>
      </template>
src/views/wls/outsourcereceipt/modules/line-list.vue
@@ -8,6 +8,7 @@
import { computed, reactive } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { BarcodeBizTypeEnum } from '@vben/constants';
import { message } from 'ant-design-vue';
@@ -227,7 +228,7 @@
            v-if="isStock"
            :biz-code="row.batchCode"
            :biz-id="row.batchId"
            biz-type="BATCH"
            :biz-type="BarcodeBizTypeEnum.BATCH"
          />
        </div>
      </template>
src/views/wls/packages/data.ts
@@ -299,11 +299,6 @@
      minWidth: 120,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      minWidth: 100,
    },
    {
      field: 'clientName',
      title: '客户名称',
      minWidth: 120,
@@ -390,11 +385,6 @@
      field: 'invoiceCode',
      title: '发票编号',
      minWidth: 120,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      minWidth: 100,
    },
    {
      field: 'clientName',
@@ -621,11 +611,6 @@
      field: 'invoiceCode',
      title: '发票编号',
      minWidth: 120,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      minWidth: 100,
    },
    {
      field: 'clientName',
src/views/wls/productreceipt/data.ts
@@ -1,5 +1,6 @@
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProWorkOrderApi } from '#/api/mes/pro/workorder';
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import type { MesWmProductReceiptApi } from '#/api/mes/wm/productreceipt';
import type { MesWmProductReceiptDetailApi } from '#/api/mes/wm/productreceipt/detail';
@@ -10,6 +11,7 @@
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesProWorkOrderStatusEnum,
} from '@vben/constants';
import { Button } from 'ant-design-vue';
@@ -106,7 +108,9 @@
      label: '生产订单',
      component: markRaw(ProWorkOrderSelect),
      componentProps: {
        checkMethod: (row: any) => row.productionStatus !== 0,
        checkMethod: (row: MesProWorkOrderApi.WorkOrder) =>
          row.status === MesProWorkOrderStatusEnum.CONFIRMED ||
          row.status === MesProWorkOrderStatusEnum.FINISHED,
      },
    },
    {
src/views/wls/productreceipt/modules/line-list.vue
@@ -244,7 +244,7 @@
            v-if="isStock"
            :biz-code="row.batchCode"
            :biz-id="row.batchId"
            biz-type="BATCH"
            :biz-type="BarcodeBizTypeEnum.BATCH"
          />
        </div>
      </template>
src/views/wls/productsales/data.ts
@@ -351,11 +351,6 @@
      minWidth: 160,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      minWidth: 120,
    },
    {
      field: 'clientName',
      title: '客户名称',
      minWidth: 120,
src/views/wls/returnissue/modules/line-list.vue
@@ -241,7 +241,7 @@
            v-if="isStock"
            :biz-code="row.batchCode"
            :biz-id="row.batchId"
            biz-type="BATCH"
            :biz-type="BarcodeBizTypeEnum.BATCH"
          />
        </div>
      </template>
src/views/wls/returnsales/data.ts
@@ -232,11 +232,6 @@
      minWidth: 140,
    },
    {
      field: 'clientCode',
      title: '客户编码',
      minWidth: 120,
    },
    {
      field: 'clientName',
      title: '客户名称',
      minWidth: 150,
src/views/wls/returnsales/modules/line-list.vue
@@ -9,6 +9,7 @@
import { computed, reactive } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { BarcodeBizTypeEnum } from '@vben/constants';
import { message } from 'ant-design-vue';
@@ -242,7 +243,7 @@
            v-if="isStock"
            :biz-code="row.batchCode"
            :biz-id="row.batchId"
            biz-type="BATCH"
            :biz-type="BarcodeBizTypeEnum.BATCH"
          />
        </div>
      </template>
src/views/wls/returnvendor/modules/line-list.vue
@@ -8,6 +8,7 @@
import { computed, reactive } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { BarcodeBizTypeEnum } from '@vben/constants';
import { message } from 'ant-design-vue';
@@ -228,7 +229,7 @@
            v-if="isStock"
            :biz-code="row.batchCode"
            :biz-id="row.batchId"
            biz-type="BATCH"
            :biz-type="BarcodeBizTypeEnum.BATCH"
          />
        </div>
      </template>
src/views/wls/transfer/modules/line-list.vue
@@ -8,6 +8,7 @@
import { computed, reactive } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { BarcodeBizTypeEnum } from '@vben/constants';
import { message } from 'ant-design-vue';
@@ -220,7 +221,7 @@
          v-if="isStock"
          :biz-code="row.batchCode"
          :biz-id="row.batchId"
          biz-type="BATCH"
          :biz-type="BarcodeBizTypeEnum.BATCH"
        />
      </template>
    </Grid>
vite.config.ts
@@ -82,7 +82,7 @@
        proxy: {
          "/admin-api": {
            changeOrigin: true,
            target: "http://localhost:48081",
            target: "http://localhost:48080",
            ws: true,
          },
        },