云
2026-10-26 64a8cd04229f7b903913fc3dc7ef4aef267ac3bc
Merge branch 'dev_pro2.0' into dev_南通_苏珩

# Conflicts:
# src/layouts/auth-scene.vue
# src/layouts/auth.vue
# src/main.ts
# src/preferences.ts
# src/styles/herbal-theme.css
# src/views/_core/authentication/login.vue
已添加12个文件
已修改47个文件
6872 ■■■■■ 文件已修改
.env 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env.development 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HSC.bat 451 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
company.config.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/agent/conversation.ts 641 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ai/model/model/index.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/attendance/record/index.ts 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/attendance/rule/index.ts 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mdm/item/agent.ts 264 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/route/index.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/route/process/index.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/route/product/index.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/workorder/process/index.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/bootstrap.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/import-modal/import-modal.vue 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/import-modal/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/biz-ai-enum.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/dict-enum.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/hooks/src/use-app-config.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes/modules/ai.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/auth.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/agent/dataEntry/components/TaskCenterDrawer.vue 298 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/agent/dataEntry/index.vue 1523 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ai/model/model/data.ts 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ai/model/model/index.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/index.vue 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/modules/agent-import.vue 610 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/index.vue 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/index.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/index.vue 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/record/modules/clock-panel.vue 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/data.ts 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/modules/form.vue 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/modules/location-map-picker.vue 406 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/components/select-dialog.vue 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/components/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/components/output-attribution.vue 135 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/data.ts 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/index.vue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/material-input-list.vue 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/process-form.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/process-list.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/product-bom-list.vue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/product-form.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/product-list.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/route-configurator.vue 425 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/data.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/process-form.vue 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/process-list.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/data.ts 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/index.vue 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/modules/form.vue 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/modules/process-form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/modules/product-form.vue 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/components/ai-assistant.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/components/ai-suggest.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/template/designer/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env
@@ -19,6 +19,9 @@
# æ–‡æ¡£åœ°å€çš„开关
VITE_APP_DOCALERT_ENABLE=true
# AI åŠŸèƒ½æ€»å¼€å…³ï¼šfalse æ—¶éšè—æ‰€æœ‰ AI å…¥å£ï¼ˆAI æ¨¡å—菜单、OCR、预测、智能判定、智能体导入)
VITE_APP_AI_ENABLE=false
# ç™¾åº¦ç»Ÿè®¡
VITE_APP_BAIDU_CODE = e98f2eab6ceb8688bc6d8fc5332ff093
@@ -35,4 +38,4 @@
# VITE_APP_API_ENCRYPT_RESPONSE_KEY = MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAOH8IfIFxL/MR9XIg1UDv5z1fGXQI93E8wrU4iPFovL/sEt9uSgSkjyidC2O7N+m7EKtoN6b1u7cEwXSkwf3kfK0jdWLSQaNpX5YshqXCBzbDfugDaxuyYrNA4/tIMs7mzZAk0APuRXB35Dmupou7Yw7TFW/7QhQmGfzeEKULQvnAgMBAAECgYAw8LqlQGyQoPv5p3gRxEMOCfgL0JzD3XBJKztiRd35RDh40Nx1ejgjW4dPioFwGiVWd2W8cAGHLzALdcQT2KDJh+T/tsd4SPmI6uSBBK6Ff2DkO+kFFcuYvfclQQKqxma5CaZOSqhgenacmgTMFeg2eKlY3symV6JlFNu/IKU42QJBAOhxAK/Eq3e61aYQV2JSguhMR3b8NXJJRroRs/QHEanksJtl+M+2qhkC9nQVXBmBkndnkU/l2tYcHfSBlAyFySMCQQD445tgm/J2b6qMQmuUGQAYDN8FIkHjeKmha+l/fv0igWm8NDlBAem91lNDIPBUzHL1X1+pcts5bjmq99YdOnhtAkAg2J8dN3B3idpZDiQbC8fd5bGPmdI/pSUudAP27uzLEjr2qrE/QPPGdwm2m7IZFJtK7kK1hKio6u48t/bg0iL7AkEAuUUs94h+v702Fnym+jJ2CHEkXvz2US8UDs52nWrZYiM1o1y4tfSHm8H8bv8JCAa9GHyriEawfBraILOmllFdLQJAQSRZy4wmlaG48MhVXodB85X+VZ9krGXZ2TLhz7kz9iuToy53l9jTkESt6L5BfBDCVdIwcXLYgK+8KFdHN5W7HQ==
# ç™¾åº¦åœ°å›¾
VITE_BAIDU_MAP_KEY=Y2aJXiswwPxy6mwFs1z9c7U5gwX9WfUN
VITE_BAIDU_MAP_KEY=5YhFUgsoi8KMm6tafbSljgz9NNpOYP1Z
.env.development
@@ -14,6 +14,9 @@
VITE_UPLOAD_TYPE=server
# æ˜¯å¦æ‰“å¼€ devtools,true ä¸ºæ‰“开,false ä¸ºå…³é—­
VITE_DEVTOOLS=false
# AI åŠŸèƒ½æ€»å¼€å…³ï¼šfalse æ—¶éšè—æ‰€æœ‰ AI å…¥å£ï¼ˆAI æ¨¡å—菜单、OCR、预测、智能判定、智能体导入)
VITE_APP_AI_ENABLE=true
# æ˜¯å¦æ³¨å…¥å…¨å±€loading
VITE_INJECT_APP_LOADING=true
HSC.bat
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,451 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
REM ============================================================
REM Vue Frontend Deploy Script
REM ============================================================
REM =========================
REM Configuration
REM =========================
set "COMPANY=HSC"
set "REMOTE_USER=root"
set "REMOTE_HOST=36.213.150.237"
set "REMOTE_PORT=65022"
set "PASSWORD=Sh@1008600"
set "ZIP_FILE=dist.zip"
set "REMOTE_DIR=/data/vue/mom/hsc/dist"
set "SSH_OPTS=-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3"
set "RETRY_COUNT=3"
set "SSHPASS_DIR=%~dp0bin"
REM ============================================================
REM Start
REM ============================================================
echo.
echo ============================================================
echo              Vue Dist Auto Deploy
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_USER%@%REMOTE_HOST%
echo Port       : %REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo.
REM ============================================================
REM Check sshpass
REM ============================================================
echo [1/7] Checking sshpass...
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
)
where sshpass >nul 2>&1
if not errorlevel 1 (
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
echo [WARN] sshpass not found.
if not exist "%SSHPASS_DIR%" (
    mkdir "%SSHPASS_DIR%" >nul 2>&1
)
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
echo [INFO] Downloading sshpass...
powershell -NoProfile -ExecutionPolicy Bypass -Command "$url='https://github.com/xhcoding/sshpass-win32/releases/download/v1.0.3/sshpass.exe'; $out='%SSHPASS_DIR%\sshpass.exe'; Invoke-WebRequest -Uri $url -OutFile $out"
if errorlevel 1 (
    echo [ERROR] Failed to download sshpass.
    echo [ERROR] Please install sshpass manually.
    goto ERROR_EXIT
)
set "PATH=%SSHPASS_DIR%;%PATH%"
echo [OK] sshpass installed.
REM ============================================================
REM Check pnpm
REM ============================================================
:CHECK_PNPM
echo.
echo [2/7] Checking pnpm...
where pnpm >nul 2>&1
if errorlevel 1 (
    echo [ERROR] pnpm not found.
    echo [HINT] npm install -g pnpm
    goto ERROR_EXIT
)
call pnpm --version
if errorlevel 1 (
    echo [ERROR] pnpm execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node
REM ============================================================
echo.
echo [3/7] Checking Node.js...
where node >nul 2>&1
if errorlevel 1 (
    echo [ERROR] Node.js not found.
    goto ERROR_EXIT
)
call node --version
if errorlevel 1 (
    echo [ERROR] Node.js execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node_modules
REM ============================================================
if not exist "node_modules" (
    echo.
    echo [INFO] node_modules not found.
    echo [INFO] Running pnpm install...
    call pnpm install
    if errorlevel 1 (
        echo [ERROR] pnpm install failed.
        goto ERROR_EXIT
    )
    echo [OK] pnpm install completed.
) else (
    echo [OK] node_modules exists.
)
REM ============================================================
REM Build
REM ============================================================
echo.
echo [4/7] Building project...
if exist "%ZIP_FILE%" (
    echo.
    echo [WARN] %ZIP_FILE% already exists.
    set "REBUILD="
    set /p "REBUILD=Rebuild? Y/N (default Y): "
    if /I "!REBUILD!"=="N" (
        echo [INFO] Skip build.
        goto CHECK_ZIP
    )
)
echo [INFO] Company: %COMPANY%
echo [INFO] Command: pnpm run build
echo.
set "VITE_COMPANY=%COMPANY%"
call pnpm run build
if errorlevel 1 (
    echo.
    echo [ERROR] Build failed.
    goto ERROR_EXIT
)
echo.
echo [OK] Build completed.
REM ============================================================
REM Check ZIP
REM ============================================================
:CHECK_ZIP
echo.
echo [5/7] Checking package...
if not exist "%ZIP_FILE%" (
    echo [ERROR] %ZIP_FILE% not found.
    echo [HINT] Please check whether your build generates dist.zip.
    goto ERROR_EXIT
)
for %%F in ("%ZIP_FILE%") do set "ZIP_BYTES=%%~zF"
set /a ZIP_MB=ZIP_BYTES/1024/1024
echo [OK] Package found.
echo [INFO] File size: !ZIP_BYTES! bytes
echo [INFO] File size: !ZIP_MB! MB
REM ============================================================
REM Set sshpass password
REM ============================================================
set "SSHPASS=%PASSWORD%"
REM ============================================================
REM Test SSH
REM ============================================================
echo.
echo [6/7] Testing SSH connection...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "echo SSH_OK"
if errorlevel 1 (
    echo [ERROR] SSH connection failed.
    goto ERROR_EXIT
)
echo [OK] SSH connection successful.
REM ============================================================
REM Create remote directory
REM ============================================================
echo.
echo [INFO] Creating remote directory...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mkdir -p '%REMOTE_DIR%'"
if errorlevel 1 (
    echo [ERROR] Failed to create remote directory.
    goto ERROR_EXIT
)
echo [OK] Remote directory ready.
REM ============================================================
REM Upload temporary file
REM ============================================================
echo.
echo [INFO] Uploading package...
set "REMOTE_TMP=%REMOTE_DIR%/.dist.zip.uploading"
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_TMP%'"
sshpass -e scp -P %REMOTE_PORT% %SSH_OPTS% "%ZIP_FILE%" %REMOTE_USER%@%REMOTE_HOST%:"%REMOTE_TMP%"
if errorlevel 1 (
    echo [ERROR] Upload failed.
    goto ERROR_EXIT
)
echo [OK] Upload completed.
REM ============================================================
REM Check remote file
REM ============================================================
echo.
echo [INFO] Checking remote package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "test -f '%REMOTE_TMP%'"
if errorlevel 1 (
    echo [ERROR] Remote package does not exist.
    goto ERROR_EXIT
)
echo [OK] Remote package exists.
REM ============================================================
REM Compare file size
REM ============================================================
echo.
echo [INFO] Checking package size...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "stat -c %%s '%REMOTE_TMP%'"
echo.
REM ============================================================
REM Rename temporary file
REM ============================================================
echo [INFO] Activating package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mv -f '%REMOTE_TMP%' '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [ERROR] Failed to activate package.
    goto ERROR_EXIT
)
echo [OK] Package activated.
REM ============================================================
REM Check unzip
REM ============================================================
echo.
echo [INFO] Checking unzip...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "command -v unzip"
if errorlevel 1 (
    echo [ERROR] unzip is not installed on server.
    echo.
    echo CentOS/RHEL:
    echo yum install -y unzip
    echo.
    echo Ubuntu/Debian:
    echo apt install -y unzip
    goto ERROR_EXIT
)
echo [OK] unzip found.
REM ============================================================
REM Test ZIP
REM ============================================================
echo.
echo [INFO] Testing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -t '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] ZIP test failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo [OK] ZIP test passed.
REM ============================================================
REM Extract
REM ============================================================
echo.
echo [INFO] Extracting package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -o '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] Remote unzip failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo.
echo [OK] Extract completed.
REM ============================================================
REM Remove ZIP
REM ============================================================
echo.
echo [INFO] Removing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [WARN] Failed to remove remote ZIP.
) else (
    echo [OK] Remote ZIP removed.
)
REM ============================================================
REM Success
REM ============================================================
echo.
echo ============================================================
echo                 DEPLOY SUCCESS
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_HOST%:%REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo Size       : !ZIP_MB! MB
echo.
echo Deployment completed successfully.
echo.
goto END
REM ============================================================
REM Error
REM ============================================================
:ERROR_EXIT
echo.
echo ============================================================
echo                  DEPLOY FAILED
echo ============================================================
echo.
echo Please check the error message above.
echo.
goto END
REM ============================================================
REM End
REM ============================================================
:END
pause
endlocal
company.config.ts
@@ -63,13 +63,13 @@
    name: '康意智能',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9004/admin-api',
    apiUrl: 'http://36.213.150.237:9006/admin-api',
  },
  CHDL: {
    name: '长辉电力',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9006/admin-api',
    apiUrl: 'http://36.213.150.237:9004/admin-api',
  },
  HM: {
    name: '惠牧',
@@ -83,4 +83,10 @@
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9015/admin-api',
  },
  HSC: {
    name: '好水川食品有限公司',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9540/admin-api',
  },
};
src/api/agent/conversation.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,641 @@
import type { InternalAxiosRequestConfig } from '@vben/request';
import {
  defaultResponseInterceptor,
  RequestClient,
} from '@vben/request';
import { useAccessStore } from '@vben/stores';
import {
  bindImportAttachment,
  createImportSession,
} from '#/api/mdm/item/agent';
import { uploadFile } from '#/api/system/storage';
/**
 * æ¨¡å—数据补录会话服务(Python)不在 /admin-api ä¸‹ï¼Œ
 * å¼€å‘期由 Vite ä»£ç† /agent-api è½¬å‘到 127.0.0.1:8091,且不返回 CommonResult åŒ…装。
 */
