已修改64个文件
已删除12个文件
已添加14个文件
5207 ■■■■■ 文件已修改
PRODUCT.md 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
company.config.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
dist.zip 补丁 | 查看 | 原始文档 | blame | 历史
hm.bat 451 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/check-analytics-permissions.cjs 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/check-logout.cjs 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/adapter/form.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/adapter/vxe-table.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bpm/autoApprove/index.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/energy/index.ts 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/resignation/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/transfer/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/dv/meteringcheck/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/iqc/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/description/description.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/table-action/table-action.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/cache/index.d.ts 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/cache/index.mjs 255 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/color/index.d.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/color/index.mjs 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/constants/index.d.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/constants/index.mjs 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/global-state.d.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/global-state.mjs 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/store.d.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/store.mjs 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/utils/index.d.ts 269 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/base/shared/dist/utils/index.mjs 1775 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/stores/src/setup.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/audit-policy.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/auth.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/company-config.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/audit-ui-policy.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/modules/import-form.vue 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/unit/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/model/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/oa/leave/create.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/backlog/index.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/components/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/data.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/detail/approve/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/detail/data.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/data.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/detail/modules/distribute-form.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/pool/data.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/pool/index.vue 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/index.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/index.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/alert-panel.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/biz-cards.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/kpi-cards.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/production-trend.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/quick-actions.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/use-home-access.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energy/alert/index.vue 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energy/analysis/index.vue 232 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energy/record/index.vue 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energy/shared.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/payment/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/request/index.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/detail/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/index.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/modules/form.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/modules/print.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/exception/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/leave/index.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/overtime/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/resignation/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/store/rtcStore.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/store/uiStore.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/meteringcheck/data.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/components/select.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/data.ts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/detail/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/index.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/parameter/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/template/index.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/iqc/data.ts 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/iqc/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/oqc/data.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/pendinginspect/index.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/FeedbackHistoryTab.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplierApply/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/miscreceipt/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnvendor/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
trt.bat 451 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
PRODUCT.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Operating Context
现有 MOM ç®¡ç†åŽå°ã€‚此次能耗功能面向青海的一家北京同仁堂企业,主要产品包含燕窝及滋补品原浆(用户提供)。
## Capabilities and Constraints
能耗管理是独立模块,手动按天录入水、电、蒸汽使用信息,提供增删改查和数据分析两个子菜单。用户已确认按天录入。区域、产品类别、用途均为本模块信息,不关联其他业务模块。分析支持按日/月汇总。
## Open Decisions
企业正式名称、实际区域名称与能源单价未提供;区域建议仅用于录入提示,价格由用户填写。
company.config.ts
@@ -71,4 +71,16 @@
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9006/admin-api',
  },
  HM: {
    name: '惠牧',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9013/admin-api',
  },
  TRT: {
    name: '北京同仁堂',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9015/admin-api',
  },
};
dist.zip
Binary files differ
hm.bat
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,451 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
REM ============================================================
REM Vue Frontend Deploy Script
REM ============================================================
REM =========================
REM Configuration
REM =========================
set "COMPANY=HM"
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/hm/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
scripts/check-analytics-permissions.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
const assert = require('node:assert/strict');
const { parse, compileTemplate } = require('vue/compiler-sfc');
const root = path.join(__dirname, '../src/views/dashboard/analytics');
function evaluate(source, mocks) {
  const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText;
  const module = { exports: {} };
  new Function('require', 'module', 'exports', 'defineOptions', 'defineProps', 'defineEmits', js)(
    (id) => mocks[id] || {}, module, module.exports, () => {}, () => ({ repairActiveCount: 0 }), () => () => {});
  return module.exports;
}
const vue = { computed: (getter) => ({ get value() { return getter(); } }), ref: (value) => ({ value }) };
async function scenario(codes, names, expected) {
  const store = { accessRoutes: [{ name: 'Parent', children: names.map((name) => ({ name })) }] };
  const home = evaluate(fs.readFileSync(path.join(root, 'use-home-access.ts'), 'utf8'), {
    vue, '@vben/stores': { useAccessStore: () => store },
    '@vben/access': { useAccess: () => ({ hasAccessByCodes: (requested) => requested.some((code) => codes.includes(code)) }) },
  });
  const calls = [];
  const mocks = { '../use-home-access': home, './use-home-access': home,
    'vue-router': { useRouter: () => ({ push: () => {} }) }, './data': { defaultSummary: {} } };
  for (const [id, method] of [
    ['#/api/mes/home', 'getHomeSummary'], ['#/api/mes/home', 'getProductionTrend'],
    ['#/api/mes/home', 'getWorkOrderStatusDistribution'], ['#/api/erp/purchase/order', 'getPurchaseOrderPage'],
    ['#/api/erp/sale/order', 'getSaleOrderPage'], ['#/api/mes/wm/materialstock', 'getMaterialStockPage'],
    ['#/api/bpm/task', 'getTaskTodoPage'],
  ]) {
    mocks[id] ||= {};
    mocks[id][method] = async () => { calls.push(method); return method.startsWith('getHome') ? {} : method.endsWith('Page') ? { total: 3 } : []; };
  }
  mocks['@vben/plugins/echarts'] = { useEcharts: () => ({ renderEcharts: () => {} }) };
  for (const file of ['index.vue', 'modules/biz-cards.vue', 'modules/production-trend.vue', 'modules/alert-panel.vue']) {
    const mounted = [];
    mocks.vue = { ...vue, onMounted: (fn) => mounted.push(fn) };
    const { descriptor } = parse(fs.readFileSync(path.join(root, file), 'utf8'));
    evaluate(descriptor.scriptSetup.content, mocks);
    mounted.forEach((fn) => fn());
  }
  await new Promise((resolve) => setImmediate(resolve));
  assert.deepEqual(calls.sort(), expected.sort());
  const access = home.useHomeAccess();
  store.accessRoutes = [{ name: 'Forbidden', meta: { menuVisibleWithForbidden: true }, children: [{ name: 'Child' }] }];
  assert.equal(access.canNavigate('Forbidden'), false);
  assert.equal(access.canNavigate('Child'), false);
}
(async () => {
  await scenario([], [], []);
  await scenario(['erp:purchase-order:query'], ['ErpPurchaseOrder'], ['getPurchaseOrderPage']);
  await scenario(['erp:purchase-order:query'], [], []);
  await scenario(['mes:home:query'], [], ['getHomeSummary', 'getProductionTrend', 'getWorkOrderStatusDistribution']);
  await scenario(['*:*:*'], ['ErpPurchaseOrder', 'ErpSaleOrder', 'MesWmMaterialStock', 'BpmTaskTodo'],
    ['getHomeSummary', 'getProductionTrend', 'getWorkOrderStatusDistribution', 'getPurchaseOrderPage', 'getSaleOrderPage', 'getMaterialStockPage', 'getTaskTodoPage']);
  for (const file of ['index.vue', ...fs.readdirSync(path.join(root, 'modules')).filter((f) => f.endsWith('.vue')).map((f) => `modules/${f}`)]) {
    const { descriptor, errors } = parse(fs.readFileSync(path.join(root, file), 'utf8'));
    assert.equal(errors.length, 0, file);
    const result = compileTemplate({ source: descriptor.template.content, filename: file, id: file });
    assert.equal(result.errors.length, 0, `${file}: ${result.errors}`);
  }
  console.log('PASS: 5 permission scenarios, forbidden routes, and all analytics Vue templates');
})();
scripts/check-logout.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,99 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
const vue = require('vue');
const pinia = require('pinia');
const root = path.join(__dirname, '..');
function load(file, mocks = {}) {
  const source = fs.readFileSync(path.join(root, file), 'utf8')
    .replaceAll('import.meta.hot', 'undefined')
    .replaceAll('import.meta.env', '({ DEV: true })');
  const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText;
  const module = { exports: {} };
  new Function('require', 'module', 'exports', 'localStorage', js)((id) => {
    if (id in mocks) return mocks[id];
    if (id === 'vue') return vue;
    if (id === 'pinia') return pinia;
    throw new Error(`Unexpected import ${id} in ${file}`);
  }, module, module.exports, {});
  return module.exports;
}
const constants = load('src/views/im/utils/constants.ts');
async function scenario(apiFails, resetFails, redirect) {
  const app = vue.createApp({});
  const setup = load('src/packages/stores/src/setup.ts', {
    'secure-ls': class {}, 'pinia-plugin-persistedstate': { createPersistedState: () => () => {} },
  });
  const instance = await setup.initStores(app, { namespace: 'logout-test' });
  pinia.setActivePinia(instance);
  const company = load('src/store/company-config.ts', {
    '#/api/system/company-config': {}, '#/utils/company-config': {},
  }).useCompanyConfigStore();
  company.companyName = 'Test company';
  company.logoUrl = '/test-logo.png';
  company.loginTitle = 'Test';
  company.loginDescription = 'Test description';
  const ui = load('src/views/im/home/store/uiStore.ts', { '../../utils/constants': constants }).useImUiStore();
  ui.userInfoCard.show = true;
  ui.userInfoCard.user = { id: 1 };
  ui.contextMenu.items = [{ key: 'private', name: 'private' }];
  ui.contextMenu.onSelect = () => {};
  ui.nextUnreadJumpNonce = 3;
  const rtc = load('src/views/im/home/store/rtcStore.ts', {
    '../../utils/constants': constants, '#/views/im/utils/auth': {}, './friendStore': {},
    './groupStore': { useGroupStore: () => ({ markGroupActiveCallLoaded: () => {} }) },
  }).useRtcStore();
  rtc.stage = constants.ImRtcCallStage.RUNNING;
  rtc.call = { id: 1 };
  rtc.setGroupCall({ groupId: 1, room: 'test', joinedUserIds: [1], inviteeIds: [] });
  const accessModule = load('src/packages/stores/src/modules/access.ts');
  const userModule = load('src/packages/stores/src/modules/user.ts');
  const access = accessModule.useAccessStore();
  access.setAccessToken('old-access');
  access.setRefreshToken('old-refresh');
  access.setIsAccessChecked(true);
  access.setLoginExpired(true);
  userModule.useUserStore().setUserInfo({ userId: 1 });
  if (resetFails) pinia.defineStore('broken-business-store', () => ({ count: vue.ref(1) }))();
  const navigations = [];
  const apiCalls = [];
  const auth = load('src/store/auth.ts', {
    'vue-router': { useRouter: () => ({ currentRoute: vue.ref({ fullPath: '/analytics?tab=1' }), replace: async (to) => navigations.push(to) }) },
    '@vben/constants': { LOGIN_PATH: '/auth/login' }, '@vben/preferences': {},
    '@vben/stores': { ...setup, ...accessModule, ...userModule }, 'ant-design-vue': {}, '#/locales': {},
    '#/api': { logoutApi: async (token) => { apiCalls.push(token); if (apiFails) throw new Error('offline'); } },
  }).useAuthStore();
  const errors = [];
  const originalError = console.error;
  console.error = (...args) => errors.push(args);
  try { await auth.logout(redirect); } finally { console.error = originalError; }
  assert.deepEqual(apiCalls, ['old-access']);
  assert.equal(navigations.length, 1);
  assert.deepEqual(navigations[0], { path: '/auth/login', query: redirect ? { redirect: encodeURIComponent('/analytics?tab=1') } : {} });
  assert.equal(access.accessToken, null);
  assert.equal(access.refreshToken, null);
  assert.equal(access.isAccessChecked, false);
  assert.equal(access.loginExpired, false);
  assert.equal(userModule.useUserStore().userInfo, null);
  assert.equal(company.companyName, '');
  assert.equal(company.logoUrl, '');
  assert.equal(company.loginTitle, '');
  assert.equal(company.loginDescription, '');
  assert.equal(ui.userInfoCard.show, false);
  assert.equal(ui.userInfoCard.user, null);
  assert.deepEqual(ui.contextMenu.items, []);
  assert.equal(ui.contextMenu.onSelect, null);
  assert.equal(ui.nextUnreadJumpNonce, 0);
  assert.equal(rtc.stage, constants.ImRtcCallStage.IDLE);
  assert.equal(rtc.call, null);
  assert.equal(rtc.getGroupCall(1), undefined);
  assert.equal(errors.length, resetFails ? 1 : 0);
}
(async () => {
  await scenario(false, false, false);
  await scenario(true, false, true);
  await scenario(false, true, false);
  await scenario(true, true, false);
  console.log('PASS: logout redirects and clears session with API success/failure and store reset failures; company and IM stores reset');
})();
src/adapter/form.ts
@@ -8,6 +8,10 @@
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { isMobile } from '@vben/utils';
import { watch } from 'vue';
import { automaticApproval } from '#/store/audit-policy';
import { isAuditField } from '#/utils/audit-ui-policy';
async function initSetupVbenForm() {
  setupVbenForm<ComponentType>({
@@ -61,7 +65,16 @@
  });
}
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
const useVbenForm = (options: FormProps<ComponentType, ComponentPropsMap>) => {
  const result = useForm<ComponentType, ComponentPropsMap>(options);
  const schema = options.schema;
  if (schema) {
    watch(automaticApproval, () => {
      result[1].setState({ schema: schema.filter((item) => !automaticApproval.value || !isAuditField(item)) });
    }, { immediate: true });
  }
  return result;
};
export { initSetupVbenForm, useVbenForm, z };
src/adapter/vxe-table.ts
@@ -3,7 +3,7 @@
import type { ComponentPropsMap, ComponentType } from './component';
import { h } from 'vue';
import { h, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $te } from '@vben/locales';
@@ -35,6 +35,8 @@
import { DictTag } from '#/components/dict-tag';
import { $t } from '#/locales';
import { automaticApproval } from '#/store/audit-policy';
import { isAuditField } from '#/utils/audit-ui-policy';
import { useVbenForm } from './form';
@@ -403,6 +405,17 @@
export * from '#/components/table-action';
export const useVbenVxeGrid = <T extends Record<string, any>>(
  ...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
) => {
  const options = rest[0];
  const columns = options.gridOptions?.columns;
  const schema = options.formOptions?.schema;
  const applyPolicy = () => ({
    ...(columns ? { gridOptions: { columns: columns.filter((item) => !automaticApproval.value || !isAuditField(item)) } } : {}),
    ...(schema ? { formOptions: { schema: schema.filter((item) => !automaticApproval.value || !isAuditField(item)) } } : {}),
  });
  const result = useGrid<T, ComponentType, ComponentPropsMap>(options);
  watch(automaticApproval, () => result[1].setState(applyPolicy()), { immediate: true });
  return result;
};
export type * from '@vben/plugins/vxe-table';
src/api/bpm/autoApprove/index.ts
@@ -1,4 +1,5 @@
import { requestClient } from '#/api/request';
import { automaticApproval } from '#/store/audit-policy';
export namespace BpmAutoApproveApi {
  /** æµç¨‹å…å®¡æ‰¹é…ç½® */
@@ -35,9 +36,11 @@
  if (remark) {
    params.append('remark', remark);
  }
  return requestClient.put<boolean>(
  const result = await requestClient.put<boolean>(
    `/bpm/process-auto-approve/update-global?${params.toString()}`,
  );
  automaticApproval.value = autoApprove;
  return result;
}
/** æŒ‰æµç¨‹å®šä¹‰çš„ Key è®¾ç½®å…å®¡æ‰¹å¼€å…³ï¼ˆä¸å­˜åœ¨åˆ™æ–°å¢žã€å­˜åœ¨åˆ™æ›´æ–°ï¼‰ */
src/api/energy/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,96 @@
import type { PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export type EnergyType = 'ELECTRICITY' | 'STEAM' | 'WATER';
export interface EnergyRecord {
  id?: number;
  recordDate: string;
  energyType: EnergyType;
  usageArea: string;
  productCategory: string;
  usagePurpose: string;
  quantity: number;
  unitPrice: number;
  amount?: number;
  recorder: string;
  remark?: string;
  createTime?: string;
}
export interface EnergyFilters {
  startDate?: string;
  endDate?: string;
  energyType?: EnergyType;
  usageArea?: string;
  productCategory?: string;
}
export interface EnergySummary {
  energyType: EnergyType;
  quantity: number;
  amount: number;
  recordCount: number;
}
export interface EnergyBucket extends EnergySummary { label: string }
export interface EnergyAnalysis {
  totalAmount: number;
  recordCount: number;
  summary: EnergySummary[];
  trend: EnergyBucket[];
  areas: EnergyBucket[];
}
export function getEnergyRecordPage(params: EnergyFilters & { pageNo: number; pageSize: number }) {
  return requestClient.get<PageResult<EnergyRecord>>('/energy/record/page', { params });
}
export function getEnergyRecord(id: number) {
  return requestClient.get<EnergyRecord>('/energy/record/get', { params: { id } });
}
export function createEnergyRecord(data: EnergyRecord) {
  return requestClient.post<number>('/energy/record/create', data);
}
export function updateEnergyRecord(data: EnergyRecord) {
  return requestClient.put('/energy/record/update', data);
}
export function deleteEnergyRecord(id: number) {
  return requestClient.delete('/energy/record/delete', { params: { id } });
}
export function getEnergyAnalysis(params: EnergyFilters & { startDate: string; endDate: string; groupBy: 'DAY' | 'MONTH' }) {
  return requestClient.get<EnergyAnalysis>('/energy/record/analysis', { params });
}
export function exportEnergyRecords(params: EnergyFilters) {
  return requestClient.download('/energy/record/export-excel', { params });
}
export function exportEnergyAnalysis(params: EnergyFilters & { startDate: string; endDate: string; groupBy: 'DAY' | 'MONTH' }) {
  return requestClient.download('/energy/record/analysis-export', { params });
}
export type EnergyAlertMetric = 'AMOUNT' | 'QUANTITY' | 'MONTH_AMOUNT' | 'MONTH_QUANTITY';
export interface EnergyAlertRule {
  id?: number;
  name: string;
  energyType: EnergyType;
  usageArea?: string;
  productCategory?: string;
  metric: EnergyAlertMetric;
  threshold: number;
  enabled: boolean;
  recipientIds: number[];
}
export interface EnergyAlertLog {
  id: number; ruleName: string; recordDate: string; energyType: EnergyType;
  scope: string; metric: EnergyAlertMetric; periodType: 'DAY' | 'MONTH'; actualValue: number; threshold: number;
  recipientName: string; status: 'FAILED' | 'SENT'; errorMessage: string; createTime: string;
}
export async function getEnergyAlertRules(params: { pageNo: number; pageSize: number }) {
  const result = await requestClient.get<PageResult<Omit<EnergyAlertRule, 'recipientIds'> & { recipientIds: string }>>('/energy/alert/page', { params });
  return { ...result, list: result.list.map(row => ({ ...row, recipientIds: row.recipientIds.split(',').filter(Boolean).map(Number) })) };
}
export function getEnergyAlertLogs(params: { pageNo: number; pageSize: number; date?: string; status?: string }) {
  return requestClient.get<PageResult<EnergyAlertLog>>('/energy/alert/logs', { params });
}
export function getEnergyAlertRecipients() {
  return requestClient.get<{ id: number; nickname: string }[]>('/energy/alert/recipients');
}
export function saveEnergyAlertRule(data: EnergyAlertRule) {
  return data.id ? requestClient.put('/energy/alert/update', data) : requestClient.post('/energy/alert/create', data);
}
export function deleteEnergyAlertRule(id: number) { return requestClient.delete('/energy/alert/delete', { params: { id } }); }
export function checkEnergyAlerts(date: string) { return requestClient.post<{ sent: number; failed: number }>('/energy/alert/check', undefined, { params: { date } }); }
src/api/hrm/resignation/index.ts
@@ -58,7 +58,7 @@
/** æäº¤ç¦»èŒç”³è¯·ï¼ˆå‘起审批) */
export function submitResignation(id: number) {
  return requestClient.post(`/hrm/resignation/submit?id=${id}`);
  return requestClient.post(`/hrm/resignation/submit?id=${id}&processDefinitionKey=hrm_resignation_approve`);
}
/** æ’¤é”€ç¦»èŒç”³è¯· */
src/api/hrm/transfer/index.ts
@@ -59,7 +59,7 @@
/** æäº¤è°ƒå²—申请(发起审批) */
export function submitTransfer(id: number) {
  return requestClient.post(`/hrm/transfer/submit?id=${id}`);
  return requestClient.post(`/hrm/transfer/submit?id=${id}&processDefinitionKey=hrm_transfer_approve`);
}
/** æ’¤é”€è°ƒå²—申请 */
src/api/mes/dv/meteringcheck/index.ts
@@ -15,6 +15,7 @@
    checkResult?: number; // æ£€æµ‹ç»“论(字典)
    certNo?: string; // è¯ä¹¦ç¼–号
    checkUserId?: number; // æ£€æµ‹äººç¼–号
    checkUserName?: string; // æ‰‹å·¥å¡«å†™çš„æ£€æµ‹äººå§“名
    checkUserNickname?: string; // æ£€æµ‹äººæ˜µç§°
    nextCheckDate?: string; // ä¸‹æ¬¡æ£€æµ‹æ—¥æœŸ
    remark?: string; // å¤‡æ³¨
src/api/mes/qc/iqc/index.ts
@@ -14,6 +14,7 @@
    sourceLineId?: number; // æ¥æºå•据行 ID
    sourceDocCode?: string; // æ¥æºå•据编号(关联查询)
    vendorId?: number; // ä¾›åº”商 ID
    vendorName?: string; // ä¾›åº”商名称(手填)
    vendorNickname?: string; // ä¾›åº”商简称(关联查询)
    vendorBatch?: string; // ä¾›åº”商批次号
    itemId?: number; // äº§å“ç‰©æ–™ ID
src/components/description/description.vue
@@ -11,6 +11,9 @@
import { Card, Descriptions } from 'ant-design-vue';
import { automaticApproval } from '#/store/audit-policy';
import { isAuditField } from '#/utils/audit-ui-policy';
const props = {
  bordered: { default: true, type: Boolean },
  column: {
@@ -99,6 +102,7 @@
      const { data, schema } = unref(getProps);
      return unref(schema)
        .map((item) => {
          if (automaticApproval.value && isAuditField(item)) return null;
          const { contentMinWidth, field, render, show, span } = item;
          if (show && isFunction(show) && !show(data)) {
src/components/table-action/table-action.vue
@@ -20,6 +20,9 @@
  Tooltip,
} from 'ant-design-vue';
import { automaticApproval } from '#/store/audit-policy';
import { auditActionMode } from '#/utils/audit-ui-policy';
const props = defineProps({
  actions: {
    type: Array as PropType<ActionItem[]>,
@@ -43,6 +46,7 @@
/** æ£€æŸ¥æ˜¯å¦æ˜¾ç¤º */
function isIfShow(action: ActionItem): boolean {
  if (auditActionMode(action.label, automaticApproval.value) === 'hidden') return false;
  const ifShow = action.ifShow;
  let isIfShow = true;
  if (isBoolean(ifShow)) {
@@ -61,15 +65,21 @@
/** å¤„理按钮 actions */
const getActions = computed(() => {
  const actions = props.actions || [];
  return actions.filter((action: ActionItem) => isIfShow(action));
  return actions.filter((action: ActionItem) => isIfShow(action)).map(displayAction);
});
/** å¤„理下拉菜单 actions */
const getDropdownList = computed(() => {
  const dropDownActions = props.dropDownActions || [];
  return dropDownActions.filter((action: ActionItem) => isIfShow(action));
  return dropDownActions.filter((action: ActionItem) => isIfShow(action)).map(displayAction);
});
function displayAction(action: ActionItem): ActionItem {
  return auditActionMode(action.label, automaticApproval.value) === 'activate'
    ? { ...action, label: '生效', tooltip: '提交后自动生效' }
    : action;
}
/** Space ç»„ä»¶çš„ size */
const spaceSize = computed(() => {
  const actions = unref(getActions);
src/packages/@core/base/shared/dist/cache/index.d.ts
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/cache/index.mjs
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/color/index.d.ts
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/color/index.mjs
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/constants/index.d.ts
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/constants/index.mjs
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/global-state.d.ts
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/global-state.mjs
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/store.d.ts
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/store.mjs
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/utils/index.d.ts
ÎļþÒÑɾ³ý
src/packages/@core/base/shared/dist/utils/index.mjs
ÎļþÒÑɾ³ý
src/packages/stores/src/setup.ts
@@ -76,6 +76,11 @@
  }
  const allStores = (pinia as any)._s;
  for (const [_key, store] of allStores) {
    store.$reset();
    try {
      store.$reset();
    } catch (error) {
      // å•个业务 Store çš„异常不能中断认证清理和退出登录跳转。
      console.error(`Failed to reset store: ${_key}`, error);
    }
  }
}
src/store/audit-policy.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
import { ref } from 'vue';
import { requestClient } from '#/api/request';
export const automaticApproval = ref(false);
/** Loaded with each authenticated session, before business pages are rendered. */
export async function loadAuditPolicy() {
  try {
    automaticApproval.value = await requestClient.get<boolean>('/bpm/process-auto-approve/ui-policy');
  } catch {
    // A failed policy request must not remove the normal submission path.
    automaticApproval.value = false;
  }
}
src/store/auth.ts
@@ -21,6 +21,7 @@
  socialLogin,
} from '#/api';
import { $t } from '#/locales';
import { automaticApproval, loadAuditPolicy } from '#/store/audit-policy';
export const useAuthStore = defineStore('auth', () => {
  const accessStore = useAccessStore();
@@ -134,6 +135,7 @@
    // eslint-disable-next-line no-useless-assignment
    let authPermissionInfo: AuthPermissionInfo | null = null;
    authPermissionInfo = await getAuthPermissionInfoApi();
    await loadAuditPolicy();
    // userStore
    userStore.setUserInfo(authPermissionInfo.user);
    userStore.setUserRoles(authPermissionInfo.roles);
@@ -145,6 +147,7 @@
  function $reset() {
    loginLoading.value = false;
    automaticApproval.value = false;
  }
  return {
src/store/company-config.ts
@@ -59,7 +59,15 @@
    applyToPreferences();
  }
  function $reset() {
    companyName.value = '';
    logoUrl.value = '';
    loginTitle.value = '';
    loginDescription.value = '';
  }
  return {
    $reset,
    companyName,
    logoUrl,
    loginTitle,
src/utils/audit-ui-policy.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
/** Review-only UI disappears in automatic mode; document status and activation remain. */
export function isAuditField(item: { field?: string; fieldName?: string; label?: unknown; title?: unknown }) {
  return /^(auditStatus|auditUserId|auditTime|approvalStatus|approvalUserId|approvalTime|processInstanceId)$/.test(item.field ?? item.fieldName ?? '')
    || /^(审批状态|审核状态|审批人|审核人|审批时间|审核时间|流程实例)$/.test(String(item.title ?? item.label ?? ''));
}
export function auditActionMode(label: string | undefined, automatic: boolean) {
  if (!automatic) return 'normal';
  if (/^(提交审批|提交审核|审核|审批|通过|审核通过)$/.test(label ?? '')) return 'activate';
  if (/^(查看审批|查看审核|审批进度|查看审批进度|反审核|撤销审批|取消审批|驳回|拒绝)$/.test(label ?? '')) return 'hidden';
  return 'normal';
}
src/views/basicData/mdm/modules/import-form.vue
@@ -1,18 +1,28 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import { h } from 'vue';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, notification, Upload } from 'ant-design-vue';
import { Button, message, Upload } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { importItem, importItemTemplate } from '#/api/mdm/item';
import { $t } from '#/locales';
import { useImportFormSchema } from '../data';
/** å¯¼å…¥ç»“果,对应后端 MdmItemImportRespVO */
interface ImportResult {
  /** æ–°å¢žæˆåŠŸçš„ç‰©æ–™ */
  createList?: string[];
  /** æ›´æ–°æˆåŠŸçš„ç‰©æ–™ */
  updateList?: string[];
  /** å¤±è´¥æ˜Žç»†ï¼škey ä¸ºã€Œç‰©æ–™ç¼–码 åç§°ã€ï¼Œvalue ä¸ºå¤±è´¥åŽŸå›  */
  failureList?: Record<string, string>;
}
const emit = defineEmits(['success']);
@@ -26,6 +36,17 @@
  showDefaultActions: false,
});
const importResult = ref<ImportResult>({});
const createCount = computed(() => importResult.value.createList?.length ?? 0);
const updateCount = computed(() => importResult.value.updateList?.length ?? 0);
const failureEntries = computed(() =>
  Object.entries(importResult.value.failureList ?? {}),
);
const failureCount = computed(() => failureEntries.value.length);
// æ³¨æ„ï¼šæœ¬ç»„件里的多个 useVbenModal æœ‰å…ˆåŽé¡ºåºè¦æ±‚——
// çˆ¶é¡µé¢ç”¨ useVbenModal({ connectedComponent: ImportForm }) é€šè¿‡ provide/inject ç»‘定,
// åªæœ‰ç¬¬ä¸€ä¸ªè°ƒç”¨ä¼šæŽ¥ç®¡è¿™ä¸ªç»‘定。所以导入窗口必须声明在最前,其余弹框(如结果弹框)排在后面。
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
@@ -37,29 +58,45 @@
    const data = await formApi.getValues();
    try {
      const response = (await importItem(data.file, data.updateSupport)) as any;
      // å…³é—­å¹¶æç¤º
      await modalApi.close();
      const resultData = (response?.data || response || {}) as ImportResult;
      const hasFailure = Object.keys(resultData.failureList ?? {}).length > 0;
      emit('success');
      const resultData = response?.data || response || {};
      const failureList = resultData.failureList || {};
      if (Object.keys(failureList).length > 0) {
        const errorNodes = Object.entries(failureList).map(([name, reason]) => {
          return h('div', `${name}: ${reason}`);
        });
        notification.warning({
          message: '导入完成,部分物料导入失败',
          description: () => h('div', errorNodes),
          duration: null,
        });
      if (hasFailure) {
        // ä¸èƒ½åœ¨è¿™é‡Œå…ˆå…³å¯¼å…¥çª—口:父页面配置了 destroyOnClose,
        // å…³çª—会把本组件整个重建,刚打开的结果弹框会被连带卸载(闪一下就消失)。
        // æ”¹ä¸ºæŠŠç»“果弹框叠在上面,等它关闭后再关导入窗口(见下面 onClosed)。
        importResult.value = resultData;
        resultModalApi.open();
      } else {
        importResult.value = {};
        message.success($t('ui.actionMessage.operationSuccess'));
        await modalApi.close();
      }
    } finally {
      modalApi.unlock();
    }
  },
});
/** å¯¼å…¥ç»“果弹框:失败明细可能很长,弹框内固定高度滚动,避免像通知条那样撑出屏幕 */
const [ResultModal, resultModalApi] = useVbenModal({
  onOpenChange(isOpen: boolean) {
    if (isOpen) {
      resultModalApi.setState({
        confirmText: '知道了',
        showCancelButton: false,
      });
    }
  },
  // å¿…须显式提供:ModalApi.onConfirm æ˜¯ this.api.onConfirm?.() çš„可选调用,不传则点按钮毫无反应
  onConfirm() {
    resultModalApi.close();
  },
  // ç»“果弹框关闭后再关导入窗口(此时本组件被销毁是安全的)
  onClosed() {
    modalApi.close();
  },
});
@@ -102,4 +139,46 @@
      </div>
    </template>
  </Modal>
  <ResultModal title="导入结果" class="w-[760px]">
    <div class="mx-4">
      <div class="mb-3 flex flex-wrap gap-x-6 gap-y-1 text-sm">
        <span>
          æ–°å¢žï¼š<span class="font-medium text-green-600">{{ createCount }}</span>
          æ¡
        </span>
        <span>
          æ›´æ–°ï¼š<span class="font-medium text-blue-600">{{ updateCount }}</span>
          æ¡
        </span>
        <span>
          å¤±è´¥ï¼š<span class="font-medium text-red-600">{{ failureCount }}</span>
          æ¡
        </span>
      </div>
      <div
        v-if="failureCount > 0"
        class="overflow-hidden rounded-md border border-red-100"
      >
        <div
          class="border-b border-red-100 bg-red-50 px-3 py-2 text-sm font-medium text-red-600"
        >
          å¤±è´¥æ˜Žç»†ï¼ˆå…± {{ failureCount }} æ¡ï¼Œä¸‹æ–¹åŒºåŸŸå¯æ»šåŠ¨æŸ¥çœ‹ï¼‰
        </div>
        <div class="max-h-[60vh] overflow-y-auto">
          <div
            v-for="[itemKey, itemReason] in failureEntries"
            :key="itemKey"
            class="flex gap-2 border-b border-gray-100 px-3 py-2 text-sm last:border-b-0"
          >
            <span class="w-56 shrink-0 break-all text-gray-700">
              {{ itemKey }}
            </span>
            <span class="break-all text-red-500">{{ itemReason }}</span>
          </div>
        </div>
      </div>
    </div>
  </ResultModal>
</template>
src/views/basicData/mdm/unit/index.vue
@@ -122,14 +122,14 @@
              label: $t('ui.actionTitle.import', ['计量单位']),
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['mdm:unit-measure:import'],
              auth: ['mdm:unit:import'],
              onClick: handleImport,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mdm:unit-measure:export'],
              auth: ['mdm:unit:export'],
              onClick: handleExport,
            },
          ]"
src/views/bpm/model/index.vue
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import type { ModelCategoryInfo } from '#/api/bpm/model';
import {
@@ -196,6 +197,7 @@
  globalAutoApproveLoading.value = true;
  try {
    await updateGlobalAutoApprove(checked);
    automaticApproval.value = checked;
    message.success(
      checked
        ? '已开启全局免审批:所有流程提交后将直接通过'
src/views/bpm/oa/leave/create.vue
@@ -18,6 +18,7 @@
import { getApprovalDetail as getApprovalDetailApi } from '#/api/bpm/processInstance';
import { $t } from '#/locales';
import { router } from '#/router';
import { automaticApproval } from '#/store/audit-policy';
import ProcessInstanceTimeline from '#/views/bpm/processInstance/detail/modules/time-line.vue';
import { useFormSchema } from './data';
@@ -67,7 +68,7 @@
    return;
  }
  // 1.2 å®¡æ‰¹ç›¸å…³ï¼šæ ¡éªŒæŒ‡å®šå®¡æ‰¹äºº
  if (startUserSelectTasks.value?.length > 0) {
  if (!automaticApproval.value && startUserSelectTasks.value?.length > 0) {
    for (const userTask of startUserSelectTasks.value) {
      const assignees = startUserSelectAssignees.value[userTask.id];
      if (Array.isArray(assignees) && assignees.length === 0) {
@@ -79,7 +80,7 @@
  // æäº¤è¡¨å•
  const data = (await formApi.getValues()) as LeaveCreateData;
  // å®¡æ‰¹ç›¸å…³ï¼šè®¾ç½®æŒ‡å®šå®¡æ‰¹äºº
  if (startUserSelectTasks.value?.length > 0) {
  if (!automaticApproval.value && startUserSelectTasks.value?.length > 0) {
    data.startUserSelectAssignees = startUserSelectAssignees.value;
  }
  // æ ¼å¼åŒ–开始时间和结束时间的值
@@ -118,6 +119,7 @@
/** å®¡æ‰¹ç›¸å…³ï¼šèŽ·å–å®¡æ‰¹è¯¦æƒ… */
async function getApprovalDetail() {
  if (automaticApproval.value) return;
  processTimeLineLoading.value = true;
  try {
    const data = await getApprovalDetailApi({
@@ -144,7 +146,7 @@
        BpmCandidateStrategyEnum.START_USER_SELECT === node.candidateStrategy,
    );
    // æ¢å¤ä¹‹å‰çš„选择审批人
    if (startUserSelectTasks.value?.length > 0) {
    if (!automaticApproval.value && startUserSelectTasks.value?.length > 0) {
      for (const node of startUserSelectTasks.value) {
        const tempAssignees = tempStartUserSelectAssignees.value[node.id];
        startUserSelectAssignees.value[node.id] = tempAssignees?.length
@@ -212,6 +214,10 @@
/** åˆå§‹åŒ– */
onMounted(async () => {
  if (automaticApproval.value) {
    if (query.id) await getDetail(Number(query.id));
    return;
  }
  const processDefinitionDetail: any = await getProcessDefinition(
    undefined,
    processDefineKey,
src/views/crm/backlog/index.vue
@@ -1,9 +1,10 @@
<script lang="ts" setup>
import { computed, onActivated, onMounted, ref } from 'vue';
import { computed, onActivated, onMounted, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { Badge, Card, List } from 'ant-design-vue';
import { automaticApproval } from '#/store/audit-policy';
import { getFollowClueCount } from '#/api/crm/clue';
import {
@@ -49,6 +50,14 @@
  receivableAuditCount,
  receivablePlanRemindCount,
);
const visibleLeftSides = computed(() => leftSides.filter((item) =>
  !automaticApproval.value || !['contractAudit', 'receivableAudit'].includes(item.menu),
));
watch(automaticApproval, (automatic) => {
  if (automatic && ['contractAudit', 'receivableAudit'].includes(leftMenu.value)) {
    leftMenu.value = 'customerTodayContact';
  }
});
const currentComponent = computed(() => {
  const components = {
@@ -75,9 +84,9 @@
  customerPutPoolRemindCount.value = await getPutPoolRemindCustomerCount();
  customerFollowCount.value = await getFollowCustomerCount();
  clueFollowCount.value = await getFollowClueCount();
  contractAuditCount.value = await getAuditContractCount();
  if (!automaticApproval.value) contractAuditCount.value = await getAuditContractCount();
  contractRemindCount.value = await getRemindContractCount();
  receivableAuditCount.value = await getAuditReceivableCount();
  if (!automaticApproval.value) receivableAuditCount.value = await getAuditReceivableCount();
  receivablePlanRemindCount.value = await getReceivablePlanRemindCount();
}
@@ -95,7 +104,7 @@
  <Page auto-content-height>
    <div class="flex h-full w-full">
      <Card class="w-1/5">
        <List item-layout="horizontal" :data-source="leftSides">
        <List item-layout="horizontal" :data-source="visibleLeftSides">
          <template #renderItem="{ item }">
            <List.Item
              @click="sideClick(item)"
src/views/crm/contract/components/data.ts
@@ -28,13 +28,13 @@
      title: '合同开始时间',
      field: 'startTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '合同结束时间',
      field: 'endTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '已回款金额(元)',
@@ -78,7 +78,7 @@
      title: '创建时间',
      field: 'createTime',
      minWidth: 150,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '创建人',
src/views/crm/contract/data.ts
@@ -368,19 +368,19 @@
      title: '下单时间',
      field: 'orderDate',
      minWidth: 120,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '合同开始时间',
      field: 'startTime',
      minWidth: 120,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '合同结束时间',
      field: 'endTime',
      minWidth: 120,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '客户签约人',
@@ -430,7 +430,7 @@
      title: '最后跟进时间',
      field: 'contactLastTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '负责人',
@@ -446,13 +446,13 @@
      title: '更新时间',
      field: 'updateTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '创建时间',
      field: 'createTime',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '创建人',
src/views/crm/contract/detail/approve/index.vue
@@ -10,7 +10,7 @@
import {
  erpCountInputFormatter,
  erpPriceInputFormatter,
  formatDateTime,
  formatDate,
} from '@vben/utils';
import { Spin } from 'ant-design-vue';
@@ -116,17 +116,17 @@
    {
      label: '下单时间',
      field: 'orderDate',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      label: '合同开始时间',
      field: 'startTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      label: '合同结束时间',
      field: 'endTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    { label: '客户签约人', field: 'signContactName', render: textRender },
    { label: '公司签约人', field: 'signUserName', render: textRender },
@@ -135,7 +135,7 @@
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      label: '合同状态',
src/views/crm/contract/detail/data.ts
@@ -3,7 +3,7 @@
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { erpPriceInputFormatter, formatDate } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
@@ -27,7 +27,7 @@
    {
      field: 'orderDate',
      label: '下单时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'totalReceivablePrice',
@@ -88,17 +88,17 @@
    {
      field: 'orderDate',
      label: '下单时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'startTime',
      label: '合同开始时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'endTime',
      label: '合同结束时间',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      field: 'signContactName',
src/views/crm/contract/index.vue
@@ -20,6 +20,7 @@
  submitContract,
} from '#/api/crm/contract';
import { $t } from '#/locales';
import { automaticApproval } from '#/store/audit-policy';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
@@ -97,10 +98,15 @@
const submitLoading = ref(false);
async function handleSubmit(row: CrmContractApi.Contract) {
  const res = await getContractApproveProcessList();
  const res = automaticApproval.value ? [] : await getContractApproveProcessList();
  processList.value = res;
  currentRow.value = row;
  selectedProcessKey.value = undefined;
  if (automaticApproval.value) {
    selectedProcessKey.value = 'contract_approve';
    await confirmSubmit();
    return;
  }
  submitModalVisible.value = true;
}
@@ -112,7 +118,7 @@
  submitLoading.value = true;
  try {
    await submitContract(currentRow.value!.id!, selectedProcessKey.value);
    message.success('提交审批成功');
    message.success(automaticApproval.value ? '已提交生效' : '提交审批成功');
    submitModalVisible.value = false;
    handleRefresh();
  } finally {
src/views/crm/customer/data.ts
@@ -354,13 +354,6 @@
      minWidth: 180,
    },
    {
      field: 'poolDay',
      title: '距离进入公海天数',
      minWidth: 140,
      formatter: ({ cellValue }) =>
        cellValue === null ? '-' : `${cellValue} å¤©`,
    },
    {
      field: 'ownerUserName',
      title: '负责人',
      minWidth: 100,
src/views/crm/customer/detail/modules/distribute-form.vue
@@ -1,4 +1,6 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
@@ -10,6 +12,7 @@
import { useDistributeFormSchema } from '../data';
const emit = defineEmits(['success']);
const customerIds = ref<number[]>([]);
const [Form, formApi] = useVbenForm({
  commonConfig: {
@@ -34,7 +37,7 @@
    // æäº¤è¡¨å•
    const data = await formApi.getValues();
    try {
      await distributeCustomer([data.id], data.ownerUserId);
      await distributeCustomer(customerIds.value, data.ownerUserId);
      // å…³é—­å¹¶æç¤º
      await modalApi.close();
      emit('success');
@@ -48,13 +51,15 @@
      return;
    }
    // åŠ è½½æ•°æ®
    const data = modalApi.getData();
    if (!data || !data.id) {
    const data = modalApi.getData<{ id?: number; ids?: number[] }>();
    customerIds.value = data?.ids ?? (data?.id ? [data.id] : []);
    if (customerIds.value.length === 0) {
      return;
    }
    modalApi.lock();
    try {
      await formApi.setValues(data);
      await formApi.resetForm();
      await formApi.setValues({ id: customerIds.value[0] });
    } finally {
      modalApi.unlock();
    }
src/views/crm/customer/pool/data.ts
@@ -60,6 +60,7 @@
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    { type: 'checkbox', width: 50, fixed: 'left' },
    {
      title: '客户名称',
      field: 'name',
@@ -157,5 +158,11 @@
      field: 'creatorName',
      minWidth: 100,
    },
    {
      title: '操作',
      width: 150,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/crm/customer/pool/index.vue
@@ -2,24 +2,86 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerApi } from '#/api/crm/customer';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button } from 'ant-design-vue';
import { Button, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { exportCustomer, getCustomerPage } from '#/api/crm/customer';
import {
  exportCustomer,
  getCustomerPage,
  receiveCustomer,
} from '#/api/crm/customer';
import { $t } from '#/locales';
import DistributeForm from '../detail/modules/distribute-form.vue';
import { useGridColumns, useGridFormSchema } from './data';
const { push } = useRouter();
const receiving = ref(false);
const [DistributeModal, distributeModalApi] = useVbenModal({
  connectedComponent: DistributeForm,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.grid.clearCheckboxRow();
  gridApi.query();
}
function getCustomerIds(row?: CrmCustomerApi.Customer): number[] {
  const records: CrmCustomerApi.Customer[] = row
    ? [row]
    : gridApi.grid.getCheckboxRecords();
  const ids = records.map((item) => item.id!);
  if (ids.length === 0) {
    message.warning('请先勾选客户');
  }
  return ids;
}
/** é¢†å–单条或勾选的公海客户 */
async function handleReceive(row?: CrmCustomerApi.Customer) {
  if (receiving.value) return;
  const ids = getCustomerIds(row);
  if (ids.length === 0) return;
  receiving.value = true;
  try {
    try {
      await confirm(
        row
          ? `确定领取客户【${row.name}】吗?`
          : `确定领取选中的 ${ids.length} ä¸ªå®¢æˆ·å—?`,
      );
    } catch {
      return;
    }
    await receiveCustomer(ids);
    message.success('领取客户成功');
    handleRefresh();
  } finally {
    receiving.value = false;
  }
}
/** åˆ†é…å•条或勾选的公海客户 */
function handleDistribute(row?: CrmCustomerApi.Customer) {
  const ids = getCustomerIds(row);
  if (ids.length === 0) return;
  distributeModalApi.setData({ ids }).open();
}
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const data = await exportCustomer(await gridApi.formApi.getValues());
  const data = await exportCustomer({
    ...(await gridApi.formApi.getValues()),
    pool: true,
    sceneType: 4,
  });
  downloadFileFromBlobPart({ fileName: '客户公海.xls', source: data });
}
@@ -62,10 +124,25 @@
</script>
<template>
  <Page auto-content-height><Grid>
  <Page auto-content-height>
    <DistributeModal @success="handleRefresh" />
    <Grid>
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '领取客户',
              type: 'primary',
              auth: ['crm:customer:receive'],
              disabled: receiving,
              onClick: () => handleReceive(),
            },
            {
              label: '分配客户',
              auth: ['crm:customer:distribute'],
              disabled: receiving,
              onClick: () => handleDistribute(),
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
@@ -76,6 +153,26 @@
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '领取',
              type: 'link',
              auth: ['crm:customer:receive'],
              disabled: receiving,
              onClick: () => handleReceive(row),
            },
            {
              label: '分配',
              type: 'link',
              auth: ['crm:customer:distribute'],
              disabled: receiving,
              onClick: () => handleDistribute(row),
            },
          ]"
        />
      </template>
      <template #name="{ row }">
        <Button type="link" @click="handleDetail(row)">
          {{ row.name }}
src/views/crm/invoice/index.vue
@@ -18,6 +18,7 @@
  submitInvoice,
} from '#/api/crm/invoice';
import { $t } from '#/locales';
import { automaticApproval } from '#/store/audit-policy';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
@@ -90,12 +91,12 @@
/** æäº¤å®¡æ ¸ */
async function handleSubmit(row: CrmInvoiceApi.Invoice) {
  const hideLoading = message.loading({
    content: '提交审核中...',
    content: automaticApproval.value ? '正在提交生效...' : '提交审核中...',
    duration: 0,
  });
  try {
    await submitInvoice(row.id!);
    message.success('提交审核成功');
    message.success(automaticApproval.value ? '已提交生效' : '提交审核成功');
    handleRefresh();
  } finally {
    hideLoading();
src/views/crm/saleQuotation/data.ts
@@ -302,13 +302,13 @@
      field: 'quotationTime',
      title: '报价日期',
      minWidth: 120,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'validUntil',
      title: '有效期至',
      minWidth: 120,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      field: 'status',
@@ -330,7 +330,7 @@
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
      formatter: 'formatDate',
    },
    {
      title: '操作',
src/views/dashboard/analytics/index.vue
@@ -4,7 +4,7 @@
import { Page } from '@vben/common-ui';
import { Card, Col, Row } from 'ant-design-vue';
import { Col, Row } from 'ant-design-vue';
import { getHomeSummary } from '#/api/mes/home';
@@ -15,11 +15,13 @@
import KpiCards from './modules/kpi-cards.vue';
import ProductionTrend from './modules/production-trend.vue';
import QuickActions from './modules/quick-actions.vue';
import { useHomeAccess } from './use-home-access';
defineOptions({ name: 'DashboardAnalytics' });
const router = useRouter();
const summary = ref(defaultSummary);
const { canNavigate, canViewHome } = useHomeAccess();
const greeting = computed(() => {
  const hour = new Date().getHours();
@@ -38,10 +40,12 @@
});
function handleNavigate(name: string) {
  if (!canNavigate(name)) return;
  router.push({ name });
}
async function loadSummary() {
  if (!canViewHome.value) return;
  summary.value = await getHomeSummary();
}
@@ -72,19 +76,20 @@
    </div>
    <!-- MES ç”Ÿäº§ KPI -->
    <KpiCards :summary="summary" @navigate="handleNavigate" />
    <KpiCards v-if="canViewHome" :summary="summary" @navigate="handleNavigate" />
    <!-- ERP/WMS/BPM ç»è¥ KPI -->
    <BizCards @navigate="handleNavigate" />
    <!-- AI æ™ºèƒ½åˆ†æž -->
    <AiInsights
      v-if="canViewHome && canNavigate('MesProWorkOrder')"
      :active-order-count="summary.workOrderActiveCount"
      @navigate="handleNavigate"
    />
    <!-- ç”Ÿäº§è¶‹åŠ¿ + é¢„警面板 -->
    <Row :gutter="16" class="mb-4">
    <Row v-if="canViewHome" :gutter="16" class="mb-4">
      <Col :lg="14" :md="24" :sm="24" :xl="14" :xs="24" class="mb-4">
        <div class="glass-card rounded-2xl p-5">
          <h3 class="mb-4 text-base font-semibold text-gray-800 dark:text-gray-100">
src/views/dashboard/analytics/modules/alert-panel.vue
@@ -12,6 +12,8 @@
import { getWorkOrderStatusDistribution } from '#/api/mes/home';
import { useHomeAccess } from '../use-home-access';
defineOptions({ name: 'DashboardAlertPanel' });
const props = defineProps<{
@@ -25,8 +27,10 @@
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const loading = ref(false);
const { canNavigate, canViewHome } = useHomeAccess();
async function loadStatus() {
  if (!canViewHome.value) return;
  loading.value = true;
  try {
    const data = await getWorkOrderStatusDistribution();
@@ -68,6 +72,7 @@
    <!-- é¢„警卡片行 -->
    <div class="grid grid-cols-1 gap-3">
      <div
        v-if="canNavigate('MesProRepair')"
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0 p-4"
        @click="emit('navigate', 'MesProRepair')"
      >
src/views/dashboard/analytics/modules/biz-cards.vue
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { CountTo } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
@@ -11,6 +11,8 @@
import { getMaterialStockPage } from '#/api/mes/wm/materialstock';
import { getTaskTodoPage } from '#/api/bpm/task';
import { useHomeAccess } from '../use-home-access';
defineOptions({ name: 'DashboardBizCards' });
const emit = defineEmits<{
@@ -21,8 +23,14 @@
const saleCount = ref(0);
const stockCount = ref(0);
const bpmTodoCount = ref(0);
const { canNavigate, canQuery } = useHomeAccess();
const canViewPurchase = computed(() => canQuery('erp:purchase-order:query') && canNavigate('ErpPurchaseOrder'));
const canViewSale = computed(() => canQuery('erp:sale-order:query') && canNavigate('ErpSaleOrder'));
const canViewStock = computed(() => canQuery('mes:wm-material-stock:query') && canNavigate('MesWmMaterialStock'));
const canViewBpmTodo = computed(() => canQuery('bpm:task:query') && canNavigate('BpmTaskTodo'));
async function loadPurchase() {
  if (!canViewPurchase.value) return;
  try {
    const result = await getPurchaseOrderPage({ pageNo: 1, pageSize: 1 });
    purchaseCount.value = result.total ?? 0;
@@ -30,6 +38,7 @@
}
async function loadSale() {
  if (!canViewSale.value) return;
  try {
    const result = await getSaleOrderPage({ pageNo: 1, pageSize: 1 });
    saleCount.value = result.total ?? 0;
@@ -37,6 +46,7 @@
}
async function loadStock() {
  if (!canViewStock.value) return;
  try {
    const result = await getMaterialStockPage({ pageNo: 1, pageSize: 1 });
    stockCount.value = result.total ?? 0;
@@ -44,6 +54,7 @@
}
async function loadBpmTodo() {
  if (!canViewBpmTodo.value) return;
  try {
    const result = await getTaskTodoPage({ pageNo: 1, pageSize: 1 });
    bpmTodoCount.value = result.total ?? 0;
@@ -83,9 +94,9 @@
</script>
<template>
  <Row :gutter="16">
  <Row v-if="canViewPurchase || canViewSale || canViewStock || canViewBpmTodo" :gutter="16">
    <!-- é‡‡è´­è®¢å• -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canViewPurchase" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('purchase')"
@@ -112,7 +123,7 @@
    </Col>
    <!-- é”€å”®è®¢å• -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canViewSale" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('sale')"
@@ -139,7 +150,7 @@
    </Col>
    <!-- ç‰©æ–™åº“å­˜ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canViewStock" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('stock')"
@@ -166,7 +177,7 @@
    </Col>
    <!-- BPM å¾…办 -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canViewBpmTodo" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('bpm')"
src/views/dashboard/analytics/modules/kpi-cards.vue
@@ -8,7 +8,11 @@
import { Col, Row } from 'ant-design-vue';
import { useHomeAccess } from '../use-home-access';
defineOptions({ name: 'DashboardKpiCards' });
const { canNavigate } = useHomeAccess();
const props = defineProps<{
  summary: MesHomeApi.Summary;
@@ -61,11 +65,12 @@
    <!-- ç”Ÿäº§è®¢å• -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        class="glass-tilt group overflow-hidden rounded-2xl border-0"
        :class="{ 'cursor-pointer': canNavigate('MesProWorkOrder') }"
        :style="tiltStyle('order')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'order')"
        @mouseleave="onMouseLeave('order')"
        @click="emit('navigate', 'MesProWorkOrder')"
        @click="canNavigate('MesProWorkOrder') && emit('navigate', 'MesProWorkOrder')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 shadow-lg shadow-blue-500/25 transition-transform duration-300 group-hover:scale-110">
@@ -92,11 +97,12 @@
    <!-- ä»Šæ—¥äº§é‡ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        class="glass-tilt group overflow-hidden rounded-2xl border-0"
        :class="{ 'cursor-pointer': canNavigate('MesProFeedback') }"
        :style="tiltStyle('output')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'output')"
        @mouseleave="onMouseLeave('output')"
        @click="emit('navigate', 'MesProFeedback')"
        @click="canNavigate('MesProFeedback') && emit('navigate', 'MesProFeedback')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-green-400 shadow-lg shadow-emerald-500/25 transition-transform duration-300 group-hover:scale-110">
@@ -123,11 +129,12 @@
    <!-- è´¨é‡åˆæ ¼çއ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        class="glass-tilt group overflow-hidden rounded-2xl border-0"
        :class="{ 'cursor-pointer': canNavigate('MesQcRecord') }"
        :style="tiltStyle('quality')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'quality')"
        @mouseleave="onMouseLeave('quality')"
        @click="emit('navigate', 'MesQcRecord')"
        @click="canNavigate('MesQcRecord') && emit('navigate', 'MesQcRecord')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-amber-400 shadow-lg shadow-orange-500/25 transition-transform duration-300 group-hover:scale-110">
@@ -156,11 +163,12 @@
    <!-- è®¾å¤‡è¿è¡Œçއ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        class="glass-tilt group overflow-hidden rounded-2xl border-0"
        :class="{ 'cursor-pointer': canNavigate('MesDvMachinery') }"
        :style="tiltStyle('equip')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'equip')"
        @mouseleave="onMouseLeave('equip')"
        @click="emit('navigate', 'MesDvMachinery')"
        @click="canNavigate('MesDvMachinery') && emit('navigate', 'MesDvMachinery')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-400 shadow-lg shadow-violet-500/25 transition-transform duration-300 group-hover:scale-110">
src/views/dashboard/analytics/modules/production-trend.vue
@@ -2,18 +2,22 @@
import type { MesHomeApi } from '#/api/mes/home';
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref, watch } from 'vue';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { getProductionTrend } from '#/api/mes/home';
import { useHomeAccess } from '../use-home-access';
defineOptions({ name: 'DashboardProductionTrend' });
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const { canViewHome } = useHomeAccess();
async function loadTrend() {
  if (!canViewHome.value) return;
  const data = await getProductionTrend(7);
  const dates = data.map((d: MesHomeApi.ProductionTrend) => d.date.slice(5));
  const quantities = data.map((d: MesHomeApi.ProductionTrend) => d.quantity);
src/views/dashboard/analytics/modules/quick-actions.vue
@@ -1,5 +1,9 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useHomeAccess } from '../use-home-access';
defineOptions({ name: 'DashboardQuickActions' });
@@ -25,14 +29,17 @@
  { title: '工艺路线', icon: 'lucide:route', route: 'MesPdRouting', gradient: 'from-teal-500 to-emerald-500', shadow: 'shadow-teal-500/15' },
  { title: '数据报表', icon: 'lucide:bar-chart-big', route: 'MesHomeIndex', gradient: 'from-pink-500 to-rose-500', shadow: 'shadow-pink-500/15' },
];
const { canNavigate } = useHomeAccess();
const accessibleActions = computed(() => actions.filter((action) => canNavigate(action.route)));
</script>
<template>
  <div class="glass-card rounded-2xl p-5">
  <div v-if="accessibleActions.length" class="glass-card rounded-2xl p-5">
    <h3 class="mb-4 text-sm font-semibold text-gray-800 dark:text-gray-100">快捷操作</h3>
    <div class="grid grid-cols-4 gap-3 sm:grid-cols-8">
      <div
        v-for="action in actions"
        v-for="action in accessibleActions"
        :key="action.route"
        class="group flex cursor-pointer flex-col items-center gap-2 rounded-xl p-3 transition-all duration-300 hover:-translate-y-1"
        @click="emit('navigate', action.route)"
src/views/dashboard/analytics/use-home-access.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
import type { RouteRecordRaw } from 'vue-router';
import { computed } from 'vue';
import { useAccess } from '@vben/access';
import { useAccessStore } from '@vben/stores';
/** é¦–页常驻,业务数据和入口仍需遵循当前用户的菜单权限。 */
export function useHomeAccess() {
  const accessStore = useAccessStore();
  const { hasAccessByCodes } = useAccess();
  function canQuery(permission: string) {
    return hasAccessByCodes([permission, '*:*:*']);
  }
  function canNavigate(name: string) {
    function contains(routes: RouteRecordRaw[]): boolean {
      return routes.some((route) => {
        if (route.meta?.menuVisibleWithForbidden) return false;
        return route.name === name || contains(route.children ?? []);
      });
    }
    return contains(accessStore.accessRoutes);
  }
  const canViewHome = computed(() => canQuery('mes:home:query'));
  return { canNavigate, canQuery, canViewHome };
}
src/views/energy/alert/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,145 @@
<script setup lang="ts">
import type { FormInstance, TablePaginationConfig } from 'ant-design-vue';
import type { EnergyAlertRule, EnergyAlertLog, EnergyType } from '#/api/energy';
import { computed, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Alert, Button, Card, DatePicker, Form, FormItem, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tabs, TabPane, Tag, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { checkEnergyAlerts, deleteEnergyAlertRule, getEnergyAlertLogs, getEnergyAlertRecipients, getEnergyAlertRules, saveEnergyAlertRule } from '#/api/energy';
import { energyInfo, energyOptions, numberText, productOptions } from '../shared';
defineOptions({ name: 'EnergyAlert' });
const tab = ref('rules');
const rules = ref<EnergyAlertRule[]>([]);
const logs = ref<EnergyAlertLog[]>([]);
const recipients = ref<{ label: string; value: number }[]>([]);
const loading = ref(false), error = ref(false), saving = ref(false), checking = ref(false), open = ref(false);
const rulePage = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const logPage = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const logFilters = reactive<{ date?: string; status?: string }>({});
const checkDate = ref(dayjs().format('YYYY-MM-DD'));
const initial = (): EnergyAlertRule => ({ name: '', energyType: 'WATER', metric: 'QUANTITY', threshold: 1, enabled: true, recipientIds: [], usageArea: '', productCategory: undefined });
const model = reactive<EnergyAlertRule>(initial());
const metricOptions = [
  { label: '每日用量', value: 'QUANTITY' }, { label: '每日费用', value: 'AMOUNT' },
  { label: '每月用量', value: 'MONTH_QUANTITY' }, { label: '每月费用', value: 'MONTH_AMOUNT' },
];
const metricLabel = (metric: string) => metricOptions.find(option => option.value === metric)?.label ?? metric;
const isAmount = (metric: string) => metric.endsWith('AMOUNT');
const formRef = ref<FormInstance>();
const unit = computed(() => isAmount(model.metric) ? '元' : energyInfo[model.energyType].unit);
const validation = {
  name: [{ required: true, whitespace: true, message: '请输入规则名称' }],
  threshold: [{ required: true, type: 'number' as const, min: 0.001, message: '阈值必须大于零' }],
  recipientIds: [{ required: true, type: 'array' as const, min: 1, max: 50, message: '请选择 1 è‡³ 50 ä½æŽ¥æ”¶äºº' }],
};
const ruleColumns = [
  { title: '规则名称', dataIndex: 'name', width: 180 }, { title: '能源', dataIndex: 'energyType', width: 80 },
  { title: '统计范围', key: 'scope', width: 200 }, { title: '统计指标 / é˜ˆå€¼', key: 'threshold', width: 190 },
  { title: '接收人', key: 'recipients', width: 180 }, { title: '状态', key: 'enabled', width: 80 },
  { title: '操作', key: 'action', width: 140, fixed: 'right' as const },
];
const logColumns = [
  { title: '统计周期', key: 'period', width: 120 }, { title: '规则名称', dataIndex: 'ruleName', width: 160 },
  { title: '能源 / èŒƒå›´', key: 'scope', width: 230 }, { title: '实际值 / é˜ˆå€¼', key: 'value', width: 210 },
  { title: '接收人', dataIndex: 'recipientName', width: 120 }, { title: '结果', key: 'status', width: 100 },
  { title: '失败原因', dataIndex: 'errorMessage', width: 300 },
];
let loadId = 0;
async function load() {
  const id = ++loadId; loading.value = true; error.value = false;
  try {
    if (tab.value === 'rules') {
      const result = await getEnergyAlertRules({ pageNo: rulePage.current, pageSize: rulePage.pageSize });
      if (id === loadId) { rules.value = result.list; rulePage.total = result.total; }
    } else {
      const result = await getEnergyAlertLogs({ ...logFilters, pageNo: logPage.current, pageSize: logPage.pageSize });
      if (id === loadId) { logs.value = result.list; logPage.total = result.total; }
    }
  } catch { if (id === loadId) error.value = true; }
  finally { if (id === loadId) loading.value = false; }
}
function changePage(page: TablePaginationConfig) {
  Object.assign(tab.value === 'rules' ? rulePage : logPage, { current: page.current ?? 1, pageSize: page.pageSize ?? 10 }); void load();
}
async function showForm(row?: EnergyAlertRule) {
  try { recipients.value = (await getEnergyAlertRecipients()).map(user => ({ label: user.nickname, value: user.id })); }
  catch { message.error('无法加载接收人,请重试'); return; }
  Object.keys(model).forEach(key => delete (model as unknown as Record<string, unknown>)[key]);
  Object.assign(model, row ? { ...row, recipientIds: [...row.recipientIds] } : initial());
  open.value = true; formRef.value?.clearValidate();
}
async function save() {
  await formRef.value?.validate(); saving.value = true;
  try { await saveEnergyAlertRule({ ...model }); open.value = false; message.success('规则已保存,后续录入自动检查;历史日期可手动检查'); await load(); }
  finally { saving.value = false; }
}
async function remove(id: number) { await deleteEnergyAlertRule(id); message.success('已删除,历史通知记录保留'); rulePage.current = 1; await load(); }
async function check() {
  if (!checkDate.value || checkDate.value > dayjs().format('YYYY-MM-DD')) { message.warning('请选择今天或之前的日期'); return; }
  checking.value = true;
  try {
    const result = await checkEnergyAlerts(checkDate.value);
    message[result.failed ? 'warning' : 'success'](`检查完成:新发送 ${result.sent} æ¡ï¼Œå¤±è´¥ ${result.failed} æ¡ã€‚已发送的通知不会重复发送。`);
    tab.value = 'logs'; logFilters.date = checkDate.value; logPage.current = 1; await load();
  } finally { checking.value = false; }
}
function recipientNames(ids: number[]) { return ids.map(id => recipients.value.find(user => user.value === id)?.label ?? `用户 ${id}`).join('、'); }
onMounted(async () => {
  await load();
  try { recipients.value = (await getEnergyAlertRecipients()).map(user => ({ label: user.nickname, value: user.id })); } catch { /* Rules remain viewable when recipient lookup fails. */ }
});
</script>
<template>
  <Page title="能耗预警" description="配置每日或每月用量、费用阈值,超过阈值时向指定人员发送站内通知。">
    <Alert class="mb-4" type="info" show-icon message="每日指标按天汇总,每月指标按自然月汇总本月已录入数据,超限即通知。同一规则、统计周期和接收人只成功通知一次。检查日期同时检查当天及所属月份。区域按名称精确匹配。" />
    <Card>
      <div class="mb-4 flex flex-wrap items-center justify-between gap-3">
        <Button v-access:code="['energy:alert:create']" type="primary" @click="showForm()">新增预警规则</Button>
        <Space wrap><span>检查日期</span><DatePicker v-model:value="checkDate" value-format="YYYY-MM-DD" :allow-clear="false" /><Button v-access:code="['energy:alert:check']" :loading="checking" @click="check">检查预警 / é‡è¯•失败通知</Button></Space>
      </div>
      <Alert v-if="error" class="mb-4" type="error" show-icon message="加载失败,请点击刷新重试。" />
      <Tabs v-model:active-key="tab" @change="load">
        <TabPane key="rules" tab="预警规则">
          <Table :columns="ruleColumns" :data-source="rules" row-key="id" :loading="loading" :pagination="rulePage" :scroll="{ x: 1100 }" :locale="{ emptyText: '暂无规则,请先设置阈值和通知接收人' }" @change="changePage">
            <template #bodyCell="{ column, record }">
              <template v-if="column.dataIndex === 'energyType'">{{ energyInfo[record.energyType as EnergyType].name }}</template>
              <template v-else-if="column.key === 'scope'">{{ record.usageArea || '全部区域' }} / {{ record.productCategory || '全部产品' }}</template>
              <template v-else-if="column.key === 'threshold'">{{ metricLabel(record.metric) }} &gt; {{ numberText(record.threshold) }} {{ isAmount(record.metric) ? '元' : energyInfo[record.energyType as EnergyType].unit }}</template>
              <template v-else-if="column.key === 'recipients'">{{ recipientNames(record.recipientIds) }}</template>
              <template v-else-if="column.key === 'enabled'"><Tag :color="record.enabled ? 'green' : 'default'">{{ record.enabled ? '启用' : '停用' }}</Tag></template>
              <template v-else-if="column.key === 'action'"><Space :size="0"><Button v-access:code="['energy:alert:update']" type="link" @click="showForm(record as EnergyAlertRule)">编辑</Button><Popconfirm title="删除此规则?历史通知仍会保留。" @confirm="remove(record.id)"><Button v-access:code="['energy:alert:delete']" type="link" danger>删除</Button></Popconfirm></Space></template>
            </template>
          </Table>
        </TabPane>
        <TabPane key="logs" tab="通知记录">
          <Space class="mb-4" wrap><DatePicker v-model:value="logFilters.date" value-format="YYYY-MM-DD" placeholder="统计日期" /><Select v-model:value="logFilters.status" :options="[{ label: '发送成功', value: 'SENT' }, { label: '发送失败', value: 'FAILED' }]" allow-clear placeholder="全部结果" style="width: 140px" /><Button @click="logPage.current = 1; load()">查询</Button></Space>
          <Table :columns="logColumns" :data-source="logs" row-key="id" :loading="loading" :pagination="logPage" :scroll="{ x: 1340 }" :locale="{ emptyText: '暂无预警通知记录' }" @change="changePage">
            <template #bodyCell="{ column, record }">
              <template v-if="column.key === 'period'">{{ record.periodType === 'MONTH' ? record.recordDate.slice(0, 7) : record.recordDate }}<div class="text-xs text-muted-foreground">{{ metricLabel(record.metric) }}</div></template>
              <template v-else-if="column.key === 'scope'">{{ energyInfo[record.energyType as EnergyType].name }} / {{ record.scope }}</template>
              <template v-else-if="column.key === 'value'">{{ numberText(record.actualValue) }} / {{ numberText(record.threshold) }} {{ isAmount(record.metric) ? '元' : energyInfo[record.energyType as EnergyType].unit }}</template>
              <template v-else-if="column.key === 'status'"><Tag :color="record.status === 'SENT' ? 'green' : 'red'">{{ record.status === 'SENT' ? '发送成功' : '发送失败' }}</Tag></template>
            </template>
          </Table>
        </TabPane>
        <template #rightExtra><Button :loading="loading" @click="load">刷新</Button></template>
      </Tabs>
    </Card>
    <Modal v-model:open="open" :title="model.id ? '编辑预警规则' : '新增预警规则'" :confirm-loading="saving" :mask-closable="false" :width="640" @ok="save">
      <Form ref="formRef" :model="model" :rules="validation" layout="vertical" class="mt-4">
        <FormItem label="规则名称" name="name"><Input v-model:value="model.name" :maxlength="100" placeholder="例如:燕窝生产区每日用水预警" /></FormItem>
        <div class="grid grid-cols-1 gap-x-4 sm:grid-cols-2">
          <FormItem label="能源"><Select v-model:value="model.energyType" :options="energyOptions" /></FormItem>
          <FormItem label="统计指标"><Select v-model:value="model.metric" :options="metricOptions" /></FormItem>
          <FormItem label="使用区域(精确匹配,可留空)"><Input v-model:value="model.usageArea" :maxlength="100" placeholder="留空统计全部区域" /></FormItem>
          <FormItem label="产品类别"><Select v-model:value="model.productCategory" :options="productOptions" allow-clear placeholder="全部产品" /></FormItem>
        </div>
        <FormItem :label="`${model.metric.startsWith('MONTH_') ? '每月' : '每日'}阈值(${unit},大于此值时通知)`" name="threshold"><InputNumber v-model:value="model.threshold" :min="0.001" :precision="3" style="width: 100%" /></FormItem>
        <FormItem label="通知接收人" name="recipientIds"><Select v-model:value="model.recipientIds" mode="multiple" :options="recipients" option-filter-prop="label" placeholder="选择本站内用户" /></FormItem>
        <FormItem label="启用规则"><Switch v-model:checked="model.enabled" /></FormItem>
      </Form>
    </Modal>
  </Page>
</template>
src/views/energy/analysis/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,232 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { EnergyAnalysis, EnergyFilters, EnergyType } from '#/api/energy';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Alert, Button, Card, DatePicker, Empty, Form, FormItem, Input, RadioGroup, Select, Space, Spin, Table, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { exportEnergyAnalysis, getEnergyAnalysis } from '#/api/energy';
import { energyInfo, energyOptions, numberText, productOptions } from '../shared';
defineOptions({ name: 'EnergyAnalysis' });
const defaults = () => ({ startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD'), groupBy: 'DAY' as 'DAY' | 'MONTH' });
const filters = reactive<EnergyFilters & ReturnType<typeof defaults>>(defaults());
const applied = ref({ ...filters });
const data = ref<EnergyAnalysis>();
const loading = ref(false);
const failed = ref(false);
const exporting = ref(false);
const selectedEnergy = ref<EnergyType>('WATER');
const trendRef = ref<EchartsUIType>();
const shareRef = ref<EchartsUIType>();
const areaRef = ref<EchartsUIType>();
const trendChart = useEcharts(trendRef);
const shareChart = useEcharts(shareRef);
const areaChart = useEcharts(areaRef);
const costRef = ref<EchartsUIType>();
const areaShareRef = ref<EchartsUIType>();
const usageRef = ref<EchartsUIType>();
const costChart = useEcharts(costRef);
const areaShareChart = useEcharts(areaShareRef);
const usageChart = useEcharts(usageRef);
const toolbox = (name: string) => ({ right: 8, feature: { saveAsImage: { title: '下载 PNG å›¾ç‰‡', name, pixelRatio: 2 } } });
async function exportReport() {
  exporting.value = true;
  try { downloadFileFromBlobPart({ fileName: '能耗分析.xlsx', source: await exportEnergyAnalysis({ ...applied.value }) }); }
  finally { exporting.value = false; }
}
const hasRecords = computed(() => (data.value?.recordCount ?? 0) > 0);
const summaryColumns = [
  { title: '能源', dataIndex: 'energyType' },
  { title: '总用量', dataIndex: 'quantity', align: 'right' as const },
  { title: '费用(元)', dataIndex: 'amount', align: 'right' as const },
  { title: '费用占比', key: 'share', align: 'right' as const },
  { title: '录入条数', dataIndex: 'recordCount', align: 'right' as const },
];
const detailColumns = [
  { title: '日期 / æœˆä»½', dataIndex: 'label' },
  { title: '能源', dataIndex: 'energyType' },
  { title: '用量', dataIndex: 'quantity', align: 'right' as const },
  { title: '费用(元)', dataIndex: 'amount', align: 'right' as const },
  { title: '录入条数', dataIndex: 'recordCount', align: 'right' as const },
];
let requestId = 0;
async function load() {
  if (!filters.startDate || !filters.endDate || filters.startDate > filters.endDate) {
    message.warning('请选择有效的开始和结束日期'); return;
  }
  if (dayjs(filters.endDate).isAfter(dayjs(filters.startDate).add(2, 'year'))) {
    message.warning('每次分析请选择两年以内的日期范围'); return;
  }
  const id = ++requestId;
  const params = { ...filters };
  loading.value = true;
  failed.value = false;
  try {
    const result = await getEnergyAnalysis(params);
    if (id !== requestId) return;
    data.value = result;
    applied.value = params;
    selectedEnergy.value = params.energyType ?? 'WATER';
    await nextTick();
    await renderCharts();
  } catch { if (id === requestId) { data.value = undefined; failed.value = true; } }
  finally { if (id === requestId) loading.value = false; }
}
function reset() {
  Object.keys(filters).forEach(key => delete (filters as Record<string, unknown>)[key]);
  Object.assign(filters, defaults());
  void load();
}
async function renderTrend() {
  if (!hasRecords.value || !data.value) return;
  const info = energyInfo[selectedEnergy.value];
  const month = applied.value.groupBy === 'MONTH';
  const periods: string[] = [];
  let cursor = dayjs(applied.value.startDate).startOf(month ? 'month' : 'day');
  const end = dayjs(applied.value.endDate);
  while (!cursor.isAfter(end)) {
    periods.push(cursor.format(month ? 'YYYY-MM' : 'YYYY-MM-DD'));
    cursor = cursor.add(1, month ? 'month' : 'day');
  }
  const values = new Map(data.value.trend.filter(row => row.energyType === selectedEnergy.value).map(row => [row.label, Number(row.quantity)]));
  await trendChart.renderEcharts({
    animation: false,
    toolbox: toolbox(`${info.name}用量趋势`),
    tooltip: { trigger: 'axis', confine: true },
    grid: { left: 20, right: 24, top: 40, bottom: 70, containLabel: true },
    xAxis: { type: 'category', data: periods },
    yAxis: { type: 'value', name: `${info.name}用量(${info.unit})` },
    dataZoom: [{ type: 'inside' }, { type: 'slider', height: 18, bottom: 12 }],
    series: [{ name: `${info.name}(${info.unit})`, type: 'line', showSymbol: periods.length < 40, connectNulls: false, itemStyle: { color: info.color }, data: periods.map(period => values.get(period) ?? null) }],
  });
  const usage = data.value.areas.filter(row => row.energyType === selectedEnergy.value).sort((a, b) => Number(b.quantity) - Number(a.quantity)).slice(0, 10).reverse();
  await usageChart.renderEcharts({
    animation: false, toolbox: toolbox(`${info.name}区域用量`),
    tooltip: { trigger: 'axis', confine: true, axisPointer: { type: 'shadow' } },
    grid: { left: 16, right: 36, top: 40, bottom: 24, containLabel: true },
    xAxis: { type: 'value', name: info.unit },
    yAxis: { type: 'category', data: usage.map(row => row.label), axisLabel: { width: 100, overflow: 'truncate' } },
    series: [{ name: `${info.name}(${info.unit})`, type: 'bar', itemStyle: { color: info.color }, data: usage.map(row => Number(row.quantity)) }],
  });
  await costChart.renderEcharts({
    animation: false, toolbox: toolbox('能源费用趋势'),
    tooltip: { trigger: 'axis', confine: true }, legend: { bottom: 0 },
    grid: { left: 16, right: 24, top: 40, bottom: 70, containLabel: true },
    xAxis: { type: 'category', data: periods }, yAxis: { type: 'value', name: '费用(元)' },
    dataZoom: [{ type: 'inside' }],
    series: energyOptions.map(option => {
      const costs = new Map(data.value!.trend.filter(row => row.energyType === option.value).map(row => [row.label, Number(row.amount)]));
      return { name: option.label, type: 'line' as const, showSymbol: periods.length < 40, connectNulls: false,
        itemStyle: { color: energyInfo[option.value as EnergyType].color }, data: periods.map(period => costs.get(period) ?? null) };
    }),
  });
}
async function renderCharts() {
  if (!hasRecords.value || !data.value) return;
  await renderTrend();
  if (Number(data.value.totalAmount) > 0) await shareChart.renderEcharts({
    animation: false,
    toolbox: toolbox('能源费用占比'),
    tooltip: { trigger: 'item', confine: true, formatter: '{b}:{c} å…ƒï¼ˆ{d}%)' },
    legend: { bottom: 0 },
    series: [{ type: 'pie', radius: '65%', center: ['50%', '45%'], label: { formatter: '{b}\n{d}%' }, data: data.value.summary.filter(row => Number(row.amount) > 0).map(row => ({ name: energyInfo[row.energyType].name, value: Number(row.amount), itemStyle: { color: energyInfo[row.energyType].color } })) }],
  });
  const totals = new Map<string, number>();
  for (const row of data.value.areas) totals.set(row.label, (totals.get(row.label) ?? 0) + Number(row.amount));
  if (Number(data.value.totalAmount) > 0) {
    const sorted = [...totals].filter(([, amount]) => amount > 0).sort((a, b) => b[1] - a[1]);
    const shares = sorted.slice(0, 10).map(([name, value]) => ({ name, value }));
    const others = sorted.slice(10).reduce((sum, [, amount]) => sum + amount, 0);
    if (others > 0) shares.push({ name: '其他区域合计', value: others });
    await areaShareChart.renderEcharts({
      animation: false, toolbox: toolbox('区域费用占比'),
      tooltip: { trigger: 'item', confine: true, formatter: '{b}:{c} å…ƒï¼ˆ{d}%)' },
      legend: { type: 'scroll', bottom: 0 },
      series: [{ type: 'pie', radius: ['35%', '60%'], center: ['50%', '44%'], label: { formatter: '{b}\n{d}%', width: 85, overflow: 'truncate' }, data: shares }],
    });
  }
  const areaLabels = [...totals].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([label]) => label).reverse();
  await areaChart.renderEcharts({
    animation: false,
    toolbox: toolbox('区域费用分布'),
    tooltip: { trigger: 'axis', confine: true, axisPointer: { type: 'shadow' } },
    legend: { bottom: 0 },
    grid: { left: 16, right: 24, top: 30, bottom: 45, containLabel: true },
    xAxis: { type: 'value', name: '元' },
    yAxis: { type: 'category', data: areaLabels, axisLabel: { width: 110, overflow: 'truncate' } },
    series: energyOptions.map(option => ({
      name: option.label, type: 'bar' as const, stack: 'amount', itemStyle: { color: energyInfo[option.value as EnergyType].color },
      data: areaLabels.map(label => data.value!.areas.filter(row => row.label === label && row.energyType === option.value).reduce((sum, row) => sum + Number(row.amount), 0)),
    })),
  });
}
watch(selectedEnergy, () => { void renderTrend(); });
onMounted(() => load());
</script>
<template>
  <Page title="能耗数据分析" description="基于手动录入的台账,查看用量趋势及费用分布。">
    <Card class="mb-4">
      <Form :model="filters" layout="inline" class="analysis-filters" @finish="load">
        <FormItem label="开始日期"><DatePicker v-model:value="filters.startDate" value-format="YYYY-MM-DD" :allow-clear="false" /></FormItem>
        <FormItem label="结束日期"><DatePicker v-model:value="filters.endDate" value-format="YYYY-MM-DD" :allow-clear="false" /></FormItem>
        <FormItem label="汇总"><Select v-model:value="filters.groupBy" :options="[{ label: '按日', value: 'DAY' }, { label: '按月', value: 'MONTH' }]" style="width: 100px" /></FormItem>
        <FormItem label="能源"><Select v-model:value="filters.energyType" :options="energyOptions" allow-clear placeholder="全部能源" style="width: 130px" /></FormItem>
        <FormItem label="区域"><Input v-model:value="filters.usageArea" placeholder="区域关键词" allow-clear style="width: 150px" /></FormItem>
        <FormItem label="产品"><Select v-model:value="filters.productCategory" :options="productOptions" allow-clear placeholder="全部类别" style="width: 150px" /></FormItem>
        <FormItem><Space wrap><Button type="primary" :loading="loading" @click="load">分析</Button><Button @click="reset">重置</Button><Button v-access:code="['energy:analysis:export']" :disabled="!data || loading" :loading="exporting" @click="exportReport">导出分析报表</Button></Space></FormItem>
      </Form>
    </Card>
    <Alert v-if="failed" type="error" show-icon message="能耗分析加载失败,请点击分析重试。" class="mb-4" />
    <Spin :spinning="loading">
      <template v-if="data">
        <Card class="mb-4" title="能源汇总">
          <template #extra>{{ applied.startDate }} è‡³ {{ applied.endDate }}</template>
          <div class="mb-4 flex flex-wrap gap-x-6 gap-y-2">
            <span>总费用:<strong>{{ numberText(data.totalAmount, 2) }} å…ƒ</strong></span><span>已录入 {{ data.recordCount }} æ¡</span>
          </div>
          <Table :columns="summaryColumns" :data-source="data.summary" row-key="energyType" :pagination="false" :scroll="{ x: 600 }" size="small">
            <template #bodyCell="{ column, record }">
              <template v-if="column.dataIndex === 'energyType'">{{ energyInfo[record.energyType as EnergyType].name }}</template>
              <template v-else-if="column.dataIndex === 'quantity'">{{ numberText(record.quantity) }} {{ energyInfo[record.energyType as EnergyType].unit }}</template>
              <template v-else-if="column.dataIndex === 'amount'">{{ numberText(record.amount, 2) }}</template>
              <template v-else-if="column.key === 'share'">{{ Number(data.totalAmount) > 0 ? numberText(Number(record.amount) / Number(data.totalAmount) * 100, 2) + '%' : '—' }}</template>
            </template>
          </Table>
          <p class="mb-0 mt-3 text-sm text-muted-foreground">水、电、蒸汽按各自单位统计。未录入日期在趋势中留空;单价为 0 çš„记录仍计入用量。</p>
        </Card>
        <Card v-if="!hasRecords"><Empty description="所选范围暂无能耗记录,请调整筛选条件或先录入能耗信息。" /></Card>
        <template v-else>
          <Card class="mb-4" title="用量趋势">
            <template #extra><RadioGroup v-model:value="selectedEnergy" :options="energyOptions" option-type="button" size="small" /></template>
            <EchartsUI ref="trendRef" class="h-[340px] w-full" />
          </Card>
          <div class="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
            <Card title="能源费用占比"><EchartsUI v-if="Number(data.totalAmount) > 0" ref="shareRef" class="h-[330px] w-full" /><Empty v-else description="尚未录入费用,填写单价后显示费用占比。" /></Card>
            <Card title="区域费用分布(前 10)"><EchartsUI ref="areaRef" class="h-[330px] w-full" /></Card>
            <Card title="能源费用趋势"><EchartsUI ref="costRef" class="h-[330px] w-full" /></Card>
            <Card title="区域费用占比"><EchartsUI v-if="Number(data.totalAmount) > 0" ref="areaShareRef" class="h-[330px] w-full" /><Empty v-else description="尚无费用数据" /></Card>
            <Card class="lg:col-span-2" :title="`${energyInfo[selectedEnergy].name} Â· åŒºåŸŸç”¨é‡å¯¹æ¯”(前 10)`"><EchartsUI ref="usageRef" class="h-[330px] w-full" /></Card>
          </div>
          <p class="text-sm text-muted-foreground">图表右上角可下载 PNG;分析报表包含统计口径、能源汇总、日期趋势、区域汇总四个工作表,使用最近一次分析的筛选条件。</p>
          <Card title="趋势明细">
            <Table :columns="detailColumns" :data-source="data.trend" :row-key="row => `${row.label}-${row.energyType}`" :scroll="{ x: 600 }" :pagination="{ pageSize: 10, showSizeChanger: true }" size="small">
              <template #bodyCell="{ column, record }">
                <template v-if="column.dataIndex === 'energyType'">{{ energyInfo[record.energyType as EnergyType].name }}</template>
                <template v-else-if="column.dataIndex === 'quantity'">{{ numberText(record.quantity) }} {{ energyInfo[record.energyType as EnergyType].unit }}</template>
                <template v-else-if="column.dataIndex === 'amount'">{{ numberText(record.amount, 2) }}</template>
              </template>
            </Table>
          </Card>
        </template>
      </template>
    </Spin>
  </Page>
</template>
<style scoped>
.analysis-filters { gap: 8px 0; }
</style>
src/views/energy/record/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,174 @@
<script setup lang="ts">
import type { FormInstance, TablePaginationConfig } from 'ant-design-vue';
import type { EnergyFilters, EnergyRecord } from '#/api/energy';
import { computed, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Alert, AutoComplete, Button, Card, DatePicker, Form, FormItem, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { createEnergyRecord, deleteEnergyRecord, exportEnergyRecords, getEnergyRecord, getEnergyRecordPage, updateEnergyRecord } from '#/api/energy';
import { areaOptions, energyInfo, energyOptions, numberText, productOptions, purposeOptions } from '../shared';
defineOptions({ name: 'EnergyRecord' });
const filters = reactive<EnergyFilters>({});
const rows = ref<EnergyRecord[]>([]);
const loading = ref(false);
const exporting = ref(false);
async function exportRecords() {
  if (filters.startDate && filters.endDate && filters.startDate > filters.endDate) { message.warning('开始日期不能晚于结束日期'); return; }
  exporting.value = true;
  try { downloadFileFromBlobPart({ fileName: '能耗台账.xlsx', source: await exportEnergyRecords({ ...filters }) }); }
  finally { exporting.value = false; }
}
const loadError = ref(false);
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const open = ref(false);
const saving = ref(false);
const detail = ref(false);
const formRef = ref<FormInstance>();
const initial = (): EnergyRecord => ({ recordDate: dayjs().format('YYYY-MM-DD'), energyType: 'WATER', usageArea: '', productCategory: '燕窝', usagePurpose: '', quantity: 1, unitPrice: 0, recorder: '', remark: '' });
const model = reactive<EnergyRecord>(initial());
const unit = computed(() => energyInfo[model.energyType].unit);
const estimatedAmount = computed(() => numberText(Number(model.quantity || 0) * Number(model.unitPrice || 0), 2));
const title = computed(() => detail.value ? '能耗记录详情' : model.id ? '编辑能耗记录' : '新增能耗记录');
const rules = {
  recordDate: [{ required: true, message: '请选择统计日期' }],
  energyType: [{ required: true, message: '请选择能源类型' }],
  usageArea: [{ required: true, whitespace: true, message: '请输入使用区域' }],
  productCategory: [{ required: true, message: '请选择产品类别' }],
  usagePurpose: [{ required: true, whitespace: true, message: '请输入用途' }],
  quantity: [{ required: true, type: 'number' as const, min: 0.001, message: '用量必须大于零' }],
  unitPrice: [{ required: true, type: 'number' as const, min: 0, message: '单价不能为负数' }],
  recorder: [{ required: true, whitespace: true, message: '请输入录入人' }],
};
const columns = [
  { title: '统计日期', dataIndex: 'recordDate', width: 120 },
  { title: '能源', dataIndex: 'energyType', width: 95 },
  { title: '使用区域', dataIndex: 'usageArea', width: 160 },
  { title: '产品类别', dataIndex: 'productCategory', width: 130 },
  { title: '用途', dataIndex: 'usagePurpose', width: 170 },
  { title: '用量', dataIndex: 'quantity', width: 150, align: 'right' as const },
  { title: '单价(元/单位)', dataIndex: 'unitPrice', width: 150, align: 'right' as const },
  { title: '费用(元)', dataIndex: 'amount', width: 145, align: 'right' as const },
  { title: '录入人', dataIndex: 'recorder', width: 110 },
  { title: '操作', key: 'action', width: 190, fixed: 'right' as const },
];
let requestId = 0;
async function load(reset = false) {
  if (filters.startDate && filters.endDate && filters.startDate > filters.endDate) {
    message.warning('开始日期不能晚于结束日期'); return;
  }
  if (reset) pagination.current = 1;
  const currentId = ++requestId;
  loading.value = true;
  loadError.value = false;
  try {
    const data = await getEnergyRecordPage({ ...filters, pageNo: pagination.current, pageSize: pagination.pageSize });
    if (currentId !== requestId) return;
    rows.value = data.list;
    pagination.total = data.total;
  } catch {
    if (currentId === requestId) { rows.value = []; loadError.value = true; }
  } finally { if (currentId === requestId) loading.value = false; }
}
function resetFilters() {
  Object.keys(filters).forEach(key => delete filters[key as keyof EnergyFilters]);
  void load(true);
}
function tableChange(page: TablePaginationConfig) {
  pagination.current = page.current ?? 1;
  pagination.pageSize = page.pageSize ?? 10;
  void load();
}
async function showForm(row?: EnergyRecord, readonly = false) {
  const values = row ? await getEnergyRecord(row.id!) : initial();
  detail.value = readonly;
  // æ¸…除前一次编辑的编号及详情字段。
  Object.keys(model).forEach(key => delete (model as unknown as Record<string, unknown>)[key]);
  Object.assign(model, values);
  open.value = true;
  formRef.value?.clearValidate();
}
async function save() {
  await formRef.value?.validate();
  saving.value = true;
  try {
    const data = { ...model, usageArea: model.usageArea.trim(), usagePurpose: model.usagePurpose.trim(), recorder: model.recorder.trim() };
    await (data.id ? updateEnergyRecord(data) : createEnergyRecord(data));
    message.success('能耗记录已保存');
    open.value = false;
    await load();
  } finally { saving.value = false; }
}
const deletingId = ref<number>();
async function remove(row: EnergyRecord) {
  deletingId.value = row.id;
  try {
    await deleteEnergyRecord(row.id!);
    message.success('能耗记录已删除');
    if (rows.value.length === 1 && pagination.current > 1) pagination.current--;
    await load();
  } finally { deletingId.value = undefined; }
}
onMounted(() => load());
</script>
<template>
  <Page title="能耗记录" description="按天手动记录水、电、蒸汽使用情况,费用自动计算。">
    <Card>
      <Form :model="filters" layout="inline" class="energy-filters" @finish="load(true)">
        <FormItem label="开始日期"><DatePicker v-model:value="filters.startDate" value-format="YYYY-MM-DD" placeholder="开始日期" /></FormItem>
        <FormItem label="结束日期"><DatePicker v-model:value="filters.endDate" value-format="YYYY-MM-DD" placeholder="结束日期" /></FormItem>
        <FormItem label="能源"><Select v-model:value="filters.energyType" :options="energyOptions" allow-clear placeholder="全部能源" style="width: 130px" /></FormItem>
        <FormItem label="区域"><Input v-model:value="filters.usageArea" allow-clear placeholder="输入区域关键词" style="width: 160px" /></FormItem>
        <FormItem label="产品"><Select v-model:value="filters.productCategory" :options="productOptions" allow-clear placeholder="全部类别" style="width: 150px" /></FormItem>
        <FormItem><Space><Button type="primary" :loading="loading" @click="load(true)">查询</Button><Button @click="resetFilters">重置</Button></Space></FormItem>
      </Form>
      <div class="mb-4 flex flex-wrap items-center justify-between gap-3">
        <span class="text-base font-semibold">能耗台账</span>
        <Space><Button v-access:code="['energy:record:export']" :loading="exporting" @click="exportRecords">导出台账</Button><Button v-access:code="['energy:record:create']" type="primary" @click="showForm()">新增能耗记录</Button></Space>
      </div>
      <Alert v-if="loadError" type="error" message="能耗记录加载失败,请点击查询重试。" show-icon class="mb-4" />
      <Table :columns="columns" :data-source="rows" row-key="id" :loading="loading" :pagination="pagination" :scroll="{ x: 1420 }" :locale="{ emptyText: '暂无能耗记录,请新增每日用能信息' }" @change="tableChange">
        <template #bodyCell="{ column, record }">
          <template v-if="column.dataIndex === 'energyType'"><Tag>{{ energyInfo[record.energyType as keyof typeof energyInfo].name }}</Tag></template>
          <template v-else-if="column.dataIndex === 'quantity'">{{ numberText(record.quantity) }} {{ energyInfo[record.energyType as keyof typeof energyInfo].unit }}</template>
          <template v-else-if="column.dataIndex === 'unitPrice'">{{ numberText(record.unitPrice, 4) }}</template>
          <template v-else-if="column.dataIndex === 'amount'">{{ numberText(record.amount, 2) }}</template>
          <template v-else-if="column.key === 'action'">
            <Space :size="0">
              <Button v-access:code="['energy:record:query']" type="link" size="small" @click="showForm(record as EnergyRecord, true)">详情</Button>
              <Button v-access:code="['energy:record:update']" type="link" size="small" @click="showForm(record as EnergyRecord)">编辑</Button>
              <Popconfirm title="确定删除这条能耗记录?删除后统计数据也会更新。" ok-text="确定" cancel-text="取消" @confirm="remove(record as EnergyRecord)">
                <Button v-access:code="['energy:record:delete']" type="link" danger size="small" :loading="deletingId === record.id">删除</Button>
              </Popconfirm>
            </Space>
          </template>
        </template>
      </Table>
    </Card>
    <Modal v-model:open="open" :title="title" :width="760" :confirm-loading="saving" :closable="!saving" :mask-closable="!saving" :keyboard="!saving" :cancel-button-props="{ disabled: saving }" :footer="detail ? null : undefined" ok-text="保存" cancel-text="取消" @ok="save">
      <Form ref="formRef" :model="model" :rules="rules" :disabled="detail || saving" layout="vertical" class="energy-form">
        <FormItem label="统计日期" name="recordDate"><DatePicker v-model:value="model.recordDate" value-format="YYYY-MM-DD" :disabled-date="date => date.isAfter(dayjs(), 'day')" class="w-full" /></FormItem>
        <FormItem label="能源类型" name="energyType"><Select v-model:value="model.energyType" :options="energyOptions" /></FormItem>
        <FormItem label="使用区域" name="usageArea"><AutoComplete v-model:value="model.usageArea" :options="areaOptions" :maxlength="100" placeholder="选择建议区域或手动输入" /></FormItem>
        <FormItem label="产品类别" name="productCategory"><Select v-model:value="model.productCategory" :options="productOptions" /></FormItem>
        <FormItem label="用途 / å·¥åº" name="usagePurpose"><AutoComplete v-model:value="model.usagePurpose" :options="purposeOptions" :maxlength="100" placeholder="如设备清洗、蒸煮提取、灭菌" /></FormItem>
        <FormItem label="录入人" name="recorder"><Input v-model:value="model.recorder" :maxlength="50" placeholder="请输入姓名" /></FormItem>
        <FormItem :label="`用量(${unit})`" name="quantity"><InputNumber v-model:value="model.quantity" :min="0.001" :max="999999999999999.999" :precision="3" style="width: 100%" /></FormItem>
        <FormItem :label="`单价(元/${unit})`" name="unitPrice"><InputNumber v-model:value="model.unitPrice" :min="0" :max="99999999.9999" :precision="4" style="width: 100%" /></FormItem>
        <div class="col-span-full mb-4 rounded border border-solid border-[hsl(var(--border))] p-3">
          è´¹ç”¨ï¼š<strong>{{ detail ? numberText(model.amount, 2) : estimatedAmount }} å…ƒ</strong>
          <div class="mt-1 text-sm text-muted-foreground">按用量 Ã— å•价计算,保存时精确到分。仅记录用量时,单价可填 0。</div>
        </div>
        <FormItem label="备注" name="remark" class="col-span-full"><Input.TextArea v-model:value="model.remark" :maxlength="500" show-count :rows="3" placeholder="如计量口径、特殊用能情况等" /></FormItem>
      </Form>
    </Modal>
  </Page>
</template>
<style scoped>
.energy-filters { gap: 8px 0; margin-bottom: 24px; }
.energy-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; margin-top: 20px; }
@media (max-width: 640px) { .energy-form { grid-template-columns: minmax(0, 1fr); } }
</style>
src/views/energy/shared.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
import type { EnergyType } from '#/api/energy';
export const energyOptions = [
  { label: 'æ°´', value: 'WATER' },
  { label: '电', value: 'ELECTRICITY' },
  { label: '蒸汽', value: 'STEAM' },
];
export const energyInfo: Record<EnergyType, { name: string; unit: string; color: string }> = {
  WATER: { name: 'æ°´', unit: 'm³', color: '#1677ff' },
  ELECTRICITY: { name: '电', unit: 'kWh', color: '#d48806' },
  STEAM: { name: '蒸汽', unit: 't', color: '#389e0d' },
};
export const productOptions = ['燕窝', '滋补品原浆', '公用能耗', '其他'].map(value => ({ label: value, value }));
export const areaOptions = ['燕窝生产区', '原浆生产区', '清洗区', '灭菌区', '灌装包装区', '动力站', '仓储区', '办公生活区'].map(value => ({ value }));
export const purposeOptions = ['原料清洗', '设备清洗 / CIP', '蒸煮提取', '灭菌', '灌装包装', '制冷', '采暖', '日常用能'].map(value => ({ value }));
export function numberText(value: number | undefined, digits = 3) {
  return Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: digits, minimumFractionDigits: digits === 2 ? 2 : 0 });
}
src/views/erp/finance/payment/index.vue
@@ -1,4 +1,6 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import { submitFinancePayment } from '#/api/erp/finance/payment';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ErpFinancePaymentApi } from '#/api/erp/finance/payment';
@@ -77,10 +79,17 @@
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ */
async function handleSubmit(row: ErpFinancePaymentApi.FinancePayment) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    content: automaticApproval.value ? '正在提交生效...' : '获取审批流程...',
    duration: 0,
  });
  try {
    if (automaticApproval.value) {
      await submitFinancePayment(row.id!, 'finance_payment_approve');
      hideLoading();
      message.success('已提交生效');
      handleRefresh();
      return;
    }
    const processList = await getFinancePaymentApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
@@ -88,6 +97,7 @@
      return;
    }
    // æ‰“开流程选择弹窗
    processModalApi.setData({ id: row.id, processList }).open();
  } catch {
    hideLoading();
src/views/erp/purchase/request/index.vue
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ErpPurchaseRequestApi } from '#/api/erp/purchase/request';
@@ -96,10 +97,17 @@
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ */
async function handleSubmit(row: ErpPurchaseRequestApi.PurchaseRequest) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    content: automaticApproval.value ? '正在提交生效...' : '获取审批流程...',
    duration: 0,
  });
  try {
    if (automaticApproval.value) {
      await submitPurchaseRequest(row.id!, 'purchase_request_approve');
      hideLoading();
      message.success('已提交生效');
      handleRefresh();
      return;
    }
    const processList = await getApproveProcessList(row.id!);
    hideLoading();
    if (!processList || processList.length === 0) {
@@ -107,6 +115,7 @@
      return;
    }
    // æ‰“开流程选择弹窗
    processModalApi.setData({ id: row.id, processList }).open();
  } catch {
    hideLoading();
src/views/erp/sale/order/data.ts
@@ -70,8 +70,8 @@
      component: 'DatePicker',
      componentProps: {
        placeholder: '选择订单时间',
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
@@ -341,6 +341,8 @@
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        showTime: false,
        format: 'YYYY-MM-DD',
        allowClear: true,
      },
    },
src/views/erp/sale/order/detail/index.vue
@@ -9,7 +9,7 @@
import {
  erpCountInputFormatter,
  erpPriceInputFormatter,
  formatDateTime,
  formatDate,
} from '@vben/utils';
import { Spin, Tag } from 'ant-design-vue';
@@ -67,7 +67,7 @@
    {
      label: '订单时间',
      field: 'orderTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    {
      label: '合计数量',
@@ -109,7 +109,7 @@
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
      render: (val) => formatDate(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
src/views/erp/sale/order/index.vue
@@ -9,6 +9,7 @@
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message, Tag, Tooltip } from 'ant-design-vue';
import dayjs from 'dayjs';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -22,6 +23,7 @@
} from '#/api/erp/sale/order';
import { generateFromSaleOrder } from '#/api/mes/wm/salesnotice';
import { $t } from '#/locales';
import { automaticApproval } from '#/store/audit-policy';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
@@ -57,8 +59,25 @@
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const data = await exportSaleOrder(await gridApi.formApi.getValues());
  const data = await exportSaleOrder(
    normalizeDateRange(await gridApi.formApi.getValues()),
  );
  downloadFileFromBlobPart({ fileName: '销售订单.xls', source: data });
}
/** æ—¥æœŸç­›é€‰è¦†ç›–所选起止日期的全天,兼容后端时间类型。 */
function normalizeDateRange(values: Record<string, any>) {
  const range = values.orderTime;
  if (!Array.isArray(range) || range.length !== 2 || !range.every(Boolean)) {
    return values;
  }
  return {
    ...values,
    orderTime: [
      dayjs(range[0]).startOf('day').format('YYYY-MM-DD HH:mm:ss'),
      dayjs(range[1]).endOf('day').format('YYYY-MM-DD HH:mm:ss'),
    ],
  };
}
/** å¯¼å…¥é”€å”®è®¢å• */
@@ -94,18 +113,18 @@
/** æäº¤é”€å”®è®¢å•审批(协同办公) */
async function handleSubmit(row: ErpSaleOrderApi.SaleOrder) {
  // èŽ·å–å¯ç”¨çš„å®¡æ‰¹æµç¨‹å®šä¹‰
  const processList = await getSaleOrderApproveProcessList();
  if (isEmpty(processList)) {
  const processList = automaticApproval.value ? [] : await getSaleOrderApproveProcessList();
  if (!automaticApproval.value && isEmpty(processList)) {
    message.error('销售订单审批流程未配置,请先在「协同办公→流程管理」中创建并发布销售订单审批流程');
    return;
  }
  const hideLoading = message.loading({
    content: '正在提交审批...',
    content: automaticApproval.value ? '正在提交生效...' : '正在提交审批...',
    duration: 0,
  });
  try {
    await submitSaleOrder(row.id!, processList[0].key);
    message.success('提交审批成功,请到协同办公中处理');
    await submitSaleOrder(row.id!, automaticApproval.value ? 'sale_order_approve' : processList[0]!.key);
    message.success(automaticApproval.value ? '已提交生效' : '提交审批成功,请到协同办公中处理');
    handleRefresh();
  } finally {
    hideLoading();
@@ -208,7 +227,7 @@
      ajax: {
        query: async ({ page }, formValues) => {
          // ç”Ÿäº§çŠ¶æ€æœç´¢å‚æ•°è½¬æ¢ï¼š-1(无需生产) â†’ needProduction=0
          const { productionStatus, ...rest } = formValues;
          const { productionStatus, ...rest } = normalizeDateRange(formValues);
          const params: Record<string, any> = { ...rest };
          if (productionStatus === -1) {
            params.needProduction = 0;
src/views/erp/sale/order/modules/form.vue
@@ -6,6 +6,7 @@
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { formatDate } from '@vben/utils';
import { message, Tag } from 'ant-design-vue';
@@ -133,7 +134,10 @@
    try {
      formData.value = await getSaleOrder(data.id);
      // è®¾ç½®åˆ° values
      await formApi.setValues(formData.value);
      await formApi.setValues({
        ...formData.value,
        productionFinishTime: formatDate(formData.value.productionFinishTime),
      });
      if (formData.value?.attachmentList?.length) {
        const blobIds = formData.value.attachmentList.map((item) => ({
          uid: String(item.id),
src/views/erp/sale/order/modules/print.vue
@@ -7,7 +7,7 @@
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { useUserStore } from '@vben/stores';
import { erpPriceMultiply, formatDateTime } from '@vben/utils';
import { erpPriceMultiply, formatDate, formatDateTime } from '@vben/utils';
import { getCustomerSimpleList } from '#/api/crm/customer';
import { getAccountSimpleList } from '#/api/erp/finance/account';
@@ -216,7 +216,7 @@
        <div>订单单号:{{ printData.no || '-' }}</div>
        <div>
          è®¢å•时间:{{
            formatDateTime(printData.orderTime) || '-'
            formatDate(printData.orderTime) || '-'
          }}
        </div>
        <div>客户:{{ customerName }}</div>
@@ -229,7 +229,7 @@
        <div>创建人:{{ creatorName }}</div>
        <div>
          åˆ›å»ºæ—¶é—´ï¼š{{
            formatDateTime(printData.createTime) || '-'
            formatDate(printData.createTime) || '-'
          }}
        </div>
        <div class="col-span-3">备注:{{ printData.remark || '-' }}</div>
src/views/hrm/attendance/exception/index.vue
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmAttendanceExceptionApi } from '#/api/hrm/attendance/exception';
@@ -48,10 +49,15 @@
/** æäº¤è€ƒå‹¤å¼‚常申请 */
async function handleSubmit(row: HrmAttendanceExceptionApi.AttendanceException) {
  const res = await getAttendanceExceptionApproveProcessList();
  const res = automaticApproval.value ? [] : await getAttendanceExceptionApproveProcessList();
  processList.value = res;
  currentRow.value = row;
  selectedProcessKey.value = undefined;
  if (automaticApproval.value) {
    selectedProcessKey.value = 'hrm_attendance_exception';
    await confirmSubmit();
    return;
  }
  submitModalVisible.value = true;
}
@@ -64,7 +70,7 @@
  submitLoading.value = true;
  try {
    await submitAttendanceException(currentRow.value!.id!, selectedProcessKey.value);
    message.success('提交审批成功');
    message.success(automaticApproval.value ? '已提交生效' : '提交审批成功');
    submitModalVisible.value = false;
    handleRefresh();
  } finally {
src/views/hrm/leave/index.vue
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmLeaveApi } from '#/api/hrm/leave';
@@ -72,9 +73,14 @@
/** æäº¤è¯·å‡ç”³è¯·ï¼ˆå…ˆé€‰æ‹©å®¡æ‰¹æµç¨‹ï¼‰ */
async function handleSubmit(row: HrmLeaveApi.Leave) {
  processList.value = await getLeaveApproveProcessDefinitionList();
  processList.value = automaticApproval.value ? [] : await getLeaveApproveProcessDefinitionList();
  currentRow.value = row;
  selectedProcessKey.value = undefined;
  if (automaticApproval.value) {
    selectedProcessKey.value = 'hrm_leave';
    await confirmSubmit();
    return;
  }
  submitModalVisible.value = true;
}
src/views/hrm/overtime/index.vue
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmOvertimeApi } from '#/api/hrm/overtime';
@@ -64,9 +65,14 @@
const submitLoading = ref(false);
async function handleSubmit(row: HrmOvertimeApi.Overtime) {
  processList.value = await getOvertimeApproveProcessDefinitionList();
  processList.value = automaticApproval.value ? [] : await getOvertimeApproveProcessDefinitionList();
  currentRow.value = row;
  selectedProcessKey.value = undefined;
  if (automaticApproval.value) {
    selectedProcessKey.value = 'hrm_overtime';
    await confirmSubmit();
    return;
  }
  submitModalVisible.value = true;
}
@@ -78,7 +84,7 @@
  submitLoading.value = true;
  try {
    await submitOvertime(currentRow.value!.id!, selectedProcessKey.value);
    message.success('已提交至协同办公审批');
    message.success(automaticApproval.value ? '已提交生效' : '已提交至协同办公审批');
    submitModalVisible.value = false;
    handleRefresh();
  } finally {
src/views/hrm/resignation/index.vue
@@ -163,7 +163,7 @@
            {
              label: '交接',
              type: 'link',
              auth: ['hrm:resignation:handover'],
              auth: ['hrm:resignation:update'],
              ifShow: row.status === 20 && row.handoverStatus !== 20,
              popConfirm: {
                title: row.handoverStatus === 0 ? '确认开始交接吗?' : '确认完成交接吗?',
src/views/im/home/store/rtcStore.ts
@@ -422,7 +422,13 @@
    })
  }
  function $reset() {
    reset()
    clearGroupCallCache()
  }
  return {
    $reset,
    stage,
    call,
    incomingPayload,
src/views/im/home/store/uiStore.ts
@@ -129,7 +129,30 @@
    nextUnreadJumpNonce.value++
  }
  function $reset() {
    Object.assign(userInfoCard, {
      show: false,
      user: null,
      position: { x: 0, y: 0 },
      addSource: ImFriendAddSource.SEARCH,
      addSourceExtra: ''
    })
    Object.assign(groupInfoCard, {
      show: false,
      group: null,
      position: { x: 0, y: 0 }
    })
    Object.assign(contextMenu, {
      show: false,
      position: { x: 0, y: 0 },
      items: [],
      onSelect: null
    })
    nextUnreadJumpNonce.value = 0
  }
  return {
    $reset,
    userInfoCard,
    openUserInfoCard,
    openUserInfoCardAtEvent,
src/views/mes/dv/meteringcheck/data.ts
@@ -7,7 +7,6 @@
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getSimpleUserList } from '#/api/system/user';
import { DvMeteringSelect } from '#/views/mes/dv/metering/components';
/** è¡¨å•类型 */
@@ -83,15 +82,13 @@
      },
    },
    {
      fieldName: 'checkUserId',
      fieldName: 'checkUserName',
      label: '检测人',
      component: 'ApiSelect',
      component: 'Input',
      componentProps: {
        allowClear: true,
        api: getSimpleUserList,
        labelField: 'nickname',
        placeholder: '请选择检测人',
        valueField: 'id',
        placeholder: '请输入检测人姓名',
        maxlength: 100,
      },
    },
    {
@@ -193,7 +190,7 @@
      },
    },
    { field: 'certNo', title: '证书编号', minWidth: 130 },
    { field: 'checkUserNickname', title: '检测人', minWidth: 100 },
    { field: 'checkUserName', title: '检测人', minWidth: 100 },
    {
      field: 'nextCheckDate',
      title: '下次检测日期',
src/views/mes/md/item/components/select.vue
@@ -42,7 +42,9 @@
const displayLabel = computed(() => {
  if (selectedItems.value.length === 0) return '';
  return selectedItems.value.map(item => item.name ?? '').join('、');
  return selectedItems.value.map(item =>
    [item.name, item.specification, item.code ? `(${item.code})` : ''].filter(Boolean).join(' '),
  ).join('、');
});
const showClear = computed(
  () =>
src/views/mes/md/item/data.ts
@@ -216,8 +216,14 @@
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入物料名称',
        placeholder: '支持名称与规格组合查询',
      },
    },
    {
      fieldName: 'specification',
      label: '规格型号',
      component: 'Input',
      componentProps: { allowClear: true, placeholder: '请输入规格型号' },
    },
    {
      fieldName: 'status',
@@ -350,9 +356,15 @@
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入物料名称',
        placeholder: '支持名称与规格组合查询',
      },
    },
    {
      fieldName: 'specification',
      label: '规格型号',
      component: 'Input',
      componentProps: { allowClear: true, placeholder: '请输入规格型号' },
    },
  ];
}
src/views/mes/pd/project/detail/index.vue
@@ -1,4 +1,6 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import { submitPdProjectApproval } from '#/api/mes/pd/project';
import type { FormType } from '../data';
import type { MesPdProjectApi } from '#/api/mes/pd/project';
@@ -137,8 +139,15 @@
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ */
async function handleSubmit() {
  const hideLoading = message.loading({ content: '获取审批流程...', duration: 0 });
  const hideLoading = message.loading({ content: automaticApproval.value ? '正在提交生效...' : '获取审批流程...', duration: 0 });
  try {
    if (automaticApproval.value) {
      await submitPdProjectApproval(projectId.value, 'pd_project_approve');
      hideLoading();
      message.success('已提交生效');
      await loadProjectDetail();
      return;
    }
    const processList = await getPdProjectApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
@@ -146,6 +155,7 @@
      return;
    }
    // æ‰“开流程选择弹窗
    processModalApi.setData({ id: projectId.value, processList }).open();
  } catch {
    hideLoading();
src/views/mes/pd/project/index.vue
@@ -1,4 +1,6 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import { submitPdProjectApproval } from '#/api/mes/pd/project';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesPdProjectApi } from '#/api/mes/pd/project';
@@ -83,10 +85,17 @@
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ */
async function handleSubmit(row: MesPdProjectApi.Project) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    content: automaticApproval.value ? '正在提交生效...' : '获取审批流程...',
    duration: 0,
  });
  try {
    if (automaticApproval.value) {
      await submitPdProjectApproval(row.id!, 'pd_project_approve');
      hideLoading();
      message.success('已提交生效');
      handleRefresh();
      return;
    }
    const processList = await getPdProjectApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
@@ -94,6 +103,7 @@
      return;
    }
    // æ‰“开流程选择弹窗
    processModalApi.setData({ id: row.id, processList }).open();
  } catch {
    hideLoading();
@@ -259,7 +269,7 @@
            {
              label: '归档',
              type: 'link',
              auth: ['mes:pd-project:archive'],
              auth: ['mes:pd-project:update'],
              ifShow: row.status === MesPdProjectStatusEnum.PENDING_ARCHIVE,
              popConfirm: {
                title: '确认归档?归档后将生成台账记录,任务不可再修改。',
src/views/mes/process-design/parameter/index.vue
@@ -120,14 +120,14 @@
              label: $t('ui.actionTitle.create', ['参数']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pd:parameter:create'],
              auth: ['mes:pd-process-param:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pd:parameter:export'],
              auth: ['mes:pd-process-param:export'],
              onClick: handleExport,
            },
          ]"
@@ -143,14 +143,14 @@
              label: '关联工序',
              type: 'link',
              icon: ACTION_ICON.LINK,
              auth: ['mes:pd:parameter:update'],
              auth: ['mes:pd-process-param:update'],
              onClick: handleViewProcessLink.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['mes:pd:parameter:update'],
              auth: ['mes:pd-process-param:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
@@ -158,7 +158,7 @@
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mes:pd:parameter:delete'],
              auth: ['mes:pd-process-param:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.paramName]),
                confirm: handleDelete.bind(null, row),
src/views/mes/process-design/template/index.vue
@@ -106,14 +106,14 @@
              label: $t('ui.actionTitle.create', ['模板']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pd:template:create'],
              auth: ['mes:pd-process-param-template:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pd:template:export'],
              auth: ['mes:pd-process-param-template:export'],
              onClick: handleExport,
            },
          ]" />
@@ -134,7 +134,7 @@
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['mes:pd:template:update'],
              auth: ['mes:pd-process-param-template:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
@@ -142,7 +142,7 @@
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mes:pd:template:delete'],
              auth: ['mes:pd-process-param-template:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.templateName]),
                confirm: handleDelete.bind(null, row),
src/views/mes/qc/iqc/data.ts
@@ -12,7 +12,6 @@
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getTemplateItemOptionList } from '#/api/mes/qc/template/item';
import { getSupplierSimpleList } from '#/api/srm/supplier';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
@@ -163,26 +162,13 @@
      },
    },
    {
      fieldName: 'vendorId',
      fieldName: 'vendorName',
      label: '供应商',
      component: 'ApiSelect',
      component: 'Input',
      componentProps: {
        allowClear: true,
        api: getSupplierSimpleList,
        labelField: 'name',
        placeholder: '请选择供应商',
        valueField: 'id',
      },
      dependencies: {
        triggerFields: ['sourceDocId'],
        componentProps: (values) => ({
          disabled: !!values.sourceDocId,
          allowClear: true,
          api: getSupplierSimpleList,
          labelField: 'name',
          placeholder: '请选择供应商',
          valueField: 'id',
        }),
        maxlength: 255,
        placeholder: '请输入供应商名称(可留空)',
      },
    },
    {
@@ -309,15 +295,12 @@
      },
    },
    {
      fieldName: 'vendorId',
      fieldName: 'vendorName',
      label: '供应商',
      component: 'ApiSelect',
      component: 'Input',
      componentProps: {
        allowClear: true,
        api: getSupplierSimpleList,
        labelField: 'name',
        placeholder: '请选择供应商',
        valueField: 'id',
        placeholder: '请输入供应商名称',
      },
    },
    {
@@ -393,8 +376,8 @@
      minWidth: 180,
    },
    {
      field: 'vendorNickname',
      title: '供应商简称',
      field: 'vendorName',
      title: '供应商',
      width: 120,
    },
    {
src/views/mes/qc/iqc/index.vue
@@ -63,7 +63,7 @@
      fileName: '来料检验单导入模板.xls',
      hints: [
        '仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›å¿…填列:检验单名称、产品物料编码、合格品数量、不合格品数量、检测日期、来料日期。',
        '「产品物料编码」需与「物料管理」中的编码完全一致;「供应商名称」需与「供应商管理」中的名称一致,留空则该单无供应商。',
        '「产品物料编码」需与「物料管理」中的编码完全一致;「供应商名称」非必填,可直接填写名称,无需在系统中建立供应商档案。',
        '合格品数量 + ä¸åˆæ ¼å“æ•°é‡ å¿…须等于「本次接收数量」(留空时自动取两者之和);「检测结果」按字典名称填写(校验通过 / æ ¡éªŒä¸é€šè¿‡ï¼‰ã€‚',
        '「质检方案」填方案名称,留空则按物料自动匹配 IQC æ–¹æ¡ˆï¼›ã€Œæ£€æµ‹äººå‘˜ã€ç•™ç©ºå–当前登录用户;日期支持 2026-01-01 10:00:00 æˆ– 2026-01-01。',
        '逐行导入,失败行不影响成功行;导入后的检验单为「待检验」状态,可在列表中继续编辑、录入检测结果。',
src/views/mes/qc/oqc/data.ts
@@ -166,14 +166,15 @@
      label: '客户',
      component: markRaw(CrmCustomerSelect),
      componentProps: {
        placeholder: '请选择客户',
        allowClear: true,
        placeholder: '请选择客户(可不填)',
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['sourceDocId'],
        componentProps: (values) => ({
          disabled: !!values.sourceDocId,
          placeholder: '请选择客户',
          allowClear: true,
          placeholder: '请选择客户(可不填)',
        }),
      },
    },
src/views/mes/qc/pendinginspect/index.vue
@@ -52,6 +52,7 @@
    sourceDocType: row.sourceDocType,
    sourceLineId: row.sourceLineId,
    vendorId: row.vendorId,
    vendorName: row.vendorName,
  };
  iqcFormModalApi.setData({ formType: 'create', prefill }).open();
}
src/views/mes/workbench/components/tabs/FeedbackHistoryTab.vue
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProFeedbackApi } from '#/api/mes/pro/feedback';
@@ -318,7 +319,7 @@
            </Popconfirm>
            <!-- å®¡æ‰¹ä¸­ï¼šæŸ¥çœ‹å®¡æ‰¹ -->
            <Button
              v-if="row.status === STATUS_APPROVING"
              v-if="!automaticApproval && row.status === STATUS_APPROVING"
              type="link"
              size="small"
              @click="handleViewProcess(row)"
src/views/srm/supplierApply/index.vue
@@ -1,4 +1,6 @@
<script lang="ts" setup>
import { automaticApproval } from '#/store/audit-policy';
import { submitApply } from '#/api/srm/supplierApply';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SrmSupplierApplyApi } from '#/api/srm/supplierApply';
@@ -70,10 +72,17 @@
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–ååŒåŠžå…¬å®¡æ‰¹æµç¨‹åˆ—è¡¨ */
async function handleSubmit(row: SrmSupplierApplyApi.ApplyVO) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    content: automaticApproval.value ? '正在提交生效...' : '获取审批流程...',
    duration: 0,
  });
  try {
    if (automaticApproval.value) {
      await submitApply(row.id!, 'supplier_apply_approve');
      hideLoading();
      message.success('已提交生效');
      handleRefresh();
      return;
    }
    const processList = await getApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
@@ -81,6 +90,7 @@
      return;
    }
    // æ‰“开流程选择弹窗
    processModalApi.setData({ id: row.id, processList }).open();
  } catch {
    hideLoading();
src/views/wls/materialstock/index.vue
@@ -241,7 +241,7 @@
                {
                  label: '移库',
                  type: 'link',
                  auth: ['mes:wm-material-stock:move'],
                  auth: ['mes:wm-material-stock:update'],
                  ifShow: !row.frozen,
                  onClick: handleOpenStockMove.bind(null, row),
                },
src/views/wls/miscreceipt/index.vue
@@ -120,14 +120,14 @@
              label: $t('ui.actionTitle.create', ['杂项入库单']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:wm-misc-receipt:create'],
              auth: ['mes:wm:misc-receipt:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:wm-misc-receipt:export'],
              auth: ['mes:wm:misc-receipt:export'],
              onClick: handleExport,
            },
          ]"
@@ -145,7 +145,7 @@
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['mes:wm-misc-receipt:update'],
              auth: ['mes:wm:misc-receipt:update'],
              ifShow: row.status === MesWmMiscReceiptStatusEnum.PREPARE,
              onClick: handleEdit.bind(null, row),
            },
@@ -154,7 +154,7 @@
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mes:wm-misc-receipt:delete'],
              auth: ['mes:wm:misc-receipt:delete'],
              ifShow: row.status === MesWmMiscReceiptStatusEnum.PREPARE,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.code]),
@@ -164,7 +164,7 @@
            {
              label: '执行入库',
              type: 'link',
              auth: ['mes:wm-misc-receipt:finish'],
              auth: ['mes:wm:misc-receipt:finish'],
              ifShow: row.status === MesWmMiscReceiptStatusEnum.APPROVED,
              onClick: handleFinish.bind(null, row),
            },
@@ -172,7 +172,7 @@
              label: '取消',
              type: 'link',
              danger: true,
              auth: ['mes:wm-misc-receipt:cancel'],
              auth: ['mes:wm:misc-receipt:cancel'],
              ifShow: row.status === MesWmMiscReceiptStatusEnum.APPROVED,
              popConfirm: {
                title: '确认取消该杂项入库单吗?',
src/views/wls/returnvendor/index.vue
@@ -176,7 +176,7 @@
            {
              label: '完成退货',
              type: 'link',
              auth: ['mes:wm-return-vendor:update-status'],
              auth: ['mes:wm-return-vendor:finish'],
              ifShow: row.status === MesWmReturnVendorStatusEnum.APPROVED,
              onClick: handleFinish.bind(null, row),
            },
trt.bat
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,451 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
REM ============================================================
REM Vue Frontend Deploy Script
REM ============================================================
REM =========================
REM Configuration
REM =========================
set "COMPANY=TRT"
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/trt/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