refactor(auth): 优化认证逻辑和界面组件结构
- 登出接口增加租户参数支持多租户场景
- 实现登录登出并发控制防止竞态条件
- 重构认证存储逻辑增加版本控制机制
- 更新认证页面布局为AuthScene组件
- 移除过时的MES生产工单状态枚举
- 调整多个表格列显示去除客户编码字段
- 在工艺路线表单中新增收率字段
- 完善质量检验相关数据表格结构
- 更新日期范围选择器工具函数引用
- 优化文档审核表单字段映射逻辑
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | --- |
| | | 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 æªå¾éªè¯æè¿°ä¸ºçå®å端ç»å½æåã |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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(); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | // 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();} |
| | | })(); |
| | | |
| | |
| | | '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; |
| | |
| | | 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'); |
| | | })(); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | // 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();} |
| | | })(); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | // 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(); } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | // 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()} |
| | | |
| | | |
| | | |
| | | |
| | | |
| | |
| | | } |
| | | |
| | | /** éåºç»å½ */ |
| | | 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 } : {}), |
| | | }, |
| | | }, |
| | | ); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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: 'éå' }; |
| | | |
| | |
| | | /** MES å®¢æ· */ |
| | | export interface Client { |
| | | id?: number; // 客æ·ç¼å· |
| | | code?: string; // 客æ·ç¼ç |
| | | name?: string; // 客æ·åç§° |
| | | nickname?: string; // 客æ·ç®ç§° |
| | | englishName?: string; // 客æ·è±æåç§° |
| | |
| | | |
| | | /** 客æ·å¯¼å
¥ç»æ */ |
| | | export interface ClientImportRespVO { |
| | | createCodes?: string[]; // æ°å¢æåç客æ·ç¼ç |
| | | updateCodes?: string[]; // æ´æ°æåç客æ·ç¼ç |
| | | failureCodes?: Record<string, string>; // 导å
¥å¤±è´¥ç客æ·ç¼ç ååå |
| | | createCustomerNames?: string[]; // æ°å¢æåç客æ·åç§° |
| | | updateCustomerNames?: string[]; // æ´æ°æåç客æ·åç§° |
| | | failureCustomerNames?: Record<string, string>; // 导å
¥å¤±è´¥ç客æ·åç§°ååå |
| | | } |
| | | } |
| | | |
| | |
| | | documentName?: string; |
| | | /** èµæçæ¬ï¼åä½ï¼ */ |
| | | documentVersion?: string; |
| | | /** èµææä»¶åï¼åèµæç¬¬ä¸ä¸ªéä»¶ï¼ */ |
| | | fileName?: string; |
| | | /** èµææä»¶å°åï¼åèµæç¬¬ä¸ä¸ªéä»¶ï¼ */ |
| | | fileUrl?: string; |
| | | /** å®¡æ ¸äººï¼ç¨æ·IDï¼ */ |
| | | reviewer?: number; |
| | | /** å®¡æ ¸äººåç§° */ |
| | |
| | | quantity?: number; // 计åç产æ°é |
| | | quantityProduced?: number; // å·²ç产æ°é |
| | | clientId?: number; // 客æ·ID |
| | | clientCode?: string; // 客æ·ç¼ç |
| | | clientName?: string; // 客æ·åç§° |
| | | requestDate?: number; // éæ±æ¥æ |
| | | issueDate?: number; // ä¸åæ¥æ |
| | |
| | | routeId?: number; |
| | | processId?: number; |
| | | paramTemplateId?: number; |
| | | /** å·¥åºæ¶çï¼äº§åº/æå
¥ï¼0~1ï¼ */ |
| | | yieldRate?: number; |
| | | processCode?: string; |
| | | processName?: string; |
| | | sort?: number; |
| | |
| | | } |
| | | |
| | | /** æ¥è¯¢å¾
æ¥å·¥å°è´¦ï¼åé¡µï¼ */ |
| | | 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, |
| | | ) { |
| | |
| | | quantityChanged?: number; // è°æ´æ°é |
| | | quantityScheduled?: number; // å·²æäº§æ°é |
| | | clientId?: number; // 客æ·ç¼å· |
| | | clientCode?: string; // 客æ·ç¼ç |
| | | clientName?: string; // 客æ·åç§° |
| | | vendorId?: number; // ä¾åºåç¼å· |
| | | vendorName?: string; // ä¾åºååç§° |
| | |
| | | cancelDate?: number; // åæ¶æ¶é´ |
| | | status?: number; // 订åç¶æ |
| | | scheduleStatus?: number; // æäº§ç¶æ |
| | | productionStatus?: number; // çäº§ç¶æ |
| | | processProgressList?: ProcessProgress[]; // å·¥åºç产è¿åº¦ |
| | | completionProgress?: number; // 宿è¿åº¦ |
| | | remark?: string; // 夿³¨ |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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; |
| | | } |
| | |
| | | qcId?: number; // å
³èè´¨æ£å ID |
| | | qcType?: number; // è´¨æ£ç±»å |
| | | itemId?: number; // 产åç©æ ID |
| | | sn?: string; // ç©èµ SN |
| | | remark?: string; // 夿³¨ |
| | | createTime?: Date; // å建æ¶é´ |
| | | items?: IndicatorResultDetail[]; // æ£éªç»ææç»å表 |
| | |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | import { normalizeQcDateFilter } from '../date-filter'; |
| | | |
| | | export namespace MesQcIpqcApi { |
| | | /** MES è¿ç¨æ£éªå */ |
| | | export interface Ipqc { |
| | |
| | | /** æ¥è¯¢è¿ç¨æ£éªåå页 */ |
| | | export function getIpqcPage(params: PageParam) { |
| | | return requestClient.get<PageResult<MesQcIpqcApi.Ipqc>>('/mes/qc/ipqc/page', { |
| | | params, |
| | | params: normalizeQcDateFilter(params), |
| | | }); |
| | | } |
| | | |
| | |
| | | |
| | | /** 导åºè¿ç¨æ£éªå */ |
| | | 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), |
| | | }); |
| | | } |
| | | |
| | | /** ä¸è½½è¿ç¨æ£éªå导å
¥æ¨¡æ¿ */ |
| | |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | import { normalizeQcDateFilter } from '../date-filter'; |
| | | |
| | | export namespace MesQcIqcApi { |
| | | /** MES æ¥ææ£éªå */ |
| | | export interface Iqc { |
| | |
| | | /** æ¥è¯¢æ¥ææ£éªåå页 */ |
| | | export function getIqcPage(params: PageParam) { |
| | | return requestClient.get<PageResult<MesQcIqcApi.Iqc>>('/mes/qc/iqc/page', { |
| | | params, |
| | | params: normalizeQcDateFilter(params), |
| | | }); |
| | | } |
| | | |
| | |
| | | |
| | | /** å¯¼åºæ¥ææ£éªå */ |
| | | 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), |
| | | }); |
| | | } |
| | | |
| | | /** ä¸è½½æ¥ææ£éªå导å
¥æ¨¡æ¿ */ |
| | |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | import { normalizeQcDateFilter } from '../date-filter'; |
| | | |
| | | export namespace MesQcInspectionLedgerApi { |
| | | /** MES æ£æµç»æå°è´¦ï¼ä¸è¡ä¸ä¸ªæ£æµé¡¹ç»æï¼æ£éªåæªå½å
¥æ£æµé¡¹æ¶è¯¥ç»åæ®µä¸ºç©ºï¼ */ |
| | | export interface Ledger { |
| | |
| | | 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), |
| | | }); |
| | | } |
| | |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | import { normalizeQcDateFilter } from '../date-filter'; |
| | | |
| | | export namespace MesQcOqcApi { |
| | | /** MES åºè´§æ£éªå */ |
| | | export interface Oqc { |
| | |
| | | /** æ¥è¯¢åºè´§æ£éªåå页 */ |
| | | export function getOqcPage(params: PageParam) { |
| | | return requestClient.get<PageResult<MesQcOqcApi.Oqc>>('/mes/qc/oqc/page', { |
| | | params, |
| | | params: normalizeQcDateFilter(params), |
| | | }); |
| | | } |
| | | |
| | |
| | | |
| | | /** 导åºåºè´§æ£éªå */ |
| | | 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), |
| | | }); |
| | | } |
| | | |
| | | /** ä¸è½½åºè´§æ£éªå导å
¥æ¨¡æ¿ */ |
| | |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | import { normalizeQcDateFilter } from '../date-filter'; |
| | | |
| | | export namespace MesQcRqcApi { |
| | | /** MES éè´§æ£éªå */ |
| | | export interface Rqc { |
| | |
| | | /** æ¥è¯¢éè´§æ£éªåå页 */ |
| | | export function getRqcPage(params: PageParam) { |
| | | return requestClient.get<PageResult<MesQcRqcApi.Rqc>>('/mes/qc/rqc/page', { |
| | | params, |
| | | params: normalizeQcDateFilter(params), |
| | | }); |
| | | } |
| | | |
| | |
| | | |
| | | /** 导åºéè´§æ£éªå */ |
| | | 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), |
| | | }); |
| | | } |
| | |
| | | vendorCode?: string; // ä¾åºåç¼ç |
| | | vendorName?: string; // ä¾åºååç§° |
| | | clientId?: number; // å®¢æ· ID |
| | | clientCode?: string; // 客æ·ç¼ç |
| | | clientName?: string; // 客æ·åç§° |
| | | purchaseOrderCode?: string; // éè´è®¢åç¼å· |
| | | salesOrderCode?: string; // éå®è®¢åç¼å· |
| | |
| | | salesDate?: Date; // åºåºæ¥æ |
| | | salesOrderCode?: string; // éå®è®¢åç¼å· |
| | | clientId?: number; // å®¢æ· ID |
| | | clientCode?: string; // 客æ·ç¼ç |
| | | clientName?: string; // 客æ·åç§° |
| | | oqcCode?: string; // OQC æ£éªåå· |
| | | checkResult?: number; // OQC æ£éªç»è®ºï¼åå
¸ mes_qc_check_resultï¼ |
| | |
| | | salesOrderCode?: string; // éå®è®¢åç¼å· |
| | | invoiceCode?: string; // å票ç¼å· |
| | | clientId?: number; // 客æ·ç¼å· |
| | | clientCode?: string; // 客æ·ç¼ç |
| | | clientName?: string; // 客æ·åç§° |
| | | clientNickname?: string; // 客æ·ç®ç§° |
| | | length?: number; // ç®±é¿åº¦ |
| | |
| | | workstationName?: string; // å·¥ä½ç«åç§° |
| | | workOrderId?: number; // ç产订åç¼å· |
| | | workOrderCode?: string; // ç产订åç¼ç |
| | | clientCode?: string; // 客æ·ç¼ç |
| | | clientName?: string; // 客æ·åç§° |
| | | requiredTime?: number; // éæ±æ¶é´ |
| | | status?: number; // åæ®ç¶æ |
| | |
| | | noticeId?: number; // åè´§éç¥åç¼å· |
| | | noticeCode?: string; // åè´§éç¥åç¼ç |
| | | clientId?: number; // 客æ·ç¼å· |
| | | clientCode?: string; // 客æ·ç¼ç |
| | | clientName?: string; // 客æ·åç§° |
| | | salesOrderCode?: string; // éå®è®¢åç¼å· |
| | | saleOrderId?: number; // éå®è®¢åID |
| | |
| | | name?: string; // éè´§ååç§° |
| | | salesOrderCode?: string; // éå®è®¢åå· |
| | | clientId?: number; // 客æ·ç¼å· |
| | | clientCode?: string; // 客æ·ç¼ç |
| | | clientName?: string; // 客æ·åç§° |
| | | returnDate?: number; // éè´§æ¥æ |
| | | returnReason?: string; // éè´§åå |
| | |
| | | saleOrderId?: number; // éå®è®¢åç¼å· |
| | | saleOrderCode?: string; // éå®è®¢åç¼å· |
| | | clientId?: number; // 客æ·ç¼å· |
| | | clientCode?: string; // 客æ·ç¼ç |
| | | clientName?: string; // 客æ·åç§° |
| | | salesDate?: number; // åè´§æ¥æ |
| | | recipientName?: string; // æ¶è´§äºº |
| | |
| | | /** |
| | | * éæ°è®¤è¯é»è¾ |
| | | */ |
| | | 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' && |
| | |
| | | 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) { |
| | |
| | | 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; |
| | | }, |
| | | }); |
| | |
| | | vendorCode?: string; // ä¾åºåç¼ç |
| | | vendorName?: string; // ä¾åºååç§° |
| | | clientId?: number; // å®¢æ· ID |
| | | clientCode?: string; // 客æ·ç¼ç |
| | | clientName?: string; // 客æ·åç§° |
| | | purchaseOrderCode?: string; // éè´è®¢åç¼å· |
| | | salesOrderCode?: string; // éå®è®¢åç¼å· |
| | |
| | | salesOrderCode?: string; // éå®è®¢åç¼å· |
| | | invoiceCode?: string; // å票ç¼å· |
| | | clientId?: number; // 客æ·ç¼å· |
| | | clientCode?: string; // 客æ·ç¼ç |
| | | clientName?: string; // 客æ·åç§° |
| | | clientNickname?: string; // 客æ·ç®ç§° |
| | | length?: number; // ç®±é¿åº¦ |
| | |
| | | workstationName?: string; // å·¥ä½ç«åç§° |
| | | workOrderId?: number; // ç产订åç¼å· |
| | | workOrderCode?: string; // ç产订åç¼ç |
| | | clientCode?: string; // 客æ·ç¼ç |
| | | clientName?: string; // 客æ·åç§° |
| | | requiredTime?: number; // éæ±æ¶é´ |
| | | status?: number; // åæ®ç¶æ |
| | |
| | | noticeId?: number; // åè´§éç¥åç¼å· |
| | | noticeCode?: string; // åè´§éç¥åç¼ç |
| | | clientId?: number; // 客æ·ç¼å· |
| | | clientCode?: string; // 客æ·ç¼ç |
| | | clientName?: string; // 客æ·åç§° |
| | | salesOrderCode?: string; // éå®è®¢åç¼å· |
| | | saleOrderId?: number; // éå®è®¢åID |
| | |
| | | name?: string; // éè´§ååç§° |
| | | salesOrderCode?: string; // éå®è®¢åå· |
| | | clientId?: number; // 客æ·ç¼å· |
| | | clientCode?: string; // 客æ·ç¼ç |
| | | clientName?: string; // 客æ·åç§° |
| | | returnDate?: number; // éè´§æ¥æ |
| | | returnReason?: string; // éè´§åå |
| | |
| | | name?: string; // éç¥ååç§° |
| | | salesOrderCode?: string; // éå®è®¢åç¼å· |
| | | clientId?: number; // 客æ·ç¼å· |
| | | clientCode?: string; // 客æ·ç¼ç |
| | | clientName?: string; // 客æ·åç§° |
| | | salesDate?: number; // åè´§æ¥æ |
| | | recipientName?: string; // æ¶è´§äºº |
| | |
| | | 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'; |
| | | |
| | |
| | | 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); |
| | |
| | | const node = parseRule(expression); |
| | | ruleCache.set(expression, node); |
| | | return node; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | | |
| | |
| | | <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ï¼ï¼è¿å
¥ç»å½é¡µæ¶æå䏿¬¡ |
| | |
| | | |
| | | 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> |
| | |
| | | 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'; |
| | |
| | | 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__ } }); |
| | |
| | | <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> |
| | |
| | | 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', |
| | |
| | | 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', |
| | |
| | | CONFIRMED: 1, // 已确认 |
| | | FINISHED: 2, // 已宿 |
| | | CANCELED: 3, // 已忶 |
| | | } as const; |
| | | |
| | | /** MES ç产工åçäº§ç¶ææä¸¾ï¼å¯¹é½å端 MesProWorkOrderProductionStatusEnumï¼ */ |
| | | export const MesProWorkOrderProductionStatusEnum = { |
| | | WAITING: 0, // å¾
ç产 |
| | | PRODUCING: 1, // çäº§ä¸ |
| | | FINISHED: 2, // çäº§å®æ |
| | | } as const; |
| | | |
| | | /** MES å·¥åç±»åæä¸¾ */ |
| | |
| | | const rememberMe = ref(!!localUsername); |
| | | |
| | | async function handleSubmit() { |
| | | if (props.loading) return; |
| | | const { valid } = await formApi.validate(); |
| | | const values = await formApi.getValues(); |
| | | if (valid) { |
| | |
| | | colorPrimary: '', |
| | | colorSuccess: '', |
| | | colorTextBase: '', |
| | | colorTextLightSolid: '', |
| | | colorWarning: '', |
| | | zIndexPopupBase: 2000, // è°æ´åºç¡å¼¹å±å±çº§ï¼é¿å
䏿çç»ä»¶è¢«å¼¹çªæè
æå¤§åç¶æä¸çè¡¨æ ¼é®æ¡ |
| | | }); |
| | |
| | | |
| | | tokens.colorTextBase = getCssVariableValue('--foreground'); |
| | | |
| | | getCssVariableValue('--primary-foreground'); |
| | | tokens.colorTextLightSolid = getCssVariableValue('--primary-foreground'); |
| | | |
| | | tokens.colorBorderSecondary = tokens.colorBorder = |
| | | getCssVariableValue('--border'); |
| | |
| | | formatToken, |
| | | }: { |
| | | client: RequestClient; |
| | | doReAuthenticate: () => Promise<void>; |
| | | doReAuthenticate: (error?: any) => Promise<void>; |
| | | doRefreshToken: () => Promise<string>; |
| | | enableRefreshToken: boolean; |
| | | formatToken: (token: string) => null | string; |
| | |
| | | // 夿æ¯å¦å¯ç¨äº 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 })); |
| | | }); |
| | |
| | | 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 { |
| | |
| | | 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']; |
| | | |
| | |
| | | */ |
| | | export const overridesPreferences = defineOverridesPreferences({ |
| | | // overrides |
| | | theme: { |
| | | builtinType: 'custom', |
| | | colorPrimary: '#a51d24', |
| | | }, |
| | | app: { |
| | | /** åç«¯è·¯ç±æ¨¡å¼ */ |
| | | accessMode: 'backend', |
| | |
| | | const router = useRouter(); |
| | | |
| | | const loginLoading = ref(false); |
| | | let sessionVersion = 0; |
| | | let logoutTask: Promise<void> | null = null; |
| | | |
| | | /** |
| | | * 弿¥å¤çç»å½æä½ |
| | |
| | | ) { |
| | | // 弿¥å¤çç¨æ·ç»å½æä½å¹¶è·å accessToken |
| | | let userInfo: null | UserInfo = null; |
| | | const version = ++sessionVersion; |
| | | try { |
| | | let loginResult: AuthApi.LoginResult; |
| | | loginLoading.value = true; |
| | |
| | | } |
| | | } |
| | | const { accessToken, refreshToken } = loginResult; |
| | | if (version !== sessionVersion) return { userInfo: null }; |
| | | |
| | | // 妿æåè·åå° accessToken |
| | | if (accessToken) { |
| | |
| | | // // getAccessCodesApi(), |
| | | // ]); |
| | | const fetchUserInfoResult = await fetchUserInfo(); |
| | | if (version !== sessionVersion) return { userInfo: null }; |
| | | |
| | | userInfo = fetchUserInfoResult.user; |
| | | |
| | |
| | | }); |
| | | } |
| | | } |
| | | } catch (error) { |
| | | if (version === sessionVersion) throw error; |
| | | return { userInfo: null }; |
| | | } finally { |
| | | loginLoading.value = false; |
| | | if (version === sessionVersion) loginLoading.value = false; |
| | | } |
| | | |
| | | return { |
| | |
| | | }; |
| | | } |
| | | |
| | | 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); |
| | | |
| | | // åç»å½é¡µå¸¦ä¸å½åè·¯ç±å°å |
| | |
| | | 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); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | /* åç䏿§è²ä¸ä¸»è²å离ï¼ä¸»è²ä»éè¿ 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%; |
| | | } |
| | | |
| | |
| | | }, |
| | | }; |
| | | } |
| | | |
| | | /** æä¸å¡æ¥æçéï¼æ¥å£ä»æ¥æ¶å®æ´æ¶é´å符串ã */ |
| | | export function getDateRangePickerDefaultProps() { |
| | | return { |
| | | ...getRangePickerDefaultProps(), |
| | | format: 'YYYY-MM-DD', |
| | | placeholder: ['å¼å§æ¥æ', 'ç»ææ¥æ'], |
| | | showTime: false, |
| | | }; |
| | | } |
| | |
| | | component: 'VbenInput', |
| | | componentProps: { |
| | | placeholder: $t('authentication.usernameTip'), |
| | | autocomplete: 'username', |
| | | 'aria-label': $t('authentication.username'), |
| | | }, |
| | | fieldName: 'username', |
| | | label: $t('authentication.username'), |
| | |
| | | component: 'VbenInputPassword', |
| | | componentProps: { |
| | | placeholder: $t('authentication.passwordTip'), |
| | | autocomplete: 'current-password', |
| | | 'aria-label': $t('authentication.password'), |
| | | }, |
| | | fieldName: 'password', |
| | | label: $t('authentication.password'), |
| | |
| | | </script> |
| | | |
| | | <template> |
| | | <div> |
| | | <div class="herbal-login"> |
| | | <AuthenticationLogin |
| | | ref="loginRef" |
| | | :form-schema="formSchema" |
| | |
| | | :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" |
| | |
| | | /> |
| | | </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> |
| | |
| | | |
| | | 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' }); |
| | |
| | | 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); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | 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', |
| | |
| | | loadData(); |
| | | }); |
| | | onBeforeUnmount(() => { |
| | | disposed = true; |
| | | requestSequence++; |
| | | if (dataRefreshTimer) clearInterval(dataRefreshTimer); |
| | | document.removeEventListener('fullscreenchange', onFullscreenChange); |
| | | resizeObserver?.disconnect(); |
| | |
| | | <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> |
| | |
| | | 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%); } |
| | | } |
| | | |
| | | /* ======== ç©ºç¶æ ======== */ |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | |
| | | } from '#/api/bi/warehouseCoordinate'; |
| | | |
| | | import ClockWidget from '../dashboard/modules/ClockWidget.vue'; |
| | | import RealtimeTable from '../dashboard/modules/RealtimeTable.vue'; |
| | | import { |
| | | getChinaMapOptions, |
| | | getWarehouseAreaLineOptions, |
| | |
| | | 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(); |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | // ======== è¡¨æ ¼æ°æ® ======== |
| | | const tableData = computed(() => tableChart.value?.data || []); |
| | | const tableColumns = computed(() => { |
| | | if (tableData.value.length === 0) return []; |
| | | return Object.keys(tableData.value[0] || {}); |
| | | }); |
| | | |
| | | |
| | | // ======== åºåé¢è¦ ======== |
| | | const warningList = computed(() => { |
| | |
| | | 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()); |
| | |
| | | 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(); |
| | |
| | | æè¿å
¥åºè®°å½ |
| | | <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> |
| | |
| | | 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); } |
| | | } |
| | | |
| | | /* ======== å
¨å± ======== */ |
| | |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'invoiceNo', |
| | | label: 'å票å·ç ', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: '请è¾å
¥å票å·ç ï¼æ¯ææ¨¡ç³æ¥è¯¢', |
| | | allowClear: true, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'customerId', |
| | | label: '客æ·', |
| | | component: markRaw(CrmCustomerSelect), |
| | |
| | | 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'; |
| | |
| | | |
| | | 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'); |
| | |
| | | :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 |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { Page } from '@vben/common-ui'; |
| | | import Records from '../spare/modules/records.vue'; |
| | | </script> |
| | | <template><Page><Records /></Page></template> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | | |
| | |
| | | <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> |
| | |
| | | export function useClientSelectGridFormSchema(): VbenFormSchema[] { |
| | | return [ |
| | | { |
| | | fieldName: 'code', |
| | | label: '客æ·ç¼ç ', |
| | | component: 'Input', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥å®¢æ·ç¼ç ', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'name', |
| | | label: '客æ·åç§°', |
| | | component: 'Input', |
| | |
| | | ): VxeTableGridOptions<MesMdClientApi.Client>['columns'] { |
| | | return [ |
| | | { type: multiple ? 'checkbox' : 'radio', width: 50 }, |
| | | { |
| | | field: 'code', |
| | | title: '客æ·ç¼ç ', |
| | | minWidth: 160, |
| | | }, |
| | | { |
| | | field: 'name', |
| | | title: '客æ·åç§°', |
| | |
| | | { 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: { |
| | |
| | | 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(); |
| | |
| | | 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> |
| | |
| | | { 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' }, |
| | |
| | | <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="[ |
| | |
| | | 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'; |
| | |
| | | <PrinterLabel |
| | | :biz-code="row.code" |
| | | :biz-id="row.id" |
| | | biz-type="PROCARD" |
| | | :biz-type="BarcodeBizTypeEnum.PROCARD" |
| | | /> |
| | | </template> |
| | | </Grid> |
| | |
| | | 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'; |
| | |
| | | formData.value.id, |
| | | BarcodeBizTypeEnum.PROCARD, |
| | | formData.value.code, |
| | | formData.value.itemName || formData.value.code, |
| | | ); |
| | | } |
| | | |
| | |
| | | > |
| | | æ¥çæ¡ç |
| | | </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> |
| | |
| | | formatter: 'formatAmount3', |
| | | }, |
| | | { |
| | | field: 'clientCode', |
| | | title: '客æ·ç¼ç ', |
| | | width: 120, |
| | | }, |
| | | { |
| | | field: 'clientName', |
| | | title: '客æ·åç§°', |
| | | minWidth: 120, |
| | |
| | | <Descriptions.Item label="å·²ç产æ°é"> |
| | | {{ formData.quantityProduced }} |
| | | </Descriptions.Item> |
| | | <Descriptions.Item label="客æ·ç¼ç "> |
| | | {{ formData.clientCode }} |
| | | </Descriptions.Item> |
| | | <Descriptions.Item label="客æ·åç§°"> |
| | | {{ formData.clientName }} |
| | | </Descriptions.Item> |
| | |
| | | }, |
| | | }, |
| | | { |
| | | 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', |
| | |
| | | /** å¾
æäº§è®¢åå表çåæ®µ */ |
| | | export function useGridColumns(): VxeTableGridOptions<MesProWorkOrderApi.WorkOrder>['columns'] { |
| | | return [ |
| | | { type: 'checkbox', width: 48, fixed: 'left' }, |
| | | { |
| | | field: 'code', |
| | | title: '订åç¼ç ', |
| | |
| | | }, |
| | | }, |
| | | { |
| | | field: 'productionStatus', |
| | | title: 'çäº§ç¶æ', |
| | | width: 100, |
| | | cellRender: { |
| | | name: 'CellDict', |
| | | props: { type: DICT_TYPE.ORDER_PRODUCTION_STATUS }, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'quantity', |
| | | title: 'è®¢åæ°é', |
| | | width: 100, |
| | |
| | | slots: { default: 'progressPercent' }, |
| | | }, |
| | | { |
| | | field: 'clientCode', |
| | | title: '客æ·ç¼ç ', |
| | | width: 120, |
| | | }, |
| | | { |
| | | field: 'clientName', |
| | | title: '客æ·åç§°', |
| | | width: 120, |
| | |
| | | |
| | | { |
| | | title: 'æä½', |
| | | width: 160, |
| | | width: 240, |
| | | fixed: 'right', |
| | | slots: { default: 'actions' }, |
| | | }, |
| | |
| | | |
| | | 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'; |
| | |
| | | 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[]>([]); // çç¹å¾é¢è§æ°æ® |
| | |
| | | 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(); |
| | | } |
| | |
| | | router.push({ name: 'MesProTaskGanttEdit' }); |
| | | } |
| | | |
| | | /** å¤æè®¢åæ¯å¦ä¸ºâå·²å®æç±»åâï¼è®¢åç¶æ=已宿 æ çäº§ç¶æ=çäº§å®æ */ |
| | | /** å¤æè®¢åæ¯å¦ä¸ºâå·²å®æç±»åâï¼è®¢åç¶æ=已宿 */ |
| | | function isCompletedWorkOrder(row: MesProWorkOrderApi.WorkOrder): boolean { |
| | | return ( |
| | | row.status === MesProWorkOrderStatusEnum.FINISHED || |
| | | row.productionStatus === MesProWorkOrderProductionStatusEnum.FINISHED |
| | | ); |
| | | return row.status === MesProWorkOrderStatusEnum.FINISHED; |
| | | } |
| | | |
| | | /** |
| | |
| | | gridOptions: { |
| | | columns: useGridColumns(), |
| | | keepSource: true, |
| | | checkboxConfig: { |
| | | checkStrictly: true, |
| | | showHeader: true, |
| | | checkMethod: ({ row }: { row: MesProWorkOrderApi.WorkOrder }) => canAutoSchedule(row), |
| | | }, |
| | | proxyConfig: { |
| | | ajax: { |
| | | query: async ({ page }, formValues) => { |
| | |
| | | <template> |
| | | <Page auto-content-height> |
| | | <ScheduleModal @success="handleRefresh" /> |
| | | <AutoScheduleModal @success="handleRefresh" /> |
| | | <!-- æäº§çç¹å¾é¢è§ --> |
| | | <Card title="æäº§çç¹å¾" class="mb-4"> |
| | | <template #extra> |
| | |
| | | <TableAction |
| | | :actions="[ |
| | | { |
| | | label: 'çç¹å¾ç¼è¾', |
| | | label: 'ä¸é®æäº§', |
| | | type: 'primary', |
| | | icon: ACTION_ICON.ADD, |
| | | auth: ['mes:pro-task:create'], |
| | | onClick: () => handleAutoSchedule(), |
| | | }, |
| | | { |
| | | label: 'çç¹å¾ç¼è¾', |
| | | icon: ACTION_ICON.EDIT, |
| | | onClick: handleGanttEdit, |
| | | }, |
| | |
| | | <TableAction |
| | | :actions="[ |
| | | { |
| | | label: 'ä¸é®æäº§', |
| | | type: 'link', |
| | | auth: ['mes:pro-task:create'], |
| | | ifShow: canAutoSchedule(row), |
| | | onClick: () => handleAutoSchedule(row), |
| | | }, |
| | | { |
| | | label: 'æäº§ç»´æ¤', |
| | | type: 'link', |
| | | auth: ['mes:pro-task:create'], |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | |
| | | <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'; |
| | |
| | | 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); |
| | | |
| | |
| | | }); |
| | | |
| | | const [SchedulePreviewModalWrap, schedulePreviewModalApi] = useVbenModal({ |
| | | connectedComponent: SchedulePreviewModal, |
| | | connectedComponent: AutoSchedule, |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | |
| | | .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({ |
| | |
| | | <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), |
| | | }, |
| | | ]" |
| | | /> |
| | |
| | | </Collapse.Panel> |
| | | </Collapse> |
| | | </Card> |
| | | <ArchiveDetail ref="archiveDetailRef" /> |
| | | <ArchiveDetail :visible="archiveVisible" :product-code="archiveProductCode" @update:visible="archiveVisible = $event" /> |
| | | </Page> |
| | | </template> |
| | |
| | | width: 100, |
| | | }, |
| | | { |
| | | field: 'clientCode', |
| | | title: '客æ·ç¼ç ', |
| | | width: 120, |
| | | }, |
| | | { |
| | | field: 'clientName', |
| | | title: '客æ·åç§°', |
| | | width: 120, |
| | |
| | | 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 }, |
| | | }, |
| | | }, |
| | | { |
| | |
| | | field: 'quantity', |
| | | title: 'è®¢åæ°é', |
| | | width: 100, |
| | | }, |
| | | { |
| | | field: 'clientCode', |
| | | title: '客æ·ç¼ç ', |
| | | width: 120, |
| | | }, |
| | | { |
| | | field: 'clientName', |
| | |
| | | |
| | | /** å®æè®¢ååæ ¡éª */ |
| | | function handleCheckFinish(row: MesProWorkOrderApi.WorkOrder) { |
| | | if (row.productionStatus !== 2) { |
| | | if (row.status !== MesProWorkOrderStatusEnum.FINISHED) { |
| | | Modal.confirm({ |
| | | title: 'æä½ç¡®è®¤', |
| | | content: 'è¯¥è®¢åæªå®æï¼æ¯å¦ç¡®è®¤è¦ç»§ç»å®æï¼', |
| | |
| | | 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", |
| | |
| | | </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"> |
| | |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </template> |
| | |
| | | placeholder: '请è¾å
¥éå®è®¢åç¼å·', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'purchaseOrderCode', |
| | | label: 'éè´è®¢åç¼å·', |
| | | component: 'Input', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥éè´è®¢åç¼å·', |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | |
| | | width: 80, |
| | | }, |
| | | { |
| | | field: 'vendorCode', |
| | | title: 'ä¾åºåç¼ç ', |
| | | minWidth: 140, |
| | | }, |
| | | { |
| | | field: 'vendorName', |
| | | title: 'ä¾åºååç§°', |
| | | minWidth: 160, |
| | | }, |
| | | { |
| | | field: 'clientCode', |
| | | title: '客æ·ç¼ç ', |
| | | minWidth: 140, |
| | | }, |
| | | { |
| | | field: 'clientName', |
| | |
| | | { |
| | | field: 'salesOrderCode', |
| | | title: 'éå®è®¢åç¼å·', |
| | | minWidth: 160, |
| | | }, |
| | | { |
| | | field: 'purchaseOrderCode', |
| | | title: 'éè´è®¢åç¼å·', |
| | | minWidth: 160, |
| | | }, |
| | | { |
| | |
| | | span: 3, |
| | | }, |
| | | { |
| | | field: 'purchaseOrderCode', |
| | | label: 'éè´è®¢åç¼å·', |
| | | }, |
| | | { |
| | | field: 'vendorCode', |
| | | label: 'ä¾åºåç¼ç ', |
| | | }, |
| | | { |
| | | field: 'vendorName', |
| | | label: 'ä¾åºååç§°', |
| | | }, |
| | | { |
| | | field: 'salesOrderCode', |
| | | label: 'éå®è®¢åç¼å·', |
| | | }, |
| | | { |
| | | field: 'clientCode', |
| | | label: '客æ·ç¼ç ', |
| | | }, |
| | | { |
| | | field: 'clientName', |
| | |
| | | title: 'å
¥åºæ¥æ', |
| | | minWidth: 160, |
| | | formatter: 'formatDate', |
| | | }, |
| | | { |
| | | field: 'purchaseOrderCode', |
| | | title: 'éè´è®¢åç¼å·', |
| | | minWidth: 160, |
| | | }, |
| | | { |
| | | field: 'vendorCode', |
| | | title: 'ä¾åºåç¼ç ', |
| | | minWidth: 140, |
| | | }, |
| | | { |
| | | field: 'vendorName', |
| | |
| | | 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 [ |
| | | { |
| | |
| | | placeholder: '请è¾å
¥æ ·åç¼å·', |
| | | }, |
| | | rules: 'required', |
| | | suffix: () => |
| | | suffix: readonly ? undefined : () => |
| | | h( |
| | | Button, |
| | | { |
| | |
| | | }, |
| | | { default: () => 'çæ' }, |
| | | ), |
| | | }, |
| | | { |
| | | fieldName: 'sn', |
| | | label: 'ç©èµ SN', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: '请è¾å
¥ç©èµ SN', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'remark', |
| | |
| | | width: 200, |
| | | }, |
| | | { |
| | | field: 'sn', |
| | | title: 'ç©èµSN', |
| | | minWidth: 200, |
| | | }, |
| | | { |
| | | field: 'remark', |
| | | title: '夿³¨', |
| | | minWidth: 200, |
| | | }, |
| | | { |
| | | title: 'æä½', |
| | | width: 150, |
| | | width: 240, |
| | | fixed: 'right', |
| | | slots: { default: 'actions' }, |
| | | }, |
| | |
| | | |
| | | 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', ['æ£éªç»æ']), |
| | | ); |
| | |
| | | : []; |
| | | } |
| | | |
| | | 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(); |
| | |
| | | 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( |
| | |
| | | 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, |
| | | })); |
| | |
| | | id: detail.id, |
| | | code: detail.code, |
| | | remark: detail.remark, |
| | | sn: detail.sn, |
| | | }); |
| | | } finally { |
| | | modalApi.unlock(); |
| | |
| | | <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 |
| | | " |
| | |
| | | .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({ |
| | |
| | | <TableAction |
| | | :actions="[ |
| | | { |
| | | label: 'æ¥ç详æ
', |
| | | type: 'link', |
| | | icon: ACTION_ICON.VIEW, |
| | | onClick: handleDetail.bind(null, row), |
| | | }, |
| | | { |
| | | label: $t('common.edit'), |
| | | type: 'link', |
| | | icon: ACTION_ICON.EDIT, |
| | |
| | | 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'; |
| | |
| | | }, |
| | | }, |
| | | { |
| | | 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(), |
| | | }, |
| | | }, |
| | | ]; |
| | |
| | | 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'; |
| | | |
| | | |
| | |
| | | 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', |
| | |
| | | label: 'æ¥ææ¥æ', |
| | | component: 'RangePicker', |
| | | componentProps: { |
| | | ...getRangePickerDefaultProps(), |
| | | ...getDateRangePickerDefaultProps(), |
| | | }, |
| | | }, |
| | | { |
| | |
| | | label: 'æ£æµæ¥æ', |
| | | component: 'RangePicker', |
| | | componentProps: { |
| | | ...getRangePickerDefaultProps(), |
| | | ...getDateRangePickerDefaultProps(), |
| | | }, |
| | | }, |
| | | { |
| | |
| | | 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'; |
| | | |
| | | /** å表çæç´¢è¡¨å */ |
| | |
| | | label: 'æ£æµæ¥æ', |
| | | component: 'RangePicker', |
| | | componentProps: { |
| | | ...getRangePickerDefaultProps(), |
| | | ...getDateRangePickerDefaultProps(), |
| | | }, |
| | | }, |
| | | { |
| | |
| | | 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'; |
| | | |
| | |
| | | }, |
| | | }, |
| | | { |
| | | 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(), |
| | | }, |
| | | }, |
| | | ]; |
| | |
| | | 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'; |
| | | |
| | | /** 表åç±»å */ |
| | |
| | | label: 'æ£æµæ¥æ', |
| | | component: 'RangePicker', |
| | | componentProps: { |
| | | ...getRangePickerDefaultProps(), |
| | | ...getDateRangePickerDefaultProps(), |
| | | }, |
| | | }, |
| | | ]; |
| | |
| | | <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('&', '&') |
| | | .replaceAll('<', '<') |
| | | .replaceAll('>', '>'); |
| | | } |
| | | |
| | | /** æå»ºæ ç¾ 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> |
| | |
| | | width: 120, |
| | | }, |
| | | { |
| | | field: 'clientCode', |
| | | title: '客æ·ç¼ç ', |
| | | width: 110, |
| | | }, |
| | | { |
| | | field: 'clientName', |
| | | title: '客æ·åç§°', |
| | | width: 110, |
| | |
| | | import { computed, reactive } from 'vue'; |
| | | |
| | | import { useVbenModal } from '@vben/common-ui'; |
| | | import { BarcodeBizTypeEnum } from '@vben/constants'; |
| | | |
| | | import { message } from 'ant-design-vue'; |
| | | |
| | |
| | | v-if="isStock" |
| | | :biz-code="row.batchCode" |
| | | :biz-id="row.batchId" |
| | | biz-type="ITEM_BATCH" |
| | | :biz-type="BarcodeBizTypeEnum.BATCH" |
| | | /> |
| | | </div> |
| | | </template> |
| | |
| | | import { computed, reactive } from 'vue'; |
| | | |
| | | import { useVbenModal } from '@vben/common-ui'; |
| | | import { BarcodeBizTypeEnum } from '@vben/constants'; |
| | | |
| | | import { message } from 'ant-design-vue'; |
| | | |
| | |
| | | v-if="isStock" |
| | | :biz-code="row.batchCode" |
| | | :biz-id="row.batchId" |
| | | biz-type="BATCH" |
| | | :biz-type="BarcodeBizTypeEnum.BATCH" |
| | | /> |
| | | </div> |
| | | </template> |
| | |
| | | minWidth: 120, |
| | | }, |
| | | { |
| | | field: 'clientCode', |
| | | title: '客æ·ç¼ç ', |
| | | minWidth: 100, |
| | | }, |
| | | { |
| | | field: 'clientName', |
| | | title: '客æ·åç§°', |
| | | minWidth: 120, |
| | |
| | | field: 'invoiceCode', |
| | | title: 'å票ç¼å·', |
| | | minWidth: 120, |
| | | }, |
| | | { |
| | | field: 'clientCode', |
| | | title: '客æ·ç¼ç ', |
| | | minWidth: 100, |
| | | }, |
| | | { |
| | | field: 'clientName', |
| | |
| | | field: 'invoiceCode', |
| | | title: 'å票ç¼å·', |
| | | minWidth: 120, |
| | | }, |
| | | { |
| | | field: 'clientCode', |
| | | title: '客æ·ç¼ç ', |
| | | minWidth: 100, |
| | | }, |
| | | { |
| | | field: 'clientName', |
| | |
| | | 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'; |
| | |
| | | import { |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesProWorkOrderStatusEnum, |
| | | } from '@vben/constants'; |
| | | |
| | | import { Button } from 'ant-design-vue'; |
| | |
| | | label: 'ç产订å', |
| | | component: markRaw(ProWorkOrderSelect), |
| | | componentProps: { |
| | | checkMethod: (row: any) => row.productionStatus !== 0, |
| | | checkMethod: (row: MesProWorkOrderApi.WorkOrder) => |
| | | row.status === MesProWorkOrderStatusEnum.CONFIRMED || |
| | | row.status === MesProWorkOrderStatusEnum.FINISHED, |
| | | }, |
| | | }, |
| | | { |
| | |
| | | v-if="isStock" |
| | | :biz-code="row.batchCode" |
| | | :biz-id="row.batchId" |
| | | biz-type="BATCH" |
| | | :biz-type="BarcodeBizTypeEnum.BATCH" |
| | | /> |
| | | </div> |
| | | </template> |
| | |
| | | minWidth: 160, |
| | | }, |
| | | { |
| | | field: 'clientCode', |
| | | title: '客æ·ç¼ç ', |
| | | minWidth: 120, |
| | | }, |
| | | { |
| | | field: 'clientName', |
| | | title: '客æ·åç§°', |
| | | minWidth: 120, |
| | |
| | | v-if="isStock" |
| | | :biz-code="row.batchCode" |
| | | :biz-id="row.batchId" |
| | | biz-type="BATCH" |
| | | :biz-type="BarcodeBizTypeEnum.BATCH" |
| | | /> |
| | | </div> |
| | | </template> |
| | |
| | | minWidth: 140, |
| | | }, |
| | | { |
| | | field: 'clientCode', |
| | | title: '客æ·ç¼ç ', |
| | | minWidth: 120, |
| | | }, |
| | | { |
| | | field: 'clientName', |
| | | title: '客æ·åç§°', |
| | | minWidth: 150, |
| | |
| | | import { computed, reactive } from 'vue'; |
| | | |
| | | import { useVbenModal } from '@vben/common-ui'; |
| | | import { BarcodeBizTypeEnum } from '@vben/constants'; |
| | | |
| | | import { message } from 'ant-design-vue'; |
| | | |
| | |
| | | v-if="isStock" |
| | | :biz-code="row.batchCode" |
| | | :biz-id="row.batchId" |
| | | biz-type="BATCH" |
| | | :biz-type="BarcodeBizTypeEnum.BATCH" |
| | | /> |
| | | </div> |
| | | </template> |
| | |
| | | import { computed, reactive } from 'vue'; |
| | | |
| | | import { useVbenModal } from '@vben/common-ui'; |
| | | import { BarcodeBizTypeEnum } from '@vben/constants'; |
| | | |
| | | import { message } from 'ant-design-vue'; |
| | | |
| | |
| | | v-if="isStock" |
| | | :biz-code="row.batchCode" |
| | | :biz-id="row.batchId" |
| | | biz-type="BATCH" |
| | | :biz-type="BarcodeBizTypeEnum.BATCH" |
| | | /> |
| | | </div> |
| | | </template> |
| | |
| | | import { computed, reactive } from 'vue'; |
| | | |
| | | import { useVbenModal } from '@vben/common-ui'; |
| | | import { BarcodeBizTypeEnum } from '@vben/constants'; |
| | | |
| | | import { message } from 'ant-design-vue'; |
| | | |
| | |
| | | v-if="isStock" |
| | | :biz-code="row.batchCode" |
| | | :biz-id="row.batchId" |
| | | biz-type="BATCH" |
| | | :biz-type="BarcodeBizTypeEnum.BATCH" |
| | | /> |
| | | </template> |
| | | </Grid> |
| | |
| | | proxy: { |
| | | "/admin-api": { |
| | | changeOrigin: true, |
| | | target: "http://localhost:48081", |
| | | target: "http://localhost:48080", |
| | | ws: true, |
| | | }, |
| | | }, |