const agentClient = new RequestClient({
  baseURL: '/agent-api',
  responseReturn: 'body',
  timeout: 120_000,
});
agentClient.addRequestInterceptor({
  fulfilled: (config: InternalAxiosRequestConfig) => {
    const token = useAccessStore().accessToken;
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
});
// Python æœåŠ¡ç›´æŽ¥è¿”å›žä¸šåŠ¡ JSON,不套 CommonResult;responseReturn: 'body' æ—¶åŽŸæ ·è¿”å›žå“åº”ä½“ã€‚
agentClient.addResponseInterceptor(
  defaultResponseInterceptor({
    codeField: 'code',
    dataField: 'data',
    successCode: 0,
  }),
);
export namespace AgentConversationApi {
  export interface FieldSpec {
    name: string;
    label: string;
    required: boolean;
    kind: string;
  }
  export interface ModuleSpec {
    module_id: string;
    title: string;
    capability: string;
    bridge: string;
    permission: string;
    description: string;
    required_fields: string[];
    fields: FieldSpec[];
  }
  export interface Capabilities {
    version: string;
    formats: string[];
    ocr: boolean;
    cross_file_dedupe: boolean;
    import_modes: string[];
    notes: string[];
    modules: ModuleSpec[];
    durable_tasks?: {
      enabled: boolean;
      events: string;
      fact_source: string;
      transport: string;
    };
  }
  export interface AuthorizedCapability {
    tool: string;
    module: string;
    operation: string;
    allowed_fields: string[];
    scope_summary: string;
    can_execute: boolean;
  }
  export interface AuthorizedCapabilityCatalog {
    policy_revision: string;
    authorization_scope: string;
    scope_summary: string;
    capabilities: AuthorizedCapability[];
  }
  export interface BusinessModelInfo {
    id: number;
    name: string;
    model: string;
    platform: string;
    temperature?: number | null;
    max_tokens?: number | null;
    max_contexts?: number | null;
  }
  export interface Issue {
    code?: string;
    field?: string;
    message: string;
    candidate_id?: string;
  }
  export interface Summary {
    total?: number;
    create?: number;
    skip?: number;
    blocked?: number;
    success?: number;
    failed?: number;
    skipped?: number;
    unknown?: number;
  }
  export interface ReceiptRow {
    candidate_id: string;
    row_no?: number;
    code?: string;
    name?: string;
    action: string;
    status: string;
    target_item_id?: number;
    error_code?: string;
    error_message?: string;
  }
  export interface Receipt {
    command_id: string;
    idempotency_key: string;
    status: string;
    attachment_id: number;
    source_sha256: string;
    preflight_hash: string;
    summary: Summary;
    rows: ReceiptRow[];
    create_time?: string;
    completed_time?: string;
    error_code?: string;
    error_message?: string;
  }
  export interface ModuleCandidate {
    module_id: string;
    title: string;
    capability: string;
    description: string;
  }
  export interface MissingField {
    name: string;
    label: string;
  }
  /** å¡ç‰‡å­—段按 kind å¤ç”¨ï¼Œæœªä½¿ç”¨çš„字段为空。 */
  export interface Card {
    kind: string;
    title?: string;
    body?: string;
    module_id?: string;
    module_title?: string;
    capability?: string;
    revision?: number;
    issues?: Issue[];
    warnings?: Issue[];
    repair_guidance?: Array<{ field?: string; guidance: string }>;
    summary?: Summary;
    can_commit?: boolean;
    side_effects?: string[];
    sync_mes?: boolean | null;
    total?: number;
    actions?: string[];
    notice?: string;
    candidates?: ModuleCandidate[];
    missing_fields?: MissingField[];
    columns?: string[];
    command_id?: string;
    status?: string;
    rows?: ReceiptRow[];
  }
  export interface Message {
    message_id: string;
    role: string;
    type: string;
    text: string;
    cards?: Card[];
  }
  export interface Interrupt {
    interrupt_id: string;
    kind: string;
    revision: number;
    validation_revision?: number | null;
    card: Card;
  }
  export interface RowView {
    candidate_id: string;
    values: Record<string, string>;
    issues: unknown[];
    warnings: unknown[];
  }
  export interface ConversationAttachment {
    attachment_id: string;
    /** ä»…最新附件解析出文件名;历史附件可能为空。 */
    filename: string;
  }
  export interface Conversation {
    conversation_id: string;
    title: string;
    status: string;
    module_id: string;
    module_title: string;
    capability: string;
    data_scope_summary: string;
    model_id?: number | null;
    policy_revision: string;
    revision: number;
    validation_revision?: number | null;
    sync_mes?: boolean;
    total: number;
    summary: Summary;
    issues: Issue[];
    warnings: Issue[];
    missing_fields: string[];
    columns: string[];
    /** å€™é€‰è¡Œå®žé™…有值的字段,用于渲染明细表。 */
    row_fields: Array<{ name: string; label: string }>;
    attachments: ConversationAttachment[];
    messages: Message[];
    interrupts: Interrupt[];
    /** æœªæäº¤æ—¶åŽç«¯è¿”回空对象 {},提交后才有完整回执。 */
    receipt?: null | Partial<Receipt>;
    last_error: string;
    rows: RowView[];
    created_at: string;
    updated_at: string;
    finished: boolean;
    commit_enabled: boolean;
    mes_sync_default: boolean;
  }
  export interface ConversationSummary {
    conversation_id: string;
    title: string;
    status: string;
    module_id: string;
    module_title: string;
    revision: number;
    created_at: string;
    updated_at: string;
  }
  export interface PageResult {
    items: ConversationSummary[];
    total: number;
    page: number;
    page_size: number;
  }
  export interface ConversationEvent {
    conversation_id: string;
    payload: Record<string, unknown>;
    seq: number;
    type:
      | 'card'
      | 'error'
      | 'message'
      | 'progress'
      | 'receipt'
      | 'resync'
      | 'status';
  }
  export interface CreateReq {
    title?: string;
    text?: string;
    attachment_id?: string;
    module_id?: string;
    model_id?: number;
    constraints?: ConversationConstraints;
  }
  export interface ConversationConstraints {
    scenario_paths: string[];
    time_start?: string;
    time_end?: string;
    record_limit?: number;
    industry?: string;
    skip_rest_days?: boolean;
    rest_day_description?: string;
    region?: string;
    instruction?: string;
    priority_fields: string[];
  }
  export interface ActionReq {
    interrupt_id: string;
    revision: number;
    action: string;
    payload: Record<string, unknown>;
    model_id?: number;
  }
  export interface MessageReq {
    text?: string;
    attachment_id?: string;
    model_id?: number;
  }
}
export namespace AgentTaskApi {
  export interface Progress {
    mode: 'determinate' | 'indeterminate';
    completed: number;
    total: null | number;
    percent: null | number;
    unit?: string;
  }
  export interface Result {
    attachment_id?: number;
    attachment_ids?: number[];
    candidate_count?: number;
    continuation_supported?: boolean;
    evidence_ref?: {
      backend: string;
      candidate_count?: number;
      revision: string;
      task_id: string;
    } | null;
    filename?: string;
    filenames?: string[];
    module_id?: string;
    module_title?: string;
    result_kind?: string;
    source_sha256?: string;
    source_sha256s?: string[];
    review_summary?: {
      conflict_groups: number;
      exact_duplicates_merged: number;
      incomplete_keys: number;
      requires_review: boolean;
    };
    warnings?: string[];
  }
  export interface Task {
    task_id: string;
    owner_id: string;
    kind: string;
    status: string;
    stage: string;
    attempt: number;
    sequence: number;
    progress: Progress;
    message: string;
    result: null | Result;
    error: null | { code?: string; message?: string };
    allowed_actions: string[];
    created_at: null | string;
    updated_at: null | string;
    started_at: null | string;
    finished_at: null | string;
  }
  export interface PageResult {
    items: Task[];
    total: number;
    page: number;
    page_size: number;
  }
  export interface CreateReq {
    attachment_id?: number;
    attachment_ids?: number[];
    module_id?: string;
    constraints?: AgentConversationApi.ConversationConstraints;
  }
}
/** èƒ½åŠ›å£°æ˜Žï¼šæ”¯æŒçš„æ ¼å¼ã€æ¨¡å—ä¸Žé™åˆ¶ */
export function getAgentCapabilities() {
  return agentClient.get<AgentConversationApi.Capabilities>('/v1/capabilities');
}
/** è¯»å–当前登录用户经 Java åŽç«¯è£å‰ªåŽçš„可查询、可操作能力。 */
export function getAuthorizedAgentCapabilities() {
  return agentClient.get<AgentConversationApi.AuthorizedCapabilityCatalog>(
    '/v1/authorization/capabilities',
  );
}
/** å½“前数据补录链路实际使用的业务模型;不返回 API å¯†é’¥æˆ–服务地址。 */
export function getActiveBusinessModel() {
  return agentClient.get<AgentConversationApi.BusinessModelInfo>('/v1/model');
}
/** å¯åˆ‡æ¢çš„业务模型列表,按后端排序返回,默认使用第一个。 */
export function getAgentModels() {
  return agentClient.get<AgentConversationApi.BusinessModelInfo[]>('/v1/models');
}
/** åˆ†é¡µè¯»å–当前用户的持久后台任务。 */
export function listAgentTasks(params?: {
  page?: number;
  page_size?: number;
  status?: string;
}) {
  return agentClient.get<AgentTaskApi.PageResult>('/v1/tasks', { params });
}
/** åˆ›å»ºæ–‡æ¡£åŽå°åˆ†æžä»»åŠ¡ï¼ŒæœåŠ¡ç«¯ç«‹å³è¿”å›ž HTTP 202 å¿«ç…§ã€‚ */
export function createDocumentTask(data: AgentTaskApi.CreateReq) {
  return agentClient.post<AgentTaskApi.Task>('/v1/tasks/document-onboarding', data);
}
/** è¯»å–任务事实快照。 */
export function getAgentTask(taskId: string) {
  return agentClient.get<AgentTaskApi.Task>(`/v1/tasks/${taskId}`);
}
/** å–消任务并撤销短期委托票据。 */
export function cancelAgentTask(taskId: string) {
  return agentClient.delete<AgentTaskApi.Task>(`/v1/tasks/${taskId}`);
}
/** ä½¿ç”¨è®¤è¯è¯·æ±‚读取任务 SSE;游标用于断线续传。 */
export async function streamAgentTaskEvents(
  taskId: string,
  since: number,
  signal: AbortSignal,
  onEvent: (event: AgentTaskApi.Task) => void,
) {
  const token = useAccessStore().accessToken;
  const response = await fetch(
    `/agent-api/v1/tasks/${encodeURIComponent(taskId)}/events?since=${since}`,
    {
      credentials: 'same-origin',
      headers: {
        Accept: 'text/event-stream',
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
      },
      signal,
    },
  );
  if (!response.ok) throw new Error(`任务事件连接失败(HTTP ${response.status})`);
  if (!response.body) throw new Error('当前浏览器不支持流式事件');
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let cursor = since;
  while (true) {
    const { done, value } = await reader.read();
    buffer += decoder.decode(value, { stream: !done }).replaceAll('\r\n', '\n');
    const frames = buffer.split('\n\n');
    buffer = frames.pop() ?? '';
    for (const frame of frames) {
      const data = frame
        .split('\n')
        .filter((line) => line.startsWith('data:'))
        .map((line) => line.slice(5).trimStart())
        .join('\n');
      if (!data) continue;
      const event = JSON.parse(data) as AgentTaskApi.Task;
      if (!Number.isSafeInteger(event.sequence) || event.sequence <= cursor) continue;
      cursor = event.sequence;
      onEvent(event);
    }
    if (done) break;
  }
  return cursor;
}
/** ä¼šè¯åˆ—表 */
export function listAgentConversations(params?: {
  page?: number;
  page_size?: number;
  status?: string;
}) {
  return agentClient.get<AgentConversationApi.PageResult>(
    '/v1/conversations',
    { params },
  );
}
/** æ–°å»ºè¡¥å½•会话 */
export function createAgentConversation(data: AgentConversationApi.CreateReq) {
  return agentClient.post<AgentConversationApi.Conversation>(
    '/v1/conversations',
    data,
  );
}
/** è¯»å–会话 */
export function getAgentConversation(conversationId: string) {
  return agentClient.get<AgentConversationApi.Conversation>(
    `/v1/conversations/${conversationId}`,
  );
}
/** åˆ é™¤ä¼šè¯ï¼šåŒæ—¶æ¸…理检查点、事件与 Java ä¾§è¡¥å½•附件 */
export function deleteAgentConversation(conversationId: string) {
  return agentClient.delete<{
    attachments_deleted: number;
    conversation_id: string;
    deleted: boolean;
  }>(`/v1/conversations/${conversationId}`);
}
/**
 * è¯»å–会话附件。mode ä¸º inline æ—¶ PDF/图片由浏览器直接预览,
 * å…¶ä½™ç±»åž‹æœåŠ¡ç«¯ä¼šè‡ªåŠ¨é™çº§ä¸ºä¸‹è½½ã€‚
 */
export function downloadAgentConversationAttachment(
  conversationId: string,
  attachmentId: string,
  mode: 'download' | 'inline',
) {
  return agentClient.download<{
    data: Blob;
    headers: Record<string, string>;
  }>(`/v1/conversations/${conversationId}/attachment`, {
    params: { attachment_id: attachmentId, mode },
    responseReturn: 'raw',
  });
}
/**
 * ä½¿ç”¨ fetch è¯»å–带认证头的 SSE。EventSource ä¸èƒ½è®¾ç½® Authorization,不能用于本接口。
 * æœåŠ¡ç«¯ä¼šä¸»åŠ¨ç»“æŸçŸ­è¿žæŽ¥ï¼›è°ƒç”¨æ–¹è´Ÿè´£æºå¸¦è¿”å›žæ¸¸æ ‡é‡è¿žã€‚
 */
export async function streamAgentConversationEvents(
  conversationId: string,
  since: number,
  signal: AbortSignal,
  onEvent: (event: AgentConversationApi.ConversationEvent) => void,
  onOpen?: () => void,
) {
  const token = useAccessStore().accessToken;
  const response = await fetch(
    `/agent-api/v1/conversations/${encodeURIComponent(conversationId)}/events?since=${since}`,
    {
      credentials: 'same-origin',
      headers: {
        Accept: 'text/event-stream',
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
      },
      signal,
    },
  );
  if (!response.ok) {
    throw new Error(`事件连接失败(HTTP ${response.status})`);
  }
  if (!response.body) {
    throw new Error('当前浏览器不支持流式事件');
  }
  onOpen?.();
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let cursor = since;
  while (true) {
    const { done, value } = await reader.read();
    buffer += decoder.decode(value, { stream: !done }).replaceAll('\r\n', '\n');
    const frames = buffer.split('\n\n');
    buffer = frames.pop() ?? '';
    for (const frame of frames) {
      const data = frame
        .split('\n')
        .filter((line) => line.startsWith('data:'))
        .map((line) => line.slice(5).trimStart())
        .join('\n');
      if (!data) continue;
      const event = JSON.parse(data) as AgentConversationApi.ConversationEvent;
      if (!Number.isSafeInteger(event.seq) || event.seq <= cursor) continue;
      cursor = event.seq;
      onEvent(event);
    }
    if (done) break;
  }
  return cursor;
}
/** å‘送文本或附件(用于补充上传文件) */
export function sendAgentMessage(
  conversationId: string,
  data: AgentConversationApi.MessageReq,
) {
  return agentClient.post<AgentConversationApi.Conversation>(
    `/v1/conversations/${conversationId}/messages`,
    data,
  );
}
/** å¡ç‰‡æ“ä½œï¼šé€‰æ‹©åœºæ™¯ã€ç¡®è®¤æ˜ å°„、修订、确认或取消 */
export function sendAgentAction(
  conversationId: string,
  data: AgentConversationApi.ActionReq,
) {
  return agentClient.post<AgentConversationApi.Conversation>(
    `/v1/conversations/${conversationId}/actions`,
    data,
  );
}
/** ä¸‹è½½å¯¼å…¥æŠ¥è¡¨ï¼ˆxlsx) */
export function downloadAgentReport(conversationId: string) {
  return agentClient.download<{
    data: Blob;
    headers: Record<string, string>;
  }>(`/v1/conversations/${conversationId}/report`, {
    responseReturn: 'raw',
  });
}
/**
 * ä¸Šä¼ é™„件到 Java å­˜å‚¨å¹¶ç»‘定补录会话,返回 attachmentId。
 * Python ä¾§é€šè¿‡ Java Bridge æŒ‰ attachmentId è¯»å–文件。
 */
export async function uploadAgentAttachment(file: File) {
  const sessionId = await createImportSession();
  const blobs = await uploadFile([file]);
  const blobId = blobs?.[0]?.id;
  if (!blobId) {
    throw new Error('文件上传失败,未取得 blobId');
  }
  return await bindImportAttachment(sessionId, blobId);
}
export { agentImportErrorMessage } from '#/api/mdm/item/agent';
src/api/ai/model/model/index.ts
@@ -10,6 +10,7 @@
    model: string; // æ¨¡åž‹æ ‡è¯†
    platform: string; // æ¨¡åž‹å¹³å°
    type: number; // æ¨¡åž‹ç±»åž‹
    capability?: string; // æ¨¡åž‹èƒ½åŠ›ï¼štext-文本,vision-视觉
    sort: number; // æŽ’序
    status: number; // çŠ¶æ€
    temperature?: number; // æ¸©åº¦å‚æ•°
@@ -27,10 +28,11 @@
}
/** èŽ·å¾—æ¨¡åž‹åˆ—è¡¨ */
export function getModelSimpleList(type?: number) {
export function getModelSimpleList(type?: number, capability?: string) {
  return requestClient.get<AiModelModelApi.Model[]>('/ai/model/simple-list', {
    params: {
      type,
      capability,
    },
  });
}
@@ -50,6 +52,11 @@
  return requestClient.put('/ai/model/update', data);
}
/** ä¿®æ”¹æ¨¡åž‹çŠ¶æ€ï¼šå¯ç”¨æ—¶ä¼šè‡ªåŠ¨å…³é—­åŒç±»åž‹åŒèƒ½åŠ›çš„å…¶ä»–æ¨¡åž‹ */
export function updateModelStatus(id: number, status: number) {
  return requestClient.put('/ai/model/update-status', { id, status });
}
/** åˆ é™¤æ¨¡åž‹ */
export function deleteModel(id: number) {
  return requestClient.delete(`/ai/model/delete?id=${id}`);
src/api/hrm/attendance/record/index.ts
@@ -21,6 +21,26 @@
    workHours?: number;
    overtimeHours?: number;
    location?: string;
    /** ä¸Šç­æ‰“卡经度(WGS84) */
    clockInLongitude?: number;
    /** ä¸Šç­æ‰“卡纬度(WGS84) */
    clockInLatitude?: number;
    /** ä¸Šç­å®šä½ç²¾åº¦ï¼Œç±³ */
    clockInAccuracy?: number;
    /** ä¸Šç­è·æ‰“卡地点距离,米 */
    clockInDistance?: number;
    /** ä¸Šç­æ‰“卡地点地址快照 */
    clockInAddress?: string;
    /** ä¸‹ç­æ‰“卡经度(WGS84) */
    clockOutLongitude?: number;
    /** ä¸‹ç­æ‰“卡纬度(WGS84) */
    clockOutLatitude?: number;
    /** ä¸‹ç­å®šä½ç²¾åº¦ï¼Œç±³ */
    clockOutAccuracy?: number;
    /** ä¸‹ç­è·æ‰“卡地点距离,米 */
    clockOutDistance?: number;
    /** ä¸‹ç­æ‰“卡地点地址快照 */
    clockOutAddress?: string;
    dataSource?: number;
    dataSourceName?: string;
    remark?: string;
@@ -40,6 +60,23 @@
    overtimeHours?: number;
    totalWorkHours?: number;
  }
}
/** å®šä½æ‰“卡坐标,后端仅接受 WGS84 */
export interface AttendanceClockLocation {
  longitude: number;
  latitude: number;
  accuracy: number;
  coordinateSystem: 'WGS84';
}
/** å®šä½æ‰“卡结果;未启用定位范围校验时,距离与半径为空 */
export interface AttendanceClockResponse {
  record?: HrmAttendanceRecordApi.AttendanceRecord;
  locationName?: string;
  distanceMeters?: number;
  radiusMeters?: number;
  withinRange?: boolean;
}
/** æŸ¥è¯¢è€ƒå‹¤è®°å½•分页列表 */
@@ -80,20 +117,32 @@
  return requestClient.download('/hrm/attendance/record/export-excel', { params });
}
/** ä¸Šç­æ‰“卡 */
export function clockIn(params: { location: string }) {
  return requestClient.post<HrmAttendanceRecordApi.AttendanceRecord>(
/**
 * ä¸Šç­æ‰“卡
 *
 * åŒæ—¶æäº¤å®šä½ï¼ˆJSON body)与旧版地点文本(query å‚数):
 * - æ–°åŽç«¯æŒ‰å®šä½æ ¡éªŒæ‰“卡范围,未启用定位时忽略定位;
 * - æ—§åŽç«¯åªè¯»å– location å‚数,保证升级前后都能正常打卡。
 */
export function clockIn(
  data?: AttendanceClockLocation | null,
  location?: string,
) {
  return requestClient.post<AttendanceClockResponse>(
    '/hrm/attendance/record/clock-in',
    null,
    { params },
    data ?? null,
    location ? { params: { location } } : undefined,
  );
}
/** ä¸‹ç­æ‰“卡 */
export function clockOut(params: { location: string }) {
  return requestClient.post<HrmAttendanceRecordApi.AttendanceRecord>(
/** ä¸‹ç­æ‰“卡,参数说明同 clockIn */
export function clockOut(
  data?: AttendanceClockLocation | null,
  location?: string,
) {
  return requestClient.post<AttendanceClockResponse>(
    '/hrm/attendance/record/clock-out',
    null,
    { params },
    data ?? null,
    location ? { params: { location } } : undefined,
  );
}
}
src/api/hrm/attendance/rule/index.ts
@@ -14,6 +14,20 @@
    lateAllowMinutes?: number;
    earlyAllowMinutes?: number;
    overtimeThreshold?: number;
    /** æ‰“卡地点名称 */
    locationName?: string;
    /** æ‰“卡地点地址 */
    locationAddress?: string;
    /** æ‰“卡中心经度(WGS84) */
    longitude?: number;
    /** æ‰“卡中心纬度(WGS84) */
    latitude?: number;
    /** å…è®¸æ‰“卡半径,单位米 */
    radiusMeters?: number;
    /** æ˜¯å¦å¯ç”¨å®šä½èŒƒå›´æ ¡éªŒ */
    locationRequired?: boolean;
    /** åæ ‡ç³»ï¼Œä»…支持 WGS84 */
    coordinateSystem?: string;
    status?: number;
    remark?: string;
    createTime?: string;
src/api/mdm/item/agent.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,264 @@
import type { InternalAxiosRequestConfig } from '@vben/request';
import { RequestClient } from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { requestClient } from '#/api/request';
/**
 * Python æ™ºèƒ½ä½“服务不在 /admin-api ä¸‹ï¼Œå¼€å‘期由 Vite ä»£ç† /agent-api è½¬å‘到 127.0.0.1:8091。
 * è¯¥æœåŠ¡ç›´æŽ¥è¿”å›žä¸šåŠ¡ JSON,不使用 CommonResult åŒ…装,因此单独建客户端。
 */
const agentRequestClient = new RequestClient({
  baseURL: '/agent-api',
  responseReturn: 'body',
  timeout: 120_000,
});
agentRequestClient.addRequestInterceptor({
  fulfilled: (config: InternalAxiosRequestConfig) => {
    const token = useAccessStore().accessToken;
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
});
export namespace MdmAgentImportApi {
  export type RowAction = 'CREATE' | 'SKIP' | 'BLOCKED';
  export type RowStatus = 'SUCCESS' | 'FAILED' | 'SKIPPED' | 'UNKNOWN';
  export interface Evidence {
    sheet: string;
    row: number;
    column: string;
    raw: string;
  }
  export interface Candidate {
    candidate_id: string;
    source_sha256: string;
    values: Record<string, string>;
    evidence: Record<string, Evidence>;
    issues: string[];
  }
  export interface Issue {
    code?: string;
    field?: string;
    message: string;
    retryable?: boolean;
  }
  export interface Resolved {
    category_id?: number;
    category_name?: string;
    unit_measure_id?: number;
    unit_measure_name?: string;
  }
  export interface PreflightRow {
    candidate_id: string;
    row_no?: number;
    code?: string;
    name?: string;
    action: RowAction;
    resolved?: Resolved;
    blockers?: Issue[];
    warnings?: Issue[];
    target_item_id?: number;
  }
  export interface Preflight {
    preflight_hash: string;
    source: {
      attachment_id: number;
      session_id?: number;
      source_sha256: string;
      proposal_revision?: string;
    };
    policy: {
      update_allowed: boolean;
      mes_sync_allowed: boolean;
      max_rows: number;
    };
    summary: {
      total: number;
      create: number;
      skip: number;
      blocked: number;
    };
    can_commit: boolean;
    side_effects: string[];
    rows: PreflightRow[];
    blocking_items: Issue[];
  }
  export interface Correction {
    candidate_id: string;
    field: 'categoryCode' | 'code' | 'name' | 'specification' | 'unitMeasureCode';
    value: string;
    reason: string;
  }
  export interface ReceiptRow {
    candidate_id: string;
    row_no?: number;
    code?: string;
    name?: string;
    action: 'CREATE' | 'SKIP';
    status: RowStatus;
    target_item_id?: number;
    target_version?: string;
    error_code?: string;
    error_message?: string;
    retryable?: boolean;
  }
  export interface Receipt {
    command_id: string;
    idempotency_key: string;
    status: 'FAILED' | 'PARTIAL_FAILED' | 'RUNNING' | 'SUCCEEDED' | 'UNKNOWN';
    attachment_id: number;
    session_id?: number;
    source_sha256: string;
    preflight_hash: string;
    proposal_hash?: string;
    summary: {
      total: number;
      success: number;
      failed: number;
      skipped: number;
      unknown: number;
    };
    error_code?: string;
    error_message?: string;
    rows: ReceiptRow[];
    create_time?: string;
    completed_time?: string;
  }
  export interface ImportState {
    task_id: string;
    attachment_id: string;
    filename: string;
    source_sha256: string;
    candidates: Candidate[];
    issues: Array<Issue & { candidate_id?: string; candidate_ids?: string[] }>;
    revision: number;
    status: string;
    preflight?: Preflight;
    preflight_hash?: string;
    proposal?: Record<string, unknown>;
    proposal_hash?: string;
    command_id?: string;
    receipt?: Receipt;
    last_error?: string;
  }
  export interface ReviewInterrupt {
    type: string;
    revision: number;
    issues: Array<Issue & { candidate_id?: string; candidate_ids?: string[] }>;
    candidates: Candidate[];
    preflight: {
      hash?: string;
      can_commit: boolean;
      summary?: Preflight['summary'];
      blocking_items: Issue[];
      needs_attention: PreflightRow[];
    };
    last_error?: string;
    notice?: string;
  }
  export interface PublicState {
    state: ImportState;
    interrupts: ReviewInterrupt[];
  }
  export interface ReviewReq {
    expected_revision: number;
    action: 'cancel' | 'confirm' | 'revise';
    corrections?: Correction[];
    expected_preflight_hash?: string;
    expected_proposal_hash?: string;
  }
}
/** åˆ›å»ºè¡¥å½•会话(Java) */
export function createImportSession() {
  return requestClient.post<number>('/agent/bridge/import-sessions');
}
/** ç»‘定附件到补录会话,返回 attachmentId(Java) */
export function bindImportAttachment(sessionId: number, blobId: number) {
  return requestClient.post<number>(
    `/agent/bridge/import-sessions/${sessionId}/attachments`,
    { blobId },
  );
}
/** åˆ›å»ºæ™ºèƒ½ä½“导入任务并触发解析与预检(Python) */
export function startMaterialImport(attachmentId: number) {
  return agentRequestClient.post<MdmAgentImportApi.PublicState>(
    '/v1/material-imports',
    { attachment_id: String(attachmentId) },
  );
}
/** è¯»å–任务当前状态(Python) */
export function getMaterialImportTask(taskId: string) {
  return agentRequestClient.get<MdmAgentImportApi.PublicState>(
    `/v1/material-imports/${taskId}`,
  );
}
/** ä¿®è®¢æˆ–确认(Python) */
export function reviewMaterialImport(
  taskId: string,
  data: MdmAgentImportApi.ReviewReq,
) {
  return agentRequestClient.post<MdmAgentImportApi.PublicState>(
    `/v1/material-imports/${taskId}/review`,
    data,
  );
}
/** æŸ¥è¯¢æäº¤å›žæ‰§ï¼ˆPython) */
export function getMaterialImportReceipt(taskId: string) {
  return agentRequestClient.get<MdmAgentImportApi.Receipt>(
    `/v1/material-imports/${taskId}/receipt`,
  );
}
interface AgentErrorDetail {
  error_code?: string;
  message?: string;
}
/** ä»Ž Python é”™è¯¯å“åº”中提取可读文案 */
export function agentImportErrorMessage(error: unknown): string {
  const err = error as
    | { data?: unknown; message?: string; response?: { data?: unknown } }
    | undefined;
  const body = (err?.response?.data ?? err?.data) as
    | AgentErrorDetail
    | string
    | undefined;
  const detail =
    typeof body === 'string'
      ? body
      : ((body as { detail?: unknown } | undefined)?.detail ?? body);
  if (typeof detail === 'string') {
    return detail;
  }
  const info = detail as AgentErrorDetail | undefined;
  if (info?.message) {
    return info.error_code
      ? `${info.message}(${info.error_code})`
      : info.message;
  }
  return err?.message || '智能体服务请求失败';
}
src/api/mes/pro/route/index.ts
@@ -61,3 +61,26 @@
export function exportRoute(params: any) {
  return requestClient.download('/mes/pro/route/export-excel', { params });
}
/** ä¸‹è½½å·¥è‰ºè·¯çº¿å¯¼å…¥æ¨¡æ¿ */
export function importRouteTemplate() {
  return requestClient.download('/mes/pro/route/get-import-template');
}
/** å¯¼å…¥å·¥è‰ºè·¯çº¿ */
export function importRoute(file: File, updateSupport: boolean) {
  return requestClient.upload(
    `/mes/pro/route/import?updateSupport=${updateSupport}`,
    { file },
  );
}
/** ä¸‹è½½åŒ…含路线、产品、工序、工序产出、工序投料五张工作表的模板 */
export function importCompleteRouteTemplate() {
  return requestClient.download('/mes/pro/route/get-complete-import-template');
}
/** æ‰¹é‡å¯¼å…¥å®Œæ•´å·¥è‰ºè·¯çº¿ */
export function importCompleteRoute(file: File) {
  return requestClient.upload('/mes/pro/route/import-complete', { file });
}
src/api/mes/pro/route/process/index.ts
@@ -10,13 +10,25 @@
    remark?: string;
  }
  /** äº§å‡ºç‰©æ–™å½’属 */
  export interface OutputItem {
    id?: number;
    routeProcessId?: number;
    productId?: number; // å½’属产品编号,为空表示多产品通用
    productName?: string;
    itemId?: number;
    itemCode?: string;
    itemName?: string;
    sort?: number;
    remark?: string;
  }
  /** MES å·¥è‰ºè·¯çº¿å·¥åº */
  export interface RouteProcess {
    id?: number;
    routeId?: number;
    processId?: number;
    paramTemplateId?: number;
    /** å·¥åºæ”¶çŽ‡ï¼ˆäº§å‡º/投入,0~1) */
    yieldRate?: number;
    processCode?: string;
    processName?: string;
@@ -35,6 +47,13 @@
    outputItemName?: string;
    outputItemIds?: number[];
    outputItemNames?: string;
    outputs?: OutputItem[]; // äº§å‡ºç‰©æ–™å½’属配置
    warehouseId?: number; // é»˜è®¤æŠ¥å·¥ä»“库编号
    warehouseName?: string; // é»˜è®¤æŠ¥å·¥ä»“库名称
    locationId?: number; // é»˜è®¤æŠ¥å·¥åº“区编号
    locationName?: string; // é»˜è®¤æŠ¥å·¥åº“区名称
    areaId?: number; // é»˜è®¤æŠ¥å·¥åº“位编号
    areaName?: string; // é»˜è®¤æŠ¥å·¥åº“位名称
    remark?: string;
    bomItems?: ProcessBomItem[];
    createTime?: Date;
src/api/mes/pro/route/product/index.ts
@@ -8,6 +8,12 @@
    itemId?: number;
    itemCode?: string;
    itemName?: string;
    warehouseId?: number;
    warehouseName?: string;
    locationId?: number;
    locationName?: string;
    areaId?: number;
    areaName?: string;
    specification?: string;
    unitName?: string;
    quantity?: number;
src/api/mes/pro/workorder/process/index.ts
@@ -1,5 +1,3 @@
import type { MesProWorkOrderProcessApi } from './index';
import { requestClient } from '#/api/request';
export namespace MesProWorkOrderProcessApi {
@@ -35,6 +33,12 @@
    outputItemName?: string; // äº§å‡ºäº§å“ç‰©æ–™åç§°ï¼ˆç¬¬ä¸€ä¸ªï¼‰
    outputItemIds?: number[]; // äº§å‡ºäº§å“ç‰©æ–™ç¼–号列表
    outputItemNames?: string; // äº§å‡ºäº§å“ç‰©æ–™åç§°ï¼ˆé€—号拼接)
    warehouseId?: number; // æŠ¥å·¥ä»“库编号
    warehouseName?: string; // æŠ¥å·¥ä»“库名称
    locationId?: number; // æŠ¥å·¥åº“区编号
    locationName?: string; // æŠ¥å·¥åº“区名称
    areaId?: number; // æŠ¥å·¥åº“位编号
    areaName?: string; // æŠ¥å·¥åº“位名称
    bomItems?: ProcessBomItem[]; // å·¥åºæŠ•æ–™BOM明细
    remark?: string; // å¤‡æ³¨
  }
@@ -79,4 +83,4 @@
/** åˆ é™¤å·¥å•工序 */
export function deleteWorkOrderProcess(id: number) {
  return requestClient.delete(`/mes/pro/work-order-process/delete?id=${id}`);
}
}
src/bootstrap.ts
@@ -7,7 +7,6 @@
import { initStores } from './packages/stores/src';
import './packages/styles/src';
import './packages/styles/src/antd/index.css';
import './styles/herbal-theme.css';
import { useTitle } from '@vueuse/core';
src/components/import-modal/import-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,145 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { VbenFormSchema } from '#/adapter/form';
import { h, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Modal as AntModal, Upload } from 'ant-design-vue';
import { useVbenForm, z } from '#/adapter/form';
import { $t } from '#/locales';
export interface ImportModalProps {
  /** å¼¹çª—标题,如「导入用户」 */
  title: string;
  /** ä¸‹è½½çš„æ¨¡æ¿æ–‡ä»¶åï¼Œå¦‚「用户导入模板.xls」 */
  fileName: string;
  /** æ‰§è¡Œå¯¼å…¥çš„æŽ¥å£å‡½æ•° (file, updateSupport) => Promise */
  importFn: (file: File, updateSupport: boolean) => Promise<unknown>;
  /** ä¸‹è½½æ¨¡æ¿çš„æŽ¥å£å‡½æ•° () => Promise(blob) */
  templateFn: () => Promise<BlobPart>;
  /** å®Œæ•´è·¯çº¿åªæ”¯æŒæ–°å¢žï¼Œä¸å±•示覆盖开关 */
  showUpdateSupport?: boolean;
}
const emit = defineEmits(['success']);
const config = ref<ImportModalProps>({
  title: '',
  fileName: '',
  importFn: async () => {},
  templateFn: () => Promise.resolve(new Blob()),
});
function createSchema(showUpdateSupport = true): VbenFormSchema[] {
  return [
    {
      fieldName: 'file',
      label: '数据文件',
      component: 'Upload' as const,
      rules: 'required',
      help: '仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶',
    },
    ...(showUpdateSupport ? [{
      fieldName: 'updateSupport',
      label: '是否覆盖',
      component: 'Switch' as const,
      componentProps: {
        checkedChildren: '是',
        unCheckedChildren: '否',
      },
      rules: z.boolean().default(false),
      help: '是否更新已经存在的数据',
    }] : []),
  ];
}
const [Form, formApi] = useVbenForm({
  commonConfig: {
    formItemClass: 'col-span-2',
    labelWidth: 120,
  },
  layout: 'horizontal',
  schema: createSchema(),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onOpenChange(isOpen: boolean) {
    if (isOpen) {
      config.value = modalApi.getData<ImportModalProps>() ?? config.value;
      await formApi.updateSchema(createSchema(config.value.showUpdateSupport !== false));
      await formApi.resetForm();
    }
  },
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    // æäº¤è¡¨å•
    const data = await formApi.getValues();
    try {
      const result = await config.value.importFn(data.file, data.updateSupport ?? false) as {
        createCodes?: string[];
        updateCodes?: string[];
        failureCodes?: Record<string, string>;
      } | undefined;
      // å…³é—­å¹¶æç¤º
      await modalApi.close();
      emit('success');
      const failures = Object.entries(result?.failureCodes ?? {});
      if (failures.length > 0) {
        AntModal.warning({
          title: `导入完成:成功 ${(result?.createCodes?.length ?? 0) + (result?.updateCodes?.length ?? 0)} æ¡ï¼Œå¤±è´¥ ${failures.length} æ¡`,
          content: h('div', { style: { whiteSpace: 'pre-wrap', maxHeight: '50vh', overflowY: 'auto' } },
            failures.map(([code, reason]) => `${code}:${reason}`).join('\n')),
        });
      } else {
        message.success($t('ui.actionMessage.operationSuccess'));
      }
    } finally {
      modalApi.unlock();
    }
  },
});
/** ä¸Šä¼ å‰ */
function beforeUpload(file: FileType) {
  formApi.setFieldValue('file', file);
  return false;
}
/** ä¸‹è½½æ¨¡ç‰ˆ */
async function handleDownload() {
  const data = await config.value.templateFn();
  downloadFileFromBlobPart({ fileName: config.value.fileName, source: data });
}
</script>
<template>
  <Modal :title="config.title" class="w-1/3">
    <Form class="mx-4">
      <template #file>
        <div class="w-full">
          <Upload
            :max-count="1"
            accept=".xls,.xlsx"
            :before-upload="beforeUpload"
          >
            <Button type="primary"> é€‰æ‹© Excel æ–‡ä»¶ </Button>
          </Upload>
        </div>
      </template>
    </Form>
    <template #prepend-footer>
      <div class="flex flex-auto items-center">
        <Button @click="handleDownload"> ä¸‹è½½å¯¼å…¥æ¨¡æ¿ </Button>
      </div>
    </template>
  </Modal>
</template>
src/components/import-modal/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1 @@
export { default } from './import-modal.vue';
src/packages/constants/src/biz-ai-enum.ts
@@ -24,6 +24,12 @@
  RERANK: 6, // é‡æŽ’
};
/** AI æ¨¡åž‹èƒ½åŠ›ï¼šä¸Žæ¨¡åž‹ç±»åž‹ä¸€èµ·å†³å®šã€Œå¯ç”¨å”¯ä¸€ã€åˆ†ç»„ */
export const AiModelCapabilityEnum = {
  TEXT: 'text', // æ–‡æœ¬
  VISION: 'vision', // è§†è§‰
};
export interface ImageModel {
  image?: string;
  key: string;
src/packages/constants/src/dict-enum.ts
@@ -158,6 +158,7 @@
const AI_DICT = {
  AI_GENERATE_MODE: 'ai_generate_mode', // AI ç”Ÿæˆæ¨¡å¼
  AI_IMAGE_STATUS: 'ai_image_status', // AI å›¾ç‰‡çŠ¶æ€
  AI_MODEL_CAPABILITY: 'ai_model_capability', // AI æ¨¡åž‹èƒ½åŠ›
  AI_MODEL_TYPE: 'ai_model_type', // AI æ¨¡åž‹ç±»åž‹
  AI_MUSIC_STATUS: 'ai_music_status', // AI éŸ³ä¹çŠ¶æ€
  AI_PLATFORM: 'ai_platform', // AI å¹³å°
src/packages/effects/hooks/src/use-app-config.ts
@@ -46,3 +46,8 @@
export function isDocAlertEnable(): boolean {
  return import.meta.env.VITE_APP_DOCALERT_ENABLE !== 'false';
}
/** AI åŠŸèƒ½æ€»å¼€å…³ï¼šå…³é—­åŽéšè—æ‰€æœ‰ AI å…¥å£ï¼ˆæ¨¡å—菜单、OCR、预测、智能判定、智能体导入等) */
export function isAiEnable(): boolean {
  return import.meta.env.VITE_APP_AI_ENABLE !== 'false';
}
src/router/routes/modules/ai.ts
@@ -1,6 +1,9 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
import { isAiEnable } from '@vben/hooks';
/** AI æ€»å¼€å…³å…³é—­æ—¶æ•´ç»„路由不注册,避免通过地址栏直接访问 */
const routes: RouteRecordRaw[] = isAiEnable() ? [
  {
    path: '/ai',
    name: 'Ai',
@@ -108,6 +111,6 @@
      },
    ],
  },
];
] : [];
export default routes;
src/store/auth.ts
@@ -6,6 +6,7 @@
import { useRouter } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { isAiEnable } from '@vben/hooks';
import { preferences } from '@vben/preferences';
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
@@ -22,6 +23,22 @@
} from '#/api';
import { $t } from '#/locales';
import { automaticApproval, loadAuditPolicy } from '#/store/audit-policy';
/** AI æ€»å¼€å…³å…³é—­æ—¶ï¼Œè¿žåŒ /ai æ¨¡å—菜单一并隐藏 */
function filterAiMenus<T extends { children?: T[]; path?: string }>(
  menus: T[],
): T[] {
  return menus
    .filter((menu) => {
      const path = menu.path?.replace(/^\//, '');
      return !(path === 'ai' || path?.startsWith('ai/'));
    })
    .map((menu) =>
      menu.children?.length
        ? { ...menu, children: filterAiMenus(menu.children) }
        : menu,
    );
}
export const useAuthStore = defineStore('auth', () => {
  const accessStore = useAccessStore();
@@ -168,7 +185,9 @@
    userStore.setUserInfo(authPermissionInfo.user);
    userStore.setUserRoles(authPermissionInfo.roles);
    // accessStore
    accessStore.setAccessMenus(authPermissionInfo.menus);
    accessStore.setAccessMenus(
      isAiEnable() ? authPermissionInfo.menus : filterAiMenus(authPermissionInfo.menus),
    );
    accessStore.setAccessCodes(authPermissionInfo.permissions);
    return authPermissionInfo;
  }
src/views/agent/dataEntry/components/TaskCenterDrawer.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,298 @@
<script lang="ts" setup>
import type { AgentTaskApi } from '#/api/agent/conversation';
import { computed, onBeforeUnmount, ref } from 'vue';
import { message } from 'ant-design-vue';
import {
  agentImportErrorMessage,
  cancelAgentTask,
  listAgentTasks,
  streamAgentTaskEvents,
} from '#/api/agent/conversation';
defineOptions({ name: 'AgentTaskCenterDrawer' });
interface Emits {
  (event: 'continue', task: AgentTaskApi.Task): void;
}
const props = defineProps<{ enabled: boolean }>();
const emit = defineEmits<Emits>();
const visible = ref(false);
const loading = ref(false);
const tasks = ref<AgentTaskApi.Task[]>([]);
let pollTimer: ReturnType<typeof setTimeout> | undefined;
let eventAbort: AbortController | undefined;
const activeCount = computed(
  () => tasks.value.filter((task) => !isTerminal(task.status)).length,
);
function isTerminal(status: string) {
  return ['cancelled', 'failed', 'partially_succeeded', 'succeeded'].includes(
    status,
  );
}
function statusText(status: string) {
  const values: Record<string, string> = {
    cancelled: '已取消',
    failed: '失败',
    partially_succeeded: '部分完成',
    queued: '排队中',
    retry_wait: '等待重试',
    running: '执行中',
    succeeded: '已完成',
    waiting_approval: '待审批',
    waiting_input: '待补充',
  };
  return values[status] ?? status;
}
function statusColor(status: string) {
  if (status === 'succeeded') return 'green';
  if (status === 'failed' || status === 'cancelled') return 'red';
  if (status.startsWith('waiting_') || status === 'retry_wait') return 'orange';
  return 'blue';
}
function stageText(stage: string) {
  const values: Record<string, string> = {
    attachment_download: '读取企业附件',
    cancelled: '任务已取消',
    document_invalid: '文档校验失败',
    document_parse: '解析与结构识别',
    evidence_persist: '保存解析证据',
    failed: '任务失败',
    queued: '等待 Worker',
    reauth_required: '需要重新授权',
    result_ready: '分析结果已生成',
    retry_wait: '等待自动重试',
    starting: '准备执行',
    worker_lost: 'Worker å¤šæ¬¡å¤±è”',
  };
  return values[stage] ?? stage;
}
function stopPolling() {
  if (pollTimer) clearTimeout(pollTimer);
  pollTimer = undefined;
  eventAbort?.abort();
  eventAbort = undefined;
}
async function loadTasks(silent = false) {
  if (!props.enabled) return;
  if (!silent) loading.value = true;
  try {
    const result = await listAgentTasks({ page_size: 50 });
    tasks.value = result.items ?? [];
  } catch (error) {
    if (!silent) message.error(agentImportErrorMessage(error));
  } finally {
    if (!silent) loading.value = false;
  }
}
function schedulePolling() {
  if (!visible.value || !props.enabled) return;
  pollTimer = setTimeout(async () => {
    await loadTasks(true);
    schedulePolling();
  }, 3000);
}
async function watchTask(taskId: string, since = 0) {
  eventAbort?.abort();
  const controller = new AbortController();
  eventAbort = controller;
  let cursor = since;
  let retryDelay = 1000;
  while (visible.value && !controller.signal.aborted) {
    try {
      cursor = await streamAgentTaskEvents(
        taskId,
        cursor,
        controller.signal,
        (event) => {
          const index = tasks.value.findIndex((task) => task.task_id === taskId);
          if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...event };
        },
      );
      retryDelay = 1000;
    } catch {
      if (controller.signal.aborted) return;
    }
    await loadTasks(true);
    const latest = tasks.value.find((task) => task.task_id === taskId);
    if (!latest || isTerminal(latest.status) || controller.signal.aborted) return;
    cursor = Math.max(cursor, latest.sequence);
    await new Promise<void>((resolve) => {
      const timer = setTimeout(resolve, retryDelay);
      controller.signal.addEventListener(
        'abort',
        () => {
          clearTimeout(timer);
          resolve();
        },
        { once: true },
      );
    });
    retryDelay = Math.min(retryDelay * 2, 30_000);
  }
}
async function open(taskId?: string) {
  stopPolling();
  visible.value = true;
  await loadTasks();
  schedulePolling();
  const target = taskId
    ? tasks.value.find((task) => task.task_id === taskId)
    : tasks.value.find((task) => !isTerminal(task.status));
  if (target && !isTerminal(target.status)) void watchTask(target.task_id, target.sequence);
}
function close() {
  visible.value = false;
  stopPolling();
}
async function cancel(task: AgentTaskApi.Task) {
  try {
    await cancelAgentTask(task.task_id);
    message.success('任务已取消');
    await loadTasks(true);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  }
}
function continueTask(task: AgentTaskApi.Task) {
  close();
  emit('continue', task);
}
defineExpose({ open, activeCount });
onBeforeUnmount(stopPolling);
</script>
<template>
  <a-drawer
    :open="visible"
    title="后台任务中心"
    width="min(560px, 100vw)"
    :body-style="{ padding: '16px' }"
    @close="close"
  >
    <a-alert
      v-if="!enabled"
      type="info"
      show-icon
      message="持久任务服务尚未启用"
      description="配置 PostgreSQL、Redis å¹¶å¯ç”¨ MOM_TASK_BACKEND åŽï¼Œå¯åœ¨è¿™é‡ŒæŸ¥çœ‹è·¨é¡µé¢æ‰§è¡Œçš„æ–‡æ¡£ä»»åŠ¡ã€‚"
    />
    <a-spin v-else :spinning="loading">
      <a-empty v-if="!tasks.length" description="暂无后台任务" />
      <div v-else class="space-y-3">
        <article
          v-for="task in tasks"
          :key="task.task_id"
          class="rounded-lg border border-border bg-card p-4 transition-colors duration-200 hover:border-blue-300"
        >
          <div class="flex items-start justify-between gap-3">
            <div class="min-w-0">
              <div class="font-medium text-gray-900 dark:text-gray-100">
                {{ task.result?.filename || '企业文档分析' }}
              </div>
              <div class="mt-1 text-xs text-gray-500">
                {{ stageText(task.stage) }} Â· ç¬¬ {{ task.attempt }} æ¬¡æ‰§è¡Œ
              </div>
            </div>
            <a-tag :color="statusColor(task.status)" class="!mr-0 shrink-0">
              {{ statusText(task.status) }}
            </a-tag>
          </div>
          <div class="mt-3" aria-live="polite">
            <a-progress
              v-if="task.progress.mode === 'determinate'"
              :percent="task.progress.percent ?? 0"
              :status="task.status === 'failed' ? 'exception' : undefined"
            />
            <div
              v-else
              class="rounded-md border border-dashed border-border bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-300"
              role="status"
            >
              å½“前阶段工作量待确定
            </div>
            <div class="mt-1 flex justify-between gap-3 text-xs text-gray-500">
              <span>{{ task.message }}</span>
              <span v-if="task.progress.total !== null" class="shrink-0">
                {{ task.progress.completed }} / {{ task.progress.total }}
                {{ task.progress.unit === 'row' ? '行' : '' }}
              </span>
            </div>
          </div>
          <a-alert
            v-if="task.error?.message"
            class="mt-3"
            type="error"
            :message="task.error.message"
          />
          <div
            v-if="task.result?.candidate_count !== undefined"
            class="mt-3 rounded-md bg-gray-50 px-3 py-2 text-sm dark:bg-gray-800"
          >
            å·²è¯†åˆ« {{ task.result.candidate_count }} æ¡å€™é€‰æ•°æ®
            <span v-if="task.result.warnings?.length" class="ml-2 text-orange-600">
              {{ task.result.warnings.length }} æ¡æç¤º
            </span>
          </div>
          <a-alert
            v-if="task.result?.review_summary?.requires_review"
            class="mt-2"
            type="warning"
            show-icon
            :message="`需要人工复核:${task.result.review_summary.conflict_groups} ç»„冲突,${task.result.review_summary.incomplete_keys} æ¡ç¼ºå°‘å½’å¹¶é”®`"
          />
          <div
            v-else-if="task.result?.review_summary?.exact_duplicates_merged"
            class="mt-2 text-xs text-gray-500"
          >
            å·²è‡ªåŠ¨åˆå¹¶
            {{ task.result.review_summary.exact_duplicates_merged }} æ¡å®Œå…¨é‡å¤è®°å½•
          </div>
          <div class="mt-3 flex flex-wrap justify-end gap-2">
            <a-button
              v-if="task.allowed_actions.includes('cancel')"
              danger
              @click="cancel(task)"
            >
              å–消任务
            </a-button>
            <a-button
              v-if="
                task.status === 'succeeded' &&
                task.result?.attachment_id &&
                task.result?.continuation_supported !== false
              "
              type="primary"
              @click="continueTask(task)"
            >
              è¿›å…¥è¡¥å½•确认
            </a-button>
          </div>
        </article>
      </div>
    </a-spin>
  </a-drawer>
</template>
src/views/agent/dataEntry/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1523 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { MenuRecordRaw } from '@vben/types';
import type { AgentConversationApi } from '#/api/agent/conversation';
import type { AgentTaskApi } from '#/api/agent/conversation';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'ant-design-vue';
import {
  agentImportErrorMessage,
  createDocumentTask,
  createAgentConversation,
  deleteAgentConversation,
  downloadAgentConversationAttachment,
  downloadAgentReport,
  getAgentCapabilities,
  getAgentModels,
  getAuthorizedAgentCapabilities,
  getAgentConversation,
  listAgentConversations,
  sendAgentAction,
  sendAgentMessage,
  streamAgentConversationEvents,
  uploadAgentAttachment,
} from '#/api/agent/conversation';
import TaskCenterDrawer from './components/TaskCenterDrawer.vue';
defineOptions({ name: 'AgentDataEntry' });
const accessStore = useAccessStore();
const MAX_BYTES = 8 * 1024 * 1024;
const ACCEPT = '.csv,.xlsx,.docx,.pdf';
const authorizationScope = ref('');
const models = ref<AgentConversationApi.BusinessModelInfo[]>([]);
const selectedModelId = ref<number>();
const conversations = ref<AgentConversationApi.ConversationSummary[]>([]);
const active = ref<AgentConversationApi.Conversation>();
const loading = ref(false);
const busy = ref(false);
const creating = ref(false);
const newOpen = ref(false);
const newScenarios = ref<string[]>([]);
const newText = ref('');
const newTimeRange = ref<string[]>([]);
const newRecordLimit = ref<number>();
const newIndustry = ref('');
const newSkipRestDays = ref(false);
const newRestDayDescription = ref('');
const newRegion = ref('');
type ConstraintKey =
  | 'industry'
  | 'instruction'
  | 'record_limit'
  | 'region'
  | 'rest_day_description'
  | 'scenario_paths'
  | 'skip_rest_days'
  | 'time_range';
const newPriorities = ref<Record<ConstraintKey, boolean>>({
  industry: false,
  instruction: false,
  record_limit: false,
  region: false,
  rest_day_description: false,
  scenario_paths: false,
  skip_rest_days: false,
  time_range: false,
});
const newFiles = ref<File[]>([]);
const newAttachmentIds = ref<number[]>([]);
const durableTasksEnabled = ref(false);
const taskCenter = ref<{ open: (taskId?: string) => Promise<void> }>();
const interruptFile = ref<File>();
const mappingDraft = ref<Record<string, string>>({});
const reviseText = ref('');
const syncMes = ref(true);
const eventState = ref<'connected' | 'connecting' | 'idle' | 'retrying'>('idle');
interface ExecutionProgress {
  candidateCount: number;
  issueCount: number;
  label: string;
  modelId?: number;
  percent: number;
  stage: string;
}
const executionProgress = ref<ExecutionProgress>();
let eventAbort: AbortController | undefined;
let eventGeneration = 0;
let eventRefreshTimer: ReturnType<typeof setTimeout> | undefined;
const attachmentPreview = ref<{
  filename: string;
  kind: 'image' | 'pdf';
  open: boolean;
  url: string;
}>({ filename: '', kind: 'image', open: false, url: '' });
const modelOptions = computed(() =>
  models.value.map((model) => ({
    label: `${model.name || model.model} Â· ${model.platform}`,
    value: model.id,
  })),
);
const interrupt = computed(() => active.value?.interrupts?.at(-1));
const card = computed(() => interrupt.value?.card);
const actions = computed(() => {
  const list = [...(card.value?.actions ?? [])];
  // å¡ç‰‡å¯èƒ½åœ¨æäº¤å¼€å…³å…³é—­æ—¶ç”Ÿæˆï¼Œä»¥æœåŠ¡ç«¯å½“å‰å¼€å…³ä¸ºå‡†è¡¥å›žç¡®è®¤åŠ¨ä½œ
  if (
    card.value?.kind === 'preview' &&
    active.value?.commit_enabled &&
    !list.includes('confirm')
  ) {
    list.push('confirm');
  }
  return list;
});
const isGatewayModule = computed(() => active.value?.module_id === 'mdm.item');
const previewValueLabels: Record<string, Record<string, string>> = {
  status: { '0': '启用', '1': '停用' },
  types: {
    '1': 'IQC(来料检验)',
    '2': 'IPQC(过程检验)',
    '3': 'OQC(出货检验)',
    '4': 'RQC(退料检验)',
  },
};
const previewColumns = computed(() => [
  { title: '行号', dataIndex: 'row_index', key: 'row_index', width: 60 },
  ...(active.value?.row_fields ?? []).map((field) => ({
    title: field.label,
    dataIndex: field.name,
    key: field.name,
    width: 140,
    customRender: ({ text }: { text: unknown }) =>
      previewValueLabels[field.name]?.[String(text ?? '')] ?? text,
  })),
]);
const previewRows = computed(() =>
  (active.value?.rows ?? []).map((row, index) => ({
    candidate_id: row.candidate_id,
    row_index: index + 1,
    ...row.values,
  })),
);
const eventStateText = computed(() => {
  if (active.value?.finished) return '已同步';
  return {
    connected: '实时更新',
    connecting: '正在连接',
    idle: '未连接',
    retrying: '断线重连',
  }[eventState.value];
});
const eventStateColor = computed(() => {
  if (active.value?.finished || eventState.value === 'connected') return 'green';
  if (eventState.value === 'retrying') return 'orange';
  return 'blue';
});
interface ScenarioOption {
  description: string;
  label: string;
  name: string;
  value: string;
}
function flattenMenuScenarios(
  menus: MenuRecordRaw[],
  parentNames: string[] = [],
): ScenarioOption[] {
  return menus.flatMap((menu) => {
    const names = [...parentNames, menu.name].filter(Boolean);
    const current = menu.show === false
      ? []
      : [
          {
            description: menu.path,
            label: names.join(' / '),
            name: menu.name,
            value: menu.path,
          },
        ];
    return [
      ...current,
      ...flattenMenuScenarios(menu.children ?? [], names),
    ];
  });
}
const scenarioOptions = computed(() => {
  const seen = new Set<string>();
  return flattenMenuScenarios(accessStore.accessMenus).filter((item) => {
    if (!item.value || seen.has(item.value)) return false;
    seen.add(item.value);
    return true;
  });
});
function togglePriority(key: ConstraintKey) {
  newPriorities.value[key] = !newPriorities.value[key];
}
function buildConstraints(): AgentConversationApi.ConversationConstraints {
  const scenarioByPath = new Map(
    scenarioOptions.value.map((item) => [item.value, item.label]),
  );
  return {
    industry: newIndustry.value.trim() || undefined,
    instruction: newText.value.trim() || undefined,
    priority_fields: Object.entries(newPriorities.value)
      .filter(([, enabled]) => enabled)
      .map(([key]) => key),
    record_limit: newRecordLimit.value,
    region: newRegion.value.trim() || undefined,
    rest_day_description: newRestDayDescription.value.trim() || undefined,
    scenario_paths: newScenarios.value.map((path) =>
      `${scenarioByPath.get(path) ?? path} (${path})`,
    ),
    skip_rest_days:
      newSkipRestDays.value || newPriorities.value.skip_rest_days
        ? newSkipRestDays.value
        : undefined,
    time_end: newTimeRange.value[1],
    time_start: newTimeRange.value[0],
  };
}
function hasNewConditions() {
  return Boolean(
    newScenarios.value.length ||
      newTimeRange.value.length ||
      newRecordLimit.value ||
      newIndustry.value.trim() ||
      newSkipRestDays.value ||
      newRestDayDescription.value.trim() ||
      newRegion.value.trim(),
  );
}
function resetNewConditions() {
  newScenarios.value = [];
  newText.value = '';
  newTimeRange.value = [];
  newRecordLimit.value = undefined;
  newIndustry.value = '';
  newSkipRestDays.value = false;
  newRestDayDescription.value = '';
  newRegion.value = '';
  for (const key of Object.keys(newPriorities.value) as ConstraintKey[]) {
    newPriorities.value[key] = false;
  }
}
watch(
  () => interrupt.value?.interrupt_id,
  () => {
    reviseText.value = '';
    mappingDraft.value = {};
    interruptFile.value = undefined;
    const value = card.value?.sync_mes;
    syncMes.value =
      typeof value === 'boolean'
        ? value
        : (active.value?.sync_mes ?? active.value?.mes_sync_default ?? true);
  },
);
function statusText(status?: string) {
  const map: Record<string, string> = {
    SUCCEEDED: '导入成功',
    PARTIAL_FAILED: '部分失败',
    FAILED: '失败',
    UNKNOWN: '结果未知',
    RUNNING: '提交中',
    attached: '已上传',
    blocked: '已阻断',
    cancelled: '已取消',
    classified: '已识别场景',
    commit_disabled: '未提交',
    confirmed: '已确认',
    created: '待上传文件',
    queued: '排队处理中',
    error: '出错',
    failed: '失败',
    mapping_applied: '映射已更新',
    needs_review: '待确认',
    parsed: '已解析',
    preview_ready: '待确认',
    revise: '待重新校验',
    stale: '已过期,需重检',
    validated: '待确认',
  };
  return map[status ?? ''] ?? status ?? '进行中';
}
function statusColor(status?: string) {
  if (status === 'SUCCEEDED' || status === 'confirmed') return 'green';
  if (
    status === 'PARTIAL_FAILED' ||
    status === 'revise' ||
    status === 'stale'
  ) {
    return 'orange';
  }
  if (
    status === 'FAILED' ||
    status === 'failed' ||
    status === 'error' ||
    status === 'blocked' ||
    status === 'cancelled'
  ) {
    return 'red';
  }
  return 'blue';
}
function checkFile(raw: File) {
  const name = raw.name.toLowerCase();
  if (!ACCEPT.split(',').some((ext) => name.endsWith(ext))) {
    message.error('仅支持 CSV、XLSX、DOCX æˆ– PDF æ–‡ä»¶');
    return false;
  }
  if (raw.size > MAX_BYTES) {
    message.error('文件不能超过 8 MiB');
    return false;
  }
  return true;
}
function beforeNewUpload(fileInfo: FileType) {
  const raw = fileInfo as unknown as File;
  if (!checkFile(raw)) return false;
  const duplicate = newFiles.value.some(
    (item) =>
      item.name === raw.name && item.size === raw.size && item.lastModified === raw.lastModified,
  );
  if (!duplicate) newFiles.value = [...newFiles.value, raw].slice(0, 20);
  newAttachmentIds.value = [];
  return false;
}
function clearNewFiles() {
  newFiles.value = [];
  newAttachmentIds.value = [];
}
function beforeInterruptUpload(fileInfo: FileType) {
  const raw = fileInfo as unknown as File;
  if (!checkFile(raw)) return false;
  interruptFile.value = raw;
  return false;
}
async function boot() {
  loading.value = true;
  try {
    const [capabilities, authorization, list, modelList] = await Promise.all([
      getAgentCapabilities(),
      getAuthorizedAgentCapabilities(),
      listAgentConversations({ page_size: 50 }),
      getAgentModels().catch(() => []),
    ]);
    authorizationScope.value = authorization.scope_summary;
    models.value = modelList;
    if (!selectedModelId.value) {
      selectedModelId.value = active.value?.model_id ?? modelList[0]?.id;
    }
    const visibleScenarioPaths = new Set(scenarioOptions.value.map((item) => item.value));
    newScenarios.value = newScenarios.value.filter((path) =>
      visibleScenarioPaths.has(path),
    );
    durableTasksEnabled.value = capabilities.durable_tasks?.enabled ?? false;
    conversations.value = list.items ?? [];
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    loading.value = false;
  }
}
async function ensureNewAttachments() {
  if (newAttachmentIds.value.length === newFiles.value.length) {
    return newAttachmentIds.value;
  }
  newAttachmentIds.value = [];
  for (const file of newFiles.value) {
    newAttachmentIds.value.push(await uploadAgentAttachment(file));
  }
  return newAttachmentIds.value;
}
async function reloadConversations() {
  try {
    const list = await listAgentConversations({ page_size: 50 });
    conversations.value = list.items ?? [];
  } catch {
    // åˆ—表刷新失败不阻塞主流程
  }
}
function abortEventStream() {
  eventGeneration += 1;
  eventAbort?.abort();
  eventAbort = undefined;
  if (eventRefreshTimer) clearTimeout(eventRefreshTimer);
  eventRefreshTimer = undefined;
  eventState.value = 'idle';
}
async function refreshActive(conversationId: string) {
  const snapshot = await getAgentConversation(conversationId);
  if (active.value?.conversation_id !== conversationId) return;
  active.value = snapshot;
  await reloadConversations();
  if (snapshot.finished) abortEventStream();
}
function queueEventRefresh(conversationId: string) {
  if (eventRefreshTimer) clearTimeout(eventRefreshTimer);
  eventRefreshTimer = setTimeout(() => {
    eventRefreshTimer = undefined;
    void refreshActive(conversationId).catch(() => {
      // SSE ä¼šç»§ç»­é‡è¿žï¼Œå¿«ç…§è¯»å–失败不重复弹窗打扰操作。
    });
  }, 120);
}
function handleConversationEvent(
  conversationId: string,
  event: AgentConversationApi.ConversationEvent,
) {
  if (active.value?.conversation_id !== conversationId) return;
  if (event.type === 'progress') {
    const payload = event.payload;
    executionProgress.value = {
      candidateCount: Number(payload.candidate_count ?? 0),
      issueCount: Number(payload.issue_count ?? 0),
      label: String(payload.label ?? '正在处理'),
      modelId:
        typeof payload.model_id === 'number' ? payload.model_id : undefined,
      percent: Math.min(100, Math.max(0, Number(payload.percent ?? 0))),
      stage: String(payload.stage ?? 'processing'),
    };
    return;
  }
  queueEventRefresh(conversationId);
}
function waitForRetry(delay: number, signal: AbortSignal) {
  return new Promise<void>((resolve) => {
    const timer = setTimeout(resolve, delay);
    signal.addEventListener(
      'abort',
      () => {
        clearTimeout(timer);
        resolve();
      },
      { once: true },
    );
  });
}
function startEventStream(conversationId: string) {
  abortEventStream();
  const generation = eventGeneration;
  const controller = new AbortController();
  eventAbort = controller;
  eventState.value = 'connecting';
  void (async () => {
    let cursor = 0;
    let retryDelay = 1000;
    while (!controller.signal.aborted && generation === eventGeneration) {
      try {
        cursor = await streamAgentConversationEvents(
          conversationId,
          cursor,
          controller.signal,
          (event) => handleConversationEvent(conversationId, event),
          () => {
            eventState.value = 'connected';
            retryDelay = 1000;
          },
        );
        if (!controller.signal.aborted) eventState.value = 'connecting';
      } catch {
        if (controller.signal.aborted) return;
        eventState.value = 'retrying';
        try {
          await refreshActive(conversationId);
        } catch {
          // è½®è¯¢å…œåº•失败后继续指数退避。
        }
        await waitForRetry(retryDelay, controller.signal);
        retryDelay = Math.min(retryDelay * 2, 30_000);
      }
    }
  })();
}
async function openConversation(conversationId: string) {
  if (busy.value) return;
  busy.value = true;
  try {
    const conversation = await getAgentConversation(conversationId);
    active.value = conversation;
    selectedModelId.value = conversation.model_id ?? models.value[0]?.id;
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    busy.value = false;
  }
}
function changeModel(value: number) {
  selectedModelId.value = value;
}
async function createConversation() {
  if (!newFiles.value.length && !newText.value.trim() && !hasNewConditions()) {
    message.warning('请上传文件、填写补录说明或设置补录条件');
    return;
  }
  creating.value = true;
  try {
    if (newFiles.value.length > 1) {
      message.warning('多文件请使用后台分析,归并检查完成后再进入补录');
      return;
    }
    const [attachmentId] = await ensureNewAttachments();
    const description = newText.value.trim();
    const baseTitle =
      newFiles.value[0]?.name || description.slice(0, 60) || '新建补录';
    active.value = await createAgentConversation({
      attachment_id: attachmentId ? String(attachmentId) : undefined,
      constraints: buildConstraints(),
      model_id: selectedModelId.value,
      text: description || undefined,
      title: baseTitle,
    });
    newOpen.value = false;
    clearNewFiles();
    resetNewConditions();
    await reloadConversations();
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    creating.value = false;
  }
}
async function startBackgroundAnalysis() {
  if (!durableTasksEnabled.value) {
    message.info('持久任务服务尚未启用');
    return;
  }
  if (!newFiles.value.length) {
    message.warning('后台分析需要先选择数据文件');
    return;
  }
  creating.value = true;
  try {
    const attachmentIds = await ensureNewAttachments();
    if (!attachmentIds.length) return;
    const task = await createDocumentTask({
      attachment_ids: attachmentIds,
      constraints: buildConstraints(),
    });
    newOpen.value = false;
    resetNewConditions();
    message.success('后台分析任务已创建,可关闭页面后继续执行');
    await taskCenter.value?.open(task.task_id);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    creating.value = false;
  }
}
async function continueFromTask(task: AgentTaskApi.Task) {
  if (task.result?.continuation_supported === false) {
    message.info('多文件结果需先在证据明细中处理冲突,当前暂不进入单文件提交');
    return;
  }
  const attachmentId = task.result?.attachment_id;
  if (!attachmentId) {
    message.error('任务结果缺少附件引用,无法进入补录');
    return;
  }
  busy.value = true;
  try {
    active.value = await createAgentConversation({
      attachment_id: String(attachmentId),
      model_id: selectedModelId.value,
      module_id: task.result?.module_id || undefined,
      text: '后台文档分析已完成,请继续进行字段核验和补录确认。',
      title: task.result?.filename || '后台分析补录',
    });
    await reloadConversations();
    message.success('已进入补录确认流程');
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    busy.value = false;
  }
}
async function apply(result: AgentConversationApi.Conversation, tip?: string) {
  active.value = result;
  selectedModelId.value = result.model_id ?? selectedModelId.value;
  if (tip) message.success(tip);
  await reloadConversations();
}
async function act(
  action: string,
  payload: Record<string, unknown> = {},
  tip?: string,
) {
  const current = active.value;
  const pending = interrupt.value;
  if (!current || !pending) return;
  busy.value = true;
  try {
    const result = await sendAgentAction(current.conversation_id, {
      action,
      interrupt_id: pending.interrupt_id,
      model_id: selectedModelId.value,
      payload,
      revision: pending.revision,
    });
    await apply(result, tip);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    busy.value = false;
  }
}
function chooseModule(moduleId: string) {
  return act('choose_scenario', { module_id: moduleId }, '已选择业务场景');
}
async function confirmMapping() {
  const missing = card.value?.missing_fields ?? [];
  const mapping: Record<string, string> = {};
  for (const field of missing) {
    const column = mappingDraft.value[field.name];
    if (!column) {
      message.warning(`请为「${field.label}」选择对应列`);
      return;
    }
    mapping[field.name] = column;
  }
  await act('confirm_mapping', { mapping }, '映射已提交,重新校验');
}
async function uploadForFileInterrupt() {
  const current = active.value;
  if (!current) return;
  if (!interruptFile.value) {
    message.warning('请先选择文件');
    return;
  }
  busy.value = true;
  try {
    const attachmentId = await uploadAgentAttachment(interruptFile.value);
    const result = await sendAgentMessage(current.conversation_id, {
      attachment_id: String(attachmentId),
      model_id: selectedModelId.value,
      text: '已上传要补录的文件。',
    });
    interruptFile.value = undefined;
    await apply(result);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    busy.value = false;
  }
}
async function revise() {
  const text = reviseText.value.trim();
  const payload: Record<string, unknown> = {};
  if (text) payload.text = text;
  if (isGatewayModule.value) payload.sync_mes = syncMes.value;
  if (!text && !isGatewayModule.value) {
    message.warning('请输入修订内容,例如“第2行 åç§° æ”¹ä¸º XXX”');
    return;
  }
  await act('revise', payload, '已提交修订,重新校验');
  reviseText.value = '';
}
async function toggleSync(value: boolean) {
  syncMes.value = value;
  await act('revise', { sync_mes: value }, '已更新同步选项,重新校验');
}
function confirmImport() {
  return act('confirm', {}, '已确认,正在提交');
}
function cancelConversation() {
  return act('cancel', {}, '已取消本次补录');
}
async function downloadReport() {
  const current = active.value;
  if (!current) return;
  try {
    const response = await downloadAgentReport(current.conversation_id);
    const disposition = String(
      response.headers?.['content-disposition'] ?? '',
    );
    const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition);
    downloadFileFromBlobPart({
      fileName: match?.[1] || `${current.title || '补录回执'}.xlsx`,
      source: response.data,
    });
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  }
}
function decodeAttachmentFilename(
  headers: Record<string, string> | undefined,
  fallback: string,
) {
  const encoded = headers?.['x-agent-filename-b64'];
  if (!encoded) return fallback;
  try {
    const binary = atob(encoded);
    const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
    return new TextDecoder().decode(bytes);
  } catch {
    return fallback;
  }
}
function canPreview(filename?: string) {
  return /\.(pdf|png|jpe?g|gif|webp|bmp)$/i.test(filename || '');
}
async function previewAttachment(
  attachment: AgentConversationApi.ConversationAttachment,
) {
  const current = active.value;
  if (!current) return;
  try {
    const response = await downloadAgentConversationAttachment(
      current.conversation_id,
      attachment.attachment_id,
      'inline',
    );
    const filename = decodeAttachmentFilename(
      response.headers,
      attachment.filename || `附件-${attachment.attachment_id}`,
    );
    closeAttachmentPreview();
    attachmentPreview.value = {
      filename,
      kind: /\.pdf$/i.test(filename) ? 'pdf' : 'image',
      open: true,
      url: URL.createObjectURL(response.data),
    };
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  }
}
function closeAttachmentPreview() {
  if (attachmentPreview.value.url) URL.revokeObjectURL(attachmentPreview.value.url);
  attachmentPreview.value = { filename: '', kind: 'image', open: false, url: '' };
}
async function downloadAttachment(
  attachment: AgentConversationApi.ConversationAttachment,
) {
  const current = active.value;
  if (!current) return;
  try {
    const response = await downloadAgentConversationAttachment(
      current.conversation_id,
      attachment.attachment_id,
      'download',
    );
    downloadFileFromBlobPart({
      fileName: decodeAttachmentFilename(
        response.headers,
        attachment.filename || `附件-${attachment.attachment_id}`,
      ),
      source: response.data,
    });
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  }
}
async function removeConversation(
  item: AgentConversationApi.ConversationSummary,
) {
  try {
    await deleteAgentConversation(item.conversation_id);
    if (active.value?.conversation_id === item.conversation_id) {
      abortEventStream();
      active.value = undefined;
    }
    await reloadConversations();
    message.success('会话及附件已删除');
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  }
}
onMounted(boot);
onBeforeUnmount(() => {
  abortEventStream();
  closeAttachmentPreview();
});
watch(
  () => active.value?.conversation_id,
  (conversationId) => {
    executionProgress.value = undefined;
    if (conversationId && !active.value?.finished) startEventStream(conversationId);
    else abortEventStream();
  },
);
</script>
<template>
  <Page auto-content-height>
    <div class="flex h-full gap-3 p-3">
      <aside
        class="flex w-64 shrink-0 flex-col overflow-hidden rounded-lg border border-border bg-card"
      >
        <div class="border-b border-border p-3">
          <a-button type="primary" block @click="newOpen = true">
            æ–°å»ºè¡¥å½•
          </a-button>
          <div class="mt-2 text-xs text-gray-500">
            ä¸Šä¼ æ–‡ä»¶åŽè‡ªåŠ¨è¯†åˆ«ä¸šåŠ¡åœºæ™¯ï¼Œç¡®è®¤å‰ä¼šå®Œæ•´æ ¡éªŒã€‚
          </div>
        </div>
        <div class="flex-1 overflow-y-auto p-2">
          <a-spin :spinning="loading">
            <a-empty
              v-if="!conversations.length"
              description="暂无补录会话"
            />
            <div
              v-for="item in conversations"
              :key="item.conversation_id"
              class="mb-1 flex w-full cursor-pointer items-start gap-1 rounded-md p-2 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
              :class="{
                'bg-blue-50 dark:bg-blue-900/30':
                  item.conversation_id === active?.conversation_id,
              }"
              role="button"
              tabindex="0"
              @click="openConversation(item.conversation_id)"
              @keydown.enter="openConversation(item.conversation_id)"
            >
              <div class="min-w-0 flex-1">
                <div class="truncate text-sm font-medium">
                  {{ item.title || '未命名补录' }}
                </div>
                <div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
                  <a-tag :color="statusColor(item.status)" class="!mr-0">
                    {{ statusText(item.status) }}
                  </a-tag>
                  <span class="truncate">
                    {{ item.module_title || '待识别' }}
                  </span>
                </div>
              </div>
              <a-popconfirm
                cancel-text="取消"
                ok-text="删除"
                title="删除会话及已上传附件?"
                @confirm="removeConversation(item)"
              >
                <a-button
                  class="shrink-0"
                  danger
                  size="small"
                  type="text"
                  @click.stop
                >
                  åˆ é™¤
                </a-button>
              </a-popconfirm>
            </div>
          </a-spin>
        </div>
      </aside>
      <section
        class="flex min-w-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card"
      >
        <header
          class="flex items-center justify-between border-b border-border px-4 py-3"
        >
          <div class="min-w-0">
            <div class="truncate text-base font-semibold">
              {{ active?.title || '模块数据补录' }}
            </div>
            <div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
              <a-tag :color="statusColor(active?.status)" class="!mr-0">
                {{ statusText(active?.status) }}
              </a-tag>
              <span v-if="active?.module_title">{{ active.module_title }}</span>
              <span v-if="active?.total">共 {{ active.total }} è¡Œ</span>
              <span v-if="active?.data_scope_summary || authorizationScope">
                æ•°æ®èŒƒå›´ï¼š{{ active?.data_scope_summary || authorizationScope }}
              </span>
              <span
                v-if="modelOptions.length"
                class="flex items-center gap-1"
                title="切换后从下一次分析开始生效"
              >
                æ¨¡åž‹ï¼š
                <a-select
                  :disabled="busy"
                  :options="modelOptions"
                  :value="selectedModelId"
                  class="w-56"
                  size="small"
                  @change="changeModel"
                />
              </span>
              <a-tag
                v-if="active"
                :color="eventStateColor"
                class="!mr-0"
                aria-live="polite"
              >
                {{ eventStateText }}
              </a-tag>
            </div>
            <div
              v-if="active?.attachments?.length"
              class="mt-1 flex flex-wrap items-center gap-2 text-xs text-gray-500"
            >
              <span>附件:</span>
              <span
                v-for="attachment in active.attachments"
                :key="attachment.attachment_id"
                class="flex items-center gap-1 rounded border border-border px-2 py-0.5"
              >
                <span class="max-w-60 truncate">
                  {{ attachment.filename || `附件 ${attachment.attachment_id}` }}
                </span>
                <a-button
                  v-if="canPreview(attachment.filename)"
                  class="!h-5 !px-1"
                  size="small"
                  type="link"
                  @click="previewAttachment(attachment)"
                >
                  é¢„览
                </a-button>
                <a-button
                  class="!h-5 !px-1"
                  size="small"
                  type="link"
                  @click="downloadAttachment(attachment)"
                >
                  ä¸‹è½½
                </a-button>
              </span>
            </div>
          </div>
          <div class="flex shrink-0 items-center gap-2">
            <a-tooltip
              :title="
                durableTasksEnabled
                  ? '查看跨页面执行的文档任务'
                  : '持久任务服务尚未启用'
              "
            >
              <a-button @click="taskCenter?.open()">后台任务</a-button>
            </a-tooltip>
            <a-button
              v-if="active?.receipt?.command_id"
              :disabled="busy"
              @click="downloadReport"
            >
              ä¸‹è½½å›žæ‰§
            </a-button>
          </div>
        </header>
        <div
          v-if="executionProgress && active"
          class="border-b border-border bg-blue-50/70 px-4 py-2 dark:bg-blue-950/20"
          aria-live="polite"
        >
          <div class="mb-1 flex items-center justify-between gap-4 text-xs">
            <span class="font-medium text-blue-700 dark:text-blue-300">
              {{ executionProgress.label }}
            </span>
            <span class="text-gray-500">
              å·²å‘现 {{ executionProgress.candidateCount }} æ¡å€™é€‰
              <template v-if="executionProgress.issueCount">
                Â· {{ executionProgress.issueCount }} é¡¹å¾…核对
              </template>
            </span>
          </div>
          <a-progress
            :percent="executionProgress.percent"
            :status="executionProgress.stage === 'error' ? 'exception' : 'active'"
            size="small"
          />
        </div>
        <div class="flex-1 overflow-y-auto p-4">
          <a-empty
            v-if="!active"
            description="从左侧选择会话,或点击「新建补录」开始"
          />
          <div v-else class="space-y-3">
            <div
              v-for="item in active.messages"
              :key="item.message_id"
              class="flex"
              :class="item.role === 'user' ? 'justify-end' : 'justify-start'"
            >
              <div
                class="max-w-[80%] rounded-lg px-3 py-2 text-sm"
                :class="
                  item.role === 'user'
                    ? 'bg-blue-500 text-white'
                    : 'bg-gray-100 dark:bg-gray-800'
                "
              >
                <div class="whitespace-pre-wrap">{{ item.text }}</div>
                <div
                  v-for="(msgCard, index) in item.cards || []"
                  :key="`${item.message_id}-${index}`"
                  class="mt-2 rounded-md border border-border bg-card p-2 text-gray-800 dark:text-gray-100"
                >
                  <template v-if="msgCard.kind === 'receipt'">
                    <div class="mb-1 font-semibold">
                      {{ msgCard.title || '导入回执' }}
                    </div>
                    <div class="text-xs text-gray-500">
                      æˆåŠŸ {{ msgCard.summary?.success ?? 0 }},跳过
                      {{ msgCard.summary?.skipped ?? 0 }},失败
                      {{ msgCard.summary?.failed ?? 0 }}
                    </div>
                    <a-table
                      class="mt-2"
                      :columns="[
                        { title: '编码', dataIndex: 'code', width: 160 },
                        { title: '名称', dataIndex: 'name' },
                        { title: '结果', dataIndex: 'status', width: 90 },
                      ]"
                      :data-source="msgCard.rows || []"
                      :pagination="false"
                      row-key="candidate_id"
                      size="small"
                    >
                      <template #bodyCell="{ column, record }">
                        <a-tag
                          v-if="column.dataIndex === 'status'"
                          :color="record.status === 'SUCCESS' ? 'green' : 'red'"
                        >
                          {{ record.status }}
                        </a-tag>
                      </template>
                    </a-table>
                    <a-button
                      class="mt-2"
                      size="small"
                      type="link"
                      @click="downloadReport"
                    >
                      ä¸‹è½½æŠ¥è¡¨
                    </a-button>
                  </template>
                </div>
              </div>
            </div>
          </div>
        </div>
        <footer v-if="active && interrupt" class="border-t border-border p-3">
          <a-spin :spinning="busy">
            <!-- åœºæ™¯é€‰æ‹© -->
            <div v-if="interrupt.kind === 'scenario'">
              <div class="mb-2 font-medium">{{ card?.title }}</div>
              <div class="mb-2 text-xs text-gray-500">{{ card?.body }}</div>
              <div class="flex flex-wrap gap-2">
                <a-button
                  v-for="candidate in card?.candidates || []"
                  :key="candidate.module_id"
                  :disabled="busy"
                  @click="chooseModule(candidate.module_id)"
                >
                  {{ candidate.title }}
                  <a-tag class="ml-1" color="blue">
                    {{ candidate.capability }}
                  </a-tag>
                </a-button>
                <a-button
                  v-if="actions.includes('cancel')"
                  danger
                  :disabled="busy"
                  @click="cancelConversation"
                >
                  å–消
                </a-button>
              </div>
            </div>
            <!-- å­—段映射 -->
            <div v-else-if="interrupt.kind === 'mapping'">
              <div class="mb-2 font-medium">{{ card?.title }}</div>
              <div class="mb-2 text-xs text-gray-500">{{ card?.body }}</div>
              <div class="space-y-2">
                <div
                  v-for="field in card?.missing_fields || []"
                  :key="field.name"
                  class="flex items-center gap-2"
                >
                  <span class="w-28 text-sm">{{ field.label }}</span>
                  <a-select
                    v-model:value="mappingDraft[field.name]"
                    class="min-w-56"
                    placeholder="选择文件中对应的列"
                    :options="
                      (card?.columns || []).map((column) => ({
                        label: column,
                        value: column,
                      }))
                    "
                  />
                </div>
              </div>
              <div class="mt-3 flex gap-2">
                <a-button
                  type="primary"
                  :loading="busy"
                  @click="confirmMapping"
                >
                  ç¡®è®¤æ˜ å°„
                </a-button>
                <a-button danger :disabled="busy" @click="cancelConversation">
                  å–消
                </a-button>
              </div>
            </div>
            <!-- è¡¥ä¼ æ–‡ä»¶ -->
            <div v-else-if="interrupt.kind === 'file'">
              <div class="mb-2 font-medium">{{ card?.title }}</div>
              <div class="mb-2 text-xs text-gray-500">{{ card?.body }}</div>
              <a-upload
                accept=".csv,.xlsx,.docx,.pdf"
                :before-upload="beforeInterruptUpload"
                :max-count="1"
                :show-upload-list="false"
              >
                <a-button>{{ interruptFile ? '重新选择' : '选择文件' }}</a-button>
              </a-upload>
              <span v-if="interruptFile" class="ml-2 text-xs text-gray-500">
                {{ interruptFile.name }}
              </span>
              <div class="mt-3">
                <a-button
                  type="primary"
                  :disabled="!interruptFile"
                  :loading="busy"
                  @click="uploadForFileInterrupt"
                >
                  ä¸Šä¼ å¹¶ç»§ç»­
                </a-button>
              </div>
            </div>
            <!-- æ ¡éªŒ / é¢„览确认 -->
            <div v-else-if="['preview', 'validation'].includes(interrupt.kind)">
              <div class="mb-2 flex items-center justify-between">
                <span class="font-medium">{{ card?.title }}</span>
                <span class="text-xs text-gray-500">
                  {{ card?.notice }}
                </span>
              </div>
              <a-alert
                v-if="card?.issues?.length"
                class="mb-2"
                type="error"
                show-icon
              >
                <template #message>
                  é˜»æ–­é¡¹ {{ card.issues.length }} æ¡ï¼Œéœ€ä¿®è®¢åŽé‡æ–°æ ¡éªŒ
                </template>
                <ul class="mt-1 list-decimal pl-4 text-xs">
                  <li v-for="(issue, index) in card.issues" :key="index">
                    {{ issue.message }}
                    <span v-if="issue.code" class="text-gray-400">
                      ï¼ˆ{{ issue.code }})
                    </span>
                  </li>
                </ul>
              </a-alert>
              <a-alert
                v-if="card?.warnings?.length"
                class="mb-2"
                type="warning"
                show-icon
              >
                <template #message>
                  æç¤º {{ card.warnings.length }} æ¡ï¼Œä¸é˜»æ–­æäº¤
                </template>
                <ul class="mt-1 list-decimal pl-4 text-xs">
                  <li v-for="(item, index) in card.warnings" :key="index">
                    {{ item.message }}
                  </li>
                </ul>
              </a-alert>
              <a-alert
                v-if="card?.repair_guidance?.length"
                class="mb-2"
                type="info"
                show-icon
              >
                <template #message>业务大模型核对建议</template>
                <ul class="mt-1 list-disc pl-4 text-xs">
                  <li v-for="(item, index) in card.repair_guidance" :key="index">
                    <span v-if="item.field" class="font-medium">{{ item.field }}:</span>
                    {{ item.guidance }}
                  </li>
                </ul>
              </a-alert>
              <div
                v-if="card?.summary"
                class="mb-2 flex flex-wrap gap-3 text-xs text-gray-600 dark:text-gray-300"
              >
                <span>共 {{ card.summary.total ?? 0 }} è¡Œ</span>
                <span class="text-green-600">
                  æ–°å»º {{ card.summary.create ?? 0 }}
                </span>
                <span class="text-orange-500">
                  è·³è¿‡ {{ card.summary.skip ?? 0 }}
                </span>
                <span class="text-red-500">
                  é˜»æ–­ {{ card.summary.blocked ?? 0 }}
                </span>
              </div>
              <div v-if="card?.side_effects?.length" class="mb-2 text-xs text-gray-500">
                é¢„期影响:{{ card.side_effects.join(';') }}
              </div>
              <div
                v-if="isGatewayModule"
                class="mb-2 flex items-center gap-2 text-sm"
              >
                <span>同步到 MES</span>
                <a-switch
                  :checked="syncMes"
                  :disabled="busy"
                  @change="toggleSync"
                />
                <span class="text-xs text-gray-500">
                  å…³é—­åŽä»…创建 ERP ç‰©æ–™
                </span>
              </div>
              <a-table
                v-if="previewRows.length"
                class="mb-2"
                :columns="previewColumns"
                :data-source="previewRows"
                :pagination="false"
                :scroll="{ x: 'max-content', y: 320 }"
                row-key="candidate_id"
                size="small"
                bordered
              />
              <div class="flex gap-2">
                <a-input
                  v-model:value="reviseText"
                  class="flex-1"
                  placeholder="如需修改,输入修订说明,例如:第2行 åç§° æ”¹ä¸º XXX"
                  :disabled="busy"
                  @press-enter="revise"
                />
                <a-button :disabled="busy" @click="revise">修订并重检</a-button>
                <a-button
                  v-if="actions.includes('confirm')"
                  type="primary"
                  :disabled="busy || card?.can_commit === false"
                  :loading="busy"
                  @click="confirmImport"
                >
                  ç¡®è®¤å¯¼å…¥
                </a-button>
                <a-button
                  v-if="actions.includes('cancel')"
                  danger
                  :disabled="busy"
                  @click="cancelConversation"
                >
                  å–消
                </a-button>
              </div>
            </div>
            <a-alert
              v-else
              type="info"
              show-icon
              :message="card?.title || '当前步骤需要处理'"
              :description="card?.body"
            />
          </a-spin>
        </footer>
      </section>
      <TaskCenterDrawer
        ref="taskCenter"
        :enabled="durableTasksEnabled"
        @continue="continueFromTask"
      />
      <a-modal
        v-model:open="attachmentPreview.open"
        :footer="null"
        :title="attachmentPreview.filename"
        width="80%"
        @cancel="closeAttachmentPreview"
      >
        <iframe
          v-if="attachmentPreview.kind === 'pdf'"
          :src="attachmentPreview.url"
          class="h-[70vh] w-full border-0"
          title="附件预览"
        />
        <img
          v-else
          :src="attachmentPreview.url"
          alt="附件预览"
          class="max-h-[70vh] w-full object-contain"
        />
      </a-modal>
      <a-modal
        v-model:open="newOpen"
        title="新建补录"
        :confirm-loading="creating"
        :width="760"
      >
        <div class="space-y-3">
          <div>
            <div class="mb-1 flex items-center justify-between text-sm">
              <span>业务场景(可多选)</span>
              <a-button
                size="small"
                :type="newPriorities.scenario_paths ? 'primary' : 'default'"
                @click="togglePriority('scenario_paths')"
              >优先</a-button>
            </div>
            <a-select
              v-model:value="newScenarios"
              class="w-full"
              mode="multiple"
              :max-tag-count="3"
              :options="scenarioOptions"
              option-filter-prop="label"
              placeholder="请选择当前补录涉及的菜单模块"
              show-search
            >
              <template #option="option">
                <div class="py-1">
                  <div class="font-medium">{{ option.label }}</div>
                  <div v-if="option.description" class="mt-0.5 text-xs text-gray-500">
                    {{ option.description }}
                  </div>
                </div>
              </template>
            </a-select>
            <div class="mt-1 text-xs text-gray-500">
              å¼€å¯â€œä¼˜å…ˆâ€åŽæŒ‰ç¡¬çº¦æŸå¤„理;最终模块仍必须通过能力与数据权限校验。
            </div>
          </div>
          <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>时间范围</span>
                <a-button size="small" :type="newPriorities.time_range ? 'primary' : 'default'" @click="togglePriority('time_range')">优先</a-button>
              </div>
              <a-range-picker v-model:value="newTimeRange" class="w-full" value-format="YYYY-MM-DD" />
            </div>
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>数据条数</span>
                <a-button size="small" :type="newPriorities.record_limit ? 'primary' : 'default'" @click="togglePriority('record_limit')">优先</a-button>
              </div>
              <a-input-number v-model:value="newRecordLimit" class="w-full" :min="1" :max="100000" placeholder="例如:500" />
            </div>
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>行业</span>
                <a-button size="small" :type="newPriorities.industry ? 'primary' : 'default'" @click="togglePriority('industry')">优先</a-button>
              </div>
              <a-input v-model:value="newIndustry" maxlength="80" placeholder="例如:汽车零部件" />
            </div>
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>区域</span>
                <a-button size="small" :type="newPriorities.region ? 'primary' : 'default'" @click="togglePriority('region')">优先</a-button>
              </div>
              <a-input v-model:value="newRegion" maxlength="120" placeholder="例如:华东 / æ±Ÿè‹å—通" />
            </div>
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>跳过休息日</span>
                <a-button size="small" :type="newPriorities.skip_rest_days ? 'primary' : 'default'" @click="togglePriority('skip_rest_days')">优先</a-button>
              </div>
              <div class="flex h-8 items-center gap-2">
                <a-switch v-model:checked="newSkipRestDays" />
                <span class="text-xs text-gray-500">{{ newSkipRestDays ? '跳过' : '不跳过' }}</span>
              </div>
            </div>
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>休息日描述</span>
                <a-button size="small" :type="newPriorities.rest_day_description ? 'primary' : 'default'" @click="togglePriority('rest_day_description')">优先</a-button>
              </div>
              <a-input v-model:value="newRestDayDescription" maxlength="300" placeholder="例如:周日及法定节假日" />
            </div>
          </div>
          <div>
            <div class="mb-1 flex items-center justify-between text-sm">
              <span>补录说明(可选)</span>
              <a-button size="small" :type="newPriorities.instruction ? 'primary' : 'default'" @click="togglePriority('instruction')">优先</a-button>
            </div>
            <a-textarea
              v-model:value="newText"
              :rows="2"
              placeholder="例如:导入一批供应商档案"
            />
          </div>
          <div>
            <div class="mb-1 text-sm">数据文件</div>
            <a-upload
              accept=".csv,.xlsx,.docx,.pdf"
              :before-upload="beforeNewUpload"
              :max-count="20"
              multiple
              :show-upload-list="false"
            >
              <a-button>选择文件</a-button>
            </a-upload>
            <span v-if="newFiles.length" class="ml-2 text-xs text-gray-500">
              å·²é€‰æ‹© {{ newFiles.length }} ä¸ªæ–‡ä»¶
            </span>
            <a-button v-if="newFiles.length" class="ml-2" size="small" type="link" @click="clearNewFiles">
              æ¸…空
            </a-button>
            <div v-if="newFiles.length" class="mt-2 max-h-20 overflow-auto text-xs text-gray-500">
              <div v-for="file in newFiles" :key="`${file.name}:${file.size}:${file.lastModified}`">
                {{ file.name }}
              </div>
            </div>
            <div class="mt-1 text-xs text-gray-500">
              æ”¯æŒä¸€æ¬¡é€‰æ‹©æœ€å¤š 20 ä¸ª CSV、XLSX、DOCX ä¸Žå¯å¤åˆ¶æ–‡æœ¬çš„ PDF,单个文件不超过 8
              MiB。多文件会在后台做跨文件重复和冲突检查;扫描件暂不支持。
            </div>
          </div>
        </div>
        <template #footer>
          <div class="flex flex-wrap justify-end gap-2">
            <a-button :disabled="creating" @click="newOpen = false">
              å–消
            </a-button>
            <a-tooltip
              :title="
                !durableTasksEnabled
                  ? '需先启用 PostgreSQL ä¸Ž Redis æŒä¹…任务服务'
                  : '任务会根据文件内容自动识别,关闭页面后仍可从任务中心恢复'
              "
            >
              <a-button
                :disabled="!durableTasksEnabled || !newFiles.length"
                :loading="creating"
                @click="startBackgroundAnalysis"
              >
                åŽå°åˆ†æž
              </a-button>
            </a-tooltip>
            <a-button
              type="primary"
              :loading="creating"
              @click="createConversation"
            >
              ç›´æŽ¥å¼€å§‹è¡¥å½•
            </a-button>
          </div>
        </template>
      </a-modal>
    </div>
  </Page>
</template>
src/views/ai/model/model/data.ts
@@ -1,7 +1,9 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { AiModelTypeEnum, CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import type { AiModelModelApi } from '#/api/ai/model/model';
import { AiModelCapabilityEnum, AiModelTypeEnum, CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
@@ -41,6 +43,26 @@
        };
      },
      rules: 'required',
    },
    {
      fieldName: 'capability',
      label: '模型能力',
      component: 'Select',
      componentProps: (values) => {
        return {
          placeholder: '请选择模型能力',
          // åªæœ‰å¯¹è¯ç±»åž‹åŒºåˆ†æ–‡æœ¬/视觉,其余类型统一按文本能力处理
          disabled: !!values.id || values.type !== AiModelTypeEnum.CHAT,
          options: getDictOptions(DICT_TYPE.AI_MODEL_CAPABILITY, 'string'),
          allowClear: true,
        };
      },
      help: '同一类型同一能力只能启用一个模型;视觉能力用于图片识别',
      dependencies: {
        triggerFields: ['type'],
        show: (values) => values.type === AiModelTypeEnum.CHAT,
      },
      rules: z.string().default(AiModelCapabilityEnum.TEXT),
    },
    {
      fieldName: 'keyId',
@@ -162,11 +184,31 @@
      },
    },
    {
      fieldName: 'type',
      label: '模型类型',
      component: 'Select',
      componentProps: {
        placeholder: '请选择模型类型',
        options: getDictOptions(DICT_TYPE.AI_MODEL_TYPE, 'number'),
        allowClear: true,
      },
    },
    {
      fieldName: 'model',
      label: '模型标识',
      component: 'Input',
      componentProps: {
        placeholder: '请输入模型标识',
        allowClear: true,
      },
    },
    {
      fieldName: 'capability',
      label: '模型能力',
      component: 'Select',
      componentProps: {
        placeholder: '请选择模型能力',
        options: getDictOptions(DICT_TYPE.AI_MODEL_CAPABILITY, 'string'),
        allowClear: true,
      },
    },
@@ -183,23 +225,34 @@
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
export function useGridColumns(
  onStatusChange?: (newStatus: number, row: AiModelModelApi.Model) => Promise<boolean | undefined>,
): VxeTableGridOptions['columns'] {
  return [
    {
      field: 'platform',
      title: '所属平台',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.AI_PLATFORM },
      },
      minWidth: 100,
    },
    {
      field: 'type',
      title: '模型类型',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.AI_MODEL_TYPE },
      },
      minWidth: 100,
    },
    {
      field: 'capability',
      title: '模型能力',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.AI_MODEL_CAPABILITY },
      },
      minWidth: 100,
    },
    {
      field: 'platform',
      title: '所属平台',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.AI_PLATFORM },
      },
      minWidth: 100,
    },
@@ -225,12 +278,17 @@
    },
    {
      field: 'status',
      title: '状态',
      title: '启用',
      minWidth: 100,
      align: 'center',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.COMMON_STATUS },
        attrs: { beforeChange: onStatusChange },
        name: 'CellSwitch',
        props: {
          checkedValue: CommonStatusEnum.ENABLE,
          unCheckedValue: CommonStatusEnum.DISABLE,
        },
      },
      minWidth: 80,
    },
    {
      field: 'temperature',
src/views/ai/model/model/index.vue
@@ -2,12 +2,15 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiModelModelApi } from '#/api/ai/model/model';
import { Page, useVbenModal } from '@vben/common-ui';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { CommonStatusEnum } from '@vben/constants';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteModel, getModelPage } from '#/api/ai/model/model';
import { deleteModel, getModelPage, updateModelStatus } from '#/api/ai/model/model';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
@@ -17,6 +20,9 @@
  connectedComponent: Form,
  destroyOnClose: true,
});
/** å½“前页数据,spanMethod ä¾èµ–它计算类型列合并行数 */
const currentRows = ref<AiModelModelApi.Model[]>([]);
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
@@ -48,22 +54,71 @@
  }
}
/** æ›´æ–°æ¨¡åž‹å¯ç”¨çŠ¶æ€ï¼šå¯ç”¨ä¼šè‡ªåŠ¨å…³é—­åŒç±»åž‹åŒèƒ½åŠ›çš„å…¶ä»–æ¨¡åž‹ */
async function handleStatusChange(
  newStatus: number,
  row: AiModelModelApi.Model,
): Promise<boolean | undefined> {
  try {
    await confirm(
      newStatus === CommonStatusEnum.ENABLE
        ? `启用「${row.name}」后,同类型同能力下已启用的其他模型会自动关闭,确定继续吗?`
        : `你要停用「${row.name}」吗?停用后该类型该能力将没有可用模型。`,
    );
  } catch {
    return false;
  }
  await updateModelStatus(row.id!, newStatus);
  message.success($t('ui.actionMessage.operationSuccess'));
  // åŒç»„其他模型的启用状态已被后端改写,必须整页刷新
  handleRefresh();
  return true;
}
/** æ¨¡åž‹ç±»åž‹åˆ—按类型纵向合并,形成分类展示 */
function handleSpanMethod({
  column,
  rowIndex,
}: {
  column: { field?: string; property?: string };
  rowIndex: number;
}) {
  const field = column.field || column.property;
  if (field !== 'type') {
    return { colspan: 1, rowspan: 1 };
  }
  const row = currentRows.value[rowIndex];
  if (rowIndex > 0 && currentRows.value[rowIndex - 1]?.type === row?.type) {
    return { colspan: 0, rowspan: 0 };
  }
  let rowspan = 1;
  for (let index = rowIndex + 1; index < currentRows.value.length; index += 1) {
    if (currentRows.value[index]?.type !== row?.type) {
      break;
    }
    rowspan += 1;
  }
  return { colspan: 1, rowspan };
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    columns: useGridColumns(handleStatusChange),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getModelPage({
          const data = await getModelPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
          currentRows.value = data.list || [];
          return data;
        },
      },
    },
@@ -71,6 +126,7 @@
      keyField: 'id',
      isHover: true,
    },
    spanMethod: handleSpanMethod,
    toolbarConfig: {
      refresh: true,
      search: true,
src/views/basicData/mdm/index.vue
@@ -5,6 +5,8 @@
import { computed, ref } from 'vue';
import { isAiEnable } from '@vben/hooks';
import { confirm, Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart, isEmpty } from '#/packages/utils/src';
@@ -23,6 +25,7 @@
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import AgentImport from './modules/agent-import.vue';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
@@ -35,6 +38,11 @@
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
});
const [AgentImportModal, agentImportModalApi] = useVbenModal({
  connectedComponent: AgentImport,
  destroyOnClose: true,
});
@@ -96,6 +104,56 @@
function handleImport() {
  importModalApi.open();
}
/** æ™ºèƒ½ä½“导入物料 */
function handleAgentImport() {
  agentImportModalApi.open();
}
/** å·¥å…·æ æŒ‰é’®ï¼šæ™ºèƒ½ä½“导入受 AI æ€»å¼€å…³æŽ§åˆ¶ */
const toolbarActions = computed(() => [
  {
    label: $t('ui.actionTitle.create', ['物料']),
    type: 'primary' as const,
    icon: ACTION_ICON.ADD,
    auth: ['mdm:item:create'],
    onClick: handleCreate,
  },
  {
    label: $t('ui.actionTitle.import', ['物料']),
    type: 'primary' as const,
    icon: ACTION_ICON.UPLOAD,
    auth: ['mdm:item:import'],
    onClick: handleImport,
  },
  ...(isAiEnable()
    ? [
        {
          label: '智能体导入',
          type: 'primary' as const,
          icon: ACTION_ICON.UPLOAD,
          auth: ['mdm:item:import'],
          onClick: handleAgentImport,
        },
      ]
    : []),
  {
    label: $t('ui.actionTitle.export'),
    type: 'primary' as const,
    icon: ACTION_ICON.DOWNLOAD,
    auth: ['mdm:item:export'],
    onClick: handleExport,
  },
  {
    label: $t('ui.actionTitle.deleteBatch'),
    type: 'primary' as const,
    danger: true,
    icon: ACTION_ICON.DELETE,
    auth: ['mdm:item:delete'],
    disabled: isEmpty(checkedIds),
    onClick: handleDeleteBatch,
  },
]);
/** ç¼–辑物料 */
function handleEdit(row: MdmItemApi.Item) {
@@ -223,42 +281,10 @@
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <AgentImportModal @success="handleRefresh" />
    <Grid table-title="物料列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['物料']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mdm:item:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.import', ['物料']),
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['mdm:item:import'],
              onClick: handleImport,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mdm:item:export'],
              onClick: handleExport,
            },
            {
              label: $t('ui.actionTitle.deleteBatch'),
              type: 'primary',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mdm:item:delete'],
              disabled: isEmpty(checkedIds),
              onClick: handleDeleteBatch,
            },
          ]"
        />
        <TableAction :actions="toolbarActions" />
      </template>
      <template #categoryName="{ row }">
        <Tag v-if="getCategoryColor(row.categoryId)" :color="getCategoryColor(row.categoryId)">{{ getCategoryName(row.categoryId) }}</Tag>
src/views/basicData/mdm/modules/agent-import.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,610 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { MdmAgentImportApi } from '#/api/mdm/item/agent';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
  Alert,
  Button,
  Input,
  message,
  Steps,
  Table,
  Tag,
  Upload,
} from 'ant-design-vue';
import {
  agentImportErrorMessage,
  bindImportAttachment,
  createImportSession,
  getMaterialImportReceipt,
  reviewMaterialImport,
  startMaterialImport,
} from '#/api/mdm/item/agent';
import { uploadFile } from '#/api/system/storage';
defineOptions({ name: 'MdmAgentImport' });
const emit = defineEmits<{ success: [] }>();
const MAX_BYTES = 8 * 1024 * 1024;
const FIELD_KEYS = [
  'code',
  'name',
  'specification',
  'categoryCode',
  'unitMeasureCode',
] as const;
type FieldKey = (typeof FIELD_KEYS)[number];
interface EditableRow {
  candidate_id: string;
  row_no?: number;
  code: string;
  name: string;
  specification: string;
  categoryCode: string;
  unitMeasureCode: string;
  original: Record<string, string>;
  preflight?: MdmAgentImportApi.PreflightRow;
  issues: string[];
}
interface RowMessage {
  type: 'error' | 'warning';
  text: string;
}
const step = ref(1);
const file = ref<File>();
const fileName = ref('');
const processing = ref(false);
const taskId = ref('');
const state = ref<MdmAgentImportApi.ImportState>();
const receipt = ref<MdmAgentImportApi.Receipt>();
const rows = ref<EditableRow[]>([]);
const lastError = ref('');
let sessionId: number | undefined;
let attachmentId: number | undefined;
const preflight = computed(() => state.value?.preflight);
const issues = computed(() => state.value?.issues ?? []);
const canCommit = computed(
  () => !!preflight.value?.can_commit && issues.value.length === 0,
);
const hasChanges = computed(() => corrections.value.length > 0);
const corrections = computed<MdmAgentImportApi.Correction[]>(() => {
  const result: MdmAgentImportApi.Correction[] = [];
  for (const row of rows.value) {
    for (const field of FIELD_KEYS) {
      const before = (row.original[field] ?? '').trim();
      const after = (row[field] ?? '').trim();
      if (before !== after) {
        result.push({
          candidate_id: row.candidate_id,
          field,
          value: after,
          reason: '人工修订',
        });
      }
    }
  }
  return result;
});
const ACTION_LABEL: Record<MdmAgentImportApi.RowAction, string> = {
  BLOCKED: '阻断',
  CREATE: '新建',
  SKIP: '已存在',
};
const ACTION_COLOR: Record<MdmAgentImportApi.RowAction, string> = {
  BLOCKED: 'red',
  CREATE: 'green',
  SKIP: 'orange',
};
const STATUS_LABEL: Record<MdmAgentImportApi.RowStatus, string> = {
  FAILED: '失败',
  SKIPPED: '已跳过',
  SUCCESS: '成功',
  UNKNOWN: '未知',
};
const STATUS_COLOR: Record<MdmAgentImportApi.RowStatus, string> = {
  FAILED: 'red',
  SKIPPED: 'orange',
  SUCCESS: 'green',
  UNKNOWN: 'default',
};
const reviewColumns = [
  { dataIndex: 'row_no', key: 'row_no', title: '行号', width: 64 },
  { dataIndex: 'code', key: 'code', title: '物料编码', width: 150 },
  { dataIndex: 'name', key: 'name', title: '物料名称', width: 180 },
  { dataIndex: 'specification', key: 'specification', title: '规格型号', width: 150 },
  { dataIndex: 'categoryCode', key: 'categoryCode', title: '分类编码', width: 140 },
  {
    dataIndex: 'unitMeasureCode',
    key: 'unitMeasureCode',
    title: '计量单位',
    width: 130,
  },
  { key: 'action', title: '预检动作', width: 100 },
  { key: 'message', title: '校验信息', width: 260 },
];
const receiptColumns = [
  { dataIndex: 'row_no', key: 'row_no', title: '行号', width: 64 },
  { dataIndex: 'code', key: 'code', title: '物料编码', width: 150 },
  { dataIndex: 'name', key: 'name', title: '物料名称', width: 180 },
  { key: 'action', title: '动作', width: 90 },
  { key: 'status', title: '结果', width: 90 },
  { dataIndex: 'target_item_id', key: 'target_item_id', title: '物料 ID', width: 100 },
  { key: 'error', title: '失败原因', width: 220 },
];
function isEditableColumn(key: unknown): key is FieldKey {
  return typeof key === 'string' && (FIELD_KEYS as readonly string[]).includes(key);
}
function rowMessages(row: EditableRow): RowMessage[] {
  return [
    ...(row.preflight?.blockers ?? []).map((item) => ({
      type: 'error' as const,
      text: item.message,
    })),
    ...(row.preflight?.warnings ?? []).map((item) => ({
      type: 'warning' as const,
      text: item.message,
    })),
    ...row.issues.map((text) => ({ type: 'warning' as const, text })),
  ];
}
function buildRows(data: MdmAgentImportApi.ImportState): EditableRow[] {
  const plannedRows = data.preflight?.rows ?? [];
  return (data.candidates ?? [])
    .map((candidate) => {
      const planned = plannedRows.find(
        (item) => item.candidate_id === candidate.candidate_id,
      );
      const values = candidate.values ?? {};
      return {
        candidate_id: candidate.candidate_id,
        row_no: planned?.row_no ?? candidate.evidence?.code?.row,
        code: values.code ?? '',
        name: values.name ?? '',
        specification: values.specification ?? '',
        categoryCode: values.categoryCode ?? '',
        unitMeasureCode: values.unitMeasureCode ?? '',
        original: { ...values },
        preflight: planned,
        issues: candidate.issues ?? [],
      };
    })
    .sort((a, b) => (a.row_no ?? 0) - (b.row_no ?? 0));
}
function applyState(payload: MdmAgentImportApi.PublicState) {
  state.value = payload.state;
  taskId.value = payload.state.task_id;
  lastError.value =
    payload.state.last_error || payload.interrupts?.[0]?.last_error || '';
  receipt.value = payload.state.receipt;
  rows.value = buildRows(payload.state);
  step.value = receipt.value ? 3 : 2;
  syncFooter();
}
function syncFooter() {
  void modalApi.setState({
    cancelText: step.value === 2 ? '取消任务' : '取消',
    confirmText:
      step.value === 1 ? '开始解析' : step.value === 2 ? '确认提交' : '完成',
    showCancelButton: step.value !== 3,
  });
}
function reset() {
  step.value = 1;
  file.value = undefined;
  fileName.value = '';
  processing.value = false;
  taskId.value = '';
  state.value = undefined;
  receipt.value = undefined;
  rows.value = [];
  lastError.value = '';
  sessionId = undefined;
  attachmentId = undefined;
}
function beforeUpload(fileInfo: FileType) {
  const raw = fileInfo as unknown as File;
  const name = raw.name.toLowerCase();
  if (!name.endsWith('.csv') && !name.endsWith('.xlsx')) {
    message.error('仅支持 CSV æˆ– XLSX æ–‡ä»¶');
    return false;
  }
  if (raw.size > MAX_BYTES) {
    message.error('文件不能超过 8 MiB');
    return false;
  }
  file.value = raw;
  fileName.value = raw.name;
  return false;
}
async function startImport() {
  if (!file.value) {
    message.warning('请先选择 CSV/XLSX æ–‡ä»¶');
    return;
  }
  processing.value = true;
  modalApi.lock();
  try {
    sessionId = await createImportSession();
    const blobs = await uploadFile([file.value]);
    const blobId = blobs?.[0]?.id;
    if (!blobId) {
      throw new Error('文件上传失败,未取得 blobId');
    }
    attachmentId = await bindImportAttachment(sessionId, blobId);
    const payload = await startMaterialImport(attachmentId);
    applyState(payload);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    processing.value = false;
    modalApi.unlock();
  }
}
async function revise() {
  if (!state.value) return;
  if (!hasChanges.value) {
    message.info('未检测到修改');
    return;
  }
  processing.value = true;
  modalApi.lock();
  try {
    const payload = await reviewMaterialImport(taskId.value, {
      action: 'revise',
      corrections: corrections.value,
      expected_revision: state.value.revision,
    });
    applyState(payload);
    message.success('已按修改重新预检,请核对结果');
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    processing.value = false;
    modalApi.unlock();
  }
}
async function confirmSubmit() {
  if (!state.value) return;
  if (hasChanges.value) {
    message.warning('存在未提交的修改,请先点「重新检查」并按新的预检结果确认');
    return;
  }
  if (!canCommit.value) {
    message.warning(lastError.value || '预检仍有阻断项,请修订后重新检查');
    return;
  }
  processing.value = true;
  modalApi.lock();
  try {
    const payload = await reviewMaterialImport(taskId.value, {
      action: 'confirm',
      expected_preflight_hash: state.value.preflight_hash,
      expected_revision: state.value.revision,
    });
    applyState(payload);
    if (receipt.value) {
      message.success('提交完成,请查看回执');
    } else if (lastError.value) {
      message.error(lastError.value);
    }
  } catch (error) {
    const text = agentImportErrorMessage(error);
    message.error(text);
    // æäº¤è¶…æ—¶/网关中断时命令可能已执行,提示用户查回执,避免重复提交
    lastError.value = text;
  } finally {
    processing.value = false;
    modalApi.unlock();
  }
}
async function fetchReceipt() {
  if (!taskId.value) return;
  processing.value = true;
  modalApi.lock();
  try {
    receipt.value = await getMaterialImportReceipt(taskId.value);
    step.value = 3;
    syncFooter();
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    processing.value = false;
    modalApi.unlock();
  }
}
async function handleCancel() {
  if (step.value === 2 && taskId.value) {
    try {
      await reviewMaterialImport(taskId.value, {
        action: 'cancel',
        expected_revision: state.value?.revision ?? 0,
      });
    } catch {
      // å–消失败不影响关闭窗口
    }
  }
  modalApi.close();
}
function finish() {
  if ((receipt.value?.summary.success ?? 0) > 0) {
    emit('success');
  }
  modalApi.close();
}
async function handlePrimary() {
  if (processing.value) return;
  if (step.value === 1) {
    await startImport();
    return;
  }
  if (step.value === 2) {
    await confirmSubmit();
    return;
  }
  finish();
}
const [Modal, modalApi] = useVbenModal({
  onCancel() {
    void handleCancel();
  },
  onClosed() {
    reset();
  },
  onConfirm() {
    void handlePrimary();
  },
  onOpenChange(isOpen) {
    if (isOpen) {
      reset();
      syncFooter();
    }
  },
});
</script>
<template>
  <Modal title="智能体导入物料" class="w-[1080px]">
    <Steps
      :current="step - 1"
      class="mx-4 mb-4"
      size="small"
      :items="[
        { title: '上传文件' },
        { title: '审核确认' },
        { title: '提交回执' },
      ]"
    />
    <div v-if="step === 1" class="mx-4">
      <Upload
        accept=".csv,.xlsx"
        :max-count="1"
        :show-upload-list="false"
        :before-upload="beforeUpload"
      >
        <Button type="primary">选择 CSV / XLSX æ–‡ä»¶</Button>
      </Upload>
      <div v-if="fileName" class="mt-2 text-sm text-gray-600">
        å·²é€‰æ‹©ï¼š{{ fileName }}
      </div>
      <div class="mt-3 text-xs text-gray-500 leading-relaxed">
        <div>文件上限 8 MiB,仅支持 CSV / XLSX。</div>
        <div>
          æµç¨‹ï¼šè§£æžæ¸…单 â†’ Java æ ¡éªŒåˆ†ç±»/单位/编码 â†’ äººå·¥æ ¸å¯¹ â†’ ç¡®è®¤æäº¤ã€‚
        </div>
        <div>
          æœ¬é˜¶æ®µåªæ–°å»ºç‰©æ–™ï¼šç¼–码已存在会跳过,不更新原物料,也不同步 MES。
        </div>
      </div>
    </div>
    <div v-else-if="step === 2" class="mx-4">
      <div class="mb-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-sm">
        <span>总计:{{ preflight?.summary.total ?? rows.length }}</span>
        <span class="text-green-600">
          æ–°å»ºï¼š{{ preflight?.summary.create ?? 0 }}
        </span>
        <span class="text-orange-500">
          è·³è¿‡ï¼š{{ preflight?.summary.skip ?? 0 }}
        </span>
        <span class="text-red-500">
          é˜»æ–­ï¼š{{ preflight?.summary.blocked ?? 0 }}
        </span>
        <span class="text-gray-500">方案版本:{{ state?.revision ?? 0 }}</span>
      </div>
      <Alert
        v-if="lastError"
        type="error"
        :message="lastError"
        show-icon
        class="mb-3"
      />
      <Alert
        v-if="issues.length > 0"
        type="warning"
        show-icon
        class="mb-3"
        message="存在需要处理的问题"
      >
        <template #description>
          <div v-for="(issue, index) in issues" :key="index">
            {{ issue.message }}
          </div>
        </template>
      </Alert>
      <Alert
        v-else-if="preflight && !preflight.can_commit"
        type="warning"
        show-icon
        class="mb-3"
        message="预检仍有阻断项,修正后请点「重新检查」,通过后才能确认提交"
      />
      <Table
        bordered
        size="small"
        :columns="reviewColumns"
        :data-source="rows"
        :pagination="false"
        :row-key="(record: EditableRow) => record.candidate_id"
        :scroll="{ y: 360 }"
      >
        <template #bodyCell="{ column, record }">
          <template v-if="isEditableColumn(column.key)">
            <Input
              v-model:value="record[column.key]"
              size="small"
              :status="
                record.preflight?.blockers?.some(
                  (item: MdmAgentImportApi.Issue) => item.field === column.key,
                )
                  ? 'error'
                  : undefined
              "
            />
            <div
              v-if="
                column.key === 'categoryCode' &&
                record.preflight?.resolved?.category_name
              "
              class="mt-1 text-xs text-gray-400"
            >
              è§£æžä¸ºï¼š{{ record.preflight.resolved.category_name }}
            </div>
            <div
              v-if="
                column.key === 'unitMeasureCode' &&
                record.preflight?.resolved?.unit_measure_name
              "
              class="mt-1 text-xs text-gray-400"
            >
              è§£æžä¸ºï¼š{{ record.preflight.resolved.unit_measure_name }}
            </div>
          </template>
          <template v-else-if="column.key === 'action'">
            <Tag
              v-if="record.preflight"
              :color="
                ACTION_COLOR[record.preflight.action as MdmAgentImportApi.RowAction]
              "
            >
              {{ ACTION_LABEL[record.preflight.action as MdmAgentImportApi.RowAction] }}
            </Tag>
          </template>
          <template v-else-if="column.key === 'message'">
            <div
              v-for="(item, index) in rowMessages(record as EditableRow)"
              :key="index"
              class="text-xs leading-relaxed"
              :class="item.type === 'error' ? 'text-red-500' : 'text-orange-500'"
            >
              {{ item.text }}
            </div>
          </template>
        </template>
      </Table>
      <div class="mt-3 flex flex-wrap items-center gap-2">
        <Button :disabled="processing || !hasChanges" @click="revise">
          é‡æ–°æ£€æŸ¥
        </Button>
        <Button :disabled="processing || !taskId" @click="fetchReceipt">
          æŸ¥è¯¢å›žæ‰§
        </Button>
        <span class="text-xs text-gray-400">
          {{ hasChanges ? '已修改,需重新检查后才能确认' : '确认后提交,已存在编码会跳过' }}
        </span>
      </div>
    </div>
    <div v-else class="mx-4">
      <div class="mb-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-sm">
        <Tag :color="receipt?.status === 'SUCCEEDED' ? 'green' : 'orange'">
          {{ receipt?.status ?? 'UNKNOWN' }}
        </Tag>
        <span>总计:{{ receipt?.summary.total ?? 0 }}</span>
        <span class="text-green-600">
          æˆåŠŸï¼š{{ receipt?.summary.success ?? 0 }}
        </span>
        <span class="text-orange-500">
          è·³è¿‡ï¼š{{ receipt?.summary.skipped ?? 0 }}
        </span>
        <span class="text-red-500">
          å¤±è´¥ï¼š{{ receipt?.summary.failed ?? 0 }}
        </span>
        <span class="text-gray-500">
          æœªçŸ¥ï¼š{{ receipt?.summary.unknown ?? 0 }}
        </span>
      </div>
      <Alert
        v-if="receipt?.error_message"
        type="error"
        :message="receipt.error_message"
        show-icon
        class="mb-3"
      />
      <Table
        bordered
        size="small"
        :columns="receiptColumns"
        :data-source="receipt?.rows ?? []"
        :pagination="false"
        :row-key="(record: MdmAgentImportApi.ReceiptRow) => record.candidate_id"
        :scroll="{ y: 360 }"
      >
        <template #bodyCell="{ column, record }">
          <template v-if="column.key === 'action'">
            <Tag :color="record.action === 'CREATE' ? 'green' : 'orange'">
              {{ record.action === 'CREATE' ? '新建' : '跳过' }}
            </Tag>
          </template>
          <template v-else-if="column.key === 'status'">
            <Tag :color="STATUS_COLOR[record.status as MdmAgentImportApi.RowStatus]">
              {{ STATUS_LABEL[record.status as MdmAgentImportApi.RowStatus] }}
            </Tag>
          </template>
          <template v-else-if="column.key === 'error'">
            <span class="text-xs text-red-500">
              {{ record.error_message ?? '' }}
            </span>
          </template>
        </template>
      </Table>
    </div>
  </Modal>
</template>
src/views/crm/saleQuotation/index.vue
@@ -19,7 +19,9 @@
import type { CrmSaleQuotationAiApi } from '#/api/crm/saleQuotation/ai';
import { nextTick, ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
import { isAiEnable } from '@vben/hooks';
import { QUOTATION_STATUS, useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
@@ -119,6 +121,27 @@
  });
}
/** å·¥å…·æ æŒ‰é’®ï¼šè¯†åˆ«å½•入受 AI æ€»å¼€å…³æŽ§åˆ¶ */
const toolbarActions = computed(() => [
  {
    label: $t('ui.actionTitle.create', ['报价单']),
    type: 'primary' as const,
    icon: ACTION_ICON.ADD,
    auth: ['crm:sale-quotation:create'],
    onClick: handleCreate,
  },
  ...(isAiEnable()
    ? [
        {
          label: '识别录入',
          type: 'default' as const,
          auth: ['crm:sale-quotation:create'],
          onClick: handleOcrCreate,
        },
      ]
    : []),
]);
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
@@ -156,23 +179,7 @@
    <OcrUpload ref="ocrUploadRef" @success="handleOcrSuccess" />
    <Grid>
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['报价单']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['crm:sale-quotation:create'],
              onClick: handleCreate,
            },
            {
              label: '识别录入',
              type: 'default',
              auth: ['crm:sale-quotation:create'],
              onClick: handleOcrCreate,
            },
          ]"
        />
        <TableAction :actions="toolbarActions" />
      </template>
      <template #name="{ row }">
        <Button type="link" @click="handleDetail(row)">
src/views/dashboard/analytics/index.vue
@@ -3,6 +3,7 @@
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { isAiEnable } from '@vben/hooks';
import { Col, Row } from 'ant-design-vue';
@@ -83,7 +84,7 @@
    <!-- AI æ™ºèƒ½åˆ†æž -->
    <AiInsights
      v-if="canViewHome && canNavigate('MesProWorkOrder')"
      v-if="canViewHome && canNavigate('MesProWorkOrder') && isAiEnable()"
      :active-order-count="summary.workOrderActiveCount"
      @navigate="handleNavigate"
    />
src/views/erp/purchase/invoice/index.vue
@@ -4,10 +4,11 @@
import type { ErpPurchaseInvoiceAiApi } from '#/api/erp/purchase/invoice/ai';
import { Page, useVbenModal } from '@vben/common-ui';
import { isAiEnable } from '@vben/hooks';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tag } from 'ant-design-vue';
import { nextTick, ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -84,6 +85,34 @@
  }
}
/** å·¥å…·æ æŒ‰é’®ï¼šè¯†åˆ«å½•入受 AI æ€»å¼€å…³æŽ§åˆ¶ */
const toolbarActions = computed(() => [
  {
    label: $t('ui.actionTitle.create', ['来票']),
    type: 'primary' as const,
    icon: ACTION_ICON.ADD,
    auth: ['erp:purchase-invoice:create'],
    onClick: handleCreate,
  },
  ...(isAiEnable()
    ? [
        {
          label: '识别录入',
          type: 'primary' as const,
          auth: ['erp:purchase-invoice:create'],
          onClick: handleOcrCreate,
        },
      ]
    : []),
  {
    label: $t('ui.actionTitle.export'),
    type: 'primary' as const,
    icon: ACTION_ICON.DOWNLOAD,
    auth: ['erp:purchase-invoice:export'],
    onClick: handleExport,
  },
]);
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
@@ -121,30 +150,7 @@
    <OcrUpload ref="ocrUploadRef" @success="handleOcrSuccess" />
    <Grid table-title="来票列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['来票']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['erp:purchase-invoice:create'],
              onClick: handleCreate,
            },
            {
              label: '识别录入',
              type: 'primary',
              auth: ['erp:purchase-invoice:create'],
              onClick: handleOcrCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['erp:purchase-invoice:export'],
              onClick: handleExport,
            },
          ]"
        />
        <TableAction :actions="toolbarActions" />
      </template>
      <template #no="{ row }">
        <Button type="link" @click="handleDetail(row)">
src/views/hrm/attendance/record/modules/clock-panel.vue
@@ -1,4 +1,6 @@
<script lang="ts" setup>
import type { AttendanceClockLocation, AttendanceClockResponse } from '#/api/hrm/attendance/record';
import { ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
@@ -9,62 +11,124 @@
const emit = defineEmits(['success']);
const location = ref('公司前台');
const loading = ref(false);
const DEFAULT_LOCATION = '公司前台';
/** ä¸Šç­æ‰“卡 */
async function handleClockIn() {
const location = ref(DEFAULT_LOCATION);
const loading = ref(false);
const locating = ref(false);
/**
 * èŽ·å–æµè§ˆå™¨å®šä½å¹¶ç»Ÿä¸€ä¸º WGS84 äº¤ç»™åŽç«¯æ ¡éªŒã€‚
 * å®šä½ä¸å¯ç”¨ï¼ˆä¸æ”¯æŒã€æ‹’绝授权、非 HTTPS)时返回 null,
 * ç”±åŽç«¯æŒ‰è€ƒå‹¤è§„则决定是否允许无定位打卡。
 */
function getCurrentLocation(): Promise<AttendanceClockLocation | null> {
  if (!navigator.geolocation) {
    return Promise.resolve(null);
  }
  return new Promise((resolve) => {
    navigator.geolocation.getCurrentPosition(
      (position) =>
        resolve({
          longitude: Number(position.coords.longitude.toFixed(7)),
          latitude: Number(position.coords.latitude.toFixed(7)),
          accuracy: Number(position.coords.accuracy.toFixed(2)),
          coordinateSystem: 'WGS84',
        }),
      () => resolve(null),
      { enableHighAccuracy: true, maximumAge: 0, timeout: 15_000 },
    );
  });
}
/** æœªå¯ç”¨å®šä½èŒƒå›´æ ¡éªŒæ—¶åŽç«¯ä¸è¿”回距离,直接提示成功 */
function buildSuccessMessage(
  action: string,
  result?: AttendanceClockResponse,
) {
  if (result?.distanceMeters === null || result?.distanceMeters === undefined) {
    return `${action}成功`;
  }
  const place = result.locationName ? `距${result.locationName}` : '距打卡地点';
  return `${action}成功,${place} ${Number(result.distanceMeters).toFixed(1)} ç±³`;
}
async function submitClock(action: 'in' | 'out') {
  if (loading.value) {
    return;
  }
  loading.value = true;
  try {
    await clockIn({ location: location.value });
    message.success('上班打卡成功');
    locating.value = true;
    const position = await getCurrentLocation();
    locating.value = false;
    if (!position) {
      message.warning(
        '未获取到定位,将按打卡地点文本提交;若考勤规则要求定位,请开启定位权限后重试(非 HTTPS çŽ¯å¢ƒæµè§ˆå™¨ä¼šç¦ç”¨å®šä½ï¼‰',
      );
    }
    const locationText = location.value.trim() || undefined;
    const result =
      action === 'in'
        ? await clockIn(position, locationText)
        : await clockOut(position, locationText);
    message.success(
      buildSuccessMessage(action === 'in' ? '上班打卡' : '下班打卡', result),
    );
    emit('success');
  } catch (e: any) {
    message.error(e.message || '打卡失败');
  } catch (error: any) {
    // æŽ¥å£é”™è¯¯å·²ç”±è¯·æ±‚拦截器统一提示,这里只兜底本地异常,避免重复弹出
    const handledByInterceptor = Boolean(
      error?.response || error?.data || error?.isAxiosError || error?.code,
    );
    if (!handledByInterceptor) {
      message.error(error?.message || '打卡失败');
    }
  } finally {
    locating.value = false;
    loading.value = false;
  }
}
/** ä¸Šç­æ‰“卡 */
function handleClockIn() {
  return submitClock('in');
}
/** ä¸‹ç­æ‰“卡 */
async function handleClockOut() {
  loading.value = true;
  try {
    await clockOut({ location: location.value });
    message.success('下班打卡成功');
    emit('success');
  } catch (e: any) {
    message.error(e.message || '打卡失败');
  } finally {
    loading.value = false;
  }
function handleClockOut() {
  return submitClock('out');
}
const [Modal, modalApi] = useVbenModal({
const [Modal] = useVbenModal({
  onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      location.value = '公司前台';
      location.value = DEFAULT_LOCATION;
    }
  },
});
</script>
<template>
  <Modal title="打卡" class="w-[400px]">
  <Modal title="打卡" class="w-[420px]">
    <div class="p-4">
      <div class="mb-4">
        <label class="mr-2">打卡地点:</label>
        <a-input
          v-model:value="location"
          placeholder="请输入打卡地点"
          style="width: 200px"
          style="width: 220px"
        />
      </div>
      <div class="mb-4 text-xs text-gray-500">
        ç‚¹å‡»æ‰“卡后会自动获取当前 GPS ä½ç½®ï¼Œå¹¶æŒ‰è€ƒå‹¤è§„则中的地点与允许半径校验;浏览器定位不可用时,将按上方地点文本提交。
      </div>
      <div class="flex gap-4">
        <a-button
          type="primary"
          size="large"
          :loading="loading"
          :disabled="loading"
          @click="handleClockIn"
        >
          ä¸Šç­æ‰“卡
@@ -72,11 +136,15 @@
        <a-button
          size="large"
          :loading="loading"
          :disabled="loading"
          @click="handleClockOut"
        >
          ä¸‹ç­æ‰“卡
        </a-button>
      </div>
      <div v-if="locating" class="mt-2 text-xs text-gray-400">
        æ­£åœ¨èŽ·å–å®šä½â€¦
      </div>
    </div>
  </Modal>
</template>
</template>
src/views/hrm/attendance/rule/data.ts
@@ -106,6 +106,105 @@
      },
    },
    {
      fieldName: 'locationRequired',
      label: '定位打卡',
      component: 'Switch',
      defaultValue: false,
      componentProps: {
        checkedChildren: '启用',
        unCheckedChildren: '关闭',
      },
      help: '启用后,员工打卡必须在下方设定地点的允许半径内',
    },
    {
      fieldName: 'locationName',
      label: '打卡地点',
      component: 'Input',
      componentProps: {
        placeholder: '例如:新高电力厂区',
        maxlength: 100,
      },
      dependencies: {
        triggerFields: ['locationRequired'],
        show: (values) => Boolean(values.locationRequired),
      },
      rules: 'required',
    },
    {
      fieldName: 'locationAddress',
      label: '地点地址',
      component: 'Input',
      componentProps: {
        placeholder: '请输入详细地址,便于员工识别',
        maxlength: 255,
      },
      dependencies: {
        triggerFields: ['locationRequired'],
        show: (values) => Boolean(values.locationRequired),
      },
    },
    {
      fieldName: 'longitude',
      label: '经度(WGS84)',
      component: 'InputNumber',
      componentProps: {
        placeholder: '地图选点后自动填充',
        min: -180,
        max: 180,
        precision: 7,
        style: { width: '100%' },
      },
      dependencies: {
        triggerFields: ['locationRequired'],
        show: (values) => Boolean(values.locationRequired),
      },
      rules: 'required',
    },
    {
      fieldName: 'latitude',
      label: '纬度(WGS84)',
      component: 'InputNumber',
      componentProps: {
        placeholder: '地图选点后自动填充',
        min: -90,
        max: 90,
        precision: 7,
        style: { width: '100%' },
      },
      dependencies: {
        triggerFields: ['locationRequired'],
        show: (values) => Boolean(values.locationRequired),
      },
      rules: 'required',
    },
    {
      fieldName: 'radiusMeters',
      label: '打卡半径(ç±³)',
      component: 'InputNumber',
      defaultValue: 10,
      componentProps: {
        min: 10,
        max: 5000,
        precision: 0,
        style: { width: '100%' },
      },
      help: '允许范围 10~5000 ç±³ï¼Œå®žé™…精度取决于手机 GPS ä¸ŽçŽ¯å¢ƒ',
      dependencies: {
        triggerFields: ['locationRequired'],
        show: (values) => Boolean(values.locationRequired),
      },
      rules: 'required',
    },
    {
      fieldName: 'coordinateSystem',
      component: 'Input',
      defaultValue: 'WGS84',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'RadioGroup',
@@ -186,6 +285,15 @@
    { field: 'earlyAllowMinutes', title: '早退允许(分钟)', width: 120 },
    { field: 'overtimeThreshold', title: '加班起算(小时)', width: 120 },
    {
      field: 'locationName',
      title: '打卡范围',
      minWidth: 180,
      formatter: ({ row }) =>
        row.locationRequired
          ? `${row.locationName || '-'}(${row.radiusMeters ?? 0}米)`
          : '不限制位置',
    },
    {
      field: 'status',
      title: '状态',
      width: 80,
src/views/hrm/attendance/rule/modules/form.vue
@@ -13,11 +13,21 @@
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import LocationMapPicker from './location-map-picker.vue';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
/** åœ°å›¾é€‰æ‹©å™¨æ‰€éœ€çš„定位状态,随表单值变化同步 */
const locationRequired = ref(false);
const longitude = ref<number>();
const latitude = ref<number>();
const radiusMeters = ref(10);
const isDetail = computed(() => formType.value === 'detail');
const modalWidth = computed(() =>
  locationRequired.value ? 'w-[1000px]' : 'w-[800px]',
);
const getTitle = computed(() => {
  if (formType.value === 'detail') {
    return '查看考勤规则';
@@ -27,6 +37,43 @@
  }
  return '新增考勤规则';
});
function toNumber(value: unknown): number | undefined {
  return value === null || value === undefined || value === ''
    ? undefined
    : Number(value);
}
/** è¡¨å•值变化时同步定位状态,用于控制地图区域与弹窗宽度 */
function syncLocationState(
  values: Partial<HrmAttendanceRuleApi.AttendanceRule>,
) {
  locationRequired.value = Boolean(values.locationRequired);
  longitude.value = toNumber(values.longitude);
  latitude.value = toNumber(values.latitude);
  radiusMeters.value = toNumber(values.radiusMeters) ?? 10;
}
/** åœ°å›¾é€‰ç‚¹åŽå›žå¡«ç»çº¬åº¦ï¼Œå¹¶å°½é‡è¡¥å…¨åœ°ç‚¹åç§°ä¸Žåœ°å€ */
async function handleMapSelect(location: {
  address?: string;
  latitude: number;
  locationName?: string;
  longitude: number;
}) {
  const currentValues = await formApi.getValues();
  longitude.value = location.longitude;
  latitude.value = location.latitude;
  await formApi.setFieldValue('longitude', location.longitude);
  await formApi.setFieldValue('latitude', location.latitude);
  await formApi.setFieldValue('coordinateSystem', 'WGS84');
  if (location.address) {
    await formApi.setFieldValue('locationAddress', location.address);
  }
  if (!currentValues.locationName && location.locationName) {
    await formApi.setFieldValue('locationName', location.locationName);
  }
}
const [Form, formApi] = useVbenForm({
  commonConfig: {
@@ -40,6 +87,9 @@
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
  handleValuesChange: (values) => {
    syncLocationState(values);
  },
});
const [Modal, modalApi] = useVbenModal({
@@ -54,6 +104,10 @@
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as HrmAttendanceRuleApi.AttendanceRule;
    // åŽç«¯ä»…接受 WGS84,且启用定位时要求坐标系必填
    if (data.locationRequired) {
      data.coordinateSystem = 'WGS84';
    }
    try {
      if (formType.value === 'update') {
        await updateAttendanceRule(data);
@@ -73,15 +127,26 @@
    }
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    locationRequired.value = false;
    longitude.value = undefined;
    latitude.value = undefined;
    radiusMeters.value = 10;
    formApi.setState({ schema: useFormSchema(data.formType) });
    formApi.setDisabled(data.formType === 'detail');
    modalApi.setState({ showConfirmButton: data.formType !== 'detail' });
    if (!data?.id) {
      await formApi.setValues({
        coordinateSystem: 'WGS84',
        locationRequired: false,
        radiusMeters: 10,
      });
      return;
    }
    modalApi.lock();
    try {
      await formApi.setValues(await getAttendanceRule(data.id));
      const rule = await getAttendanceRule(data.id);
      await formApi.setValues(rule);
      syncLocationState(rule);
    } finally {
      modalApi.unlock();
    }
@@ -90,7 +155,17 @@
</script>
<template>
  <Modal :title="getTitle" class="w-[800px]">
  <Modal :title="getTitle" :class="modalWidth">
    <Form class="mx-4" />
    <section v-if="locationRequired" class="mx-4 mt-2">
      <div class="mb-2 text-sm font-medium">地图选择打卡范围</div>
      <LocationMapPicker
        :disabled="isDetail"
        :latitude="latitude"
        :longitude="longitude"
        :radius-meters="radiusMeters"
        @select="handleMapSelect"
      />
    </section>
  </Modal>
</template>
</template>
src/views/hrm/attendance/rule/modules/location-map-picker.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,406 @@
<!-- è€ƒå‹¤åœ°ç‚¹åœ°å›¾é€‰æ‹©å™¨ï¼šç™¾åº¦åœ°å›¾å±•示,对外统一输出 WGS84 åæ ‡ -->
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Alert, Button, Input, Spin, message } from 'ant-design-vue';
import { loadBaiduMapSdk } from '#/components/map';
/** å¯¹å¤–输出:WGS84 åæ ‡ + åæŸ¥åˆ°çš„地址/地点名 */
interface LocationSelection {
  address?: string;
  latitude: number;
  locationName?: string;
  longitude: number;
}
interface Props {
  disabled?: boolean;
  latitude?: number;
  longitude?: number;
  radiusMeters?: number;
}
const props = withDefaults(defineProps<Props>(), {
  disabled: false,
  latitude: undefined,
  longitude: undefined,
  radiusMeters: 10,
});
const emit = defineEmits<{
  select: [location: LocationSelection];
}>();
/** é»˜è®¤ä¸­å¿ƒç‚¹ï¼šæ–°ç–†ä¹Œé²æœ¨é½ï¼Œæœªé…ç½®åæ ‡æ—¶ä½¿ç”¨ */
const DEFAULT_CENTER: [number, number] = [87.625, 43.835];
const PI = Math.PI;
const X_PI = (PI * 3000) / 180;
const EARTH_SEMI_MAJOR_AXIS = 6_378_245;
const EARTH_ECCENTRICITY_SQUARED = 0.006_693_421_622_965_943;
/* eslint-disable @typescript-eslint/no-explicit-any */
type AnyMap = any;
const mapContainer = ref<HTMLElement>();
const loading = ref(false);
const searchKeyword = ref('');
const selectedAddress = ref('');
let map: AnyMap;
let marker: AnyMap;
let rangeCircle: AnyMap;
let geocoder: AnyMap;
const baiduMapKey = String(import.meta.env.VITE_BAIDU_MAP_KEY ?? '').trim();
const hasMapConfig = computed(() => Boolean(baiduMapKey));
function outOfChina(longitude: number, latitude: number) {
  return (
    longitude < 72.004 ||
    longitude > 137.8347 ||
    latitude < 0.8293 ||
    latitude > 55.8271
  );
}
function transformLatitude(longitude: number, latitude: number) {
  let result =
    -100 +
    2 * longitude +
    3 * latitude +
    0.2 * latitude * latitude +
    0.1 * longitude * latitude +
    0.2 * Math.sqrt(Math.abs(longitude));
  result +=
    ((20 * Math.sin(6 * longitude * PI) + 20 * Math.sin(2 * longitude * PI)) * 2) / 3;
  result +=
    ((20 * Math.sin(latitude * PI) + 40 * Math.sin((latitude / 3) * PI)) * 2) / 3;
  result +=
    ((160 * Math.sin((latitude / 12) * PI) +
      320 * Math.sin((latitude * PI) / 30)) *
      2) /
    3;
  return result;
}
function transformLongitude(longitude: number, latitude: number) {
  let result =
    300 +
    longitude +
    2 * latitude +
    0.1 * longitude * longitude +
    0.1 * longitude * latitude +
    0.1 * Math.sqrt(Math.abs(longitude));
  result +=
    ((20 * Math.sin(6 * longitude * PI) + 20 * Math.sin(2 * longitude * PI)) * 2) / 3;
  result +=
    ((20 * Math.sin(longitude * PI) + 40 * Math.sin((longitude / 3) * PI)) * 2) / 3;
  result +=
    ((150 * Math.sin((longitude / 12) * PI) +
      300 * Math.sin((longitude / 30) * PI)) *
      2) /
    3;
  return result;
}
/** WGS84 â†’ GCJ02 */
function wgs84ToGcj02(longitude: number, latitude: number): [number, number] {
  if (outOfChina(longitude, latitude)) {
    return [longitude, latitude];
  }
  let deltaLatitude = transformLatitude(longitude - 105, latitude - 35);
  let deltaLongitude = transformLongitude(longitude - 105, latitude - 35);
  const radianLatitude = (latitude / 180) * PI;
  let magic = Math.sin(radianLatitude);
  magic = 1 - EARTH_ECCENTRICITY_SQUARED * magic * magic;
  const sqrtMagic = Math.sqrt(magic);
  deltaLatitude =
    (deltaLatitude * 180) /
    (((EARTH_SEMI_MAJOR_AXIS * (1 - EARTH_ECCENTRICITY_SQUARED)) /
      (magic * sqrtMagic)) *
      PI);
  deltaLongitude =
    (deltaLongitude * 180) /
    ((EARTH_SEMI_MAJOR_AXIS / sqrtMagic) * Math.cos(radianLatitude) * PI);
  return [longitude + deltaLongitude, latitude + deltaLatitude];
}
/** GCJ02 â†’ WGS84,迭代逼近,误差可控制在 0.5 ç±³å†… */
function gcj02ToWgs84(longitude: number, latitude: number): [number, number] {
  if (outOfChina(longitude, latitude)) {
    return [longitude, latitude];
  }
  let wgsLongitude = longitude;
  let wgsLatitude = latitude;
  for (let index = 0; index < 5; index++) {
    const [gcjLongitude, gcjLatitude] = wgs84ToGcj02(wgsLongitude, wgsLatitude);
    wgsLongitude += longitude - gcjLongitude;
    wgsLatitude += latitude - gcjLatitude;
  }
  return [wgsLongitude, wgsLatitude];
}
/** GCJ02 â†’ BD09 */
function gcj02ToBd09(longitude: number, latitude: number): [number, number] {
  const z =
    Math.sqrt(longitude * longitude + latitude * latitude) +
    0.000_02 * Math.sin(latitude * X_PI);
  const theta =
    Math.atan2(latitude, longitude) + 0.000_003 * Math.cos(longitude * X_PI);
  return [z * Math.cos(theta) + 0.0065, z * Math.sin(theta) + 0.006];
}
/** BD09 â†’ GCJ02 */
function bd09ToGcj02(longitude: number, latitude: number): [number, number] {
  const x = longitude - 0.0065;
  const y = latitude - 0.006;
  const z = Math.sqrt(x * x + y * y) - 0.000_02 * Math.sin(y * X_PI);
  const theta = Math.atan2(y, x) - 0.000_003 * Math.cos(x * X_PI);
  return [z * Math.cos(theta), z * Math.sin(theta)];
}
function wgs84ToBd09(longitude: number, latitude: number): [number, number] {
  return gcj02ToBd09(...wgs84ToGcj02(longitude, latitude));
}
function bd09ToWgs84(longitude: number, latitude: number): [number, number] {
  return gcj02ToWgs84(...bd09ToGcj02(longitude, latitude));
}
function updateOverlay(
  longitude: number,
  latitude: number,
  centerMap = false,
) {
  if (!map || !window.BMapGL) {
    return;
  }
  const point = new window.BMapGL.Point(longitude, latitude);
  if (!marker) {
    marker = new window.BMapGL.Marker(point);
    map.addOverlay(marker);
  } else {
    marker.setPosition(point);
  }
  if (!rangeCircle) {
    rangeCircle = new window.BMapGL.Circle(point, props.radiusMeters, {
      fillColor: '#1677ff',
      fillOpacity: 0.16,
      strokeColor: '#1677ff',
      strokeOpacity: 0.8,
      strokeWeight: 2,
    });
    map.addOverlay(rangeCircle);
  } else {
    rangeCircle.setCenter(point);
    rangeCircle.setRadius(props.radiusMeters);
  }
  if (centerMap) {
    map.centerAndZoom(point, 19);
  }
}
function reverseGeocode(longitude: number, latitude: number) {
  return new Promise<{ address?: string; name?: string }>((resolve) => {
    if (!geocoder) {
      resolve({});
      return;
    }
    geocoder.getLocation(
      new window.BMapGL.Point(longitude, latitude),
      (result: any) => {
        const components = result?.addressComponents ?? {};
        const nearestPoi = result?.surroundingPois?.[0]?.title;
        resolve({
          address: result?.address,
          name:
            nearestPoi ||
            components.street ||
            components.district ||
            components.city,
        });
      },
    );
  });
}
/** åœ°å›¾åæ ‡ï¼ˆBD09)落点后,回传 WGS84 ç»™è¡¨å• */
async function selectBaiduLocation(
  longitude: number,
  latitude: number,
  centerMap = true,
) {
  updateOverlay(longitude, latitude, centerMap);
  const [wgsLongitude, wgsLatitude] = bd09ToWgs84(longitude, latitude);
  const location = await reverseGeocode(longitude, latitude);
  selectedAddress.value = location.address ?? '';
  emit('select', {
    address: location.address,
    latitude: Number(wgsLatitude.toFixed(7)),
    locationName: location.name,
    longitude: Number(wgsLongitude.toFixed(7)),
  });
}
async function handleSearch() {
  const keyword = searchKeyword.value.trim();
  if (!keyword || !geocoder) {
    return;
  }
  loading.value = true;
  try {
    const point = await new Promise<any>((resolve, reject) => {
      geocoder.getPoint(keyword, (result: any) => {
        result
          ? resolve(result)
          : reject(
              new Error('没有搜索到该地址,请输入更完整的省市区和地点名称'),
            );
      });
    });
    await selectBaiduLocation(point.lng, point.lat);
  } catch (error: any) {
    message.warning(error?.message || '地址搜索失败');
  } finally {
    loading.value = false;
  }
}
function handleCurrentLocation() {
  if (!navigator.geolocation) {
    message.warning('当前浏览器不支持定位');
    return;
  }
  loading.value = true;
  navigator.geolocation.getCurrentPosition(
    async (position) => {
      const [longitude, latitude] = wgs84ToBd09(
        position.coords.longitude,
        position.coords.latitude,
      );
      await selectBaiduLocation(longitude, latitude);
      loading.value = false;
    },
    () => {
      message.warning(
        '定位失败,请开启浏览器定位权限(非 localhost çŽ¯å¢ƒéœ€ä½¿ç”¨ HTTPS)',
      );
      loading.value = false;
    },
    { enableHighAccuracy: true, maximumAge: 0, timeout: 15_000 },
  );
}
async function initializeMap() {
  if (!hasMapConfig.value || !mapContainer.value) {
    return;
  }
  loading.value = true;
  try {
    await loadBaiduMapSdk(15_000);
    await nextTick();
    const hasCoordinates =
      Number.isFinite(props.longitude) && Number.isFinite(props.latitude);
    const center = hasCoordinates
      ? wgs84ToBd09(Number(props.longitude), Number(props.latitude))
      : DEFAULT_CENTER;
    map = new window.BMapGL.Map(mapContainer.value);
    map.centerAndZoom(
      new window.BMapGL.Point(center[0], center[1]),
      hasCoordinates ? 19 : 12,
    );
    map.enableScrollWheelZoom(true);
    map.addControl(new window.BMapGL.ZoomControl());
    map.addControl(new window.BMapGL.ScaleControl());
    geocoder = new window.BMapGL.Geocoder();
    map.addEventListener('click', (event: any) => {
      if (!props.disabled) {
        void selectBaiduLocation(event.latlng.lng, event.latlng.lat, false);
      }
    });
    if (hasCoordinates) {
      updateOverlay(center[0], center[1]);
      const location = await reverseGeocode(center[0], center[1]);
      selectedAddress.value = location.address ?? '';
    }
  } catch (error: any) {
    message.error(error?.message || '地图初始化失败');
  } finally {
    loading.value = false;
  }
}
watch(
  () => [props.longitude, props.latitude] as const,
  ([longitude, latitude]) => {
    if (
      !map ||
      !Number.isFinite(longitude) ||
      !Number.isFinite(latitude)
    ) {
      return;
    }
    const point = wgs84ToBd09(Number(longitude), Number(latitude));
    updateOverlay(point[0], point[1]);
  },
);
watch(
  () => props.radiusMeters,
  (radius) => {
    if (Number.isFinite(radius)) {
      rangeCircle?.setRadius(Number(radius));
    }
  },
);
onMounted(initializeMap);
onBeforeUnmount(() => {
  map?.clearOverlays?.();
  map?.destroy?.();
  map = undefined;
  marker = undefined;
  rangeCircle = undefined;
  geocoder = undefined;
});
</script>
<template>
  <Alert
    v-if="!hasMapConfig"
    type="warning"
    show-icon
    message="百度地图尚未配置"
    description="请配置 VITE_BAIDU_MAP_KEY åŽé‡æ–°æž„建前端;未配置时可手工填写经纬度。"
  />
  <Spin v-else :spinning="loading">
    <div v-if="!disabled" class="mb-2 flex gap-2">
      <Input
        v-model:value="searchKeyword"
        allow-clear
        placeholder="输入省、市、区和具体地点,例如:乌鲁木齐市厂区"
        @press-enter="handleSearch"
      />
      <Button :loading="loading" @click="handleSearch">搜索地址</Button>
      <Button @click="handleCurrentLocation" v-if="false">定位到当前位置</Button>
    </div>
    <div
      ref="mapContainer"
      class="h-[360px] w-full overflow-hidden rounded border border-gray-200"
    ></div>
    <div
      class="mt-2 flex flex-wrap justify-between gap-2 text-xs text-gray-500"
    >
      <span>
        {{
          disabled
            ? '打卡位置及允许范围'
            : '点击地图选择打卡中心点;蓝色圆圈表示允许打卡范围'
        }}
      </span>
      <span v-if="selectedAddress">{{ selectedAddress }}</span>
    </div>
  </Spin>
</template>
src/views/mes/md/item/components/select-dialog.vue
@@ -3,11 +3,11 @@
  import type { MesMdItemApi } from "#/api/mes/md/item";
  import type { MesMdItemTypeApi } from "#/api/mes/md/item/type";
  import { nextTick, ref } from "vue";
  import { computed, nextTick, ref } from "vue";
  import { CommonStatusEnum } from "@vben/constants";
  import { Card, message, Modal } from "ant-design-vue";
  import { Alert, Card, message, Modal, Tag } from "ant-design-vue";
  import { useVbenVxeGrid } from "#/adapter/vxe-table";
  import { getItemPage } from "#/api/mes/md/item";
@@ -27,6 +27,9 @@
  const selectedItemTypeId = ref<number>(); // å½“前筛选分类编号
  const preSelectedIds = ref<number[]>([]); // é¢„选物料编号列表
  const productIds = ref<number[]>([]); // é™å®šå¯é€‰çš„物料编号列表(为空则不限制)
  const limitedToProducts = computed(() => productIds.value.length > 0);
  const dialogTitle = computed(() => limitedToProducts.value ? "路线产出选择" : "物料产品选择");
  const tableTitle = computed(() => limitedToProducts.value ? "已配置的产出产品" : "物料产品列表");
  const typeTreeRef = ref<InstanceType<typeof MdItemTypeTree>>(); // ç‰©æ–™åˆ†ç±»æ ‘
  /** èŽ·å–å½“å‰è¡¨æ ¼æ•°æ® */
@@ -161,7 +164,7 @@
    },
    gridOptions: {
      columns: useItemSelectGridColumns(true),
      height: 560,
      height: '100%',
      keepSource: true,
      checkboxConfig: {
        highlight: true,
@@ -265,18 +268,29 @@
<template>
  <Modal v-model:open="open"
         title="物料产品选择"
         :title="dialogTitle"
         width="80%"
         :destroy-on-close="true"
         @ok="handleConfirm"
         @cancel="closeModal">
    <div class="flex h-full w-full">
      <Card class="mr-4 h-full w-1/5">
        <MdItemTypeTree ref="typeTreeRef"
                        @node-click="handleItemTypeNodeClick" />
      </Card>
      <div class="w-4/5">
        <Grid table-title="物料产品列表" />
    <div class="flex h-[calc(100vh-300px)] min-h-[380px] w-full flex-col">
      <Alert
        v-if="limitedToProducts"
        class="mb-3 shrink-0"
        message="仅可选择本产品在工艺路线中已配置的产出物料"
        description="如需增加可选项,请先在工艺路线对应工序中配置产出并保存。"
        show-icon
        type="info"
      >
        <template #action><Tag color="blue">{{ productIds.length }} ä¸ªäº§å‡º</Tag></template>
      </Alert>
      <div class="flex min-h-0 w-full flex-1 gap-4">
        <Card v-if="!limitedToProducts" class="h-full w-1/5 shrink-0" :body-style="{ height: '100%', overflow: 'hidden' }">
          <MdItemTypeTree ref="typeTreeRef" @node-click="handleItemTypeNodeClick" />
        </Card>
        <div class="h-full min-h-0 min-w-0 flex-1">
          <Grid class="h-full" :table-title="tableTitle" />
        </div>
      </div>
    </div>
  </Modal>
src/views/mes/pro/route/components/index.ts
@@ -1 +1,2 @@
export { default as RouteColorPicker } from './color-picker.vue';
export { default as RouteOutputAttribution } from './output-attribution.vue';
src/views/mes/pro/route/components/output-attribution.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,135 @@
<script lang="ts" setup>
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, Select } from 'ant-design-vue';
defineOptions({ name: 'RouteOutputAttribution' });
/** å½’属选择中代表“多产品通用”的哨兵值(产品编号均为正数) */
const GENERAL_PRODUCT = 0;
const props = withDefaults(
  defineProps<{
    disabled?: boolean;
    modelValue?: MesProRouteProcessApi.OutputItem[];
    products?: MesProRouteProductApi.RouteProduct[];
  }>(),
  {
    disabled: false,
    modelValue: () => [],
    products: () => [],
  },
);
const emit = defineEmits<{
  'update:modelValue': [value: MesProRouteProcessApi.OutputItem[]];
}>();
const rows = computed(() => props.modelValue ?? []);
const productOptions = computed(() =>
  props.products
    .filter((product) => product.itemId != null)
    .map((product) => ({
      label: product.itemName ?? product.itemCode ?? String(product.itemId),
      value: product.itemId!,
    })),
);
const attributionOptions = computed(() => [
  { label: '通用(所有产品)', value: GENERAL_PRODUCT },
  ...productOptions.value,
]);
function commit(next: MesProRouteProcessApi.OutputItem[]) {
  emit('update:modelValue', next);
}
function updateRow(
  index: number,
  patch: Partial<MesProRouteProcessApi.OutputItem>,
) {
  commit(rows.value.map((row, i) => (i === index ? { ...row, ...patch } : row)));
}
/** é€‰æ‹©äº§å‡ºç‰©æ–™æ—¶ï¼Œé»˜è®¤å½’属到自身产品(半成品共用时再手动改成“通用”) */
function handleItemChange(index: number, itemId: number | undefined) {
  const current = rows.value[index];
  const patch: Partial<MesProRouteProcessApi.OutputItem> = { itemId };
  // åŽŸæ¥å½’å±žè‡ªèº«æ—¶ï¼Œåˆ‡æ¢äº§å‡ºç‰©æ–™ä¹Ÿè¦åŒæ­¥å½’å±žï¼Œé¿å…ç•™ä¸‹æ—§äº§å“ç¼–å·ã€‚
  if ((current?.productId == null || current.productId === current.itemId) && itemId != null) {
    patch.productId = itemId;
  }
  updateRow(index, patch);
}
function handleAttributionChange(index: number, value: number | undefined) {
  updateRow(index, {
    productId: value === GENERAL_PRODUCT || value == null ? undefined : value,
  });
}
function addRow() {
  commit([...rows.value, { itemId: undefined, productId: undefined }]);
}
function removeRow(index: number) {
  commit(rows.value.filter((_, i) => i !== index));
}
</script>
<template>
  <div class="w-full">
    <div class="flex flex-col gap-2">
      <div
        v-for="(row, index) in rows"
        :key="index"
        class="flex items-center gap-2"
      >
        <Select
          :disabled="disabled"
          :options="productOptions"
          :placeholder="'选择产出物料'"
          :value="row.itemId"
          class="min-w-0 flex-1"
          option-filter-prop="label"
          show-search
          @change="(value) => handleItemChange(index, value as number | undefined)"
        />
        <Select
          :disabled="disabled || row.itemId == null"
          :options="attributionOptions"
          :value="row.productId ?? GENERAL_PRODUCT"
          class="min-w-0 flex-1"
          @change="(value) => handleAttributionChange(index, value as number | undefined)"
        />
        <Button
          :aria-label="`删除第 ${index + 1} æ¡äº§å‡ºé…ç½®`"
          :disabled="disabled"
          danger
          size="small"
          type="text"
          @click="removeRow(index)"
        >
          <IconifyIcon class="size-4" icon="lucide:trash-2" />
        </Button>
      </div>
      <div v-if="rows.length === 0" class="text-sm text-gray-500">
        æš‚无产出;形成半成品或成品时添加
      </div>
    </div>
    <Button
      v-if="!disabled"
      class="mt-2"
      size="small"
      type="dashed"
      @click="addRow"
    >
      <IconifyIcon class="mr-1 size-4" icon="lucide:plus" />
      æ·»åŠ äº§å‡º
    </Button>
  </div>
</template>
src/views/mes/pro/route/data.ts
@@ -19,12 +19,15 @@
import { z } from '#/adapter/form';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getTemplatePage } from '#/api/mes/pro/processdesign/template';
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
import { getWarehouseAreaSimpleList } from '#/api/mes/wm/warehouse/area';
import { getWarehouseLocationSimpleList } from '#/api/mes/wm/warehouse/location';
import {
  MdItemSelect,
  MdProductBomSelect,
} from '#/views/mes/md/item/components';
import { RouteColorPicker } from './components';
import { RouteColorPicker, RouteOutputAttribution } from './components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'update';
@@ -167,7 +170,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDate',
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
@@ -181,6 +184,7 @@
/** å·¥è‰ºè·¯çº¿å·¥åºæ˜Žç»†è¡¨å• */
export function useRouteProcessFormSchema(
  processOptions: Array<{ label: string; value: number }>,
  products: MesProRouteProductApi.RouteProduct[] = [],
): VbenFormSchema[] {
  return [
    {
@@ -231,36 +235,38 @@
    },
    {
      fieldName: 'yieldRate',
      label: '收率',
      label: '工序收率',
      component: 'InputNumber',
      help: '产出量 Ã· æŠ•入量;未填写按 100% å‚与排产计算。',
      componentProps: {
        class: '!w-full',
        min: 0.0001,
        max: 1,
        step: 0.01,
        precision: 4,
        placeholder: '产出/投入,如 0.98',
        placeholder: '如 0.98(98%)',
      },
      rules: z.number().min(0.0001).max(1).nullish(),
    },
    {
      fieldName: 'outputs',
      label: '本工序产出',
      component: markRaw(RouteOutputAttribution),
      componentProps: {
        products,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'linkType',
      label: '与下道工序关系',
      component: 'Select',
      help: () =>
        h(
          'div',
          {
            style:
              'white-space: pre-line; max-width: 320px; line-height: 1.6;',
          },
          '0=开始-开始(SS):本工序开始后,下道工序才能开始(可重叠并行开工)\n1=结束-结束(FF):下道工序必须等到本工序结束才能结束(并行但同步完工)\n2=开始-结束(SF):本工序开始后,下道工序才能结束\n3=结束-开始(FS):本工序结束后,下道工序才能开始(默认值)',
        ),
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.MES_PRO_LINK_TYPE, 'number'),
        placeholder: '请选择',
      },
      rules: z.number().optional(),
      rules: z.number().default(3),
    },
    {
      fieldName: 'colorCode',
@@ -284,6 +290,16 @@
      componentProps: {
        checkedChildren: '是',
        unCheckedChildren: '否',
      },
      rules: z.boolean().default(false),
    },
    {
      fieldName: 'backflushFlag',
      label: '报工自动扣料',
      component: 'Switch',
      componentProps: {
        checkedChildren: '自动',
        unCheckedChildren: '手动',
      },
      rules: z.boolean().default(false),
    },
@@ -329,6 +345,25 @@
    { field: 'sort', title: '序号', width: 70, align: 'center', fixed: 'left' },
    { field: 'processCode', title: '工序编码', width: 140, fixed: 'left' },
    { field: 'processName', title: '工序名称', width: 140, fixed: 'left' },
    {
      field: 'outputItemNames',
      title: '本工序产出',
      minWidth: 240,
      formatter: ({ cellValue, row }) => {
        const outputs = row.outputs ?? [];
        if (!outputs.length) {
          return cellValue ?? '';
        }
        return outputs
          .map((output) => {
            const name = output.itemName ?? output.itemCode ?? output.itemId;
            return output.productId == null
              ? `${name}(通用)`
              : `${name}(${output.productName ?? output.productId})`;
          })
          .join('、');
      },
    },
    { field: 'nextProcessName', title: '下一道工序', width: 140 },
    {
      field: 'linkType',
@@ -357,6 +392,15 @@
        props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
      },
    },
    {
      field: 'backflushFlag',
      title: '自动扣料',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
      },
    },
    { field: 'prepareTime', title: '准备时间(分)', width: 110 },
    { field: 'waitTime', title: '等待时间(分)', width: 110 },
    {
@@ -378,11 +422,14 @@
/** å·¥è‰ºè·¯çº¿äº§å“åˆ—表字段 */
export function useRouteProductGridColumns(): VxeTableGridOptions<MesProRouteProductApi.RouteProduct>['columns'] {
  return [
    { field: 'itemCode', title: '产品物料编码', width: 150 },
    { field: 'itemName', title: '产品物料名称', width: 150 },
    { field: 'itemCode', title: '产出编码', width: 150 },
    { field: 'itemName', title: '产出名称', width: 150 },
    { field: 'specification', title: '规格型号', width: 150 },
    { field: 'unitName', title: '单位', width: 80 },
    { field: 'quantity', title: '生产数量', width: 100 },
    { field: 'warehouseName', title: '报工入库仓', width: 140 },
    { field: 'locationName', title: '入库库区', width: 120 },
    { field: 'areaName', title: '入库库位', width: 120 },
    {
      field: 'productionTime',
      title: '生产用时',
@@ -402,8 +449,8 @@
/** å·¥è‰ºè·¯çº¿äº§å“ BOM åˆ—表字段 */
export function useRouteProductBomGridColumns(): VxeTableGridOptions<MesProRouteProductBomApi.RouteProductBom>['columns'] {
  return [
    { field: 'itemCode', title: 'BOM ç‰©æ–™ç¼–码', width: 150 },
    { field: 'itemName', title: 'BOM ç‰©æ–™åç§°', width: 150 },
    { field: 'itemCode', title: '投料编码', width: 150 },
    { field: 'itemName', title: '投料名称', width: 150 },
    { field: 'specification', title: '规格型号', width: 150 },
    { field: 'unitName', title: '单位', width: 80 },
    { field: 'quantity', title: '用料比例', width: 100 },
@@ -420,6 +467,7 @@
/** å·¥è‰ºè·¯çº¿äº§å“è¡¨å• */
export function useRouteProductFormSchema(
  onItemChange?: (item: any) => void,
  getFormApi?: () => VbenFormApi,
): VbenFormSchema[] {
  return [
    {
@@ -435,11 +483,12 @@
    // äº§å“ç‰©æ–™ï¼šä½¿ç”¨ä¸šåŠ¡è‡ªå®šä¹‰é€‰æ‹©å™¨ï¼Œchange å›žå¡«ç¼–码/名称/规格/单位
    {
      fieldName: 'itemId',
      label: '产品',
      label: '产出物料',
      component: markRaw(MdItemSelect),
      componentProps: {
        onChange: onItemChange,
      },
      help: '可选择主数据类型为成品或半成品的物料,也兼容 MES æˆå“/半成品分类;已有路线产品可继续修改仓位。',
      formItemClass: 'col-span-2',
      rules: 'selectRequired',
    },
@@ -455,6 +504,63 @@
      rules: z.number().default(1),
    },
    {
      fieldName: 'warehouseId',
      label: '报工仓库',
      component: 'ApiSelect',
      componentProps: {
        api: getWarehouseSimpleList,
        labelField: 'name',
        valueField: 'id',
        placeholder: '请选择报工产出仓库',
        onChange: async () => {
          await getFormApi?.().setFieldValue('locationId', undefined);
          await getFormApi?.().setFieldValue('areaId', undefined);
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'locationId',
      label: '报工库区',
      component: 'Select',
      dependencies: {
        triggerFields: ['warehouseId'],
        disabled: (values) => !values.warehouseId,
        async componentProps(values) {
          const options = values.warehouseId
            ? await getWarehouseLocationSimpleList(values.warehouseId)
            : [];
          return {
            fieldNames: { label: 'name', value: 'id' },
            options,
            placeholder: '请选择报工产出库区',
            onChange: async () => getFormApi?.().setFieldValue('areaId', undefined),
          };
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'areaId',
      label: '报工库位',
      component: 'Select',
      dependencies: {
        triggerFields: ['locationId'],
        disabled: (values) => !values.locationId,
        async componentProps(values) {
          const options = values.locationId
            ? await getWarehouseAreaSimpleList(values.locationId)
            : [];
          return {
            fieldNames: { label: 'name', value: 'id' },
            options,
            placeholder: '请选择报工产出库位',
          };
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'productionTime',
      label: '生产用时',
      component: 'InputNumber',
src/views/mes/pro/route/index.vue
@@ -8,25 +8,33 @@
import { getDictLabel } from '@vben/hooks';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tooltip } from 'ant-design-vue';
import { Alert, Button, message, Tooltip } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteRoute,
  exportRoute,
  getRoutePage,
  importRoute,
  importRouteTemplate,
  updateRouteStatus,
} from '#/api/mes/pro/route';
import ImportModalComponent from '#/components/import-modal';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import Form from './modules/route-configurator.vue';
const { hasAccessByCodes } = useAccess();
const statusEditable = hasAccessByCodes(['mes:pro-route:update']); // æ˜¯å¦å¯åˆ‡æ¢çŠ¶æ€
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportModalComponent,
  destroyOnClose: true,
});
@@ -38,6 +46,18 @@
/** åˆ›å»ºå·¥è‰ºè·¯çº¿ */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** å¯¼å…¥å·¥è‰ºè·¯çº¿ */
function handleImport() {
  importModalApi
    .setData({
      title: $t('ui.actionTitle.import', ['工艺路线']),
      fileName: '工艺路线导入模板.xls',
      importFn: importRoute,
      templateFn: importRouteTemplate,
    })
    .open();
}
/** ç¼–辑工艺路线(仅停用状态可编辑) */
@@ -57,7 +77,9 @@
): Promise<boolean | undefined> {
  try {
    await confirm(
      `确认要将"${row.name}"工艺路线切换为【${getDictLabel(DICT_TYPE.COMMON_STATUS, newStatus)}】吗?`,
      newStatus === CommonStatusEnum.ENABLE
        ? `确认启用“${row.name}”吗?系统将检查产出入库位置、工序产出和工序投料是否完整。`
        : `确认要将“${row.name}”工艺路线切换为【${getDictLabel(DICT_TYPE.COMMON_STATUS, newStatus)}】吗?`,
    );
  } catch {
    return false;
@@ -118,6 +140,14 @@
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <Alert
      class="mb-3"
      message="路线配置顺序"
      description="基本信息 â†’ äº§å‡ºäº§å“åŠå…¥åº“仓 â†’ å·¥åºåŠå„工序产出 â†’ å·¥åºæŠ•æ–™ â†’ æ ¡éªŒå¯ç”¨"
      show-icon
      type="info"
    />
    <Grid table-title="工艺路线列表">
      <template #toolbar-tools>
        <TableAction
@@ -130,6 +160,12 @@
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.import', ['工艺路线']),
              icon: ACTION_ICON.UPLOAD,
              auth: ['mes:pro-route:import'],
              onClick: handleImport,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
src/views/mes/pro/route/modules/material-input-list.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,145 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
import type { MesProRouteProductBomApi } from '#/api/mes/pro/route/productbom';
import { computed, ref, watch } from 'vue';
import { Alert, Button, Empty, Tag } from 'ant-design-vue';
import { getRouteProductBomList } from '#/api/mes/pro/route/productbom';
import ProductBomList from './product-bom-list.vue';
const props = defineProps<{
  formType: FormType;
  processes: MesProRouteProcessApi.RouteProcess[];
  products: MesProRouteProductApi.RouteProduct[];
  routeId: number;
}>();
const emit = defineEmits<{
  change: [list: MesProRouteProductBomApi.RouteProductBom[]];
}>();
const activeProductId = ref<number>();
const allBomList = ref<MesProRouteProductBomApi.RouteProductBom[]>([]);
const activeProduct = computed(() =>
  props.products.find((item) => item.itemId === activeProductId.value),
);
function materialCount(productId?: number) {
  if (!productId) return 0;
  return allBomList.value.filter((item) => item.productId === productId).length;
}
async function loadAll() {
  allBomList.value = (await getRouteProductBomList({ routeId: props.routeId })) || [];
  emit('change', allBomList.value);
}
watch(
  () => props.products.map((item) => item.itemId).filter(Boolean),
  (productIds) => {
    if (!productIds.length) {
      activeProductId.value = undefined;
      return;
    }
    if (!activeProductId.value || !productIds.includes(activeProductId.value)) {
      activeProductId.value = productIds[0];
    }
  },
  { immediate: true },
);
watch(
  () => props.routeId,
  (value) => value && loadAll(),
  { immediate: true },
);
</script>
<template>
  <Alert
    class="mb-4"
    message="按产出产品配置每道工序需要投入的物料"
    description="先选择产出产品,再切换到具体工序添加投料物料和用料比例。开启“报工自动扣料”的工序,会在报工时按这里的配置自动扣减库存。"
    show-icon
    type="info"
  />
  <Empty
    v-if="!products.length || !processes.length"
    :description="!products.length ? '请先完成产出产品配置' : '请先完成工序配置'"
  />
  <div v-else class="material-layout">
    <aside class="product-selector" aria-label="选择要配置投料的产出产品">
      <div class="selector-heading">
        <strong>产出产品</strong>
        <span>{{ products.length }} ä¸ª</span>
      </div>
      <Button
        v-for="product in products"
        :key="product.itemId"
        :class="['product-button', { active: product.itemId === activeProductId }]"
        type="text"
        @click="activeProductId = product.itemId"
      >
        <span class="product-copy">
          <strong>{{ product.itemName }}</strong>
          <small>{{ product.itemCode }}</small>
        </span>
        <Tag :color="materialCount(product.itemId) ? 'success' : 'warning'">
          {{ materialCount(product.itemId) ? `${materialCount(product.itemId)} æ¡` : '未配置' }}
        </Tag>
      </Button>
    </aside>
    <section v-if="activeProduct" class="material-detail">
      <header class="material-detail-header">
        <div>
          <h3>{{ activeProduct.itemName }} Â· å·¥åºæŠ•æ–™</h3>
          <p>依次切换工序,维护该产品在该工序实际消耗的物料。</p>
        </div>
        <Tag color="blue">{{ processes.length }} é“工序</Tag>
      </header>
      <ProductBomList
        :form-type="formType"
        :product-id="activeProduct.itemId!"
        :product-name="activeProduct.itemName"
        :route-id="routeId"
        @change="loadAll"
      />
    </section>
  </div>
</template>
<style scoped>
.material-layout { display: grid; grid-template-columns: minmax(210px, 260px) minmax(0, 1fr); gap: 18px; }
.product-selector { overflow: hidden; align-self: start; border: 1px solid rgb(226 232 240); border-radius: 12px; background: rgb(248 250 252); }
.selector-heading { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid rgb(226 232 240); color: rgb(15 23 42); }
.selector-heading span { color: rgb(100 116 139); font-size: 12px; }
.product-button { display: flex; width: 100%; height: auto; min-height: 62px; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border-radius: 0; text-align: left; }
.product-button + .product-button { border-top: 1px solid rgb(226 232 240); }
.product-button.active { background: rgb(219 234 254); color: rgb(29 78 216); }
.product-copy { display: grid; min-width: 0; gap: 2px; }
.product-copy strong, .product-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.product-copy small { color: rgb(100 116 139); }
.material-detail { min-width: 0; padding: 18px; border: 1px solid rgb(226 232 240); border-radius: 12px; }
.material-detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.material-detail-header h3 { margin: 0; color: rgb(15 23 42); font-size: 16px; font-weight: 650; }
.material-detail-header p { margin: 4px 0 0; color: rgb(100 116 139); font-size: 13px; }
:global(.dark) .product-selector, :global(.dark) .material-detail { border-color: rgb(51 65 85); background: rgb(15 23 42); }
:global(.dark) .selector-heading, :global(.dark) .product-button + .product-button { border-color: rgb(51 65 85); color: rgb(241 245 249); }
:global(.dark) .product-button.active { background: rgb(30 64 175 / 35%); color: rgb(147 197 253); }
:global(.dark) .material-detail-header h3 { color: rgb(241 245 249); }
@media (max-width: 900px) {
  .material-layout { grid-template-columns: 1fr; }
  .product-selector { display: flex; overflow-x: auto; }
  .selector-heading { display: none; }
  .product-button { min-width: 210px; border-top: 0 !important; border-right: 1px solid rgb(226 232 240); }
}
</style>
src/views/mes/pro/route/modules/process-form.vue
@@ -6,7 +6,7 @@
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { getProcessSimpleList } from '#/api/mes/pro/process';
@@ -15,6 +15,7 @@
  getRouteProcess,
  updateRouteProcess,
} from '#/api/mes/pro/route/process';
import { getRouteProductListByRoute } from '#/api/mes/pro/route/product';
import { $t } from '#/locales';
import { useRouteProcessFormSchema } from '../data';
@@ -31,7 +32,7 @@
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 150,
    labelWidth: 130,
  },
  layout: 'horizontal',
  schema: [],
@@ -40,13 +41,14 @@
});
/** åŠ è½½å·¥åºé€‰é¡¹åŽå†ç”Ÿæˆ schema(避免下拉空选项) */
async function loadSchema(): Promise<VbenFormSchema[]> {
async function loadSchema(routeId: number): Promise<VbenFormSchema[]> {
  const list = await getProcessSimpleList();
  const options = (list || []).map((item) => ({
    label: item.name ?? '',
    value: item.id!,
  }));
  return useRouteProcessFormSchema(options);
  const products = routeId ? await getRouteProductListByRoute(routeId) : [];
  return useRouteProcessFormSchema(options, products);
}
const [Modal, modalApi] = useVbenModal({
@@ -76,15 +78,6 @@
      formData.value = undefined;
      return;
    }
    modalApi.lock();
    try {
      // å·¥åºä¸‹æ‹‰ä¾èµ–远程数据,schema åœ¨å¼¹çª—打开时再生成
      const schema = await loadSchema();
      formApi.setState({ schema });
    } finally {
      modalApi.unlock();
    }
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{
      id?: number;
      maxSort?: number;
@@ -93,6 +86,15 @@
    if (!data) {
      return;
    }
    modalApi.lock();
    try {
      // å·¥åºä¸‹æ‹‰ä¾èµ–远程数据,schema åœ¨å¼¹çª—打开时再生成
      const schema = await loadSchema(data.routeId);
      formApi.setState({ schema });
    } finally {
      modalApi.unlock();
    }
    // åŠ è½½æ•°æ®
    if (!data.id) {
      // æ–°å¢žæ—¶ï¼Œé»˜è®¤åºå· = maxSort + 1,并给一个默认甘特图颜色
      await formApi.setValues({
@@ -116,6 +118,13 @@
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Alert
      class="mx-4 mb-4"
      message="按实际生产节点配置产出"
      description="中间工序没有独立产出时可留空;形成半成品时选择对应半成品,末道工序必须选择全部成品。归属表示该产出属于哪个产品:多产品共用的半成品选“通用”,产品专属(尤其是成品)归属到自身产品。如开启报工自动扣料,报工时会按工序投料配置扣减库存。"
      show-icon
      type="info"
    />
    <Form class="mx-4" />
  </Modal>
</template>
src/views/mes/pro/route/modules/process-list.vue
@@ -4,11 +4,11 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { message, Tag } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -25,8 +25,15 @@
  routeId: number;
}>();
const isEditable = ref(props.formType !== 'detail'); // æ˜¯å¦å¯ç¼–辑
const emit = defineEmits<{
  change: [list: MesProRouteProcessApi.RouteProcess[]];
}>();
const isEditable = computed(() => props.formType !== 'detail'); // æ˜¯å¦å¯ç¼–辑
const list = ref<MesProRouteProcessApi.RouteProcess[]>([]); // å·¥è‰ºè·¯çº¿å·¥åºåˆ—表
const sortedList = computed(() =>
  [...list.value].sort((a, b) => (a.sort || 0) - (b.sort || 0)),
);
const [ProcessFormModal, processFormModalApi] = useVbenModal({
  connectedComponent: ProcessForm,
@@ -53,6 +60,7 @@
  try {
    list.value = await getRouteProcessListByRoute(props.routeId);
    gridApi.setGridOptions({ data: list.value });
    emit('change', list.value);
  } finally {
    gridApi.setLoading(false);
  }
@@ -89,6 +97,16 @@
<template>
  <ProcessFormModal @success="getList" />
  <div v-if="sortedList.length" class="mb-4 flex flex-wrap items-center gap-2 rounded-lg bg-slate-50 p-3 dark:bg-slate-900">
    <template v-for="(item, index) in sortedList" :key="item.id">
      <div class="flex items-center gap-2 rounded-md border border-slate-200 bg-white px-3 py-2 dark:border-slate-700 dark:bg-slate-800">
        <span class="text-xs font-semibold text-blue-600">{{ item.sort }}</span>
        <span class="font-medium">{{ item.processName }}</span>
        <Tag v-if="index === sortedList.length - 1" color="blue">末道</Tag>
      </div>
      <span v-if="index < sortedList.length - 1" class="text-slate-400">→</span>
    </template>
  </div>
  <div v-if="isEditable" class="mb-3 flex items-center justify-start">
    <TableAction
      :actions="[
@@ -100,7 +118,7 @@
      ]"
    />
  </div>
  <Grid class="w-full" table-title="组成工序">
  <Grid class="w-full" table-title="工序顺序及各工序产出">
    <template #colorCode="{ row }">
      <div v-if="row.colorCode" class="flex items-center justify-center gap-1">
        <div
src/views/mes/pro/route/modules/product-bom-list.vue
@@ -1,9 +1,10 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
import type { MesProRouteProductBomApi } from '#/api/mes/pro/route/productbom';
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
@@ -21,10 +22,17 @@
import BomForm from './bom-form.vue';
const props = defineProps<{
  formType: FormType;
  productId: number;
  productName?: string;
  routeId: number;
}>();
const emit = defineEmits<{
  change: [];
}>();
const isEditable = computed(() => props.formType !== 'detail');
const processList = ref<MesProRouteProcessApi.RouteProcess[]>([]); // å·¥åºåˆ—表(用于 Tab)
const activeProcessId = ref<number>(); // å½“前选中的工序 Tab
@@ -112,6 +120,12 @@
  await deleteRouteProductBom(row.id!);
  message.success($t('ui.actionMessage.deleteSuccess', ['BOM ç‰©æ–™']));
  await getList();
  emit('change');
}
async function handleSaved() {
  await getList();
  emit('change');
}
watch(
@@ -126,7 +140,7 @@
</script>
<template>
  <BomFormModal @success="getList" />
  <BomFormModal @success="handleSaved" />
  <Tabs v-model:active-key="activeProcessId" @change="getList">
    <TabPane
      v-for="item in processList"
@@ -134,11 +148,11 @@
      :tab="item.processName"
    />
  </Tabs>
  <div class="mb-3 flex items-center justify-start">
  <div v-if="isEditable" class="mb-3 flex items-center justify-start">
    <TableAction
      :actions="[
        {
          label: $t('ui.actionTitle.create', ['BOM ç‰©æ–™']),
          label: $t('ui.actionTitle.create', ['投料物料']),
          type: 'primary',
          disabled: !activeProcessId,
          onClick: handleCreate,
@@ -146,21 +160,23 @@
      ]"
    />
  </div>
  <Grid class="w-full" table-title="产品 BOM">
  <Grid class="w-full" table-title="当前工序投料">
    <template #actions="{ row }">
      <TableAction
        :actions="[
          {
            label: $t('common.edit'),
            type: 'link',
            ifShow: () => isEditable,
            onClick: handleEdit.bind(null, row),
          },
          {
            label: $t('common.delete'),
            type: 'link',
            danger: true,
            ifShow: () => isEditable,
            popConfirm: {
              title: $t('ui.actionMessage.deleteConfirm', ['BOM ç‰©æ–™']),
              title: $t('ui.actionMessage.deleteConfirm', ['投料物料']),
              confirm: handleDelete.bind(null, row),
            },
          },
src/views/mes/pro/route/modules/product-form.vue
@@ -5,7 +5,7 @@
import { useVbenModal } from '@vben/common-ui';
import { Divider, message } from 'ant-design-vue';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
@@ -16,7 +16,6 @@
import { $t } from '#/locales';
import { useRouteProductFormSchema } from '../data';
import ProductBomList from './product-bom-list.vue';
const emit = defineEmits(['success']);
const formData = ref<MesProRouteProductApi.RouteProduct>();
@@ -46,7 +45,7 @@
    labelWidth: 100,
  },
  layout: 'horizontal',
  schema: useRouteProductFormSchema(handleItemChange),
  schema: useRouteProductFormSchema(handleItemChange, () => formApi),
  showDefaultActions: false,
  wrapperClass: 'grid-cols-2',
});
@@ -64,14 +63,10 @@
    try {
      if (formData.value?.id) {
        await updateRouteProduct(data);
        // ç”¨æœ€æ–°è¡¨å•值同步 formData,确保产品 BOM å­è¡¨ç»‘定的 itemId ä¸Žè¡¨å•一致
        formData.value = { ...formData.value, ...data };
      } else {
        // æ–°å¢žæˆåŠŸåŽåˆ‡æ¢åˆ°ç¼–è¾‘æ¨¡å¼ï¼Œæ–¹ä¾¿ç»§ç»­ç»´æŠ¤äº§å“ BOM
        const id = await createRouteProduct(data);
        formData.value = { ...data, id };
        await formApi.setFieldValue('id', id);
        await createRouteProduct(data);
      }
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
@@ -109,17 +104,13 @@
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Alert
      class="mx-4 mb-4"
      message="这里维护产出及其报工入库位置"
      description="投料物料将在路线配置的“工序投料”步骤中按产品、按工序统一维护。"
      show-icon
      type="info"
    />
    <Form class="mx-4" />
    <!-- ç¼–辑模式下展示产品 BOM å­è¡¨ï¼Œæ–°å¢žæ¨¡å¼ä¸‹éšè— -->
    <template v-if="formData?.id && formData?.itemId">
      <Divider class="!my-3" orientation="left">产品 BOM é…ç½®</Divider>
      <div class="mx-4">
        <ProductBomList
          :route-id="formData.routeId!"
          :product-id="formData.itemId"
          :product-name="formData.itemName"
        />
      </div>
    </template>
  </Modal>
</template>
src/views/mes/pro/route/modules/product-list.vue
@@ -4,7 +4,7 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
@@ -27,7 +27,11 @@
  routeId: number;
}>();
const isEditable = ref(props.formType !== 'detail'); // æ˜¯å¦å¯ç¼–辑
const emit = defineEmits<{
  change: [list: MesProRouteProductApi.RouteProduct[]];
}>();
const isEditable = computed(() => props.formType !== 'detail'); // æ˜¯å¦å¯ç¼–辑
const list = ref<MesProRouteProductApi.RouteProduct[]>([]); // å·¥è‰ºè·¯çº¿äº§å“åˆ—表
const [ProductFormModal, productFormModalApi] = useVbenModal({
@@ -55,6 +59,7 @@
  try {
    list.value = await getRouteProductListByRoute(props.routeId);
    gridApi.setGridOptions({ data: list.value });
    emit('change', list.value);
  } finally {
    gridApi.setLoading(false);
  }
@@ -94,14 +99,14 @@
    <TableAction
      :actions="[
        {
          label: $t('ui.actionTitle.create', ['关联产品']),
          label: $t('ui.actionTitle.create', ['产出产品']),
          type: 'primary',
          onClick: handleCreate,
        },
      ]"
    />
  </div>
  <Grid class="w-full" table-title="关联产品">
  <Grid class="w-full" table-title="产出产品及报工入库位置">
    <template #productionTime="{ row }">
      <span v-if="row.productionTime">
        {{ row.productionTime }}
src/views/mes/pro/route/modules/route-configurator.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,425 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { MesProRouteApi } from '#/api/mes/pro/route';
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
import type { MesProRouteProductBomApi } from '#/api/mes/pro/route/productbom';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Alert, Button, Empty, message, Steps, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createRoute, getRoute, updateRoute } from '#/api/mes/pro/route';
import { getRouteProcessListByRoute } from '#/api/mes/pro/route/process';
import { getRouteProductListByRoute } from '#/api/mes/pro/route/product';
import { getRouteProductBomList } from '#/api/mes/pro/route/productbom';
import { useFormSchema } from '../data';
import MaterialInputList from './material-input-list.vue';
import ProcessList from './process-list.vue';
import ProductList from './product-list.vue';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const formData = ref<MesProRouteApi.Route>();
const activeStep = ref(0);
const productList = ref<MesProRouteProductApi.RouteProduct[]>([]);
const processList = ref<MesProRouteProcessApi.RouteProcess[]>([]);
const bomList = ref<MesProRouteProductBomApi.RouteProductBom[]>([]);
const isDetail = computed(() => formType.value === 'detail');
const isNew = computed(() => !formData.value?.id);
const routeId = computed(() => formData.value?.id);
const sortedProcessList = computed(() =>
  [...processList.value].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)),
);
const title = computed(() =>
  isDetail.value ? '查看工艺路线' : isNew.value ? '新建工艺路线' : '编辑工艺路线',
);
const stepItems = [
  { title: '基本信息', description: '路线名称与说明' },
  { title: '产出与入库', description: '产品及报工仓位' },
  { title: '工序与产出', description: '顺序和半成品/成品' },
  { title: '工序投料', description: '按产品与工序配置' },
  { title: '启用前检查', description: '集中检查缺失项' },
];
const productsWithWarehouse = computed(() =>
  productList.value.filter((item) => item.warehouseId && item.locationId && item.areaId).length,
);
const processesWithOutput = computed(() =>
  processList.value.filter((item) => item.outputItemIds?.length).length,
);
const lastProcess = computed(() => sortedProcessList.value.at(-1));
const configuredProductIds = computed(
  () => new Set(bomList.value.map((item) => item.productId).filter(Boolean)),
);
const productsWithBom = computed(() => configuredProductIds.value.size);
const reviewIssues = computed(() => {
  const issues: Array<{ step: number; text: string }> = [];
  if (!productList.value.length) issues.push({ step: 1, text: '还没有配置产出产品' });
  const missingWarehouse = productList.value.filter(
    (item) => !item.warehouseId || !item.locationId || !item.areaId,
  );
  if (missingWarehouse.length) {
    issues.push({
      step: 1,
      text: `${missingWarehouse.length} ä¸ªäº§å‡ºäº§å“æœªé…ç½®å®Œæ•´ä»“库、库区和库位`,
    });
  }
  if (!processList.value.length) issues.push({ step: 2, text: '还没有配置生产工序' });
  if (processList.value.length && !processList.value.some((item) => item.keyFlag)) {
    issues.push({ step: 2, text: '未设置关键工序' });
  }
  if (lastProcess.value && !lastProcess.value.outputItemIds?.length) {
    issues.push({
      step: 2,
      text: `末道工序“${lastProcess.value.processName || lastProcess.value.processCode || lastProcess.value.sort}”必须配置成品产出`,
    });
  }
  const lastOutputs = lastProcess.value?.outputs ?? [];
  if (
    lastOutputs.length &&
    lastOutputs.some(
      (output) => output.productId == null || output.productId !== output.itemId,
    )
  ) {
    issues.push({
      step: 2,
      text: '末道工序的成品产出必须归属到自身产品,不能使用“通用”',
    });
  }
  const productIds = new Set(productList.value.map((item) => item.itemId));
  const invalidOutputCount = processList.value.filter((process) =>
    process.outputItemIds?.some((itemId) => !productIds.has(itemId)),
  ).length;
  if (invalidOutputCount) {
    issues.push({
      step: 2,
      text: `${invalidOutputCount} é“工序的产出未包含在路线产出产品中`,
    });
  }
  const noBomProducts = productList.value.filter(
    (item) => item.itemId && !configuredProductIds.value.has(item.itemId),
  );
  if (noBomProducts.length) {
    issues.push({ step: 3, text: `${noBomProducts.length} ä¸ªäº§å“è¿˜æ²¡æœ‰é…ç½®ä»»ä½•工序投料` });
  }
  return issues;
});
const primaryText = computed(() => {
  if (activeStep.value === stepItems.length - 1) return isDetail.value ? '关闭' : '完成配置';
  if (activeStep.value === 0 && isNew.value) return '保存并配置产出';
  return '下一步';
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 100,
  },
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
  wrapperClass: 'grid-cols-2',
});
async function loadConfiguration() {
  if (!routeId.value) return;
  const [products, processes, boms] = await Promise.all([
    getRouteProductListByRoute(routeId.value),
    getRouteProcessListByRoute(routeId.value),
    getRouteProductBomList({ routeId: routeId.value }),
  ]);
  productList.value = products || [];
  processList.value = processes || [];
  bomList.value = boms || [];
}
async function saveBaseInformation() {
  const { valid } = await formApi.validate();
  if (!valid) return false;
  modalApi.lock();
  try {
    const data = (await formApi.getValues()) as MesProRouteApi.Route;
    if (!routeId.value) {
      const id = await createRoute(data);
      formData.value = { ...data, id };
      await formApi.setFieldValue('id', id);
      formType.value = 'update';
      message.success('基本信息已保存,请继续配置产出产品和入库位置');
    } else {
      await updateRoute({ ...data, id: routeId.value });
      formData.value = { ...formData.value, ...data, id: routeId.value };
    }
    emit('success');
    return true;
  } finally {
    modalApi.unlock();
  }
}
function validateProducts() {
  if (!productList.value.length) {
    message.warning('请至少添加一个产出产品,再配置工序');
    return false;
  }
  if (productsWithWarehouse.value !== productList.value.length) {
    message.warning('请为每个产出产品配置完整的仓库、库区和库位');
    return false;
  }
  return true;
}
function validateProcesses() {
  if (!processList.value.length) {
    message.warning('请至少添加一道生产工序');
    return false;
  }
  return true;
}
function validateBom() {
  if (productsWithBom.value !== productList.value.length) {
    message.warning('每个产出产品至少要配置一条工序投料');
    return false;
  }
  return true;
}
async function goToStep(target: number) {
  if (target < 0 || target >= stepItems.length) return;
  if (target <= activeStep.value || isDetail.value) {
    activeStep.value = target;
    return;
  }
  if (!(await saveBaseInformation())) return;
  await loadConfiguration();
  if (target >= 2 && !validateProducts()) return void (activeStep.value = 1);
  if (target >= 3 && !validateProcesses()) return void (activeStep.value = 2);
  if (target >= 4 && !validateBom()) return void (activeStep.value = 3);
  activeStep.value = target;
}
async function handlePrimaryAction() {
  if (activeStep.value === stepItems.length - 1) {
    if (!isDetail.value && reviewIssues.value.length) {
      message.warning('仍有缺失配置,请根据检查结果补充后再完成');
      return;
    }
    await modalApi.close();
    return;
  }
  await goToStep(activeStep.value + 1);
}
const [Modal, modalApi] = useVbenModal({
  showConfirmButton: false,
  cancelText: '关闭',
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      productList.value = [];
      processList.value = [];
      bomList.value = [];
      activeStep.value = 0;
      return;
    }
    formApi.setState({ schema: useFormSchema(formApi) });
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setDisabled(isDetail.value);
    if (!data.id) return;
    modalApi.lock();
    try {
      formData.value = await getRoute(data.id);
      await formApi.setValues(formData.value);
      await loadConfiguration();
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="title" class="w-[94vw] max-w-[1560px]">
    <div class="route-config-shell">
      <header class="route-config-header">
        <div class="min-w-0">
          <div class="flex flex-wrap items-center gap-2">
            <h2 class="m-0 text-lg font-semibold text-gray-900 dark:text-gray-100">
              {{ formData?.name || '新工艺路线' }}
            </h2>
            <Tag :color="isDetail ? 'default' : 'processing'">
              {{ isDetail ? '只读查看' : '配置中' }}
            </Tag>
          </div>
          <p class="mb-0 mt-1 text-sm text-gray-600 dark:text-gray-300">
            å·¥åºæŠ•料依赖产出产品和工序,因此在工序编排完成后集中配置。
          </p>
        </div>
        <div class="route-config-summary" aria-label="当前配置概览">
          <span>产出 {{ productList.length }}</span>
          <span>工序 {{ processList.length }}</span>
          <span>投料 {{ bomList.length }}</span>
        </div>
      </header>
      <Steps
        :current="activeStep"
        :items="stepItems"
        class="route-config-steps"
        responsive
        size="small"
        type="navigation"
        @change="goToStep"
      />
      <main class="route-config-content">
        <section v-show="activeStep === 0" class="route-step-panel">
          <div class="route-step-copy">
            <IconifyIcon icon="lucide:route" />
            <div>
              <h3>先定义这条路线是什么</h3>
              <p>编码用于系统识别,名称和说明帮助现场人员快速选择。</p>
            </div>
          </div>
          <Form class="route-base-form" />
        </section>
        <section v-if="routeId" v-show="activeStep === 1" class="route-step-panel">
          <Alert
            class="mb-4"
            message="先维护所有可能的工序产出"
            description="实际形成的中间半成品和末道工序的成品都要加入,并分别指定报工后进入的仓库、库区和库位。"
            show-icon
            type="info"
          />
          <ProductList
            :form-type="formType"
            :route-id="routeId"
            @change="productList = $event"
          />
        </section>
        <section v-if="routeId" v-show="activeStep === 2" class="route-step-panel">
          <Alert
            class="mb-4"
            message="按实际生产顺序编排工序"
            description="中间工序没有独立产出时可留空;形成半成品时选择对应半成品,最后一道工序必须选择路线绑定的全部成品。多产品共用的半成品归属选“通用”,成品必须归属到自身产品。"
            show-icon
            type="info"
          />
          <ProcessList
            :form-type="formType"
            :route-id="routeId"
            @change="processList = $event"
          />
        </section>
        <section v-if="routeId" v-show="activeStep === 3" class="route-step-panel">
          <MaterialInputList
            :form-type="formType"
            :processes="sortedProcessList"
            :products="productList"
            :route-id="routeId"
            @change="bomList = $event"
          />
        </section>
        <section v-show="activeStep === 4" class="route-step-panel">
          <div class="review-heading">
            <div>
              <h3>启用前检查</h3>
              <p>确认产出、仓位、工序和投料形成闭环后,再回到列表启用路线。</p>
            </div>
            <Tag :color="reviewIssues.length ? 'warning' : 'success'">
              {{ reviewIssues.length ? `${reviewIssues.length} é¡¹å¾…处理` : '配置完整' }}
            </Tag>
          </div>
          <div class="review-metrics">
            <div><strong>{{ productsWithWarehouse }}/{{ productList.length }}</strong><span>产品仓位完整</span></div>
            <div><strong>{{ processesWithOutput }}</strong><span>配置产出的工序</span></div>
            <div><strong>{{ productsWithBom }}/{{ productList.length }}</strong><span>产品已配置投料</span></div>
          </div>
          <div v-if="reviewIssues.length" class="issue-list" role="status">
            <button
              v-for="issue in reviewIssues"
              :key="issue.text"
              class="issue-item"
              type="button"
              @click="activeStep = issue.step"
            >
              <IconifyIcon icon="lucide:circle-alert" />
              <span>{{ issue.text }}</span>
              <IconifyIcon class="ml-auto" icon="lucide:chevron-right" />
            </button>
          </div>
          <Empty v-else description="所有必需配置均已完成">
            <template #image>
              <div class="review-success-icon"><IconifyIcon icon="lucide:badge-check" /></div>
            </template>
          </Empty>
        </section>
      </main>
    </div>
    <template #prepend-footer>
      <div class="flex flex-auto items-center justify-between gap-3">
        <Button v-if="activeStep > 0" @click="activeStep--">
          <IconifyIcon class="mr-1" icon="lucide:arrow-left" />上一步
        </Button>
        <span v-else class="text-sm text-gray-500">新建路线默认停用,配置完整后再启用</span>
        <Button type="primary" @click="handlePrimaryAction">
          {{ primaryText }}
          <IconifyIcon
            v-if="activeStep < stepItems.length - 1"
            class="ml-1"
            icon="lucide:arrow-right"
          />
        </Button>
      </div>
    </template>
  </Modal>
</template>
<style scoped>
.route-config-shell { min-height: min(720px, 72vh); background: rgb(248 250 252); color: rgb(15 23 42); }
.route-config-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 18px 24px 14px; background: white; border-bottom: 1px solid rgb(226 232 240); }
.route-config-summary { display: flex; flex-shrink: 0; gap: 8px; }
.route-config-summary span { padding: 5px 10px; border-radius: 8px; background: rgb(239 246 255); color: rgb(29 78 216); font-size: 12px; font-weight: 600; }
.route-config-steps { padding: 12px 20px 0; background: white; }
.route-config-content { padding: 20px 24px 28px; }
.route-step-panel { min-height: 460px; padding: 22px; border-radius: 14px; background: white; box-shadow: 0 10px 28px rgb(15 23 42 / 6%); }
.route-step-copy, .review-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 22px; }
.route-step-copy { justify-content: flex-start; }
.route-step-copy > svg { width: 38px; height: 38px; padding: 9px; border-radius: 10px; background: rgb(219 234 254); color: rgb(29 78 216); }
.route-step-copy h3, .review-heading h3 { margin: 0; color: rgb(15 23 42); font-size: 17px; font-weight: 650; }
.route-step-copy p, .review-heading p { margin: 4px 0 0; color: rgb(71 85 105); font-size: 14px; }
.route-base-form { max-width: 1040px; margin: 34px auto 0; }
.review-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 24px 0; }
.review-metrics > div { display: flex; align-items: baseline; gap: 10px; padding: 18px; border-radius: 12px; background: rgb(248 250 252); }
.review-metrics strong { color: rgb(29 78 216); font-size: 24px; }
.review-metrics span { color: rgb(71 85 105); font-size: 13px; }
.issue-list { display: grid; gap: 10px; }
.issue-item { display: flex; width: 100%; min-height: 48px; align-items: center; gap: 10px; padding: 10px 14px; border: 1px solid rgb(253 230 138); border-radius: 10px; background: rgb(255 251 235); color: rgb(146 64 14); cursor: pointer; text-align: left; transition: background-color 180ms ease, border-color 180ms ease; }
.issue-item:hover, .issue-item:focus-visible { border-color: rgb(245 158 11); background: rgb(254 243 199); outline: none; }
.review-success-icon { display: grid; width: 72px; height: 72px; place-items: center; margin: 0 auto; border-radius: 50%; background: rgb(220 252 231); color: rgb(22 163 74); font-size: 38px; }
:global(.dark) .route-config-shell { background: rgb(15 23 42); color: rgb(226 232 240); }
:global(.dark) .route-config-header, :global(.dark) .route-config-steps, :global(.dark) .route-step-panel { background: rgb(30 41 59); border-color: rgb(51 65 85); }
:global(.dark) .route-step-copy h3, :global(.dark) .review-heading h3 { color: rgb(241 245 249); }
:global(.dark) .route-step-copy p, :global(.dark) .review-heading p, :global(.dark) .review-metrics span { color: rgb(203 213 225); }
:global(.dark) .review-metrics > div { background: rgb(15 23 42); }
@media (max-width: 900px) {
  .route-config-header { align-items: flex-start; flex-direction: column; }
  .review-metrics { grid-template-columns: 1fr; }
  .route-step-panel { padding: 16px; }
}
</style>
src/views/mes/pro/workorder/data.ts
@@ -760,6 +760,17 @@
      width: 160,
    },
    {
      field: 'warehouseName',
      title: '报工仓库',
      width: 130,
      formatter: ({ cellValue, row }) => {
        if (!cellValue) return '-';
        return row.areaName
          ? `${cellValue}/${row.locationName ?? ''}/${row.areaName}`
          : cellValue;
      },
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 120,
src/views/mes/pro/workorder/modules/form.vue
@@ -10,6 +10,7 @@
  import {
    MesProWorkOrderStatusEnum,
  } from "@vben/constants";
  import { isAiEnable } from "@vben/hooks";
  import { Button, message, Tabs } from "ant-design-vue";
@@ -56,6 +57,7 @@
  );
  const canUseAi = computed(
    () =>
      isAiEnable() &&
      formData.value?.id &&
      (formData.value?.status === MesProWorkOrderStatusEnum.PREPARE ||
        formData.value?.status === MesProWorkOrderStatusEnum.CONFIRMED)
src/views/mes/pro/workorder/modules/process-form.vue
@@ -10,11 +10,15 @@
import { useVbenForm } from '#/adapter/form';
import { getProcessSimpleList } from '#/api/mes/pro/process';
import { getRouteProcessListByProduct } from '#/api/mes/pro/route/process';
import {
  createWorkOrderProcess,
  getWorkOrderProcess,
  updateWorkOrderProcess,
} from '#/api/mes/pro/workorder/process';
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
import { getWarehouseAreaSimpleList } from '#/api/mes/wm/warehouse/area';
import { getWarehouseLocationSimpleList } from '#/api/mes/wm/warehouse/location';
import { $t } from '#/locales';
import { MdItemSelect } from '#/views/mes/md/item/components';
@@ -23,6 +27,7 @@
const workOrderId = ref<number>();
const processList = ref<MesProWorkOrderProcessApi.WorkOrderProcess[]>([]);
const isCreateMode = ref(false);
const allowedOutputItemIds = ref<number[]>([]);
const getTitle = computed(() => {
  return formData.value?.id
@@ -39,9 +44,37 @@
  }));
}
/**
 * è½½å…¥æœ¬å·¥å•产品可选的产出物料范围
 *
 * å–工艺路线按产出归属过滤后的产出(通用半成品 + æœ¬äº§å“ä¸“属成品),
 * å¹¶å¹¶å…¥è¯¥å·¥åºå·²é…ç½®çš„产出,避免历史数据在重新选择时被误清空。
 */
async function loadAllowedOutputItemIds(
  productId?: number,
  currentOutputItemIds: number[] = [],
): Promise<number[]> {
  if (!productId) {
    return [];
  }
  const ids = new Set<number>([productId, ...currentOutputItemIds]);
  const routeProcessList = await getRouteProcessListByProduct(productId);
  (routeProcessList || []).forEach((process) => {
    (process.outputItemIds || []).forEach((id) => {
      if (id) {
        ids.add(id);
      }
    });
  });
  return [...ids];
}
/** è¡¨å• schema */
async function loadSchema(): Promise<VbenFormSchema[]> {
  const processOptions = await loadProcessOptions();
  const [processOptions, warehouseList] = await Promise.all([
    loadProcessOptions(),
    getWarehouseSimpleList(),
  ]);
  return [
    {
      fieldName: 'id',
@@ -50,6 +83,21 @@
    },
    {
      fieldName: 'workOrderId',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'warehouseName',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'locationName',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'areaName',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
@@ -185,6 +233,80 @@
      componentProps: {
        multiple: true,
        placeholder: '请选择产出物料',
        productIds: allowedOutputItemIds.value,
      },
    },
    {
      fieldName: 'warehouseId',
      label: '报工仓库',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: (warehouseList || []).map((item) => ({
          label: item.name ?? '',
          value: item.id!,
        })),
        placeholder: '默认取工艺路线配置,可修改',
        onChange: async (value: number) => {
          const warehouse = (warehouseList || []).find(
            (item) => item.id === value,
          );
          await formApi.setFieldValue('warehouseName', warehouse?.name);
          await formApi.setFieldValue('locationId', undefined);
          await formApi.setFieldValue('locationName', undefined);
          await formApi.setFieldValue('areaId', undefined);
          await formApi.setFieldValue('areaName', undefined);
        },
      },
    },
    {
      fieldName: 'locationId',
      label: '报工库区',
      component: 'Select',
      dependencies: {
        triggerFields: ['warehouseId'],
        disabled: (values) => !values.warehouseId,
        async componentProps(values) {
          const options = values.warehouseId
            ? await getWarehouseLocationSimpleList(values.warehouseId)
            : [];
          return {
            allowClear: true,
            fieldNames: { label: 'name', value: 'id' },
            options,
            placeholder: '请选择报工库区',
            onChange: async (value: number) => {
              const location = options.find((item) => item.id === value);
              await formApi.setFieldValue('locationName', location?.name);
              await formApi.setFieldValue('areaId', undefined);
              await formApi.setFieldValue('areaName', undefined);
            },
          };
        },
      },
    },
    {
      fieldName: 'areaId',
      label: '报工库位',
      component: 'Select',
      dependencies: {
        triggerFields: ['locationId'],
        disabled: (values) => !values.locationId,
        async componentProps(values) {
          const options = values.locationId
            ? await getWarehouseAreaSimpleList(values.locationId)
            : [];
          return {
            allowClear: true,
            fieldNames: { label: 'name', value: 'id' },
            options,
            placeholder: '请选择报工库位',
            onChange: async (value: number) => {
              const area = options.find((item) => item.id === value);
              await formApi.setFieldValue('areaName', area?.name);
            },
          };
        },
      },
    },
    {
@@ -271,16 +393,9 @@
      formData.value = undefined;
      processList.value = [];
      isCreateMode.value = false;
      allowedOutputItemIds.value = [];
      return;
    }
    modalApi.lock();
    try {
      const schema = await loadSchema();
      formApi.setState({ schema });
    } finally {
      modalApi.unlock();
    }
    const data = modalApi.getData<{
      id?: number;
      productId?: number;
@@ -296,27 +411,33 @@
    processList.value = data.processList || [];
    isCreateMode.value = !data.workOrderId;
    if (data.row) {
      // ç¼–辑临时列表中的工序
      formData.value = data.row;
      await formApi.setValues(data.row);
    } else if (data.id) {
      // ç¼–辑已保存的工序
      modalApi.lock();
      try {
        formData.value = await getWorkOrderProcess(data.id);
    modalApi.lock();
    try {
      // å…ˆå–工序数据:编辑已保存工序时用于回显,也用于保留其已有产出
      const savedProcess = !data.row && data.id
        ? await getWorkOrderProcess(data.id)
        : undefined;
      formData.value = data.row ?? savedProcess;
      allowedOutputItemIds.value = await loadAllowedOutputItemIds(
        data.productId,
        formData.value?.outputItemIds || [],
      );
      formApi.setState({ schema: await loadSchema() });
      if (formData.value) {
        // ç¼–辑临时列表中的工序 / ç¼–辑已保存的工序
        await formApi.setValues(formData.value);
      } finally {
        modalApi.unlock();
      } else {
        // æ–°å¢ž
        const maxSort = Math.max(0, ...processList.value.map((item) => item.sort || 0));
        await formApi.setValues({
          colorCode: '#00AEF3',
          sort: maxSort + 1,
        });
      }
    } else {
      // æ–°å¢ž
      formData.value = undefined;
      const maxSort = Math.max(0, ...processList.value.map((item) => item.sort || 0));
      await formApi.setValues({
        colorCode: '#00AEF3',
        sort: maxSort + 1,
      });
    } finally {
      modalApi.unlock();
    }
  },
});
src/views/mes/pro/workorder/modules/process-list.vue
@@ -158,6 +158,12 @@
          backflushFlag: item.backflushFlag,
          outputItemIds: item.outputItemIds,
          outputItemNames: item.outputItemNames,
          warehouseId: item.warehouseId,
          warehouseName: item.warehouseName,
          locationId: item.locationId,
          locationName: item.locationName,
          areaId: item.areaId,
          areaName: item.areaName,
          remark: item.remark,
          bomItems: item.bomItems,
        }));
src/views/mes/process-design/route/data.ts
@@ -108,6 +108,16 @@
      },
    },
    {
      fieldName: "productKeyword",
      label: "产品",
      component: "Input",
      componentProps: {
        allowClear: true,
        maxLength: 100,
        placeholder: "输入产品名称或编码",
      },
    },
    {
      fieldName: "status",
      label: "状态",
      component: "Select",
@@ -154,7 +164,7 @@
      field: "createTime",
      title: "创建时间",
      width: 180,
      formatter: 'formatDate',
      formatter: "formatDateTime",
    },
    {
      title: "操作",
@@ -205,7 +215,7 @@
      fieldName: "yieldRate",
      label: "工序收率",
      component: "InputNumber",
      help: "产出量 Ã· æŠ•入量。填写 0.98 è¡¨ç¤º 98%,1 è¡¨ç¤ºæ— æŸè€—;未填写按 100% å‚与排产计算。",
      help: "产出量 Ã· æŠ•入量。填写 0.98 è¡¨ç¤º 98%,未填写按 100% å‚与排产计算。",
      componentProps: {
        class: "!w-full",
        min: 0.0001,
@@ -220,21 +230,12 @@
      fieldName: "linkType",
      label: "与下道工序关系",
      component: "Select",
      help: () =>
        h(
          "div",
          {
            style:
              "white-space: pre-line; max-width: 320px; line-height: 1.6;",
          },
          "0=开始-开始(SS):本工序开始后,下道工序才能开始(可重叠并行开工)\n1=结束-结束(FF):下道工序必须等到本工序结束才能结束(并行但同步完工)\n2=开始-结束(SF):本工序开始后,下道工序才能结束\n3=结束-开始(FS):本工序结束后,下道工序才能开始(默认值)",
        ),
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.MES_PRO_LINK_TYPE, "number"),
        placeholder: "请选择",
      },
      rules: z.number().optional(),
      rules: z.number().default(3),
    },
    {
      fieldName: "colorCode",
@@ -425,11 +426,8 @@
  ];
}
/** å·¥è‰ºè·¯çº¿äº§å“è¡¨å•ï¼›multiple = true(新增模式)时产品可多选,一次性绑定多个 */
export function useRouteProductFormSchema(
  onItemChange?: (item: any) => void,
  multiple = false,
): VbenFormSchema[] {
/** å·¥è‰ºè·¯çº¿äº§å“è¡¨å• */
export function useRouteProductFormSchema(onItemChange?: (item: any) => void): VbenFormSchema[] {
  return [
    {
      fieldName: "id",
@@ -441,29 +439,17 @@
      component: "Input",
      dependencies: { triggerFields: [""], show: () => false },
    },
    // æ–°å¢žï¼ˆå¤šé€‰ï¼‰ï¼šä¸€æ¬¡ç»‘定多个产品;编辑(单选):change å›žå¡«ç¼–码/名称/规格/单位
    multiple
      ? {
          fieldName: "itemIds",
          label: "产品(可多选)",
          component: markRaw(MdItemSelect),
          componentProps: {
            multiple: true,
            placeholder: "请选择要绑定的产品(可多选)",
          },
          formItemClass: "col-span-2",
          rules: "selectRequired",
        }
      : {
          fieldName: "itemId",
          label: "产品",
          component: markRaw(MdItemSelect),
          componentProps: {
            onChange: onItemChange,
          },
          formItemClass: "col-span-2",
          rules: "selectRequired",
        },
    // äº§å“ç‰©æ–™ï¼šä½¿ç”¨ä¸šåŠ¡è‡ªå®šä¹‰é€‰æ‹©å™¨ï¼Œchange å›žå¡«ç¼–码/名称/规格/单位
    {
      fieldName: "itemId",
      label: "产品",
      component: markRaw(MdItemSelect),
      componentProps: {
        onChange: onItemChange,
      },
      formItemClass: "col-span-2",
      rules: "selectRequired",
    },
    {
      fieldName: "quantity",
      label: "生产数量",
src/views/mes/process-design/route/index.vue
@@ -8,19 +8,26 @@
import { getDictLabel } from '@vben/hooks';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tooltip } from 'ant-design-vue';
import { Alert, Button, message, Tooltip } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteRoute,
  exportRoute,
  getRoutePage,
  importRoute,
  importRouteTemplate,
  importCompleteRoute,
  importCompleteRouteTemplate,
  updateRouteStatus,
} from '#/api/mes/pro/route';
import { $t } from '#/locales';
import ImportModalComponent from '#/components/import-modal';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
// å·¥è‰ºè®¾è®¡ä¸Žç”Ÿäº§åŸºç¡€æ•°æ®å…±ç”¨åŒä¸€ä¸ªé…ç½®å™¨ï¼Œé¿å…ä¸¤å¥—页面能力不一致。
import Form from '#/views/mes/pro/route/modules/route-configurator.vue';
const { hasAccessByCodes } = useAccess();
const statusEditable = hasAccessByCodes(['mes:pro-route:update']); // æ˜¯å¦å¯åˆ‡æ¢çŠ¶æ€
@@ -30,9 +37,38 @@
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportModalComponent,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** å¯¼å…¥å·¥è‰ºè·¯çº¿ */
function handleImport() {
  importModalApi
    .setData({
      title: $t('ui.actionTitle.import', ['工艺路线']),
      fileName: '工艺路线导入模板.xls',
      importFn: importRoute,
      templateFn: importRouteTemplate,
    })
    .open();
}
function handleCompleteImport() {
  importModalApi
    .setData({
      title: '批量导入完整工艺路线',
      fileName: '完整工艺路线导入模板.xlsx',
      importFn: importCompleteRoute,
      templateFn: importCompleteRouteTemplate,
      showUpdateSupport: false,
    })
    .open();
}
/** åˆ›å»ºå·¥è‰ºè·¯çº¿ */
@@ -57,7 +93,9 @@
): Promise<boolean | undefined> {
  try {
    await confirm(
      `确认要将"${row.name}"工艺路线切换为【${getDictLabel(DICT_TYPE.COMMON_STATUS, newStatus)}】吗?`,
      newStatus === CommonStatusEnum.ENABLE
        ? `确认启用“${row.name}”吗?系统将检查产出入库位置、工序产出和工序投料是否完整。`
        : `确认要将“${row.name}”工艺路线切换为【${getDictLabel(DICT_TYPE.COMMON_STATUS, newStatus)}】吗?`,
    );
  } catch {
    return false;
@@ -118,6 +156,14 @@
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <Alert
      class="mb-3"
      message="路线配置顺序"
      description="基本信息 â†’ äº§å‡ºäº§å“åŠå…¥åº“仓 â†’ å·¥åºåŠå„工序产出 â†’ å·¥åºæŠ•æ–™ â†’ æ ¡éªŒå¯ç”¨"
      show-icon
      type="info"
    />
    <Grid table-title="工艺路线列表">
      <template #toolbar-tools>
        <TableAction
@@ -136,6 +182,20 @@
              auth: ['mes:pro-route:export'],
              onClick: handleExport,
            },
            {
              label: $t('ui.actionTitle.import', ['工艺路线']),
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['mes:pro-route:import'],
              onClick: handleImport,
            },
            {
              label: '批量导入完整路线',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['mes:pro-route:import'],
              onClick: handleCompleteImport,
            },
          ]"
        />
      </template>
src/views/mes/process-design/route/modules/form.vue
@@ -2,28 +2,24 @@
import type { FormType } from '../data';
import type { MesProRouteApi } from '#/api/mes/pro/route';
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
import { computed, ref } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { useVbenModal } from '@vben/common-ui';
import { Button, Divider, message, Select } from 'ant-design-vue';
import { message, TabPane, Tabs } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createRoute, getRoute, updateRoute } from '#/api/mes/pro/route';
import {
  deleteRouteProduct,
  getRouteProductListByRoute,
} from '#/api/mes/pro/route/product';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import ProductForm from './product-form.vue';
import Workbench from './workbench.vue';
import ProcessList from './process-list.vue';
import ProductList from './product-list.vue';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create'); // è¡¨å•模式
const subTab = ref('process'); // å½“前激活的子表 Tab
const formData = ref<MesProRouteApi.Route>();
const isDetail = computed(() => formType.value === 'detail'); // æ˜¯å¦æŸ¥çœ‹æ¨¡å¼
@@ -36,72 +32,6 @@
    : $t('ui.actionTitle.create', ['工艺路线']);
});
// ==================== äº§å“ç»‘定 ====================
const products = ref<MesProRouteProductApi.RouteProduct[]>([]); // å…³è”产品列表
const selectedProductItemId = ref<number>(); // å½“前选中的产品物料编号
const selectedProduct = computed(() =>
  products.value.find((item) => item.itemId === selectedProductItemId.value),
);
const productOptions = computed(() =>
  products.value.map((item) => ({ label: item.itemName, value: item.itemId })),
);
async function loadProducts() {
  const routeId = formData.value?.id;
  if (!routeId) {
    products.value = [];
    return;
  }
  products.value = (await getRouteProductListByRoute(routeId)) || [];
  if (
    !products.value.some((item) => item.itemId === selectedProductItemId.value)
  ) {
    selectedProductItemId.value = products.value[0]?.itemId;
  }
}
const [ProductFormModal, productFormModalApi] = useVbenModal({
  connectedComponent: ProductForm,
  destroyOnClose: true,
});
/** æ‰“开产品弹窗(绑定 / ç¼–辑) */
function openProductForm(row?: MesProRouteProductApi.RouteProduct) {
  if (!formData.value?.id) {
    return;
  }
  productFormModalApi
    .setData(
      row
        ? { id: row.id, routeId: formData.value.id }
        : {
            routeId: formData.value.id,
            boundItemIds: products.value
              .map((item) => item.itemId)
              .filter((id): id is number => id != null),
          },
    )
    .open();
}
/** ç§»é™¤äº§å“ï¼ˆä¼šçº§è”删除该产品的投入物料配置) */
async function handleRemoveProduct() {
  if (!selectedProduct.value?.id) {
    return;
  }
  try {
    await confirm(
      `确认将产品「${selectedProduct.value.itemName}」从该工艺路线移除?其全部投入物料配置将一并删除。`,
    );
  } catch {
    return;
  }
  await deleteRouteProduct(selectedProduct.value.id);
  message.success($t('ui.actionMessage.operationSuccess'));
  await loadProducts();
}
// ==================== åŸºæœ¬ä¿¡æ¯è¡¨å• ====================
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
@@ -129,12 +59,11 @@
    const data = (await formApi.getValues()) as MesProRouteApi.Route;
    try {
      if (formType.value === 'create') {
        // æ–°å¢žæˆåŠŸåŽåˆ‡åˆ°ç¼–è¾‘æ¨¡å¼ï¼Œè¿›å…¥ã€Œäº§å“ä¸Žå·¥åºé…ç½®ã€å·¥ä½œå°
        // æ–°å¢žæˆåŠŸåŽåˆ‡åˆ°ç¼–è¾‘æ¨¡å¼ï¼Œæ–¹ä¾¿ç»§ç»­ç»´æŠ¤ç»„æˆå·¥åºã€å…³è”äº§å“
        const id = await createRoute(data);
        formData.value = { ...data, id };
        await formApi.setFieldValue('id', id);
        formType.value = 'update';
        await loadProducts();
      } else {
        await updateRoute(data);
        formData.value = { ...formData.value, ...data };
@@ -153,11 +82,10 @@
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      products.value = [];
      selectedProductItemId.value = undefined;
      return;
    }
    formApi.setState({ schema: useFormSchema(formApi) });
    subTab.value = 'process';
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
@@ -171,7 +99,6 @@
      formData.value = await getRoute(data.id);
      // è®¾ç½®åˆ° values
      await formApi.setValues(formData.value);
      await loadProducts();
    } finally {
      modalApi.unlock();
    }
@@ -180,49 +107,20 @@
</script>
<template>
  <Modal :title="getTitle" class="w-11/12">
  <Modal :title="getTitle" class="w-4/5">
    <Form class="mx-4" />
    <!-- ç¼–辑/详情模式下展示「产品与工序配置」工作台,新增模式下隐藏 -->
    <template v-if="formData?.id">
      <div class="mx-4 mt-4">
        <Divider class="!my-2" orientation="left">
          <span class="text-sm">产品与工序配置</span>
        </Divider>
        <div class="flex flex-wrap items-center gap-3">
          <span class="shrink-0 text-sm font-medium">生产产品</span>
          <Select
            v-model:value="selectedProductItemId"
            class="!w-64"
            allow-clear
            :disabled="isDetail"
            placeholder="请选择或绑定产品"
            :options="productOptions"
          />
          <template v-if="!isDetail">
            <Button type="primary" @click="openProductForm()"> ç»‘定产品 </Button>
            <Button
              :disabled="!selectedProduct"
              @click="openProductForm(selectedProduct)"
            >
              ç¼–辑产品
            </Button>
            <Button danger :disabled="!selectedProduct" @click="handleRemoveProduct">
              ç§»é™¤äº§å“
            </Button>
          </template>
        </div>
        <p v-if="products.length === 0" class="mt-2 text-xs text-gray-400">
          å…ˆç»‘定要生产的物料,再为每道工序配置「投入 + äº§å‡ºã€
        </p>
        <div class="mt-3">
          <Workbench
            :form-type="formType"
            :product="selectedProduct"
            :route-id="formData.id"
          />
        </div>
      </div>
    </template>
    <!-- ç¼–辑/详情模式下展示子表 Tab,新增模式下隐藏 -->
    <Tabs
      v-if="formType !== 'create' && formData?.id"
      v-model:active-key="subTab"
      class="mx-4 mt-4"
    >
      <TabPane key="process" tab="组成工序">
        <ProcessList :form-type="formType" :route-id="formData.id" />
      </TabPane>
      <TabPane key="product" tab="关联产品">
        <ProductList :form-type="formType" :route-id="formData.id" />
      </TabPane>
    </Tabs>
  </Modal>
  <ProductFormModal @success="loadProducts" />
</template>
</template>
src/views/mes/process-design/route/modules/process-form.vue
@@ -31,7 +31,7 @@
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 150,
    labelWidth: 130,
  },
  layout: 'horizontal',
  schema: [],
src/views/mes/process-design/route/modules/product-form.vue
@@ -5,7 +5,7 @@
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { Divider, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
@@ -16,17 +16,17 @@
import { $t } from '#/locales';
import { useRouteProductFormSchema } from '../data';
import ProductBomList from './product-bom-list.vue';
const emit = defineEmits(['success']);
const formData = ref<MesProRouteProductApi.RouteProduct>();
const boundItemIds = ref<number[]>([]); // å·²ç»‘定本路线的产品物料编号(新增多选时跳过)
const getTitle = computed(() =>
  formData.value?.id
    ? $t('ui.actionTitle.edit', ['工艺路线产品'])
    : $t('ui.actionTitle.create', ['工艺路线产品']),
);
/** MdItemSelect å•选 change å›žè°ƒï¼šæŠŠç‰©æ–™ç¼–码/名称/规格/单位回填到 form */
/** MdItemSelect change å›žè°ƒï¼šæŠŠç‰©æ–™ç¼–码/名称/规格/单位回填到 form */
async function handleItemChange(item?: any) {
  if (!item) {
    return;
@@ -58,62 +58,22 @@
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as MesProRouteProductApi.RouteProduct & {
      itemIds?: number[];
    };
    // æäº¤è¡¨å•
    const data =
      (await formApi.getValues()) as MesProRouteProductApi.RouteProduct;
    try {
      // ç¼–辑模式:更新单个产品
      if (formData.value?.id) {
        await updateRouteProduct(data);
        await modalApi.close();
        emit('success');
        message.success($t('ui.actionMessage.operationSuccess'));
        return;
        // ç”¨æœ€æ–°è¡¨å•值同步 formData,确保产品 BOM å­è¡¨ç»‘定的 itemId ä¸Žè¡¨å•一致
        formData.value = { ...formData.value, ...data };
      } else {
        // æ–°å¢žæˆåŠŸåŽåˆ‡æ¢åˆ°ç¼–è¾‘æ¨¡å¼ï¼Œæ–¹ä¾¿ç»§ç»­ç»´æŠ¤äº§å“ BOM
        const id = await createRouteProduct(data);
        formData.value = { ...data, id };
        await formApi.setFieldValue('id', id);
      }
      // æ–°å¢žæ¨¡å¼ï¼šå¤šé€‰äº§å“ä¸€æ¬¡æ€§ç»‘定,跳过已在路线中的产品
      const itemIds = Array.isArray(data.itemIds) ? data.itemIds : [];
      if (itemIds.length === 0) {
        message.warning('请至少选择一个产品');
        return;
      }
      let created = 0;
      let skipped = 0;
      const failedIds: number[] = [];
      for (const itemId of itemIds) {
        if (itemId == null) {
          continue;
        }
        if (boundItemIds.value.includes(itemId)) {
          skipped++;
          continue;
        }
        try {
          await createRouteProduct({
            routeId: data.routeId,
            itemId,
            quantity: data.quantity,
            productionTime: data.productionTime,
            timeUnitType: data.timeUnitType,
            remark: data.remark,
          });
          created++;
        } catch {
          failedIds.push(itemId);
        }
      }
      if (skipped > 0) {
        message.warning(`已跳过 ${skipped} ä¸ªå·²ç»‘定的产品`);
      }
      if (failedIds.length > 0) {
        message.error(
          `有 ${failedIds.length} ä¸ªäº§å“ç»‘定失败(可能已被其它工艺路线占用)`,
        );
      }
      if (created > 0) {
        message.success(`成功绑定 ${created} ä¸ªäº§å“`);
        await modalApi.close();
        emit('success');
      }
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
@@ -121,42 +81,24 @@
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      boundItemIds.value = [];
      return;
    }
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{
      id?: number;
      routeId: number;
      boundItemIds?: number[];
    }>();
    if (!data) {
      return;
    }
    boundItemIds.value = data.boundItemIds || [];
    if (!data.id) {
      // æ–°å¢žæ¨¡å¼ï¼šå¤šé€‰äº§å“ç»‘定
      formData.value = undefined;
      await formApi.setState({
        schema: useRouteProductFormSchema(handleItemChange, true),
      });
      await formApi.setValues({
        routeId: data.routeId,
        itemIds: [],
        quantity: 1,
        productionTime: 1,
        timeUnitType: 'MINUTE',
        remark: undefined,
      });
      await formApi.setValues({ routeId: data.routeId });
      return;
    }
    // ç¼–辑模式:加载单个产品回填
    modalApi.lock();
    try {
      formData.value = await getRouteProduct(data.id);
      await formApi.setState({
        schema: useRouteProductFormSchema(handleItemChange, false),
      });
      // è®¾ç½®åˆ° values
      await formApi.setValues(formData.value);
    } finally {
      modalApi.unlock();
@@ -166,7 +108,18 @@
</script>
<template>
  <Modal :title="getTitle" class="w-2/5">
  <Modal :title="getTitle" class="w-3/5">
    <Form class="mx-4" />
    <!-- ç¼–辑模式下展示产品 BOM å­è¡¨ï¼Œæ–°å¢žæ¨¡å¼ä¸‹éšè— -->
    <template v-if="formData?.id && formData?.itemId">
      <Divider class="!my-3" orientation="left">产品 BOM é…ç½®</Divider>
      <div class="mx-4">
        <ProductBomList
          :route-id="formData.routeId!"
          :product-id="formData.itemId"
          :product-name="formData.itemName"
        />
      </div>
    </template>
  </Modal>
</template>
</template>
src/views/mes/qc/components/ai-assistant.vue
@@ -2,6 +2,7 @@
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { isAiEnable } from '@vben/hooks';
import { Empty, Input, Spin } from 'ant-design-vue';
@@ -43,7 +44,7 @@
</script>
<template>
  <a-button type="default" @click="modalApi.open()">
  <a-button v-if="isAiEnable()" type="default" @click="modalApi.open()">
    è´¨æ£€åŠ©æ‰‹
  </a-button>
src/views/mes/qc/components/ai-suggest.vue
@@ -4,6 +4,7 @@
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { isAiEnable } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { Alert, Button, message, Tag } from 'ant-design-vue';
@@ -51,7 +52,12 @@
</script>
<template>
  <Button type="default" :loading="loading" @click="handleSuggest">
  <Button
    v-if="isAiEnable()"
    type="default"
    :loading="loading"
    @click="handleSuggest"
  >
    <template #icon>
      <IconifyIcon icon="ant-design:robot-outlined" />
    </template>
src/views/mes/qc/report/template/designer/index.vue
@@ -7,7 +7,7 @@
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { isAiEnable, useTabs } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import {
@@ -48,8 +48,8 @@
const { hasAccessByCodes } = useAccess();
/** AI å¯¼å…¥æ˜¯ç‹¬ç«‹æƒé™ç ï¼šèƒ½æ”¹æ¨¡æ¿ä¸ç­‰äºŽèƒ½ç”¨å¤§æ¨¡åž‹è¯†åˆ«æ–‡ä»¶ï¼ˆæ¯æ¬¡è°ƒç”¨éƒ½è¦èŠ±é’±ï¼‰ */
const canAiImport = computed(() =>
  hasAccessByCodes(['qc-report:template:ai-import']),
const canAiImport = computed(
  () => isAiEnable() && hasAccessByCodes(['qc-report:template:ai-import']),
);
const loading = ref(true);
vite.config.ts
@@ -85,6 +85,12 @@
            target: "http://127.0.0.1:48080",
            ws: true,
          },
          "/agent-api": {
            changeOrigin: true,
            target: "http://127.0.0.1:8091",
            rewrite: (path) => path.replace(/^\/agent-api/, ""),
            ws: true,
          },
        },
      },
    },