liding
2026-06-24 ec226b03fbd26bc178137ad20d7bd030ef2c5c8b
Merge branch 'refs/heads/dev_NEW_pro' into dev_宁夏_铭远环保

# Conflicts:
# multiple/config.json
已复制1个文件
已修改72个文件
已重命名1个文件
已添加51个文件
14237 ■■■■ 文件已修改
.gitignore 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
bin/sshpass.exe 补丁 | 查看 | 原始文档 | blame | 历史
deploy.bat 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/mock_data_check.md 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/mock_data_generate.md 243 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/知识库RAG功能实现文档.md 1034 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/知识库模块传参方式和参数命名规范文档.md 941 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/知识库模块前端实现文档.md 1212 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/BLSGRfavicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/BTWfavicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/DCHXfavicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/HTHBfavicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/JFHYfavicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/KHYYfavicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/LSYfavicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/NYfavicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/QFSWfavicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/RFSYLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/RFSYfavicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/SDZZfavicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/XHKSfavicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/XLGSfavicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/YSSfavicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/favicon/ZDJCfavicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/BLSGRLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/BTWLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/DCHXLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/HTHBLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/JFHYLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/KHYYLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/LSYLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/NYLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/QFSWLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/RFSYLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/SDZZLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/XDRJ.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/XHKSLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/XLGSLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/YSSLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/ZDJCLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
src/api/collaborativeApproval/knowledgeBase.js 161 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/inventoryManagement/stockOut.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mock/dataCheck.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mock/generate.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/personnelManagement/staffSalaryMain.js 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/procurementManagement/paymentLedger.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/procurementManagement/procurementLedger.js 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/viewIndex.js 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/logo/XDRJ.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/styles/sidebar.scss 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/PIMTable/PIMTable.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layout/components/Sidebar/SidebarItem.vue 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/index.js 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/customerFile/index.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/customerFileOpenSea/index.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/parameterMaintenance/index.vue 14 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/knowledgeBase/index.vue 741 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/notificationManagement/meetApplication/index.vue 741 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/notificationManagement/meetExamine/index.vue 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/notificationManagement/meetIndex/index.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/notificationManagement/meetPublish/index.vue 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/notificationManagement/summary/index.vue 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/sealManagement/index.vue 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/assets/fixedAssets.vue 11 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/assets/intangibleAssets.vue 16 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/payable/input-invoice.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/payable/payment.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/payable/paymentApply.vue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/payable/purchaseIn.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/payable/purchaseReturn.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/payable/reconciliation.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/receivable/invoiceApply.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/receivable/outputInvoice.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/receivable/receipt.vue 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/receivable/reconciliation.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/receivable/salesOut.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/receivable/salesReturn.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/voucher/index.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/index.vue 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/inventoryManagement/dispatchLog/Record.vue 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/inventoryManagement/stockManagement/New.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mock/dataCheck/index.vue 241 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalInstanceModule.js 10 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue 73 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/personnelManagement/attendanceCheckin/checkinRules/components/form.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/personnelManagement/employeeRecord/index.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/personnelManagement/monthlyStatistics/components/auditDia.vue 17 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/personnelManagement/monthlyStatistics/components/formDia.vue 1452 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/personnelManagement/monthlyStatistics/index.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/personnelManagement/socialSecuritySet/components/formDia.vue 808 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/procurementManagement/procurementLedger/index.vue 64 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/procurementManagement/procurementPlan/index.vue 13 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/procurementManagement/purchaseOrder/index.vue 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/procurementManagement/qualityInspection/index.vue 16 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productManagement/productIdentifier/index.vue 11 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productStructure/DetailNew/MaterialCard.vue 236 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productStructure/DetailNew/index.vue 626 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productionOrder/index.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/BomDemo.vue 430 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/CombinedDemo.vue 518 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/FactoryDemo.vue 1153 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/ProcessRouteDemo.vue 535 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/components/DemoControls.vue 186 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/components/ProcessNode.vue 354 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/components/TreeNode.vue 423 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/teachingDemo/index.vue 333 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/workOrder/index.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/workOrderEdit/index.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/workOrderManagement/index.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionPlan/productionPlan/index.vue 22 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/qualityManagement/metricBinding/index.vue 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safeProduction/safetyTrainingAssessment/index.vue 11 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/salesManagement/deliveryLedger/index.vue 14 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/salesManagement/returnOrder/components/formDia.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/salesManagement/salesLedger/index.vue 44 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.js 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -6,6 +6,8 @@
yarn-error.log*
**/*.log
.claude/
tests/**/coverage/
tests/e2e/reports
selenium-debug.log
@@ -19,5 +21,8 @@
*.sln
*.local
# Claude Code
.claude/
package-lock.json
yarn.lock
bin/sshpass.exe
Binary files differ
deploy.bat
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,124 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
:: ================= Config =================
set "USER=dell"
set "PASS=xd123456@.."
set "IP=1.15.17.182"
set "PORT=10000"
set "DIST_DIR=dist"
set "REMOTE_DIR=/home/vue/newpro-product"
set "SSH_OPTS=-o StrictHostKeyChecking=no -o ConnectTimeout=10"
:: ===========================================
echo ==========================================
echo        Vue Dist Auto Deploy Script
echo ==========================================
echo.
:: Check if sshpass is available
where sshpass >nul 2>&1
if %errorlevel% neq 0 (
    echo [WARN] sshpass not found, trying to install...
    echo.
    where bash >nul 2>&1
    if %errorlevel% equ 0 (
        echo [INFO] Git Bash detected, trying pacman install...
        bash -c "pacman -S --noconfirm sshpass" 2>nul
        if !errorlevel! equ 0 (
            echo [OK] sshpass installed via pacman!
            goto :check_dist
        )
    )
    set "SSHPASS_DIR=%~dp0bin"
    if not exist "!SSHPASS_DIR!" mkdir "!SSHPASS_DIR!"
    echo [INFO] Downloading sshpass...
    powershell -NoProfile -Command "$url='https://github.com/xhcoding/sshpass-win32/releases/download/v1.0.3/sshpass.exe'; $out='!SSHPASS_DIR!\sshpass.exe'; Write-Host \"Downloading to: $out\"; try { Invoke-WebRequest -Uri $url -OutFile $out -UseBasicParsing; exit 0 } catch { Write-Host $_.Exception.Message; exit 1 }"
    if !errorlevel! neq 0 (
        echo [ERROR] Failed to download sshpass!
        echo [HINT] Please install sshpass manually or use Git Bash: pacman -S sshpass
        goto :error_exit
    )
    set "PATH=!SSHPASS_DIR!;%PATH%"
    echo [OK] sshpass downloaded to: !SSHPASS_DIR!
    echo.
)
:check_dist
if not exist "%DIST_DIR%" (
    echo [ERROR] Dist directory not found: %DIST_DIR%
    echo [HINT] Please run 'npm run build' first!
    goto :error_exit
)
:: Count files and calculate size
set "FILE_COUNT=0"
set "TOTAL_SIZE=0"
for /r "%DIST_DIR%" %%F in (*) do (
    set /a "FILE_COUNT+=1"
    set /a "TOTAL_SIZE+=%%~zF"
)
set /a "TOTAL_SIZE_MB=TOTAL_SIZE/1024/1024"
echo [INFO] Dist directory: %DIST_DIR%
echo [INFO] Files count: %FILE_COUNT%
echo [INFO] Total size: %TOTAL_SIZE_MB% MB
echo [INFO] Target: %USER%@%IP%:%PORT%
echo [INFO] Remote dir: %REMOTE_DIR%
echo.
:: Compress dist to zip
set "ZIP_FILE=dist.zip"
echo [STEP 1/3] Compressing dist to %ZIP_FILE%...
if exist "%ZIP_FILE%" del "%ZIP_FILE%"
powershell -NoProfile -Command "Compress-Archive -Path '%DIST_DIR%\*' -DestinationPath '%ZIP_FILE%' -CompressionLevel Optimal"
if %errorlevel% neq 0 (
    echo [ERROR] Compression failed!
    goto :error_exit
)
for %%F in ("%ZIP_FILE%") do set "ZIP_SIZE_MB=%%~zF"
set /a "ZIP_SIZE_MB=ZIP_SIZE_MB/1024/1024"
echo [OK] Compressed: %ZIP_SIZE_MB% MB
echo.
:: Upload zip file
echo [STEP 2/3] Uploading %ZIP_FILE%...
sshpass -p "%PASS%" scp -P %PORT% %SSH_OPTS% "%ZIP_FILE%" %USER%@%IP%:%REMOTE_DIR%/
if %errorlevel% neq 0 (
    echo [ERROR] Upload failed! Check network or credentials.
    goto :error_exit
)
echo [OK] Upload completed!
echo.
:: Extract on remote server
echo [STEP 3/3] Extracting on remote server...
sshpass -p "%PASS%" ssh -p %PORT% %SSH_OPTS% %USER%@%IP% "cd '%REMOTE_DIR%' && rm -rf dist && unzip -o '%ZIP_FILE%' -d dist && rm -f '%ZIP_FILE%'"
if %errorlevel% neq 0 (
    echo [ERROR] Extraction failed!
    goto :error_exit
)
echo [OK] Extraction completed!
:: Cleanup local zip
del "%ZIP_FILE%"
echo.
echo ==========================================
echo [SUCCESS] Deploy completed!
echo ==========================================
goto :end
:error_exit
echo.
echo ==========================================
echo [FAILED] Deploy failed!
echo ==========================================
:end
pause
doc/mock_data_check.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,183 @@
# æ•°æ®æ¨¡æ‹Ÿ - åŸºç¡€æ•°æ®æ£€æµ‹
## æ¶‰åŠé¡µé¢
- æ•°æ®æ¨¡æ‹Ÿæ“ä½œé¡µé¢ï¼ˆæ–°å¢žï¼‰
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /mock/dataCheck | æ£€æµ‹æŒ‡å®šæ¨¡å—的基础数据是否就绪 |
**请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| modules | List\<String\> | æ˜¯ | è¦æ£€æµ‹çš„æ¨¡å—列表,可选值:sales(销售)、purchase(采购)、quality(质量)、production(生产) |
请求体示例:
```json
{ "modules": ["sales", "purchase", "quality", "production"] }
```
**响应:**
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "totalItems": 6,
    "passedItems": 3,
    "items": [
      {
        "module": "common",
        "itemName": "产品数据",
        "minRequired": 1,
        "currentCount": 5,
        "passed": true,
        "message": "通过"
      },
      {
        "module": "sales",
        "itemName": "客户数据",
        "minRequired": 1,
        "currentCount": 0,
        "passed": false,
        "message": "缺少客户数据,请先在【基础数据-客户管理】中添加至少1条客户"
      }
    ]
  }
}
```
## æ£€æµ‹è§„则
### é”€å”®æ¨¡å— (sales)
| æ£€æµ‹é¡¹ | æœ€ä½Žæ•°é‡ | æœªé€šè¿‡æç¤º |
|--------|----------|------------|
| äº§å“æ•°æ® | 1 | ç¼ºå°‘产品数据,请先在【基础数据-产品管理】中添加至少1条产品 |
| å®¢æˆ·æ•°æ® | 1 | ç¼ºå°‘客户数据,请先在【基础数据-客户管理】中添加至少1条客户 |
| æŠ¥ä»·å®¡æ‰¹æ¨¡æ¿ | 1 | ç¼ºå°‘报价审批模板,请先在【系统管理-审批模板】中创建报价审批模板 |
| å‘货审批模板 | 1 | ç¼ºå°‘发货审批模板,请先在【系统管理-审批模板】中创建发货审批模板 |
### é‡‡è´­æ¨¡å— (purchase)
| æ£€æµ‹é¡¹ | æœ€ä½Žæ•°é‡ | æœªé€šè¿‡æç¤º |
|--------|----------|------------|
| äº§å“æ•°æ® | 1 | ç¼ºå°‘产品数据,请先在【基础数据-产品管理】中添加至少1条产品 |
| ä¾›åº”商数据 | 1 | ç¼ºå°‘供应商数据,请先在【基础数据-供应商管理】中添加至少1条供应商 |
| é‡‡è´­å®¡æ‰¹æ¨¡æ¿ | 1 | ç¼ºå°‘采购审批模板,请先在【系统管理-审批模板】中创建采购审批模板 |
### ç”Ÿäº§æ¨¡å— (production)
| æ£€æµ‹é¡¹ | æœ€ä½Žæ•°é‡ | æœªé€šè¿‡æç¤º |
|--------|----------|------------|
| äº§å“æ•°æ® | 1 | ç¼ºå°‘产品数据,请先在【基础数据-产品管理】中添加至少1条产品 |
| äº§å“è§„æ ¼ | 1 | ç¼ºå°‘产品规格,请先在【基础数据-产品管理】中为产品添加规格型号 |
| å·¥åº | 1 | ç¼ºå°‘工序,请先在【工艺设计-工序管理】中创建工序 |
| BOM | 1 | ç¼ºå°‘BOM,请先在【工艺设计-BOM管理】中创建BOM |
| BOM产品结构 | 1 | ç¼ºå°‘BOM产品结构,请先在【工艺设计-BOM管理】中为BOM添加产品结构节点 |
| å·¥è‰ºè·¯çº¿ | 1 | ç¼ºå°‘工艺路线,请先在【工艺设计-工艺路线】中创建工艺路线 |
| å·¥è‰ºè·¯çº¿å·¥åº | 1 | ç¼ºå°‘工艺路线工序,请先在【工艺设计-工艺路线】中为工艺路线添加工序 |
| äº§å“-工艺路线关联 | â‰¥1 | éƒ¨åˆ†äº§å“è§„格未关联工艺路线,请先在【工艺设计-工艺路线】中为产品规格创建工艺路线 |
| å·¥è‰ºè·¯çº¿-工序关联 | â‰¥1 | éƒ¨åˆ†å·¥è‰ºè·¯çº¿æœªæ·»åŠ å·¥åºï¼Œè¯·å…ˆåœ¨ã€å·¥è‰ºè®¾è®¡-工艺路线】中为工艺路线添加工序 |
| BOM-产品结构关联 | â‰¥1 | éƒ¨åˆ†BOM未添加产品结构,请先在【工艺设计-BOM管理】中为BOM添加产品结构节点 |
### è´¨é‡æ¨¡å— (quality)
| æ£€æµ‹é¡¹ | æœ€ä½Žæ•°é‡ | æœªé€šè¿‡æç¤º |
|--------|----------|------------|
| äº§å“æ•°æ® | 1 | ç¼ºå°‘产品数据,请先在【基础数据-产品管理】中添加至少1条产品 |
| æ£€æµ‹æ ‡å‡† | 1 | ç¼ºå°‘检测标准,请先在【质量管理-检测标准】中创建检测标准 |
| æŒ‡æ ‡ç»‘定 | 1 | ç¼ºå°‘指标绑定,请先在【质量管理-检测标准绑定】中将检测标准与产品绑定 |
## å‰ç«¯ä¿®æ”¹ç‚¹
### 1. æ•°æ®æ¨¡æ‹Ÿé¡µé¢å…¥å£
在左侧菜单新增"数据模拟"菜单项,路由 `/mock`。
### 2. æ•°æ®æ£€æµ‹åŒºåŸŸ
```html
<template>
  <div class="mock-container">
    <el-card header="基础数据检测">
      <el-checkbox-group v-model="selectedModules">
        <el-checkbox label="sales">销售模块</el-checkbox>
        <el-checkbox label="purchase">采购模块</el-checkbox>
        <el-checkbox label="quality">质量模块</el-checkbox>
        <el-checkbox label="production">生产模块</el-checkbox>
      </el-checkbox-group>
      <el-button type="primary" @click="handleCheck" :loading="checking">
        å¼€å§‹æ£€æµ‹
      </el-button>
    </el-card>
    <el-card v-if="checkResult" header="检测结果" style="margin-top: 16px">
      <el-alert
        :title="`通过 ${checkResult.passedItems} / ${checkResult.totalItems} é¡¹`"
        :type="checkResult.passedItems === checkResult.totalItems ? 'success' : 'warning'"
        :closable="false"
        show-icon
      />
      <el-table :data="checkResult.items" style="margin-top: 12px">
        <el-table-column prop="module" label="模块" width="100" />
        <el-table-column prop="itemName" label="检测项" width="160" />
        <el-table-column prop="minRequired" label="最低要求" width="80" />
        <el-table-column prop="currentCount" label="当前数量" width="80" />
        <el-table-column label="状态" width="80">
          <template #default="{ row }">
            <el-tag :type="row.passed ? 'success' : 'danger'">
              {{ row.passed ? '通过' : '未通过' }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="message" label="提示" />
      </el-table>
    </el-card>
  </div>
</template>
```
### 3. data æ•°æ®
```js
data() {
  return {
    selectedModules: ['sales', 'purchase', 'quality', 'production'],
    checking: false,
    checkResult: null,
  }
}
```
### 4. æ–¹æ³•
```js
import request from '@/utils/request'
methods: {
  async handleCheck() {
    if (this.selectedModules.length === 0) {
      this.$message.warning('请至少选择一个模块')
      return
    }
    this.checking = true
    try {
      const res = await request.post('/mock/dataCheck', {
        modules: this.selectedModules
      })
      this.checkResult = res.data
    } finally {
      this.checking = false
    }
  }
}
```
## æ³¨æ„äº‹é¡¹
- äº§å“æ•°æ®ï¼ˆå…¬å…±åŸºç¡€ï¼‰åœ¨å¤šä¸ªæ¨¡å—间共享,检测结果中只出现一次,不会重复展示
- æ£€æµ‹ä»…做只读查询,不写入任何数据
- æ¨¡å—参数为空数组时返回空检测列表
- å»ºè®®åœ¨æ•°æ®æ¨¡æ‹Ÿå¼€å§‹å‰å…ˆè°ƒç”¨æ­¤æŽ¥å£ç¡®è®¤åŸºç¡€æ•°æ®å°±ç»ª
- ç”Ÿäº§æ¨¡å—除了检测数据量,还会检测关联性(产品-工艺路线、工艺路线-工序、BOM-产品结构),确保工艺设计数据完整
doc/mock_data_generate.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,243 @@
# æ•°æ®æ¨¡æ‹Ÿ - AI æ•°æ®ç”Ÿæˆ
## æ¶‰åŠé¡µé¢
- æ•°æ®æ¨¡æ‹Ÿæ“ä½œé¡µé¢ï¼ˆæ–°å¢ž/修改)
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /mock/generate | AI ç”Ÿæˆæ¨¡æ‹Ÿæ•°æ®ï¼ˆå«å‰ç½®åŸºç¡€æ•°æ®æ£€æµ‹ï¼‰ |
**请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| modules | List\<String\> | æ˜¯ | è¦ç”Ÿæˆæ•°æ®çš„æ¨¡å—:sales(销售)、purchase(采购)、quality(质量)、production(生产)、stock(库存) |
| industries | List\<String\> | æ˜¯ | è¡Œä¸šï¼Œå¦‚:["机械制造", "食品加工", "电子装配"] |
| countMin | int | å¦ | æ¯ä¸ªå®žä½“生成条数最小值,默认 3 |
| countMax | int | å¦ | æ¯ä¸ªå®žä½“生成条数最大值,默认 10 |
| dateStart | String | å¦ | æ—¶é—´èŒƒå›´å¼€å§‹ï¼ˆyyyy-MM-dd) |
| dateEnd | String | å¦ | æ—¶é—´èŒƒå›´ç»“束(yyyy-MM-dd) |
| additionalInfo | String | å¦ | å…¶ä»–补充描述,如"产品以金属零部件为主" |
请求体示例:
```json
{
  "modules": ["sales", "purchase", "quality"],
  "industries": ["机械制造", "电子装配"],
  "countMin": 3,
  "countMax": 8,
  "dateStart": "2026-01-01",
  "dateEnd": "2026-06-01",
  "additionalInfo": "产品以金属零部件和电子元器件为主"
}
```
**响应(成功):**
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "status": "SUCCESS",
    "totalGenerated": 24,
    "moduleSummaries": [
      { "module": "sales", "entityName": "客户", "generatedCount": 5, "successCount": 5, "failCount": 0 },
      { "module": "sales", "entityName": "销售台账", "generatedCount": 5, "successCount": 5, "failCount": 0 },
      { "module": "purchase", "entityName": "供应商", "generatedCount": 4, "successCount": 4, "failCount": 0 },
      { "module": "purchase", "entityName": "采购台账", "generatedCount": 4, "successCount": 4, "failCount": 0 },
      { "module": "quality", "entityName": "检测标准", "generatedCount": 3, "successCount": 3, "failCount": 0 },
      { "module": "quality", "entityName": "指标绑定", "generatedCount": 3, "successCount": 3, "failCount": 0 }
    ],
    "errors": [],
    "checkResult": null
  }
}
```
**响应(基础数据不足,前置检测未通过):**
```json
{
  "code": 500,
  "msg": "基础数据不足,请先补充后再生成",
  "data": {
    "status": "CHECK_FAILED",
    "checkResult": {
      "totalItems": 6,
      "passedItems": 3,
      "items": [
        { "module": "common", "itemName": "产品数据", "passed": true, "message": "通过" },
        { "module": "sales", "itemName": "客户数据", "passed": false, "message": "缺少客户数据,请先..." }
      ]
    }
  }
}
```
## å·¥ä½œæµç¨‹
1. ç”¨æˆ·åœ¨é¡µé¢é€‰æ‹©æ¨¡å— + å¡«å†™è¡Œä¸š/数量/时间等信息
2. è°ƒç”¨ `POST /mock/generate`
3. åŽç«¯é¦–先调用 `POST /mock/dataCheck` æ£€æµ‹åŸºç¡€æ•°æ®æ˜¯å¦å°±ç»ª
4. è‹¥åŸºç¡€æ•°æ®ä¸è¶³ï¼Œè¿”回检测结果,提示用户先补齐
5. è‹¥åŸºç¡€æ•°æ®å°±ç»ªï¼Œè°ƒç”¨ AI å¤§æ¨¡åž‹ç”Ÿæˆç¬¦åˆè¡Œä¸šç‰¹å¾çš„ JSON æ•°æ®
6. æŒ‰ä¾èµ–顺序创建数据(客户→销售台账、供应商→采购台账、检测标准→指标绑定...)
7. è¿”回生成摘要
## å‰ç«¯ä¿®æ”¹ç‚¹
### 1. æ•°æ®ç”ŸæˆåŒºåŸŸï¼ˆåœ¨æ•°æ®æ£€æµ‹åŒºåŸŸä¸‹æ–¹ï¼‰
```html
<el-card header="AI æ•°æ®ç”Ÿæˆ" style="margin-top: 16px">
  <el-form :model="generateForm" label-width="100px">
    <el-form-item label="选择模块">
      <el-checkbox-group v-model="generateForm.modules">
        <el-checkbox label="sales">销售</el-checkbox>
        <el-checkbox label="purchase">采购</el-checkbox>
        <el-checkbox label="quality">质量</el-checkbox>
        <el-checkbox label="production">生产</el-checkbox>
        <el-checkbox label="stock">库存</el-checkbox>
      </el-checkbox-group>
    </el-form-item>
    <el-form-item label="行业">
      <el-select
        v-model="generateForm.industries"
        multiple
        filterable
        allow-create
        placeholder="输入或选择行业"
      >
        <el-option label="机械制造" value="机械制造" />
        <el-option label="食品加工" value="食品加工" />
        <el-option label="电子装配" value="电子装配" />
        <el-option label="汽车零部件" value="汽车零部件" />
        <el-option label="医疗器械" value="医疗器械" />
        <el-option label="化工材料" value="化工材料" />
        <el-option label="纺织服装" value="纺织服装" />
        <el-option label="家具制造" value="家具制造" />
      </el-select>
    </el-form-item>
    <el-row :gutter="16">
      <el-col :span="12">
        <el-form-item label="生成条数">
          <el-slider
            v-model="generateForm.countRange"
            range
            :min="1"
            :max="50"
            :marks="{1:'1', 10:'10', 20:'20', 50:'50'}"
          />
        </el-form-item>
      </el-col>
      <el-col :span="12">
        <el-form-item label="时间范围">
          <el-date-picker
            v-model="generateForm.dateRange"
            type="daterange"
            range-separator="至"
            start-placeholder="开始日期"
            end-placeholder="结束日期"
            value-format="yyyy-MM-dd"
          />
        </el-form-item>
      </el-col>
    </el-row>
    <el-form-item label="补充信息">
      <el-input
        v-model="generateForm.additionalInfo"
        type="textarea"
        :rows="2"
        placeholder="如:产品以金属零部件为主,客户集中在华东地区"
      />
    </el-form-item>
    <el-form-item>
      <el-button type="primary" @click="handleGenerate" :loading="generating">
        å¼€å§‹ç”Ÿæˆ
      </el-button>
      <el-button @click="handleCheckFirst" :loading="checking">
        å…ˆæ£€æµ‹å†ç”Ÿæˆ
      </el-button>
    </el-form-item>
  </el-form>
</el-card>
```
### 3. data æ•°æ®
```js
data() {
  return {
    generateForm: {
      modules: [],
      industries: [],
      countRange: [3, 10],
      dateRange: [],
      additionalInfo: '',
    },
    generating: false,
    generateResult: null,
  }
}
```
### 4. æ–¹æ³•
```js
methods: {
  // ç›´æŽ¥ç”Ÿæˆï¼ˆå†…部自动检测)
  async handleGenerate() {
    if (this.generateForm.modules.length === 0) {
      this.$message.warning('请至少选择一个模块')
      return
    }
    this.generating = true
    try {
      const res = await request.post('/mock/generate', {
        modules: this.generateForm.modules,
        industries: this.generateForm.industries,
        countMin: this.generateForm.countRange[0],
        countMax: this.generateForm.countRange[1],
        dateStart: this.generateForm.dateRange[0] || null,
        dateEnd: this.generateForm.dateRange[1] || null,
        additionalInfo: this.generateForm.additionalInfo,
      })
      if (res.code === 200) {
        this.generateResult = res.data
        this.$message.success(`成功生成 ${res.data.totalGenerated} æ¡æ•°æ®`)
      } else {
        // åŸºç¡€æ•°æ®ä¸è¶³
        this.generateResult = res.data
        this.checkResult = res.data.checkResult
        this.$message.warning(res.msg)
      }
    } finally {
      this.generating = false
    }
  },
  // å…ˆæ£€æµ‹å†ç”Ÿæˆ
  async handleCheckFirst() {
    await this.handleCheck()
    if (this.checkResult && this.checkResult.passedItems === this.checkResult.totalItems) {
      await this.handleGenerate()
    } else {
      this.$message.warning('请先补齐基础数据后再生成')
    }
  },
}
```
## æ³¨æ„äº‹é¡¹
- `POST /mock/generate` å†…部会自动调用数据检测,不需要手动分两步走
- AI ç”Ÿæˆçš„æ•°æ®ä¼šå°½é‡ç¬¦åˆè¡Œä¸šç‰¹å¾ï¼Œä½†å»ºè®®ç”ŸæˆåŽæ£€æŸ¥å…³é”®æ•°æ®çš„合理性
- ç”Ÿäº§æ¨¡å—和库存模块依赖产品数据(Product/ProductModel),请确保基础数据中有产品
- é”€å”®å°è´¦çš„合同金额由产品明细自动计算,AI åªéœ€ç”Ÿæˆäº§å“è¡Œçš„单价和数量
- äº§ç”Ÿçš„æ•°æ®é€šè¿‡æœåŠ¡å±‚åˆ›å»ºï¼Œä¼šè‡ªåŠ¨å¡«å…… tenantId/deptId/createUser ç­‰å­—段
doc/֪ʶ¿âRAG¹¦ÄÜʵÏÖÎĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1034 @@
# çŸ¥è¯†åº“RAG向量检索功能实现文档
## ä¸€ã€åŠŸèƒ½æ¦‚è¿°
基于 RAG(Retrieval-Augmented Generation)技术实现知识库问答功能,支持:
- çŸ¥è¯†åº“管理(CRUD)
- æ–‡ä»¶ä¸Šä¼ ä¸Žå‘量化处理
- åŸºäºŽå‘量检索的智能问答
- å¤šç§æ–‡ä»¶æ ¼å¼æ”¯æŒï¼ˆtxt、md、docx、xlsx、xls、pdf)
## äºŒã€æŠ€æœ¯æž¶æž„
### 2.1 æŠ€æœ¯æ ˆ
| ç»„ä»¶ | æŠ€æœ¯ |
|------|------|
| å‘量数据库 | Pinecone |
| Embedding模型 | é˜¿é‡Œäº‘ DashScope text-embedding-v3 |
| LLM | é˜¿é‡Œäº‘通义千问 qwen-max |
| æ¡†æž¶ | langchain4j |
| ORM | MyBatis-Plus |
### 2.2 æž¶æž„图
```
┌─────────────────────────────────────────────────────────────┐
│                        å‰ç«¯åº”用                              â”‚
└─────────────────────────────────────────────────────────────┘
                              â”‚
                              â–¼
┌─────────────────────────────────────────────────────────────┐
│                     Controller Layer                         â”‚
│  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”   â”‚
│  â”‚ KnowledgeBaseCtrl   â”‚  â”‚ KnowledgeChatController     â”‚   â”‚
│  â”‚ (知识库管理)         â”‚  â”‚ (知识库问答)                 â”‚   â”‚
│  â””─────────────────────┘  â””─────────────────────────────┘   â”‚
└─────────────────────────────────────────────────────────────┘
                              â”‚
                              â–¼
┌─────────────────────────────────────────────────────────────┐
│                      Service Layer                           â”‚
│  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”   â”‚
│  â”‚KnowledgeBaseService â”‚  â”‚ KnowledgeRagService         â”‚   â”‚
│  â”‚ (知识库CRUD)         â”‚  â”‚ (向量化/检索)                â”‚   â”‚
│  â””─────────────────────┘  â””─────────────────────────────┘   â”‚
└─────────────────────────────────────────────────────────────┘
                              â”‚
                              â–¼
┌─────────────────────────────────────────────────────────────┐
│                      AI Layer                                â”‚
│  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”   â”‚
│  â”‚ KnowledgeChatAgent  â”‚  â”‚ EmbeddingStore (Pinecone)   â”‚   â”‚
│  â”‚ (问答Agent)          â”‚  â”‚ (向量存储)                   â”‚   â”‚
│  â””─────────────────────┘  â””─────────────────────────────┘   â”‚
└─────────────────────────────────────────────────────────────┘
```
---
## ä¸‰ã€åŽç«¯å®žçް
### 3.1 æ•°æ®åº“设计
#### 3.1.1 çŸ¥è¯†åº“表(knowledge_base)
```sql
CREATE TABLE knowledge_base (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) COMMENT '知识标题',
    type VARCHAR(50) COMMENT '知识类型',
    scenario VARCHAR(255) COMMENT '适用场景',
    efficiency VARCHAR(20) COMMENT '解决效率',
    problem TEXT COMMENT '问题描述',
    solution TEXT COMMENT '解决方案',
    key_points TEXT COMMENT '关键要点',
    creator VARCHAR(100) COMMENT '创建人',
    usage_count INT DEFAULT 0 COMMENT '使用次数',
    file_count INT DEFAULT 0 COMMENT '文件数量',
    total_chunk_count INT DEFAULT 0 COMMENT '总切片数量',
    description VARCHAR(500) COMMENT '知识库描述',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    create_user INT,
    update_user INT,
    tenant_id BIGINT,
    dept_id BIGINT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库表';
```
#### 3.1.2 çŸ¥è¯†åº“向量记录表(knowledge_base_vector)
```sql
CREATE TABLE knowledge_base_vector (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
    knowledge_base_id BIGINT NOT NULL COMMENT '关联知识库ID',
    storage_blob_id BIGINT NOT NULL COMMENT '关联文件blob ID',
    file_name VARCHAR(255) NOT NULL COMMENT '文件名称',
    file_type VARCHAR(50) NOT NULL COMMENT '文件类型',
    vector_status TINYINT DEFAULT 0 COMMENT '向量化状态: 0-待处理, 1-处理中, 2-已完成, 3-失败',
    vector_error VARCHAR(500) COMMENT '向量化失败原因',
    chunk_count INT DEFAULT 0 COMMENT '切片数量',
    namespace VARCHAR(100) COMMENT '向量命名空间',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    create_user INT,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    update_user INT,
    tenant_id BIGINT,
    dept_id BIGINT,
    INDEX idx_knowledge_base_id (knowledge_base_id),
    INDEX idx_storage_blob_id (storage_blob_id),
    INDEX idx_vector_status (vector_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库文件向量记录表';
```
### 3.2 Maven依赖
```xml
<!-- langchain4j BOM -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-bom</artifactId>
            <version>1.0.0-beta3</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
<dependencies>
    <!-- langchain4j æ ¸å¿ƒ -->
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-spring-boot-starter</artifactId>
    </dependency>
    <!-- Pinecone å‘量数据库 -->
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-pinecone</artifactId>
    </dependency>
    <!-- é˜¿é‡Œäº‘ DashScope -->
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-community-dashscope-spring-boot-starter</artifactId>
    </dependency>
</dependencies>
```
### 3.3 é…ç½®æ–‡ä»¶ï¼ˆapplication.yml)
```yaml
# Pinecone å‘量数据库配置
pinecone:
  api-key: your-pinecone-api-key
  index: your-index-name
  namespace: knowledge-base
# langchain4j é…ç½®
langchain4j:
  community:
    dashscope:
      streaming-chat-model:
        api-key: your-dashscope-api-key
        model-name: "qwen-max"
      embedding-model:
        api-key: your-dashscope-api-key
        model-name: "text-embedding-v3"
```
### 3.4 æ ¸å¿ƒä»£ç å®žçް
#### 3.4.1 å®žä½“ç±»
**KnowledgeBase.java**
```java
@Data
@TableName("knowledge_base")
public class KnowledgeBase implements Serializable {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String title;
    private String type;
    private String scenario;
    private String efficiency;
    private String problem;
    private String solution;
    private String keyPoints;
    private String creator;
    private Integer usageCount;
    private Integer fileCount;
    private Integer totalChunkCount;
    private String description;
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    @TableField(fill = FieldFill.INSERT)
    private Integer createUser;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Integer updateUser;
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
```
**KnowledgeBaseVector.java**
```java
@Data
@TableName("knowledge_base_vector")
public class KnowledgeBaseVector implements Serializable {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long knowledgeBaseId;
    private Long storageBlobId;
    private String fileName;
    private String fileType;
    private Integer vectorStatus;
    private String vectorError;
    private Integer chunkCount;
    private String namespace;
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT)
    private Integer createUser;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Integer updateUser;
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
    // å‘量化状态常量
    public static final int STATUS_PENDING = 0;
    public static final int STATUS_PROCESSING = 1;
    public static final int STATUS_COMPLETED = 2;
    public static final int STATUS_FAILED = 3;
}
```
#### 3.4.2 EmbeddingStore配置
**EmbeddingStoreConfig.java**
```java
@Configuration
public class EmbeddingStoreConfig {
    @Value("${pinecone.api-key}")
    private String pineconeApiKey;
    @Value("${pinecone.index}")
    private String indexName;
    @Value("${pinecone.namespace}")
    private String namespace;
    @Bean
    public Pinecone pinecone() {
        return new Pinecone.Builder(pineconeApiKey).build();
    }
    @Bean
    public Index pineconeIndex(Pinecone pinecone) {
        return pinecone.getIndexConnection(indexName);
    }
    @Bean
    public EmbeddingStore<TextSegment> embeddingStore(EmbeddingModel embeddingModel) {
        return PineconeEmbeddingStore.builder()
                .apiKey(pineconeApiKey)
                .index(indexName)
                .nameSpace(namespace)
                .createIndex(PineconeServerlessIndexConfig.builder()
                        .cloud("AWS")
                        .region("us-east-1")
                        .dimension(embeddingModel.dimension())
                        .build())
                .build();
    }
}
```
#### 3.4.3 RAG服务实现
**KnowledgeRagService.java**
```java
public interface KnowledgeRagService {
    void processVectorAsync(Long vectorId);
    void processVector(Long vectorId);
    List<String> searchRelevantContent(String namespace, String query, int maxResults);
    void deleteEmbeddings(String namespace, Long storageBlobId);
}
```
**KnowledgeRagServiceImpl.java**(核心实现)
```java
@Slf4j
@Service
public class KnowledgeRagServiceImpl implements KnowledgeRagService {
    private final KnowledgeBaseVectorService knowledgeBaseVectorService;
    private final StorageBlobService storageBlobService;
    private final EmbeddingModel embeddingModel;
    private final EmbeddingStore<TextSegment> embeddingStore;
    private final FileProperties fileProperties;
    private final Index pineconeIndex;
    @Value("${pinecone.namespace}")
    private String namespace;
    private static final int CHUNK_SIZE = 500;
    private static final int CHUNK_OVERLAP = 100;
    private static final long CHUNK_THRESHOLD_BYTES = 80L * 1024 * 1024;
    private static final int EMBEDDING_MAX_LENGTH = 8000;
    @Override
    @Async("threadPoolTaskExecutor")
    public void processVectorAsync(Long vectorId) {
        processVector(vectorId);
    }
    @Override
    public void processVector(Long vectorId) {
        KnowledgeBaseVector vector = knowledgeBaseVectorService.getById(vectorId);
        if (vector == null) return;
        try {
            // æ›´æ–°çŠ¶æ€ä¸ºå¤„ç†ä¸­
            knowledgeBaseVectorService.updateVectorStatus(vectorId, STATUS_PROCESSING, null, null);
            // èŽ·å–æ–‡ä»¶å†…å®¹
            StorageBlob blob = storageBlobService.getById(vector.getStorageBlobId());
            File file = getFile(blob);
            String content = extractFileContent(file, vector.getFileName());
            if (content == null || content.trim().isEmpty()) {
                throw new RuntimeException("文件内容为空");
            }
            // æ–‡æœ¬åˆ‡ç‰‡
            List<TextSegment> chunks;
            boolean needChunk = file.length() > CHUNK_THRESHOLD_BYTES || content.length() > EMBEDDING_MAX_LENGTH;
            if (needChunk) {
                chunks = splitText(content, vector);
            } else {
                Map<String, Object> metadata = buildMetadata(vector);
                chunks = List.of(TextSegment.from(content, new Metadata(metadata)));
            }
            // ç”ŸæˆåµŒå…¥å‘量并存储
            int chunkCount = 0;
            for (TextSegment chunk : chunks) {
                Embedding embedding = embeddingModel.embed(chunk).content();
                embeddingStore.add(embedding, chunk);
                chunkCount++;
            }
            // æ›´æ–°çŠ¶æ€ä¸ºå®Œæˆ
            knowledgeBaseVectorService.updateVectorStatus(vectorId, STATUS_COMPLETED, chunkCount, null);
        } catch (Exception e) {
            log.error("向量化处理失败", e);
            knowledgeBaseVectorService.updateVectorStatus(vectorId, STATUS_FAILED, null, e.getMessage());
        }
    }
    @Override
    public List<String> searchRelevantContent(String namespace, String query, int maxResults) {
        Embedding queryEmbedding = embeddingModel.embed(query).content();
        EmbeddingSearchRequest searchRequest = EmbeddingSearchRequest.builder()
                .queryEmbedding(queryEmbedding)
                .maxResults(maxResults)
                .minScore(0.7)
                .build();
        EmbeddingSearchResult<TextSegment> searchResult = embeddingStore.search(searchRequest);
        return searchResult.matches().stream()
                .map(match -> match.embedded().text())
                .collect(Collectors.toList());
    }
    @Override
    public void deleteEmbeddings(String namespace, Long storageBlobId) {
        Struct filter = Struct.newBuilder()
                .putFields("storageBlobId", Value.newBuilder()
                        .setStructValue(Struct.newBuilder()
                                .putFields("$eq", Value.newBuilder()
                                        .setNumberValue(storageBlobId.doubleValue())
                                        .build()))
                        .build())
                .build();
        pineconeIndex.delete(new ArrayList<>(), false, this.namespace, filter);
    }
    private String extractFileContent(File file, String fileName) throws Exception {
        String ext = getFileExtension(fileName);
        if (isPlainText(ext)) {
            return readFileWithEncoding(file);
        }
        if ("docx".equals(ext)) {
            return extractDocx(file);
        }
        if ("xlsx".equals(ext) || "xls".equals(ext)) {
            return extractExcel(file);
        }
        return readFileWithEncoding(file);
    }
    // ... å…¶ä»–辅助方法
}
```
#### 3.4.4 çŸ¥è¯†åº“问答Agent
**KnowledgeChatAgent.java**
```java
@AiService(
        wiringMode = EXPLICIT,
        streamingChatModel = "qwenStreamingChatModel",
        chatMemoryProvider = "chatMemoryProvider"
)
public interface KnowledgeChatAgent {
    @SystemMessage("""
            ä½ æ˜¯ä¼ä¸šçŸ¥è¯†åº“问答助手。
            ä½ éœ€è¦åŸºäºŽæä¾›çš„知识库内容回答用户问题。
            éµå¾ªä»¥ä¸‹è§„则:
            1. ä¸¥æ ¼åŸºäºŽçŸ¥è¯†åº“内容回答,不要编造信息
            2. å¦‚果知识库中没有相关信息,明确告知用户
            3. å›žç­”要准确、简洁、有条理
            4. å¼•用来源时注明"根据知识库内容"
            """)
    Flux<String> chat(@MemoryId String memoryId, @UserMessage String userMessage);
}
```
#### 3.4.5 Controller层
**KnowledgeBaseController.java**
```java
@RestController
@RequestMapping("/knowledgeBase")
@Tag(name = "知识库管理")
public class KnowledgeBaseController {
    @GetMapping("/getList")
    public AjaxResult getList(@RequestParam(defaultValue = "1") long current,
                              @RequestParam(defaultValue = "10") long size,
                              KnowledgeBase knowledgeBase) {
        Page page = new Page(current, size);
        return AjaxResult.success(knowledgeBaseService.listpage(page, knowledgeBase));
    }
    @PostMapping("/add")
    public AjaxResult add(@RequestBody KnowledgeBase knowledgeBase) {
        return AjaxResult.success(knowledgeBaseService.save(knowledgeBase));
    }
    @PostMapping("/update")
    public AjaxResult update(@RequestBody KnowledgeBase knowledgeBase) {
        return AjaxResult.success(knowledgeBaseService.updateById(knowledgeBase));
    }
    @DeleteMapping("/delete")
    public AjaxResult delete(@RequestBody List<Long> ids) {
        return AjaxResult.success(knowledgeBaseService.removeByIds(ids));
    }
    @GetMapping("/vector/status/{knowledgeBaseId}")
    @Operation(summary = "查询知识库文件向量化状态")
    public AjaxResult getVectorStatus(@PathVariable Long knowledgeBaseId) {
        return AjaxResult.success(knowledgeBaseVectorService.getVectorStatusByKnowledgeBaseId(knowledgeBaseId));
    }
    @PostMapping("/vector/reprocess/{vectorId}")
    @Operation(summary = "重新向量化文件")
    public AjaxResult reprocessVector(@PathVariable Long vectorId) {
        knowledgeBaseVectorService.reprocessVector(vectorId);
        return AjaxResult.success("已重新提交向量化任务");
    }
    @PostMapping("/file/save")
    @Operation(summary = "保存知识库文件关联")
    public AjaxResult saveKnowledgeBaseFiles(@RequestBody KnowledgeBaseFileDTO dto) {
        // ä¿å­˜é™„件关联并触发向量化
        // ...
    }
    @DeleteMapping("/file/delete")
    @Operation(summary = "删除知识库文件")
    public AjaxResult deleteKnowledgeBaseFiles(@RequestBody List<Long> vectorIds) {
        knowledgeBaseVectorService.deleteVectors(vectorIds);
        return AjaxResult.success();
    }
}
```
**KnowledgeChatController.java**
```java
@RestController
@RequestMapping("/ai/knowledge")
@Tag(name = "知识库问答")
public class KnowledgeChatController {
    private final KnowledgeChatAgent knowledgeChatAgent;
    private final KnowledgeRagService knowledgeRagService;
    private final KnowledgeBaseService knowledgeBaseService;
    @PostMapping(value = "/chat", produces = "text/stream;charset=utf-8")
    @Operation(summary = "知识库问答")
    public Flux<String> chat(@RequestBody KnowledgeChatRequest request) {
        // æ£€ç´¢ç›¸å…³å†…容
        String namespace = "kb-" + request.getKnowledgeBaseId();
        List<String> relevantContents = knowledgeRagService.searchRelevantContent(
                namespace, request.getQuestion(), 5);
        if (relevantContents.isEmpty()) {
            return Flux.just("知识库中未找到相关内容");
        }
        // æž„建上下文
        StringBuilder context = new StringBuilder();
        context.append("以下是从知识库中检索到的相关内容:\n\n");
        for (int i = 0; i < relevantContents.size(); i++) {
            context.append("【内容").append(i + 1).append("】\n");
            context.append(relevantContents.get(i)).append("\n\n");
        }
        context.append("---\n请基于以上知识库内容回答:\n").append(request.getQuestion());
        return knowledgeChatAgent.chat(request.getMemoryId(), context.toString());
    }
    @GetMapping("/list")
    @Operation(summary = "知识库列表")
    public AjaxResult listKnowledgeBases() {
        return AjaxResult.success(knowledgeBaseService.list());
    }
}
```
---
## å››ã€API接口文档
### 4.1 çŸ¥è¯†åº“管理接口
| æŽ¥å£ | æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|------|
| èŽ·å–åˆ—è¡¨ | GET | /knowledgeBase/getList | åˆ†é¡µæŸ¥è¯¢çŸ¥è¯†åº“列表 |
| æ–°å¢žçŸ¥è¯†åº“ | POST | /knowledgeBase/add | åˆ›å»ºçŸ¥è¯†åº“ |
| æ›´æ–°çŸ¥è¯†åº“ | POST | /knowledgeBase/update | æ›´æ–°çŸ¥è¯†åº“信息 |
| åˆ é™¤çŸ¥è¯†åº“ | DELETE | /knowledgeBase/delete | æ‰¹é‡åˆ é™¤çŸ¥è¯†åº“ |
| æŸ¥è¯¢å‘量化状态 | GET | /knowledgeBase/vector/status/{id} | æŸ¥è¯¢æ–‡ä»¶å‘量化状态 |
| é‡æ–°å‘量化 | POST | /knowledgeBase/vector/reprocess/{id} | é‡æ–°å¤„理失败的文件 |
| ä¿å­˜æ–‡ä»¶å…³è” | POST | /knowledgeBase/file/save | ä¸Šä¼ æ–‡ä»¶åŽå…³è”到知识库 |
| åˆ é™¤æ–‡ä»¶ | DELETE | /knowledgeBase/file/delete | åˆ é™¤çŸ¥è¯†åº“文件 |
### 4.2 çŸ¥è¯†åº“问答接口
| æŽ¥å£ | æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|------|
| çŸ¥è¯†åº“问答 | POST | /ai/knowledge/chat | æµå¼è¿”回问答结果 |
| çŸ¥è¯†åº“列表 | GET | /ai/knowledge/list | èŽ·å–å¯é€‰çŸ¥è¯†åº“åˆ—è¡¨ |
### 4.3 æŽ¥å£è¯¦ç»†è¯´æ˜Ž
#### 4.3.1 ä¿å­˜çŸ¥è¯†åº“文件关联
**请求**
```json
POST /knowledgeBase/file/save
{
    "knowledgeBaseId": 1,
    "storageBlobIds": [100, 101, 102]
}
```
**响应**
```json
{
    "code": 200,
    "msg": "操作成功"
}
```
#### 4.3.2 çŸ¥è¯†åº“问答
**请求**
```json
POST /ai/knowledge/chat
Content-Type: application/json
{
    "knowledgeBaseId": 1,
    "memoryId": "session-uuid",
    "question": "如何处理库存盘点差异?"
}
```
**响应**(SSE流式)
```
根据知识库内容,库存盘点差异的处理流程如下:
1. å‘现差异后,首先核对盘点记录...
2. æ£€æŸ¥æ˜¯å¦æœ‰æ¼ç›˜æˆ–错盘...
3. ...
```
---
## äº”、前端实现
### 5.1 çŸ¥è¯†åº“管理页面
```vue
<template>
  <div class="knowledge-base">
    <!-- åˆ—表 -->
    <el-table :data="tableData" border>
      <el-table-column prop="title" label="知识标题" />
      <el-table-column prop="type" label="知识类型" />
      <el-table-column prop="fileCount" label="文件数量" />
      <el-table-column prop="totalChunkCount" label="切片数量" />
      <el-table-column label="操作">
        <template #default="{ row }">
          <el-button @click="handleEdit(row)">编辑</el-button>
          <el-button @click="handleFiles(row)">文件管理</el-button>
          <el-button @click="handleChat(row)">问答</el-button>
          <el-button type="danger" @click="handleDelete(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getKnowledgeBaseList, deleteKnowledgeBase } from '@/api/knowledge'
const tableData = ref([])
const loadData = async () => {
  const res = await getKnowledgeBaseList({ current: 1, size: 10 })
  tableData.value = res.data.records
}
onMounted(loadData)
</script>
```
### 5.2 æ–‡ä»¶ä¸Šä¼ ä¸Žå‘量化状态
```vue
<template>
  <div class="file-manager">
    <!-- æ–‡ä»¶ä¸Šä¼  -->
    <el-upload
      :action="uploadUrl"
      :on-success="handleUploadSuccess"
      multiple
    >
      <el-button type="primary">上传文件</el-button>
    </el-upload>
    <!-- æ–‡ä»¶åˆ—表与向量化状态 -->
    <el-table :data="fileList">
      <el-table-column prop="fileName" label="文件名" />
      <el-table-column label="向量化状态">
        <template #default="{ row }">
          <el-tag :type="getStatusType(row.vectorStatus)">
            {{ getStatusText(row.vectorStatus) }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column prop="chunkCount" label="切片数" />
      <el-table-column label="操作">
        <template #default="{ row }">
          <el-button v-if="row.vectorStatus === 3" @click="reprocess(row)">
            é‡æ–°å¤„理
          </el-button>
          <el-button type="danger" @click="deleteFile(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>
<script setup>
const uploadUrl = import.meta.env.VITE_APP_BASE_API + '/common/upload'
// ä¸Šä¼ æˆåŠŸåŽä¿å­˜å…³è”
const uploadedBlobIds = ref([])
const handleUploadSuccess = (response, file) => {
  if (response.code === 200) {
    uploadedBlobIds.value.push(response.data.id)
  }
}
// ä¿å­˜æ–‡ä»¶å…³è”
const saveFiles = async () => {
  await saveKnowledgeBaseFiles({
    knowledgeBaseId: props.knowledgeBaseId,
    storageBlobIds: uploadedBlobIds.value
  })
  // åˆ·æ–°æ–‡ä»¶åˆ—表
  loadFileList()
}
// çŠ¶æ€æ–‡æœ¬æ˜ å°„
const getStatusText = (status) => {
  const map = {
    0: '待处理',
    1: '处理中',
    2: '已完成',
    3: '失败'
  }
  return map[status] || '未知'
}
const getStatusType = (status) => {
  const map = {
    0: 'info',
    1: 'warning',
    2: 'success',
    3: 'danger'
  }
  return map[status] || 'info'
}
</script>
```
### 5.3 çŸ¥è¯†åº“问答界面
```vue
<template>
  <div class="knowledge-chat">
    <!-- çŸ¥è¯†åº“选择 -->
    <el-select v-model="selectedKbId" placeholder="选择知识库">
      <el-option
        v-for="kb in knowledgeBases"
        :key="kb.id"
        :label="kb.title"
        :value="kb.id"
      />
    </el-select>
    <!-- å¯¹è¯åŒºåŸŸ -->
    <div class="chat-messages">
      <div
        v-for="(msg, index) in messages"
        :key="index"
        :class="['message', msg.role]"
      >
        <div class="content">{{ msg.content }}</div>
      </div>
    </div>
    <!-- è¾“入框 -->
    <el-input
      v-model="inputQuestion"
      placeholder="请输入问题"
      @keyup.enter="sendMessage"
    >
      <template #append>
        <el-button @click="sendMessage" :loading="loading">发送</el-button>
      </template>
    </el-input>
  </div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getKnowledgeBaseList, knowledgeChat } from '@/api/knowledge'
const knowledgeBases = ref([])
const selectedKbId = ref(null)
const messages = ref([])
const inputQuestion = ref('')
const loading = ref(false)
const memoryId = ref(crypto.randomUUID())
const sendMessage = async () => {
  if (!inputQuestion.value.trim()) return
  if (!selectedKbId.value) {
    ElMessage.warning('请选择知识库')
    return
  }
  // æ·»åŠ ç”¨æˆ·æ¶ˆæ¯
  messages.value.push({
    role: 'user',
    content: inputQuestion.value
  })
  loading.value = true
  try {
    // æµå¼è¯·æ±‚
    const response = await fetch('/api/ai/knowledge/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        knowledgeBaseId: selectedKbId.value,
        memoryId: memoryId.value,
        question: inputQuestion.value
      })
    })
    // å¤„理SSE流式响应
    const reader = response.body.getReader()
    const decoder = new TextDecoder()
    let aiContent = ''
    messages.value.push({ role: 'assistant', content: '' })
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      const text = decoder.decode(value)
      aiContent += text
      messages.value[messages.value.length - 1].content = aiContent
    }
  } finally {
    loading.value = false
    inputQuestion.value = ''
  }
}
onMounted(async () => {
  const res = await getKnowledgeBaseList()
  knowledgeBases.value = res.data
})
</script>
```
### 5.4 API封装
```javascript
// api/knowledge.js
import request from '@/utils/request'
// èŽ·å–çŸ¥è¯†åº“åˆ—è¡¨
export function getKnowledgeBaseList(params) {
  return request({
    url: '/knowledgeBase/getList',
    method: 'get',
    params
  })
}
// æ–°å¢žçŸ¥è¯†åº“
export function addKnowledgeBase(data) {
  return request({
    url: '/knowledgeBase/add',
    method: 'post',
    data
  })
}
// æ›´æ–°çŸ¥è¯†åº“
export function updateKnowledgeBase(data) {
  return request({
    url: '/knowledgeBase/update',
    method: 'post',
    data
  })
}
// åˆ é™¤çŸ¥è¯†åº“
export function deleteKnowledgeBase(ids) {
  return request({
    url: '/knowledgeBase/delete',
    method: 'delete',
    data: ids
  })
}
// èŽ·å–æ–‡ä»¶å‘é‡åŒ–çŠ¶æ€
export function getVectorStatus(knowledgeBaseId) {
  return request({
    url: `/knowledgeBase/vector/status/${knowledgeBaseId}`,
    method: 'get'
  })
}
// é‡æ–°å‘量化
export function reprocessVector(vectorId) {
  return request({
    url: `/knowledgeBase/vector/reprocess/${vectorId}`,
    method: 'post'
  })
}
// ä¿å­˜æ–‡ä»¶å…³è”
export function saveKnowledgeBaseFiles(data) {
  return request({
    url: '/knowledgeBase/file/save',
    method: 'post',
    data
  })
}
// åˆ é™¤æ–‡ä»¶
export function deleteKnowledgeBaseFiles(vectorIds) {
  return request({
    url: '/knowledgeBase/file/delete',
    method: 'delete',
    data: vectorIds
  })
}
// çŸ¥è¯†åº“问答(流式)
export async function knowledgeChat(data) {
  const response = await fetch('/api/ai/knowledge/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  })
  return response.body
}
// èŽ·å–çŸ¥è¯†åº“åˆ—è¡¨ï¼ˆé—®ç­”ç”¨ï¼‰
export function getKnowledgeBaseListForChat() {
  return request({
    url: '/ai/knowledge/list',
    method: 'get'
  })
}
```
---
## å…­ã€æ ¸å¿ƒæµç¨‹
### 6.1 æ–‡ä»¶ä¸Šä¼ ä¸Žå‘量化流程
```
1. å‰ç«¯è°ƒç”¨ /common/upload ä¸Šä¼ æ–‡ä»¶ â†’ è¿”回 storageBlobId
2. å‰ç«¯è°ƒç”¨ /knowledgeBase/file/save å…³è”文件到知识库
3. åŽç«¯åˆ›å»º KnowledgeBaseVector è®°å½•(状态:待处理)
4. åŽç«¯å¼‚步调用 KnowledgeRagService.processVectorAsync()
   â”œâ”€â”€ æ›´æ–°çŠ¶æ€ä¸º"处理中"
   â”œâ”€â”€ æå–文件内容(支持多种格式)
   â”œâ”€â”€ è‡ªåŠ¨æ£€æµ‹æ–‡ä»¶ç¼–ç ï¼ˆUTF-8/GBK)
   â”œâ”€â”€ æ–‡æœ¬åˆ‡ç‰‡ï¼ˆå¤§æ–‡ä»¶æˆ–长内容才切片)
   â”œâ”€â”€ ç”Ÿæˆ Embedding å‘量
   â”œâ”€â”€ å­˜å‚¨åˆ° Pinecone
   â””── æ›´æ–°çŠ¶æ€ä¸º"完成"或"失败"
```
### 6.2 çŸ¥è¯†åº“问答流程
```
1. ç”¨æˆ·é€‰æ‹©çŸ¥è¯†åº“,输入问题
2. å‰ç«¯è°ƒç”¨ /ai/knowledge/chat(流式接口)
3. åŽç«¯å¤„理:
   â”œâ”€â”€ æž„建命名空间:kb-{knowledgeBaseId}
   â”œâ”€â”€ è°ƒç”¨ Embedding æ¨¡åž‹ç”Ÿæˆé—®é¢˜å‘量
   â”œâ”€â”€ ä»Ž Pinecone æ£€ç´¢ç›¸å…³å†…容(minScore=0.7, maxResults=5)
   â”œâ”€â”€ æž„建上下文 Prompt
   â”œâ”€â”€ è°ƒç”¨ LLM ç”Ÿæˆå›žç­”
   â””── æµå¼è¿”回结果
```
---
## ä¸ƒã€æ³¨æ„äº‹é¡¹
1. **Pinecone å‘½åç©ºé—´**:不能使用 `__default__`,必须使用自定义命名空间
2. **文件编码**:自动检测 UTF-8/GBK,避免乱码
3. **切片策略**:
   - æ–‡ä»¶ > 80MB æˆ–内容 > 8000 å­—符时才切片
   - åˆ‡ç‰‡å¤§å° 500 å­—符,重叠 100 å­—符
   - ä¼˜å…ˆåœ¨å¥å­è¾¹ç•Œåˆ‡åˆ†
4. **Embedding é™åˆ¶**:阿里云 DashScope é™åˆ¶å•次输入最大 8192 å­—符
5. **向量删除**:使用 Pinecone åŽŸç”Ÿå®¢æˆ·ç«¯ï¼Œé€šè¿‡ metadata filter åˆ é™¤
6. **异步处理**:向量化使用 `@Async` å¼‚步执行,避免阻塞接口
---
## å…«ã€æ–‡ä»¶æ¸…单
### åŽç«¯æ–‡ä»¶
```
src/main/java/com/ruoyi/
├── approve/
│   â”œâ”€â”€ controller/
│   â”‚   â””── KnowledgeBaseController.java
│   â”œâ”€â”€ pojo/
│   â”‚   â”œâ”€â”€ KnowledgeBase.java
│   â”‚   â””── KnowledgeBaseVector.java
│   â”œâ”€â”€ service/
│   â”‚   â”œâ”€â”€ KnowledgeBaseService.java
│   â”‚   â”œâ”€â”€ KnowledgeBaseVectorService.java
│   â”‚   â””── impl/
│   â”‚       â”œâ”€â”€ KnowledgeBaseServiceImpl.java
│   â”‚       â””── KnowledgeBaseVectorServiceImpl.java
│   â”œâ”€â”€ mapper/
│   â”‚   â”œâ”€â”€ KnowledgeBaseMapper.java
│   â”‚   â””── KnowledgeBaseVectorMapper.java
│   â””── dto/
│       â””── KnowledgeBaseVectorVO.java
└── ai/
    â”œâ”€â”€ config/
    â”‚   â”œâ”€â”€ EmbeddingStoreConfig.java
    â”‚   â””── XiaozhiAgentConfig.java
    â”œâ”€â”€ controller/
    â”‚   â””── KnowledgeChatController.java
    â”œâ”€â”€ assistant/
    â”‚   â””── KnowledgeChatAgent.java
    â”œâ”€â”€ service/
    â”‚   â”œâ”€â”€ KnowledgeRagService.java
    â”‚   â””── impl/
    â”‚       â””── KnowledgeRagServiceImpl.java
    â””── dto/
        â””── KnowledgeChatRequest.java
```
### å‰ç«¯æ–‡ä»¶
```
src/views/knowledge/
├── index.vue              # çŸ¥è¯†åº“列表
├── form.vue               # æ–°å¢ž/编辑表单
├── files.vue              # æ–‡ä»¶ç®¡ç†
└── chat.vue               # çŸ¥è¯†åº“问答
src/api/knowledge.js       # API封装
```
doc/֪ʶ¿âÄ£¿é´«²Î·½Ê½ºÍ²ÎÊýÃüÃû¹æ·¶Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,941 @@
# çŸ¥è¯†åº“模块传参方式和参数命名规范文档
## ä¸€ã€æ¦‚è¿°
本文档详细说明知识库模块中所有接口、组件、方法的传参方式和参数命名规范,旨在:
- ç»Ÿä¸€å‰åŽç«¯å‚数命名规范
- æ˜Žç¡®å‚数类型和必填性
- è§„范传参方式(GET params、POST body、DELETE data)
- æä¾›æ¸…晰的参数映射关系
---
## äºŒã€å‚数命名规范
### 2.1 åŸºæœ¬è§„范
#### å‘½åé£Žæ ¼
- **后端参数**: éµå¾ª Java é©¼å³°å‘½åæ³• (camelCase)
- **前端参数**: éµå¾ª JavaScript é©¼å³°å‘½åæ³• (camelCase)
- **数据库字段**: éµå¾ª MySQL ä¸‹åˆ’线命名法 (snake_case)
- **接口URL**: éµå¾ª RESTful é£Žæ ¼,使用小写和连字符
#### å‘½åçº¦å®š
1. **ID相关**: ç»Ÿä¸€ä½¿ç”¨ `id`、`Id` åŽç¼€
   - `knowledgeBaseId` - çŸ¥è¯†åº“ID
   - `storageBlobId` - æ–‡ä»¶blob ID
   - `vectorId` - å‘量记录ID
   - `memoryId` - ä¼šè¯ID
2. **列表相关**: ç»Ÿä¸€ä½¿ç”¨ `Ids` åŽç¼€æˆ–数组类型
   - `storageBlobIds` - æ–‡ä»¶blob ID列表
   - `ids` - é€šç”¨ID列表
3. **状态相关**: ç»Ÿä¸€ä½¿ç”¨ `Status` åŽç¼€
   - `vectorStatus` - å‘量化状态
4. **数量相关**: ç»Ÿä¸€ä½¿ç”¨ `Count` åŽç¼€
   - `fileCount` - æ–‡ä»¶æ•°é‡
   - `chunkCount` - åˆ‡ç‰‡æ•°é‡
   - `totalChunkCount` - æ€»åˆ‡ç‰‡æ•°é‡
   - `usageCount` - ä½¿ç”¨æ¬¡æ•°
5. **时间相关**: ç»Ÿä¸€ä½¿ç”¨ `Time` åŽç¼€
   - `createTime` - åˆ›å»ºæ—¶é—´
   - `updateTime` - æ›´æ–°æ—¶é—´
---
## ä¸‰ã€æŽ¥å£ä¼ å‚方式规范
### 3.1 GET è¯·æ±‚ - ä½¿ç”¨ params
**适用场景**: æŸ¥è¯¢ã€åˆ—表、分页等获取数据的接口
**传参方式**: é€šè¿‡ URL å‚数传递,使用 `params`
**示例**:
```javascript
// æŸ¥è¯¢çŸ¥è¯†åº“列表
export function listKnowledgeBase(query) {
  return request({
    url: "/knowledgeBase/getList",
    method: "get",
    params: query,  // âœ… GET请求使用 params
  });
}
// å®žé™…调用
listKnowledgeBase({
  current: 1,      // å½“前页码
  size: 20,        // æ¯é¡µæ¡æ•°
  title: "",       // çŸ¥è¯†æ ‡é¢˜(可选)
  type: ""         // çŸ¥è¯†ç±»åž‹(可选)
});
```
**URL格式**: `/knowledgeBase/getList?current=1&size=20&title=&type=`
**规范要点**:
- âœ… æŸ¥è¯¢å‚数统一放在 `params` ä¸­
- âœ… åˆ†é¡µå‚数命名: `current` (当前页)、`size` (每页条数)
- âœ… æœç´¢å‚数命名: ä¸Žå®žä½“字段保持一致
- âœ… è·¯å¾„参数使用 URL å ä½ç¬¦: `/path/{id}`
---
### 3.2 POST è¯·æ±‚ - ä½¿ç”¨ data
**适用场景**: æ–°å¢žã€æ›´æ–°ã€ä¿å­˜ç­‰æäº¤æ•°æ®çš„æŽ¥å£
**传参方式**: é€šè¿‡è¯·æ±‚体传递,使用 `data`
**示例**:
```javascript
// æ–°å¢žçŸ¥è¯†åº“
export function addKnowledgeBase(data) {
  return request({
    url: "/knowledgeBase/add",
    method: "post",
    data: data,  // âœ… POST请求使用 data
  });
}
// å®žé™…调用
addKnowledgeBase({
  title: "操作手册",
  type: "guide",
  scenario: "系统操作指导",
  efficiency: "high",
  problem: "用户不会操作系统",
  solution: "按照操作手册执行...",
  keyPoints: "步骤1,步骤2,步骤3",
  creator: "张三",
  usageCount: 0
});
```
**请求体格式**: JSON æ ¼å¼,`Content-Type: application/json`
**规范要点**:
- âœ… æäº¤æ•°æ®ç»Ÿä¸€æ”¾åœ¨ `data` ä¸­
- âœ… å‚数名与后端实体字段保持一致
- âœ… å¿…填参数需要在表单验证规则中声明
- âœ… æ•°å€¼ç±»åž‹å‚数需指定默认值
---
### 3.3 DELETE è¯·æ±‚ - ä½¿ç”¨ data
**适用场景**: åˆ é™¤ã€æ‰¹é‡åˆ é™¤ç­‰æ“ä½œ
**传参方式**: é€šè¿‡è¯·æ±‚体传递数组或对象,使用 `data`
**示例**:
```javascript
// åˆ é™¤çŸ¥è¯†åº“
export function delKnowledgeBase(query) {
  return request({
    url: "/knowledgeBase/delete",
    method: "delete",
    data: query,  // âœ… DELETE请求使用 data ä¼ é€’数组
  });
}
// å®žé™…调用(批量删除)
delKnowledgeBase([1, 2, 3]);  // âœ… ç›´æŽ¥ä¼ é€’ID数组
```
**请求体格式**: JSON æ•°ç»„ `[1, 2, 3]`
**规范要点**:
- âœ… DELETE请求的参数放在 `data` ä¸­
- âœ… æ‰¹é‡åˆ é™¤ä¼ é€’ID数组
- âœ… å•个删除也可以传递数组 `[id]`
- âš ï¸ ä¸è¦ä½¿ç”¨ `params` ä¼ é€’删除参数
---
### 3.4 æµå¼è¯·æ±‚ - ä½¿ç”¨ Fetch API
**适用场景**: AI问答、流式输出等需要实时响应的接口
**传参方式**: ä½¿ç”¨åŽŸç”Ÿ Fetch API,不支持 axios
**示例**:
```javascript
// çŸ¥è¯†åº“问答(流式)
export function knowledgeChat(data) {
  const token = getToken();
  return fetch(import.meta.env.VITE_APP_BASE_API + '/ai/knowledge/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + token
    },
    body: JSON.stringify(data)  // âœ… ä½¿ç”¨ body ä¼ é€’参数
  });
}
// å®žé™…调用
knowledgeChat({
  knowledgeBaseId: 10,
  memoryId: "session-xxx",
  question: "如何操作审批流程?"
});
```
**规范要点**:
- âœ… æµå¼æŽ¥å£å¿…须使用 Fetch API
- âœ… å‚数使用 `JSON.stringify()` åºåˆ—化
- âœ… å¿…须携带 Authorization header
- âš ï¸ axios ä¸æ”¯æŒæµå¼å“åº”,不要使用
---
## å››ã€å®Œæ•´å‚数对照表
### 4.1 çŸ¥è¯†åº“管理接口参数
#### æŸ¥è¯¢åˆ—表 (`GET /knowledgeBase/getList`)
| å‚数名 | ç±»åž‹ | å¿…å¡« | ä¼ å‚位置 | è¯´æ˜Ž | ç¤ºä¾‹å€¼ |
|--------|------|------|----------|------|--------|
| current | Integer | æ˜¯ | params | å½“前页码 | 1 |
| size | Integer | æ˜¯ | params | æ¯é¡µæ¡æ•° | 20 |
| title | String | å¦ | params | çŸ¥è¯†æ ‡é¢˜(模糊搜索) | "操作" |
| type | String | å¦ | params | çŸ¥è¯†ç±»åž‹(精确匹配) | "guide" |
**前端调用**:
```javascript
listKnowledgeBase({
  current: 1,
  size: 20,
  title: "",
  type: ""
});
```
---
#### æ–°å¢žçŸ¥è¯†åº“ (`POST /knowledgeBase/add`)
| å‚数名 | ç±»åž‹ | å¿…å¡« | ä¼ å‚位置 | è¯´æ˜Ž | ç¤ºä¾‹å€¼ |
|--------|------|------|----------|------|--------|
| title | String | æ˜¯ | data | çŸ¥è¯†æ ‡é¢˜ | "操作手册" |
| type | String | æ˜¯ | data | çŸ¥è¯†ç±»åž‹ | "guide" |
| scenario | String | å¦ | data | é€‚用场景 | "系统操作" |
| efficiency | String | å¦ | data | è§£å†³æ•ˆçއ | "high" |
| problem | String | æ˜¯ | data | é—®é¢˜æè¿° | "用户不会操作" |
| solution | String | æ˜¯ | data | è§£å†³æ–¹æ¡ˆ | "参考手册" |
| keyPoints | String | å¦ | data | å…³é”®è¦ç‚¹ | "步骤1,步骤2" |
| creator | String | å¦ | data | åˆ›å»ºäºº | "张三" |
| usageCount | Integer | å¦ | data | ä½¿ç”¨æ¬¡æ•° | 0 |
**前端调用**:
```javascript
addKnowledgeBase({
  title: "操作手册",
  type: "guide",
  scenario: "系统操作指导",
  efficiency: "high",
  problem: "用户不会操作系统",
  solution: "按照操作手册执行...",
  keyPoints: "步骤1,步骤2,步骤3",
  creator: "张三",
  usageCount: 0
});
```
---
#### æ›´æ–°çŸ¥è¯†åº“ (`POST /knowledgeBase/update`)
| å‚数名 | ç±»åž‹ | å¿…å¡« | ä¼ å‚位置 | è¯´æ˜Ž | ç¤ºä¾‹å€¼ |
|--------|------|------|----------|------|--------|
| id | Long | æ˜¯ | data | çŸ¥è¯†åº“ID | 10 |
| *(其他参数同新增)* | - | - | data | - | - |
**前端调用**:
```javascript
updateKnowledgeBase({
  id: 10,
  title: "操作手册(更新)",
  type: "guide",
  // ...其他参数
});
```
---
#### åˆ é™¤çŸ¥è¯†åº“ (`DELETE /knowledgeBase/delete`)
| å‚数名 | ç±»åž‹ | å¿…å¡« | ä¼ å‚位置 | è¯´æ˜Ž | ç¤ºä¾‹å€¼ |
|--------|------|------|----------|------|--------|
| ids | Long[] | æ˜¯ | data | çŸ¥è¯†åº“ID数组 | [1, 2, 3] |
**前端调用**:
```javascript
delKnowledgeBase([1, 2, 3]);
```
---
### 4.2 æ–‡ä»¶ç®¡ç†æŽ¥å£å‚æ•°
#### æŸ¥è¯¢å‘量化状态 (`GET /knowledgeBase/vector/status/{knowledgeBaseId}`)
| å‚数名 | ç±»åž‹ | å¿…å¡« | ä¼ å‚位置 | è¯´æ˜Ž | ç¤ºä¾‹å€¼ |
|--------|------|------|----------|------|--------|
| knowledgeBaseId | Long | æ˜¯ | URL路径 | çŸ¥è¯†åº“ID | 10 |
**前端调用**:
```javascript
getVectorStatus(10);
```
**URL**: `/knowledgeBase/vector/status/10`
---
#### ä¿å­˜æ–‡ä»¶å…³è” (`POST /knowledgeBase/file/save`)
| å‚数名 | ç±»åž‹ | å¿…å¡« | ä¼ å‚位置 | è¯´æ˜Ž | ç¤ºä¾‹å€¼ |
|--------|------|------|----------|------|--------|
| knowledgeBaseId | Long | æ˜¯ | data | çŸ¥è¯†åº“ID | 10 |
| storageBlobIds | Long[] | æ˜¯ | data | æ–‡ä»¶blob ID数组 | [123, 124] |
**前端调用**:
```javascript
saveKnowledgeBaseFiles({
  knowledgeBaseId: 10,
  storageBlobIds: [123, 124]
});
```
**重要说明**:
- âš ï¸ **必须先调用 `/common/upload` ä¸Šä¼ æ–‡ä»¶**
- âš ï¸ **获取返回的 `data.id` ä½œä¸º `storageBlobId`**
- âš ï¸ **此接口触发异步向量化处理**
---
#### åˆ é™¤çŸ¥è¯†åº“文件 (`DELETE /knowledgeBase/file/delete`)
| å‚数名 | ç±»åž‹ | å¿…å¡« | ä¼ å‚位置 | è¯´æ˜Ž | ç¤ºä¾‹å€¼ |
|--------|------|------|----------|------|--------|
| ids | Long[] | æ˜¯ | data | å‘量记录ID数组 | [1, 2, 3] |
**前端调用**:
```javascript
deleteKnowledgeBaseFile([row.id]);
// æ³¨æ„: row.id æ˜¯å‘量记录的ID,不是 storageBlobId
```
---
#### é‡æ–°å‘量化 (`POST /knowledgeBase/vector/reprocess/{vectorId}`)
| å‚数名 | ç±»åž‹ | å¿…å¡« | ä¼ å‚位置 | è¯´æ˜Ž | ç¤ºä¾‹å€¼ |
|--------|------|------|----------|------|--------|
| vectorId | Long | æ˜¯ | URL路径 | å‘量记录ID | 1 |
**前端调用**:
```javascript
reprocessVector(1);
```
**URL**: `/knowledgeBase/vector/reprocess/1`
---
### 4.3 çŸ¥è¯†é—®ç­”接口参数
#### çŸ¥è¯†åº“问答 (`POST /ai/knowledge/chat` - æµå¼)
| å‚数名 | ç±»åž‹ | å¿…å¡« | ä¼ å‚位置 | è¯´æ˜Ž | ç¤ºä¾‹å€¼ |
|--------|------|------|----------|------|--------|
| knowledgeBaseId | Long | æ˜¯ | body | çŸ¥è¯†åº“ID | 10 |
| memoryId | String | æ˜¯ | body | ä¼šè¯ID | "kb-chat-xxx" |
| question | String | æ˜¯ | body | ç”¨æˆ·é—®é¢˜ | "如何操作?" |
**前端调用**:
```javascript
knowledgeChat({
  knowledgeBaseId: chatKnowledgeBaseId.value,
  memoryId: memoryId.value,
  question: currentQuestion
});
```
**会话ID生成规范**:
```javascript
// æ–¹å¼1: ä½¿ç”¨ crypto.randomUUID() (推荐)
memoryId.value = crypto.randomUUID();
// æ–¹å¼2: ä½¿ç”¨æ—¶é—´æˆ³
memoryId.value = 'kb-chat-' + Date.now();
```
---
#### æŸ¥è¯¢é—®ç­”历史 (`GET /ai/knowledge/history/{memoryId}`)
| å‚数名 | ç±»åž‹ | å¿…å¡« | ä¼ å‚位置 | è¯´æ˜Ž | ç¤ºä¾‹å€¼ |
|--------|------|------|----------|------|--------|
| memoryId | String | æ˜¯ | URL路径 | ä¼šè¯ID | "kb-chat-xxx" |
**前端调用**:
```javascript
getKnowledgeHistory('kb-chat-xxx');
```
---
## äº”、响应数据字段对照表
### 5.1 çŸ¥è¯†åº“列表响应
```javascript
{
  code: 200,
  data: {
    total: 100,
    records: [
      {
        id: 1,                     // çŸ¥è¯†åº“ID
        title: "操作手册",          // çŸ¥è¯†æ ‡é¢˜
        type: "guide",             // çŸ¥è¯†ç±»åž‹
        scenario: "系统操作",       // é€‚用场景
        efficiency: "high",        // è§£å†³æ•ˆçއ
        problem: "...",            // é—®é¢˜æè¿°
        solution: "...",           // è§£å†³æ–¹æ¡ˆ
        keyPoints: "...",          // å…³é”®è¦ç‚¹
        creator: "张三",           // åˆ›å»ºäºº
        usageCount: 10,            // ä½¿ç”¨æ¬¡æ•°
        fileCount: 3,              // æ–‡ä»¶æ•°é‡
        totalChunkCount: 45,       // æ€»åˆ‡ç‰‡æ•°é‡
        createTime: "2026-06-08",  // åˆ›å»ºæ—¶é—´
        updateTime: "2026-06-08"   // æ›´æ–°æ—¶é—´
      }
    ]
  }
}
```
---
### 5.2 æ–‡ä»¶å‘量化状态响应
```javascript
{
  code: 200,
  data: [
    {
      id: 1,                      // å‘量记录ID
      storageBlobId: 123,         // æ–‡ä»¶blob ID
      fileName: "操作手册.docx",  // æ–‡ä»¶å
      fileType: "docx",           // æ–‡ä»¶ç±»åž‹
      vectorStatus: 2,            // å‘量化状态: 0-待处理, 1-处理中, 2-已完成, 3-失败
      chunkCount: 15,             // åˆ‡ç‰‡æ•°é‡
      namespace: "kb-10",         // å‘量命名空间
      vectorError: null,          // å‘量化错误信息
      createTime: "2026-06-08"    // åˆ›å»ºæ—¶é—´
    }
  ]
}
```
---
### 5.3 æ–‡ä»¶ä¸Šä¼ å“åº”
```javascript
{
  code: 200,
  data: {
    id: 123,                      // âš ï¸ è¿™æ˜¯ storageBlobId,用于保存文件关联
    name: "操作手册.docx",        // æ–‡ä»¶å
    url: "/profile/upload/...",   // æ–‡ä»¶URL
    previewURL: "...",            // é¢„览URL
    downloadURL: "..."            // ä¸‹è½½URL
  }
}
```
**重要**: ä¸Šä¼ æˆåŠŸåŽ,需要提取 `response.data.id` ä½œä¸º `storageBlobId`
---
## å…­ã€å‰ç«¯ç»„件传参规范
### 6.1 è¡¨æ ¼ç»„件传参
```vue
<PIMTable
  rowKey="id"              <!-- è¡Œå”¯ä¸€æ ‡è¯†å­—段 -->
  :column="tableColumn"    <!-- åˆ—配置 -->
  :tableData="tableData"   <!-- è¡¨æ ¼æ•°æ® -->
  :page="page"             <!-- åˆ†é¡µé…ç½® -->
  :isSelection="true"      <!-- æ˜¯å¦æ”¯æŒé€‰æ‹© -->
  @selection-change="handleSelectionChange"
  :tableLoading="tableLoading"
  @pagination="pagination"
  :total="page.total"
/>
```
**分页配置**:
```javascript
page: {
  current: 1,    // å½“前页码
  size: 20,      // æ¯é¡µæ¡æ•°
  total: 0       // æ€»è®°å½•æ•°
}
```
---
### 6.2 å¼¹çª—组件传参
```vue
<FormDialog
  v-model="dialogVisible"       <!-- æŽ§åˆ¶æ˜¾ç¤º -->
  :title="dialogTitle"          <!-- å¼¹çª—标题 -->
  :width="'800px'"              <!-- å¼¹çª—宽度 -->
  @close="closeDialog"          <!-- å…³é—­å›žè°ƒ -->
  @confirm="submitForm"         <!-- ç¡®è®¤å›žè°ƒ -->
  @cancel="closeDialog"         <!-- å–消回调 -->
>
  <!-- å¼¹çª—内容 -->
</FormDialog>
```
---
### 6.3 ä¸Šä¼ ç»„件传参
```vue
<el-upload
  :action="uploadUrl"                    <!-- ä¸Šä¼ åœ°å€ -->
  :headers="uploadHeaders"              <!-- è¯·æ±‚头 -->
  :on-success="handleUploadSuccess"     <!-- æˆåŠŸå›žè°ƒ -->
  :on-error="handleUploadError"         <!-- å¤±è´¥å›žè°ƒ -->
  :before-upload="beforeUpload"         <!-- ä¸Šä¼ å‰æ ¡éªŒ -->
  multiple                               <!-- æ”¯æŒå¤šé€‰ -->
  :show-file-list="false"               <!-- ä¸æ˜¾ç¤ºæ–‡ä»¶åˆ—表 -->
  accept=".txt,.md,.docx,.xlsx,.xls,.pdf"  <!-- æ–‡ä»¶ç±»åž‹é™åˆ¶ -->
/>
```
**上传配置**:
```javascript
const uploadUrl = import.meta.env.VITE_APP_BASE_API + "/common/upload";
const uploadHeaders = {
  Authorization: "Bearer " + getToken()
};
```
---
## ä¸ƒã€å‚数类型转换规范
### 7.1 å­—符串转数值
```javascript
// åŽç«¯è¿”回的数值可能是字符串,需要转换
const id = Number(row.id);
const count = parseInt(row.chunkCount, 10);
```
---
### 7.2 æ•°å€¼è½¬å­—符串
```javascript
// URL路径参数需要字符串
const url = `/knowledgeBase/vector/status/${String(knowledgeBaseId)}`;
```
---
### 7.3 æ•°ç»„处理
```javascript
// ID数组处理
const ids = selection.map(item => item.id);  // âœ… æå–ID
await delKnowledgeBase(ids);                 // âœ… ä¼ é€’数组
// æ–‡ä»¶blob ID数组
const blobIds = uploadedFiles.map(file => file.id);
await saveKnowledgeBaseFiles({
  knowledgeBaseId: currentKnowledgeBase.id,
  storageBlobIds: blobIds
});
```
---
## å…«ã€å‚数验证规范
### 8.1 è¡¨å•验证规则
```javascript
const rules = {
  title: [
    { required: true, message: "请输入知识标题", trigger: "blur" }
  ],
  type: [
    { required: true, message: "请选择知识类型", trigger: "change" }
  ],
  problem: [
    { required: true, message: "请描述遇到的问题", trigger: "blur" }
  ],
  solution: [
    { required: true, message: "请详细描述解决方案", trigger: "blur" }
  ]
};
```
---
### 8.2 ä¸Šä¼ æ–‡ä»¶æ ¡éªŒ
```javascript
const beforeUpload = (file) => {
  // æ–‡ä»¶ç±»åž‹æ ¡éªŒ
  const allowedTypes = ['.txt', '.md', '.docx', '.xlsx', '.xls', '.pdf'];
  const fileName = file.name.toLowerCase();
  const isAllowed = allowedTypes.some(type => fileName.endsWith(type));
  if (!isAllowed) {
    ElMessage.error('只支持 txt、md、docx、xlsx、xls、pdf æ ¼å¼çš„æ–‡ä»¶');
    return false;
  }
  // æ–‡ä»¶å¤§å°æ ¡éªŒ
  const isLt50M = file.size / 1024 / 1024 < 50;
  if (!isLt50M) {
    ElMessage.error('文件大小不能超过 50MB');
    return false;
  }
  return true;
};
```
---
### 8.3 é—®ç­”参数校验
```javascript
const sendQuestion = async () => {
  // ç©ºå†…容校验
  if (!questionInput.value.trim()) {
    ElMessage.warning("请输入问题");
    return;
  }
  // çŸ¥è¯†åº“选择校验
  if (!chatKnowledgeBaseId.value) {
    ElMessage.warning("请先选择知识库");
    return;
  }
  // å‘送状态校验
  if (sending.value) {
    return;  // é˜²æ­¢é‡å¤å‘送
  }
  // ...发送请求
};
```
---
## ä¹ã€å¸¸è§é”™è¯¯å’Œè§£å†³æ–¹æ¡ˆ
### 9.1 å‚数名不匹配
**错误示例**:
```javascript
// âŒ é”™è¯¯: ä½¿ç”¨äº†ä¸‹åˆ’线命名
saveKnowledgeBaseFiles({
  knowledge_base_id: 10,
  storage_blob_ids: [123]
});
// âœ… æ­£ç¡®: ä½¿ç”¨é©¼å³°å‘½å
saveKnowledgeBaseFiles({
  knowledgeBaseId: 10,
  storageBlobIds: [123]
});
```
---
### 9.2 ä¼ å‚位置错误
**错误示例**:
```javascript
// âŒ é”™è¯¯: POST请求使用 params
export function addKnowledgeBase(data) {
  return request({
    url: "/knowledgeBase/add",
    method: "post",
    params: data  // âŒ é”™è¯¯
  });
}
// âœ… æ­£ç¡®: POST请求使用 data
export function addKnowledgeBase(data) {
  return request({
    url: "/knowledgeBase/add",
    method: "post",
    data: data  // âœ… æ­£ç¡®
  });
}
```
---
### 9.3 DELETE请求参数错误
**错误示例**:
```javascript
// âŒ é”™è¯¯: DELETE使用 params ä¼ é€’数组
export function delKnowledgeBase(ids) {
  return request({
    url: "/knowledgeBase/delete",
    method: "delete",
    params: ids  // âŒ é”™è¯¯
  });
}
// âœ… æ­£ç¡®: DELETE使用 data ä¼ é€’数组
export function delKnowledgeBase(ids) {
  return request({
    url: "/knowledgeBase/delete",
    method: "delete",
    data: ids  // âœ… æ­£ç¡®
  });
}
```
---
### 9.4 æµå¼æŽ¥å£é”™è¯¯
**错误示例**:
```javascript
// âŒ é”™è¯¯: æµå¼æŽ¥å£ä½¿ç”¨ axios
export function knowledgeChat(data) {
  return request({
    url: "/ai/knowledge/chat",
    method: "post",
    data: data  // âŒ axios不支持流式
  });
}
// âœ… æ­£ç¡®: æµå¼æŽ¥å£ä½¿ç”¨ Fetch API
export function knowledgeChat(data) {
  const token = getToken();
  return fetch(import.meta.env.VITE_APP_BASE_API + '/ai/knowledge/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + token
    },
    body: JSON.stringify(data)  // âœ… æ­£ç¡®
  });
}
```
---
### 9.5 æ–‡ä»¶ä¸Šä¼ ID获取错误
**错误示例**:
```javascript
// âŒ é”™è¯¯: ä¸Šä¼ æˆåŠŸåŽæ²¡æœ‰ä¿å­˜ storageBlobId
const handleUploadSuccess = (response) => {
  if (response.code === 200) {
    ElMessage.success("上传成功");
    // âŒ æ²¡æœ‰ä¿å­˜ response.data.id
  }
};
// âœ… æ­£ç¡®: ä¿å­˜ storageBlobId ç”¨äºŽåŽç»­å…³è”
const handleUploadSuccess = (response, file) => {
  if (response.code === 200) {
    uploadedBlobIds.value.push(response.data.id);  // âœ… ä¿å­˜ID
    ElMessage.success(`文件 ${file.name} ä¸Šä¼ æˆåŠŸ`);
  }
};
```
---
## åã€æœ€ä½³å®žè·µå»ºè®®
### 10.1 å‚数命名一致性
✅ **前端参数名与后端实体字段保持一致**
```javascript
// å‰ç«¯
{
  knowledgeBaseId: 10,
  storageBlobIds: [123]
}
// åŽç«¯å®žä½“字段
private Long knowledgeBaseId;
private List<Long> storageBlobIds;
```
---
### 10.2 å‚数类型一致性
✅ **明确参数类型,避免自动类型转换**
```javascript
// æ•°å€¼ç±»åž‹
const id = 10;  // number
const count = 0;  // number
// å­—符串类型
const title = "";  // string
const type = "";  // string
// æ•°ç»„类型
const ids = [];  // array
```
---
### 10.3 å¿…填参数校验
✅ **在调用接口前校验必填参数**
```javascript
const saveFiles = async () => {
  if (!currentKnowledgeBase.value?.id) {
    ElMessage.error("知识库信息异常");
    return;
  }
  if (uploadedBlobIds.value.length === 0) {
    ElMessage.warning("请先上传文件");
    return;
  }
  // ...调用接口
};
```
---
### 10.4 å‚数默认值
✅ **为可选参数设置合理的默认值**
```javascript
const form = {
  title: "",
  type: "",
  usageCount: 0,  // âœ… æ•°å€¼ç±»åž‹é»˜è®¤å€¼ä¸º0
  creator: userStore.nickName || ""  // âœ… ä½¿ç”¨å½“前用户名作为默认值
};
```
---
### 10.5 URL路径参数
✅ **路径参数使用模板字符串**
```javascript
const url = `/knowledgeBase/vector/status/${knowledgeBaseId}`;
const url = `/knowledgeBase/vector/reprocess/${vectorId}`;
```
---
## åä¸€ã€å‚数映射关系总结
### çŸ¥è¯†åº“ID相关
| åœºæ™¯ | å‚数名 | ç±»åž‹ | æ¥æº |
|------|--------|------|------|
| çŸ¥è¯†åº“列表查询 | - | - | URL路径无参数 |
| çŸ¥è¯†åº“详情 | id | Long | URL路径参数 |
| æ–‡ä»¶å…³è”保存 | knowledgeBaseId | Long | POST body参数 |
| å‘量化状态查询 | knowledgeBaseId | Long | URL路径参数 |
| çŸ¥è¯†é—®ç­” | knowledgeBaseId | Long | POST body参数 |
---
### æ–‡ä»¶ID相关
| åœºæ™¯ | å‚数名 | ç±»åž‹ | æ¥æº |
|------|--------|------|------|
| æ–‡ä»¶ä¸Šä¼ å“åº” | data.id | Long | å“åº”数据(作为storageBlobId) |
| æ–‡ä»¶å…³è”保存 | storageBlobIds | Long[] | POST body参数 |
| æ–‡ä»¶åˆ é™¤ | ids | Long[] | DELETE body参数(向量记录ID) |
| é‡æ–°å‘量化 | vectorId | Long | URL路径参数 |
---
### ä¼šè¯ID相关
| åœºæ™¯ | å‚数名 | ç±»åž‹ | æ¥æº |
|------|--------|------|------|
| çŸ¥è¯†é—®ç­” | memoryId | String | å‰ç«¯ç”ŸæˆUUID |
| é—®ç­”历史查询 | memoryId | String | URL路径参数 |
---
## åäºŒã€é™„录
### é™„录A: å‚数类型对照表
| å‚数类型 | JavaScript | Java | MySQL |
|----------|------------|------|-------|
| ID | number/Long | Long | BIGINT |
| æ ‡é¢˜ | String | String | VARCHAR |
| ç±»åž‹ | String | String | VARCHAR |
| çŠ¶æ€ | Integer | Integer | TINYINT |
| æ•°é‡ | Integer | Integer | INT |
| æ—¶é—´ | String/Date | LocalDateTime | DATETIME |
| æ•°ç»„ | Array | List | - |
---
### é™„录B: HTTP方法与传参位置对照表
| HTTP方法 | ä¼ å‚位置 | request配置 | é€‚用场景 |
|----------|----------|--------------|----------|
| GET | URL参数 | `params: query` | æŸ¥è¯¢ã€åˆ—表 |
| POST | è¯·æ±‚体 | `data: data` | æ–°å¢žã€æ›´æ–°ã€ä¿å­˜ |
| DELETE | è¯·æ±‚体 | `data: ids` | åˆ é™¤ã€æ‰¹é‡åˆ é™¤ |
| PUT | è¯·æ±‚体 | `data: data` | æ›´æ–°(部分使用POST) |
| æµå¼POST | è¯·æ±‚体 | `body: JSON.stringify()` | AI问答 |
---
### é™„录C: å‘量化状态值对照表
| çŠ¶æ€å€¼ | çŠ¶æ€åç§° | å‰ç«¯æ˜¾ç¤º | æ ‡ç­¾é¢œè‰² |
|--------|----------|----------|----------|
| 0 | å¾…处理 | "待处理" | info (灰色) |
| 1 | å¤„理中 | "处理中" | warning (橙色) |
| 2 | å·²å®Œæˆ | "已完成" | success (绿色) |
| 3 | å¤±è´¥ | "失败" | danger (红色) |
---
## åä¸‰ã€æ€»ç»“
本文档详细规范了知识库模块的参数命名和传参方式,遵循以下原则:
1. âœ… **命名一致性**: å‰åŽç«¯å‚数名保持一致(驼峰命名)
2. âœ… **传参规范性**: GET用params、POST用data、DELETE用data
3. âœ… **类型明确性**: æ˜Žç¡®å‚数类型,合理设置默认值
4. âœ… **校验完整性**: å¿…填参数需校验,可选参数有默认值
5. âœ… **错误避免**: éµå¾ªè§„范避免常见错误
建议团队成员严格遵循本规范,确保前后端参数传递的一致性和可靠性。
doc/֪ʶ¿âÄ£¿éǰ¶ËʵÏÖÎĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1212 @@
# çŸ¥è¯†åº“模块完整前端实现文档
## ä¸€ã€æ¨¡å—概述
知识库模块是一个集成了RAG(检索增强生成)技术的智能知识管理系统,支持:
- **知识库CRUD管理** - åˆ›å»ºã€ç¼–辑、删除、查询知识库
- **文件上传与向量化** - æ”¯æŒå¤šç§æ–‡ä»¶æ ¼å¼,自动进行向量切片处理
- **智能问答** - åŸºäºŽä¸Šä¼ æ–‡ä»¶å†…容进行AI问答
- **文件管理** - æŸ¥çœ‹æ–‡ä»¶å‘量化状态,支持重新处理和删除
### æŠ€æœ¯æž¶æž„
- **前端框架**: Vue 3 + Composition API
- **UI组件库**: Element Plus
- **向量数据库**: Pinecone
- **AI模型**: é˜¿é‡Œäº‘通义千问
- **文件处理**: æ”¯æŒdocx、xlsx、pdf、txt、md等格式
---
## äºŒã€æ–‡ä»¶ç»“æž„
```
src/
├── api/
│   â””── collaborativeApproval/
│       â””── knowledgeBase.js          # API接口封装
├── views/
│   â””── collaborativeApproval/
│       â””── knowledgeBase/
│           â””── index.vue              # ä¸»é¡µé¢ç»„ä»¶
└── components/
    â”œâ”€â”€ PIMTable/
    â”‚   â””── PIMTable.vue              # è¡¨æ ¼ç»„ä»¶
    â””── Dialog/
        â””── FormDialog.vue             # å¼¹çª—组件
```
---
## ä¸‰ã€API接口定义
### 3.1 æ–‡ä»¶ä½ç½®
`src/api/collaborativeApproval/knowledgeBase.js`
### 3.2 å®Œæ•´æŽ¥å£åˆ—表
```javascript
import request from "@/utils/request";
import { getToken } from '@/utils/auth';
// 1. æŸ¥è¯¢çŸ¥è¯†åº“列表(分页)
export function listKnowledgeBase(query) {
  return request({
    url: "/knowledgeBase/getList",
    method: "get",
    params: query,
  });
}
// 2. æ–°å¢žçŸ¥è¯†åº“
export function addKnowledgeBase(data) {
  return request({
    url: "/knowledgeBase/add",
    method: "post",
    data: data,
  });
}
// 3. ä¿®æ”¹çŸ¥è¯†åº“
export function updateKnowledgeBase(data) {
  return request({
    url: "/knowledgeBase/update",
    method: "post",
    data: data,
  });
}
// 4. åˆ é™¤çŸ¥è¯†åº“
export function delKnowledgeBase(query) {
  return request({
    url: "/knowledgeBase/delete",
    method: "delete",
    data: query,
  });
}
// 5. æŸ¥è¯¢çŸ¥è¯†åº“文件向量化状态(包含文件列表)
export function getVectorStatus(knowledgeBaseId) {
  return request({
    url: `/knowledgeBase/vector/status/${knowledgeBaseId}`,
    method: "get",
  });
}
// 6. ä¿å­˜çŸ¥è¯†åº“文件关联(触发向量化)
export function saveKnowledgeBaseFiles(data) {
  return request({
    url: "/knowledgeBase/file/save",
    method: "post",
    data,
  });
}
// 7. åˆ é™¤çŸ¥è¯†åº“文件
export function deleteKnowledgeBaseFile(ids) {
  return request({
    url: "/knowledgeBase/file/delete",
    method: "delete",
    data: ids,
  });
}
// 8. é‡æ–°å‘量化文件
export function reprocessVector(vectorId) {
  return request({
    url: `/knowledgeBase/vector/reprocess/${vectorId}`,
    method: "post",
  });
}
// 9. çŸ¥è¯†åº“问答(流式)
export function knowledgeChat(data, onMessage) {
  const token = getToken();
  return fetch(import.meta.env.VITE_APP_BASE_API + '/ai/knowledge/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + token
    },
    body: JSON.stringify(data)
  });
}
// 10. æŸ¥è¯¢çŸ¥è¯†åº“问答历史
export function getKnowledgeHistory(memoryId) {
  return request({
    url: `/ai/knowledge/history/${memoryId}`,
    method: "get",
  });
}
```
### 3.3 æŽ¥å£å‚数说明
#### çŸ¥è¯†åº“列表查询
```javascript
// è¯·æ±‚参数
{
  current: 1,        // å½“前页码
  size: 20,          // æ¯é¡µæ¡æ•°
  title: "",         // çŸ¥è¯†æ ‡é¢˜(可选)
  type: ""           // çŸ¥è¯†ç±»åž‹(可选)
}
// å“åº”数据
{
  code: 200,
  data: {
    total: 100,
    records: [
      {
        id: 1,
        title: "操作手册",
        type: "guide",
        scenario: "系统操作指导",
        efficiency: "high",
        problem: "用户不会操作系统",
        solution: "按照操作手册执行...",
        keyPoints: "步骤1,步骤2,步骤3",
        creator: "张三",
        usageCount: 10,
        fileCount: 3,           // æ–‡ä»¶æ•°é‡
        totalChunkCount: 45,    // æ€»åˆ‡ç‰‡æ•°é‡
        createTime: "2026-06-08 10:00:00"
      }
    ]
  }
}
```
#### ä¿å­˜æ–‡ä»¶å…³è”
```javascript
// è¯·æ±‚参数
{
  knowledgeBaseId: 10,         // çŸ¥è¯†åº“ID
  storageBlobIds: [123, 124]   // ä¸Šä¼ æ–‡ä»¶è¿”回的blob ID列表
}
// å“åº”数据
{
  code: 200,
  msg: "操作成功"
}
```
#### å‘量化状态查询
```javascript
// å“åº”数据
{
  code: 200,
  data: [
    {
      id: 1,
      storageBlobId: 123,
      fileName: "操作手册.docx",
      fileType: "docx",
      vectorStatus: 2,         // 0-待处理,1-处理中,2-已完成,3-失败
      chunkCount: 15,          // åˆ‡ç‰‡æ•°é‡
      namespace: "kb-10",
      vectorError: null,
      createTime: "2026-06-08 10:00:00"
    }
  ]
}
```
#### çŸ¥è¯†åº“问答
```javascript
// è¯·æ±‚参数
{
  knowledgeBaseId: 10,
  memoryId: "session-xxx",     // ä¼šè¯ID,用于保持上下文
  question: "如何操作审批流程?"
}
// å“åº”(流式返回 text/stream;charset=utf-8)
// æ ¹æ®çŸ¥è¯†åº“内容,审批流程的操作步骤如下:
// 1. ç™»å½•系统后进入审批管理模块...
```
---
## å››ã€æ ¸å¿ƒç»„件实现
### 4.1 ä¸»é¡µé¢ç»“æž„
页面采用Tab页签布局,包含两个主要功能模块:
- **知识库管理** - çŸ¥è¯†åº“CRUD操作
- **知识库问答** - åŸºäºŽRAG的智能问答
### 4.2 æ•°æ®æ¨¡åž‹å®šä¹‰
```javascript
// å“åº”式数据
const data = reactive({
  // æœç´¢è¡¨å•
  searchForm: {
    title: "",
    type: "",
  },
  // åˆ†é¡µé…ç½®
  page: {
    current: 1,
    size: 20,
    total: 0,
  },
  // è¡¨æ ¼æ•°æ®
  tableData: [],
  tableLoading: false,
  selectedIds: [],
  // çŸ¥è¯†åº“表单
  form: {
    title: "",
    type: "",
    scenario: "",
    efficiency: "",
    problem: "",
    solution: "",
    keyPoints: "",
    creator: "",
    usageCount: 0
  },
  // å¼¹çª—控制
  dialogVisible: false,
  dialogTitle: "",
  dialogType: "add",  // add or edit
  viewDialogVisible: false,
  currentKnowledge: {},
  // æ–‡ä»¶ç®¡ç†
  filesDialogVisible: false,
  currentKnowledgeBase: null,
  fileList: [],
  uploadedBlobIds: [],
  savingFiles: false,
  // çŸ¥è¯†åº“问答
  chatDialogVisible: false,
  messages: [],
  inputQuestion: "",
  chatLoading: false,
  memoryId: ""
});
```
### 4.3 è¡¨æ ¼åˆ—配置
```javascript
const tableColumn = ref([
  {
    label: "知识标题",
    prop: "title",
    showOverflowTooltip: true,
  },
  {
    label: "知识类型",
    prop: "type",
    dataType: "tag",
    formatData: (params) => getKnowledgeTypeLabel(params),
    formatType: (params) => getKnowledgeTypeTagType(params)
  },
  {
    label: "适用场景",
    prop: "scenario",
    width: 150,
    showOverflowTooltip: true,
  },
  {
    label: "解决效率",
    prop: "efficiency",
    dataType: "tag",
    formatData: (params) => {
      const efficiencyMap = {
        high: "显著提升",
        medium: "一般提升",
        low: "轻微提升"
      };
      return efficiencyMap[params] || params;
    },
    formatType: (params) => {
      const typeMap = {
        high: "success",
        medium: "warning",
        low: "info"
      };
      return typeMap[params] || "info";
    }
  },
  {
    label: "文件数量",
    prop: "fileCount",
    width: 100,
    align: "center"
  },
  {
    label: "切片数量",
    prop: "totalChunkCount",
    width: 100,
    align: "center"
  },
  {
    label: "使用次数",
    prop: "usageCount",
    width: 100,
    align: "center"
  },
  {
    label: "创建人",
    prop: "creator",
    width: 120,
  },
  {
    label: "创建时间",
    prop: "createTime",
    width: 180,
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: "right",
    width: 280,
    operation: [
      {
        name: "编辑",
        type: "text",
        clickFun: (row) => openForm("edit", row)
      },
      {
        name: "文件",
        type: "text",
        clickFun: (row) => openFilesDialog(row)
      },
      {
        name: "问答",
        type: "text",
        clickFun: (row) => openChatDialog(row)
      },
      {
        name: "详情",
        type: "text",
        clickFun: (row) => viewKnowledge(row)
      }
    ]
  }
]);
```
---
## äº”、核心业务逻辑
### 5.1 æ–‡ä»¶ä¸Šä¼ ä¸Žå‘量化流程
#### æµç¨‹å›¾
```
用户点击"上传文件"
    â†“
选择文件(支持多选)
    â†“
前端校验文件类型和大小
    â†“
调用 /common/upload ä¸Šä¼ æ–‡ä»¶
    â†“
获取 storageBlobId åˆ—表
    â†“
用户点击"保存文件关联"
    â†“
调用 /knowledgeBase/file/save
    â†“
后端创建向量记录 + å¼‚步触发向量化
    â†“
前端延迟1秒刷新文件列表
    â†“
显示向量化状态(待处理→处理中→已完成)
```
#### ä»£ç å®žçް
```vue
<template>
  <div class="file-manager">
    <!-- æ–‡ä»¶ä¸Šä¼  -->
    <div class="upload-section">
      <el-upload
        :action="uploadUrl"
        :headers="uploadHeaders"
        :on-success="handleUploadSuccess"
        :on-error="handleUploadError"
        :before-upload="beforeUpload"
        multiple
        :show-file-list="false"
        accept=".txt,.md,.docx,.xlsx,.xls,.pdf"
      >
        <el-button type="primary">上传文件</el-button>
      </el-upload>
      <el-button
        type="success"
        @click="saveFiles"
        :disabled="uploadedBlobIds.length === 0"
        :loading="savingFiles"
      >
        ä¿å­˜æ–‡ä»¶å…³è”
      </el-button>
    </div>
    <!-- æ–‡ä»¶åˆ—表 -->
    <el-table :data="fileList" style="margin-top: 20px" border>
      <el-table-column prop="fileName" label="文件名" />
      <el-table-column prop="fileType" label="文件类型" width="100" />
      <el-table-column label="向量化状态" width="120">
        <template #default="{ row }">
          <el-tag :type="getStatusType(row.vectorStatus)">
            {{ getStatusText(row.vectorStatus) }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column prop="chunkCount" label="切片数" width="100" />
      <el-table-column label="操作" width="150">
        <template #default="{ row }">
          <el-button
            v-if="row.vectorStatus === 3"
            type="text"
            @click="reprocessFile(row)"
          >
            é‡æ–°å¤„理
          </el-button>
          <el-button type="text" @click="deleteFile(row)" style="color: #f56c6c">
            åˆ é™¤
          </el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>
<script setup>
import { getToken } from "@/utils/auth";
// æ–‡ä»¶ä¸Šä¼ é…ç½®
const uploadUrl = import.meta.env.VITE_APP_BASE_API + "/common/upload";
const uploadHeaders = { Authorization: "Bearer " + getToken() };
const uploadedBlobIds = ref([]);
const fileList = ref([]);
// ä¸Šä¼ å‰æ ¡éªŒ
const beforeUpload = (file) => {
  const allowedTypes = ['.txt', '.md', '.docx', '.xlsx', '.xls', '.pdf'];
  const fileName = file.name.toLowerCase();
  const isAllowed = allowedTypes.some(type => fileName.endsWith(type));
  if (!isAllowed) {
    ElMessage.error('只支持 txt、md、docx、xlsx、xls、pdf æ ¼å¼çš„æ–‡ä»¶');
    return false;
  }
  const isLt50M = file.size / 1024 / 1024 < 50;
  if (!isLt50M) {
    ElMessage.error('文件大小不能超过 50MB');
    return false;
  }
  return true;
};
// ä¸Šä¼ æˆåŠŸ
const handleUploadSuccess = (response, file) => {
  if (response.code === 200) {
    uploadedBlobIds.value.push(response.data.id);
    ElMessage.success(`文件 ${file.name} ä¸Šä¼ æˆåŠŸ`);
  } else {
    ElMessage.error(response.msg || "上传失败");
  }
};
// ä¿å­˜æ–‡ä»¶å…³è”
const saveFiles = async () => {
  if (uploadedBlobIds.value.length === 0) {
    ElMessage.warning("请先上传文件");
    return;
  }
  savingFiles.value = true;
  try {
    await saveKnowledgeBaseFiles({
      knowledgeBaseId: currentKnowledgeBase.value.id,
      storageBlobIds: uploadedBlobIds.value
    });
    ElMessage.success("文件关联保存成功,正在后台处理向量化");
    uploadedBlobIds.value = [];
    // å»¶è¿Ÿåˆ·æ–°æ–‡ä»¶åˆ—表
    setTimeout(() => {
      loadFileList();
    }, 1000);
  } catch (error) {
    console.error("保存文件关联失败:", error);
    ElMessage.error("保存文件关联失败");
  } finally {
    savingFiles.value = false;
  }
};
// åŠ è½½æ–‡ä»¶åˆ—è¡¨
const loadFileList = async () => {
  if (!currentKnowledgeBase.value?.id) return;
  try {
    const res = await getVectorStatus(currentKnowledgeBase.value.id);
    fileList.value = res.data || [];
  } catch (error) {
    console.error("加载文件列表失败:", error);
    ElMessage.error("加载文件列表失败");
  }
};
// çŠ¶æ€æ˜ å°„
const getStatusText = (status) => {
  const map = {
    0: '待处理',
    1: '处理中',
    2: '已完成',
    3: '失败'
  };
  return map[status] || '未知';
};
const getStatusType = (status) => {
  const map = {
    0: 'info',
    1: 'warning',
    2: 'success',
    3: 'danger'
  };
  return map[status] || 'info';
};
// é‡æ–°å¤„理向量化的文件
const reprocessFile = async (row) => {
  try {
    await reprocessVector(row.id);
    ElMessage.success("已重新提交向量化任务");
    setTimeout(() => {
      loadFileList();
    }, 1000);
  } catch (error) {
    console.error("重新处理失败:", error);
    ElMessage.error("重新处理失败");
  }
};
// åˆ é™¤æ–‡ä»¶
const deleteFile = async (row) => {
  try {
    await ElMessageBox.confirm(
      "确定要删除该文件吗?删除后将无法恢复向量数据",
      "删除确认",
      {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning"
      }
    );
    await deleteKnowledgeBaseFiles([row.id]);
    ElMessage.success("删除成功");
    loadFileList();
  } catch (error) {
    if (error !== 'cancel') {
      console.error("删除文件失败:", error);
      ElMessage.error("删除文件失败");
    }
  }
};
</script>
```
### 5.2 çŸ¥è¯†åº“问答流程
#### æµç¨‹å›¾
```
用户选择知识库
    â†“
输入问题并提交
    â†“
前端生成memoryId(用于会话上下文)
    â†“
调用 /ai/knowledge/chat (流式接口)
    â†“
后端处理:
  - å¯¹é—®é¢˜è¿›è¡Œå‘量化
  - åœ¨Pinecone中检索相关切片
  - æž„建上下文Prompt
  - è°ƒç”¨LLM生成回答
    â†“
流式返回AI回答
    â†“
前端实时显示回答内容
    â†“
自动滚动到底部
```
#### ä»£ç å®žçް
```vue
<template>
  <div class="knowledge-chat">
    <div class="chat-header">
      <el-tag type="success">当前知识库: {{ currentKnowledgeBase?.title }}</el-tag>
    </div>
    <!-- å¯¹è¯åŒºåŸŸ -->
    <div class="chat-messages" ref="chatMessagesRef">
      <div
        v-for="(msg, index) in messages"
        :key="index"
        :class="['message', msg.role]"
      >
        <div class="message-role">{{ msg.role === 'user' ? '我' : 'AI助手' }}</div>
        <div class="message-content">{{ msg.content }}</div>
      </div>
      <div v-if="chatLoading" class="message assistant">
        <div class="message-role">AI助手</div>
        <div class="message-content typing">正在思考中...</div>
      </div>
    </div>
    <!-- è¾“入框 -->
    <div class="chat-input">
      <el-input
        v-model="inputQuestion"
        placeholder="请输入问题,按回车发送"
        @keyup.enter="sendMessage"
        :disabled="chatLoading"
      >
        <template #append>
          <el-button @click="sendMessage" :loading="chatLoading">发送</el-button>
        </template>
      </el-input>
    </div>
  </div>
</template>
<script setup>
import { nextTick } from 'vue';
import { getToken } from "@/utils/auth";
const messages = ref([]);
const inputQuestion = ref("");
const chatLoading = ref(false);
const memoryId = ref("");
const chatMessagesRef = ref();
// æ‰“开问答弹窗
const openChatDialog = (row) => {
  currentKnowledgeBase.value = row;
  chatDialogVisible.value = true;
  memoryId.value = crypto.randomUUID(); // ç”Ÿæˆå”¯ä¸€ä¼šè¯ID
  messages.value = [];
  inputQuestion.value = "";
};
// å‘送消息
const sendMessage = async () => {
  if (!inputQuestion.value.trim()) {
    ElMessage.warning("请输入问题");
    return;
  }
  const question = inputQuestion.value.trim();
  // æ·»åŠ ç”¨æˆ·æ¶ˆæ¯
  messages.value.push({
    role: 'user',
    content: question
  });
  inputQuestion.value = "";
  chatLoading.value = true;
  // æ»šåŠ¨åˆ°åº•éƒ¨
  await nextTick();
  scrollToBottom();
  try {
    // æµå¼è¯·æ±‚
    const response = await fetch('/api/ai/knowledge/chat', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + getToken()
      },
      body: JSON.stringify({
        knowledgeBaseId: currentKnowledgeBase.value.id,
        memoryId: memoryId.value,
        question: question
      })
    });
    if (!response.ok) {
      throw new Error('请求失败');
    }
    // å¤„理SSE流式响应
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let aiContent = '';
    messages.value.push({ role: 'assistant', content: '' });
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      const text = decoder.decode(value);
      aiContent += text;
      messages.value[messages.value.length - 1].content = aiContent;
      // æ»šåŠ¨åˆ°åº•éƒ¨
      await nextTick();
      scrollToBottom();
    }
  } catch (error) {
    console.error("问答请求失败:", error);
    ElMessage.error("问答请求失败,请稍后重试");
    messages.value.push({
      role: 'assistant',
      content: '抱歉,发生了错误,请稍后重试'
    });
  } finally {
    chatLoading.value = false;
  }
};
// æ»šåŠ¨åˆ°åº•éƒ¨
const scrollToBottom = () => {
  if (chatMessagesRef.value) {
    chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight;
  }
};
</script>
<style scoped>
.knowledge-chat {
  display: flex;
  flex-direction: column;
  height: 500px;
}
.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  background: #f5f7fa;
  border-radius: 8px;
  margin-bottom: 16px;
}
.message {
  margin-bottom: 16px;
  max-width: 80%;
}
.message.user {
  margin-left: auto;
  text-align: right;
}
.message.assistant {
  margin-right: auto;
}
.message-role {
  font-size: 12px;
  color: #909399;
  margin-bottom: 4px;
}
.message-content {
  display: inline-block;
  padding: 10px 14px;
  border-radius: 8px;
  line-height: 1.6;
  word-wrap: break-word;
  white-space: pre-wrap;
}
.message.user .message-content {
  background: #409eff;
  color: white;
}
.message.assistant .message-content {
  background: white;
  color: #303133;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.typing {
  animation: typing 1.5s infinite;
}
@keyframes typing {
  0%, 50%, 100% { opacity: 1; }
  25%, 75% { opacity: 0.5; }
}
</style>
```
---
## å…­ã€å…³é”®å®žçŽ°ç»†èŠ‚
### 6.1 æ–‡ä»¶ä¸Šä¼ é…ç½®
```javascript
// ä¸Šä¼ åœ°å€
const uploadUrl = import.meta.env.VITE_APP_BASE_API + "/common/upload";
// è¯·æ±‚头(必须携带Token)
const uploadHeaders = {
  Authorization: "Bearer " + getToken()
};
// æ”¯æŒçš„æ–‡ä»¶ç±»åž‹
const acceptTypes = '.txt,.md,.docx,.xlsx,.xls,.pdf';
// æ–‡ä»¶å¤§å°é™åˆ¶
const maxSize = 50 * 1024 * 1024; // 50MB
```
### 6.2 å‘量化状态轮询
```javascript
// å¼€å§‹è½®è¯¢å‘量化状态
const startVectorStatusPolling = () => {
  const timer = setInterval(async () => {
    const res = await getVectorStatus(currentKnowledgeBase.value.id);
    const hasProcessing = res.data.some(item => item.vectorStatus === 1);
    if (!hasProcessing) {
      clearInterval(timer);
    }
    fileList.value = res.data;
  }, 3000); // æ¯3秒轮询一次
};
```
### 6.3 æµå¼å“åº”处理
```javascript
// ä½¿ç”¨ Fetch API å¤„理流式响应
const response = await fetch('/api/ai/knowledge/chat', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer ' + getToken()
  },
  body: JSON.stringify({
    knowledgeBaseId: 10,
    memoryId: "session-xxx",
    question: "问题内容"
  })
});
// èŽ·å–å¯è¯»æµ
const reader = response.body.getReader();
const decoder = new TextDecoder();
// é€å—读取数据
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const text = decoder.decode(value);
  // å¤„理文本块
  processText(text);
}
```
### 6.4 ä¼šè¯ç®¡ç†
```javascript
// ç”Ÿæˆå”¯ä¸€ä¼šè¯ID
const memoryId = crypto.randomUUID();
// æˆ–使用时间戳
const memoryId = 'kb-chat-' + Date.now();
// ä¼šè¯ID用于:
// 1. ä¿æŒå¯¹è¯ä¸Šä¸‹æ–‡
// 2. æ”¯æŒå¤šè½®å¯¹è¯
// 3. æŸ¥è¯¢åŽ†å²è®°å½•
```
---
## ä¸ƒã€çŠ¶æ€ç®¡ç†
### 7.1 å‘量化状态定义
| çŠ¶æ€å€¼ | çŠ¶æ€åç§° | è¯´æ˜Ž | æ ‡ç­¾é¢œè‰² |
|--------|----------|------|----------|
| 0 | å¾…处理 | æ–‡ä»¶å·²ä¸Šä¼ ,等待向量化处理 | info(灰色) |
| 1 | å¤„理中 | æ­£åœ¨è¿›è¡Œå‘量切片处理 | warning(橙色) |
| 2 | å·²å®Œæˆ | å‘量化完成,可进行检索问答 | success(绿色) |
| 3 | å¤±è´¥ | å‘量化失败,需重新处理 | danger(红色) |
### 7.2 çŸ¥è¯†ç±»åž‹é…ç½®
```javascript
// ä½¿ç”¨å­—典配置知识类型
const { knowledge_type } = proxy.useDict("knowledge_type");
// ç¤ºä¾‹æ•°æ®
const knowledgeTypeOptions = [
  { value: 'contract', label: '合同知识', elTagType: 'success' },
  { value: 'approval', label: '审批流程', elTagType: 'warning' },
  { value: 'solution', label: '解决方案', elTagType: 'primary' },
  { value: 'experience', label: '经验分享', elTagType: 'info' },
  { value: 'guide', label: '操作指南', elTagType: 'danger' }
];
```
### 7.3 è§£å†³æ•ˆçŽ‡æ˜ å°„
```javascript
const efficiencyMap = {
  high: { label: '显著提升', color: 'success', score: 40, time: '2-3天' },
  medium: { label: '一般提升', color: 'warning', score: 25, time: '1-2天' },
  low: { label: '轻微提升', color: 'info', score: 15, time: '0.5-1天' }
};
```
---
## å…«ã€æ ·å¼è®¾è®¡
### 8.1 æ–‡ä»¶ç®¡ç†æ ·å¼
```css
.file-manager {
  padding: 20px 0;
}
.upload-section {
  display: flex;
  align-items: center;
  gap: 10px;
}
```
### 8.2 é—®ç­”界面样式
```css
.knowledge-chat {
  display: flex;
  flex-direction: column;
  height: 500px;
}
.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  background: #f5f7fa;
  border-radius: 8px;
  margin-bottom: 16px;
}
.message {
  margin-bottom: 16px;
  max-width: 80%;
}
.message.user {
  margin-left: auto;
  text-align: right;
}
.message.assistant {
  margin-right: auto;
}
.message-content {
  display: inline-block;
  padding: 10px 14px;
  border-radius: 8px;
  line-height: 1.6;
  word-wrap: break-word;
  white-space: pre-wrap;
}
.message.user .message-content {
  background: #409eff;
  color: white;
}
.message.assistant .message-content {
  background: white;
  color: #303133;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
```
---
## ä¹ã€æ³¨æ„äº‹é¡¹
### 9.1 æ–‡ä»¶ä¸Šä¼ 
1. **必须调用保存接口**: ä¸Šä¼ æˆåŠŸåŽå¿…é¡»è°ƒç”¨ `/knowledgeBase/file/save` æ‰èƒ½è§¦å‘向量化
2. **文件类型限制**: åªæ”¯æŒ txt、md、docx、xlsx、xls、pdf æ ¼å¼
3. **文件大小限制**: å•文件最大 50MB
4. **异步处理**: å‘量化是异步处理,不会阻塞用户操作
### 9.2 å‘量化状态
1. **状态轮询**: å»ºè®®æ¯3-5秒轮询一次状态
2. **停止轮询**: å½“所有文件状态都不是"处理中"时停止轮询
3. **失败处理**: çŠ¶æ€ä¸º"失败"时可点击"重新处理"按钮
### 9.3 çŸ¥è¯†åº“问答
1. **会话ID**: æ¯æ¬¡æ‰“开问答弹窗需要生成新的 memoryId
2. **流式处理**: ä½¿ç”¨ Fetch API å¤„理流式响应,不支持 axios
3. **错误处理**: éœ€è¦å¤„理网络错误和AI响应错误
4. **自动滚动**: æ¯æ¬¡æ”¶åˆ°æ–°æ¶ˆæ¯è‡ªåŠ¨æ»šåŠ¨åˆ°åº•éƒ¨
### 9.4 æ•°æ®ä¸€è‡´æ€§
1. **删除知识库**: éœ€è¦åŒæ—¶åˆ é™¤å…³è”的文件和向量数据
2. **删除文件**: åˆ é™¤æ–‡ä»¶æ—¶åŒæ­¥åˆ é™¤å‘量库中的相关切片
3. **刷新列表**: æ–‡ä»¶æ“ä½œåŽéœ€è¦åˆ·æ–°çŸ¥è¯†åº“列表,更新文件数量和切片数量
---
## åã€æµ‹è¯•检查清单
### 10.1 çŸ¥è¯†åº“管理
- [ ] æ–°å¢žçŸ¥è¯†åº“成功
- [ ] ç¼–辑知识库成功
- [ ] åˆ é™¤çŸ¥è¯†åº“成功(单个/批量)
- [ ] æœç´¢åŠŸèƒ½æ­£å¸¸(按标题、类型)
- [ ] åˆ†é¡µåŠŸèƒ½æ­£å¸¸
- [ ] å¯¼å‡ºåŠŸèƒ½æ­£å¸¸
### 10.2 æ–‡ä»¶ä¸Šä¼ 
- [ ] å•文件上传成功
- [ ] å¤šæ–‡ä»¶ä¸Šä¼ æˆåŠŸ
- [ ] æ–‡ä»¶ç±»åž‹æ ¡éªŒæ­£å¸¸
- [ ] æ–‡ä»¶å¤§å°æ ¡éªŒæ­£å¸¸
- [ ] ä¿å­˜æ–‡ä»¶å…³è”成功
- [ ] å‘量化状态正确显示
### 10.3 æ–‡ä»¶ç®¡ç†
- [ ] æŸ¥çœ‹æ–‡ä»¶åˆ—表正常
- [ ] å‘量化状态轮询正常
- [ ] é‡æ–°å¤„理失败文件成功
- [ ] åˆ é™¤æ–‡ä»¶æˆåŠŸ
- [ ] æ–‡ä»¶é¢„览/下载正常
### 10.4 çŸ¥è¯†åº“问答
- [ ] é—®ç­”弹窗打开正常
- [ ] å‘送问题成功
- [ ] æµå¼å“åº”显示正常
- [ ] å¤šè½®å¯¹è¯æ­£å¸¸
- [ ] è‡ªåŠ¨æ»šåŠ¨åˆ°åº•éƒ¨
- [ ] é”™è¯¯å¤„理正常
---
## åä¸€ã€å¸¸è§é—®é¢˜
### Q1: æ–‡ä»¶ä¸Šä¼ åŽæ²¡æœ‰è§¦å‘向量化?
**A**: æ£€æŸ¥æ˜¯å¦è°ƒç”¨äº† `/knowledgeBase/file/save` æŽ¥å£ã€‚上传成功后必须调用此接口才能触发向量化。
### Q2: å‘量化状态一直是"处理中"?
**A**: å¯èƒ½åŽŸå› :
1. åŽå°æœåŠ¡æœªå¯åŠ¨
2. Embedding模型调用失败
3. Pinecone连接失败
建议查看后台日志排查问题。
### Q3: é—®ç­”返回空内容?
**A**: å¯èƒ½åŽŸå› :
1. çŸ¥è¯†åº“中没有文件
2. æ–‡ä»¶æœªå®Œæˆå‘量化
3. æ£€ç´¢ç›¸ä¼¼åº¦ä½ŽäºŽé˜ˆå€¼
建议检查文件数量和向量化状态。
### Q4: æµå¼å“åº”显示乱码?
**A**: ç¡®ä¿è¯·æ±‚头包含正确的编码设置:
```javascript
headers: {
  'Content-Type': 'application/json'
}
```
### Q5: å¦‚何调试流式接口?
**A**: ä½¿ç”¨æµè§ˆå™¨å¼€å‘者工具:
1. æ‰“å¼€ Network æ ‡ç­¾
2. æ‰¾åˆ° `/ai/knowledge/chat` è¯·æ±‚
3. æŸ¥çœ‹ Response æ ‡ç­¾,可以看到流式返回的内容
---
## åäºŒã€ä¼˜åŒ–建议
### 12.1 æ€§èƒ½ä¼˜åŒ–
1. **虚拟滚动**: æ¶ˆæ¯åˆ—表超过100条时使用虚拟滚动
2. **防抖节流**: æœç´¢è¾“入使用防抖,状态轮询使用节流
3. **懒加载**: æ–‡ä»¶åˆ—表使用懒加载
### 12.2 ç”¨æˆ·ä½“验优化
1. **进度提示**: å‘量化时显示进度条
2. **快捷键**: æ”¯æŒå¿«æ·é”®æ“ä½œ(如 Ctrl+Enter å‘送)
3. **历史记录**: æ”¯æŒæŸ¥çœ‹åŽ†å²é—®ç­”è®°å½•
4. **导出对话**: æ”¯æŒå¯¼å‡ºå¯¹è¯å†…容
### 12.3 åŠŸèƒ½æ‰©å±•
1. **文件预览**: æ”¯æŒåœ¨çº¿é¢„览文件内容
2. **批量操作**: æ”¯æŒæ‰¹é‡åˆ é™¤ã€æ‰¹é‡é‡æ–°å¤„理
3. **向量化配置**: å…è®¸ç”¨æˆ·é…ç½®åˆ‡ç‰‡å¤§å°ã€é‡å å¤§å°
4. **相似度阈值**: å…è®¸ç”¨æˆ·è°ƒæ•´æ£€ç´¢ç›¸ä¼¼åº¦é˜ˆå€¼
---
## åä¸‰ã€æ›´æ–°æ—¥å¿—
### v1.0.0 (2026-06-08)
- âœ… å®ŒæˆçŸ¥è¯†åº“CRUD功能
- âœ… å®Œæˆæ–‡ä»¶ä¸Šä¼ ä¸Žå‘量化功能
- âœ… å®ŒæˆçŸ¥è¯†åº“问答功能
- âœ… å®Œæˆæ–‡ä»¶ç®¡ç†åŠŸèƒ½
- âœ… å®Œæˆå‘量化状态显示
### v1.1.0 (计划中)
- ðŸ”² æ·»åŠ åŽ†å²è®°å½•æŸ¥è¯¢
- ðŸ”² æ·»åŠ æ‰¹é‡æ“ä½œåŠŸèƒ½
- ðŸ”² æ·»åŠ æ–‡ä»¶é¢„è§ˆåŠŸèƒ½
- ðŸ”² ä¼˜åŒ–向量化进度显示
multiple/assets/favicon/BLSGRfavicon.ico
multiple/assets/favicon/BTWfavicon.ico
multiple/assets/favicon/DCHXfavicon.ico
multiple/assets/favicon/HTHBfavicon.ico
multiple/assets/favicon/JFHYfavicon.ico
multiple/assets/favicon/KHYYfavicon.ico
multiple/assets/favicon/LSYfavicon.ico
multiple/assets/favicon/NYfavicon.ico
multiple/assets/favicon/QFSWfavicon.ico
multiple/assets/favicon/RFSYLogo.png
multiple/assets/favicon/RFSYfavicon.ico
multiple/assets/favicon/SDZZfavicon.ico
multiple/assets/favicon/XHKSfavicon.ico
multiple/assets/favicon/XLGSfavicon.ico
multiple/assets/favicon/YSSfavicon.ico
multiple/assets/favicon/ZDJCfavicon.ico
multiple/assets/logo/BLSGRLogo.png
multiple/assets/logo/BTWLogo.png
multiple/assets/logo/DCHXLogo.png
multiple/assets/logo/HTHBLogo.png
multiple/assets/logo/JFHYLogo.png
multiple/assets/logo/KHYYLogo.png
multiple/assets/logo/LSYLogo.png
multiple/assets/logo/NYLogo.png
multiple/assets/logo/QFSWLogo.png
multiple/assets/logo/RFSYLogo.png
multiple/assets/logo/SDZZLogo.png
multiple/assets/logo/XDRJ.png

multiple/assets/logo/XHKSLogo.png
multiple/assets/logo/XLGSLogo.png
multiple/assets/logo/YSSLogo.png
multiple/assets/logo/ZDJCLogo.png
src/api/collaborativeApproval/knowledgeBase.js
@@ -1,55 +1,172 @@
import request from "@/utils/request";
import { getToken } from '@/utils/auth';
// æŸ¥è¯¢çŸ¥è¯†åº“列表
/**
 * çŸ¥è¯†åº“管理接口
 * ä¼ å‚规范:
 * - GET请求: ä½¿ç”¨ params
 * - POST请求: ä½¿ç”¨ data
 * - DELETE请求: ä½¿ç”¨ data
 * - æµå¼è¯·æ±‚: ä½¿ç”¨ Fetch API
 */
// ==================== çŸ¥è¯†åº“CRUD接口 ====================
/**
 * æŸ¥è¯¢çŸ¥è¯†åº“列表
 * @param {Object} query - æŸ¥è¯¢å‚æ•°
 * @param {number} query.current - å½“前页码(必填)
 * @param {number} query.size - æ¯é¡µæ¡æ•°(必填)
 * @param {string} [query.title] - çŸ¥è¯†æ ‡é¢˜(可选,模糊搜索)
 * @param {string} [query.type] - çŸ¥è¯†ç±»åž‹(可选,精确匹配)
 * @returns {Promise}
 */
export function listKnowledgeBase(query) {
  return request({
    url: "/knowledgeBase/getList",
    method: "get",
    params: query,
    params: query,  // GET请求使用params
  });
}
// æŸ¥è¯¢çŸ¥è¯†åº“详细
// export function getKnowledgeBase(knowledgeBaseId) {
//   return request({
//     url: "/collaborativeApproval/knowledgeBase/" + knowledgeBaseId,
//     method: "get",
//   });
// }
// æ–°å¢žçŸ¥è¯†åº“
/**
 * æ–°å¢žçŸ¥è¯†åº“
 * @param {Object} data - çŸ¥è¯†åº“数据
 * @param {string} data.title - çŸ¥è¯†æ ‡é¢˜(必填)
 * @param {string} data.type - çŸ¥è¯†ç±»åž‹(必填)
 * @param {string} [data.scenario] - é€‚用场景(可选)
 * @param {string} [data.efficiency] - è§£å†³æ•ˆçއ(可选)
 * @param {string} data.problem - é—®é¢˜æè¿°(必填)
 * @param {string} data.solution - è§£å†³æ–¹æ¡ˆ(必填)
 * @param {string} [data.keyPoints] - å…³é”®è¦ç‚¹(可选)
 * @param {string} [data.creator] - åˆ›å»ºäºº(可选)
 * @param {number} [data.usageCount=0] - ä½¿ç”¨æ¬¡æ•°(可选)
 * @returns {Promise}
 */
export function addKnowledgeBase(data) {
  return request({
    url: "/knowledgeBase/add",
    method: "post",
    data: data,
    data: data,  // POST请求使用data
  });
}
// ä¿®æ”¹çŸ¥è¯†åº“
/**
 * ä¿®æ”¹çŸ¥è¯†åº“
 * @param {Object} data - çŸ¥è¯†åº“数据
 * @param {number} data.id - çŸ¥è¯†åº“ID(必填)
 * @param {string} data.title - çŸ¥è¯†æ ‡é¢˜(必填)
 * @param {string} data.type - çŸ¥è¯†ç±»åž‹(必填)
 * @returns {Promise}
 */
export function updateKnowledgeBase(data) {
  return request({
    url: "/knowledgeBase/update",
    method: "post",
    data: data,
    data: data,  // POST请求使用data
  });
}
// åˆ é™¤çŸ¥è¯†åº“
export function delKnowledgeBase(query) {
/**
 * åˆ é™¤çŸ¥è¯†åº“(支持批量删除)
 * @param {number[]} ids - çŸ¥è¯†åº“ID数组
 * @returns {Promise}
 */
export function delKnowledgeBase(ids) {
  return request({
    url: "/knowledgeBase/delete",
    method: "delete",
    data: query,
    data: ids,  // DELETE请求使用data传递数组
  });
}
// æ‰¹é‡åˆ é™¤çŸ¥è¯†åº“
export function delKnowledgeBaseBatch(knowledgeBaseIds) {
// ==================== æ–‡ä»¶ç®¡ç†æŽ¥å£ ====================
/**
 * æŸ¥è¯¢çŸ¥è¯†åº“文件向量化状态
 * @param {number} knowledgeBaseId - çŸ¥è¯†åº“ID
 * @returns {Promise} è¿”回文件列表及向量化状态
 */
export function getVectorStatus(knowledgeBaseId) {
  return request({
    url: "/knowledgeBase/batch",
    method: "delete",
    data: knowledgeBaseIds,
    url: `/knowledgeBase/vector/status/${knowledgeBaseId}`,
    method: "get",
  });
}
/**
 * ä¿å­˜çŸ¥è¯†åº“文件关联(触发向量化)
 * @param {Object} data - æ–‡ä»¶å…³è”数据
 * @param {number} data.knowledgeBaseId - çŸ¥è¯†åº“ID(必填)
 * @param {number[]} data.storageBlobIds - æ–‡ä»¶blob ID数组(必填)
 * @returns {Promise}
 */
export function saveKnowledgeBaseFiles(data) {
  return request({
    url: "/knowledgeBase/file/save",
    method: "post",
    data: data,  // POST请求使用data
  });
}
/**
 * åˆ é™¤çŸ¥è¯†åº“文件
 * @param {number[]} ids - å‘量记录ID数组
 * @returns {Promise}
 */
export function deleteKnowledgeBaseFile(ids) {
  return request({
    url: "/knowledgeBase/file/delete",
    method: "delete",
    data: ids,  // DELETE请求使用data传递数组
  });
}
/**
 * é‡æ–°å‘量化文件
 * @param {number} vectorId - å‘量记录ID
 * @returns {Promise}
 */
export function reprocessVector(vectorId) {
  return request({
    url: `/knowledgeBase/vector/reprocess/${vectorId}`,
    method: "post",
  });
}
// ==================== çŸ¥è¯†é—®ç­”接口 ====================
/**
 * çŸ¥è¯†åº“问答(流式)
 * åŽç«¯æŽ¥å£: POST /ai/knowledge/chat
 * å“åº”类型: text/stream;charset=utf-8 (Spring Flux<String>)
 *
 * @param {Object} data - é—®ç­”参数
 * @param {number} data.knowledgeBaseId - çŸ¥è¯†åº“ID(必填)
 * @param {string} data.memoryId - ä¼šè¯ID(必填,用于保持上下文)
 * @param {string} data.question - ç”¨æˆ·é—®é¢˜(必填)
 * @returns {Promise<Response>} è¿”回Fetch Response对象
 */
export function knowledgeChat(data) {
  const token = getToken();
  return fetch(import.meta.env.VITE_APP_BASE_API + '/ai/knowledge/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + token
    },
    body: JSON.stringify(data)
  });
}
/**
 * æŸ¥è¯¢çŸ¥è¯†åº“问答历史
 * @param {string} memoryId - ä¼šè¯ID
 * @returns {Promise}
 */
export function getKnowledgeHistory(memoryId) {
  return request({
    url: `/ai/knowledge/history/${memoryId}`,
    method: "get",
  });
}
src/api/inventoryManagement/stockOut.js
@@ -35,3 +35,12 @@
        data,
    });
}
// æ‰¹é‡åå®¡å‡ºåº“记录
export const batchUnapproveStockOutRecords = (data) => {
    return request({
        url: "/stockOutRecord/reAudit",
        method: "post",
        data,
    });
}
src/api/mock/dataCheck.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,10 @@
import request from "@/utils/request";
// æ£€æµ‹æŒ‡å®šæ¨¡å—的基础数据是否就绪
export function dataCheck(modules) {
  return request({
    url: "/mock/dataCheck",
    method: "post",
    data: { modules },
  });
}
src/api/mock/generate.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,10 @@
import request from "@/utils/request";
// AI ç”Ÿæˆæ¨¡æ‹Ÿæ•°æ®ï¼ˆå«å‰ç½®åŸºç¡€æ•°æ®æ£€æµ‹ï¼‰
export function mockGenerate(data) {
  return request({
    url: "/mock/generate",
    method: "post",
    data,
  });
}
src/api/personnelManagement/staffSalaryMain.js
@@ -17,6 +17,14 @@
  });
}
export function staffSalaryMainCalculateByEmployeeId(data) {
  return request({
    url: "/staffSalaryMain/calculateByEmployeeId",
    method: "post",
    data,
  });
}
export function staffSalaryMainAdd(data) {
  return request({
    url: "/staffSalaryMain/add",
@@ -41,3 +49,11 @@
  });
}
// èŽ·å–å¯ç”¨éƒ¨é—¨åˆ—è¡¨ï¼ˆæ ¹æ®æœˆä»½æŸ¥è¯¢ï¼ŒæŽ’é™¤å·²å‘æ”¾å·¥èµ„è¡¨çš„éƒ¨é—¨ï¼‰
export function staffSalaryMainAvailableDepts(salaryMonth) {
  return request({
    url: "/staffSalaryMain/availableDepts",
    method: "get",
    params: { salaryMonth },
  });
}
src/api/procurementManagement/paymentLedger.js
@@ -18,3 +18,12 @@
    params,
  });
}
/** ä»˜æ¬¾å°è´¦ - ä»˜æ¬¾ç™»è®°åˆ—表 */
export function registrationList(params) {
  return request({
    url: "/purchase/report/registration",
    method: "get",
    params,
  });
}
src/api/procurementManagement/procurementLedger.js
@@ -74,53 +74,61 @@
  });
}
export function updateApprovalStatus(query) {
    return request({
        url: "/purchase/ledger/updateApprovalStatus",
        method: "post",
        data: query,
    });
  return request({
    url: "/purchase/ledger/updateApprovalStatus",
    method: "post",
    data: query,
  });
}
// ä¿å­˜é‡‡è´­æ¨¡æ¿
export function addPurchaseTemplate(data) {
    return request({
        url: "/purchaseLedgerTemplate/add",
        method: "post",
        data: data,
    });
  return request({
    url: "/purchaseLedgerTemplate/add",
    method: "post",
    data: data,
  });
}
// ä¿®æ”¹é‡‡è´­æ¨¡æ¿
export function updatePurchaseTemplate(data) {
    return request({
        url: "/purchaseLedgerTemplate/update",
        method: "post",
        data: data,
    });
  return request({
    url: "/purchaseLedgerTemplate/update",
    method: "post",
    data: data,
  });
}
// æŸ¥è¯¢é‡‡è´­æ¨¡æ¿
export function getPurchaseTemplateList(query) {
    return request({
        url: "/purchase/ledger/getPurchaseTemplateList",
        method: "get",
        params: query,
    });
  return request({
    url: "/purchase/ledger/getPurchaseTemplateList",
    method: "get",
    params: query,
  });
}
// åˆ é™¤é‡‡è´­æ¨¡æ¿
export function delPurchaseTemplate(id) {
    return request({
        url: "/purchaseLedgerTemplate/delete",
        method: "delete",
        data: id,
    });
  return request({
    url: "/purchaseLedgerTemplate/delete",
    method: "delete",
    data: id,
  });
}
// æŸ¥è¯¢é‡‡è´­è¯¦æƒ…
export function getPurchaseByCode(id) {
    return request({
        url: "/purchase/ledger/getPurchaseByCode",
        method: "get",
        params: id,
    });
}
  return request({
    url: "/purchase/ledger/getPurchaseByCode",
    method: "get",
    params: id,
  });
}
export function batchGeneratePurchaseInboundSteps(query) {
  return request({
    url: "/purchase/ledger/batchInsertPurchaseSteps",
    method: "post",
    data: query,
  });
}
src/api/viewIndex.js
@@ -349,7 +349,6 @@
export const productionOrderProgress = (params = {}) => {
  const safePageNum = Math.max(1, Number(params.pageNum || 1));
  const safePageSize = Math.min(50, Math.max(1, Number(params.pageSize || 10)));
  const safeTab = ["all", "inProgress", "completed", "paused"].includes(params.tab)
    ? params.tab
    : "all";
@@ -360,7 +359,7 @@
      ...params,
      tab: safeTab,
      pageNum: safePageNum,
      pageSize: safePageSize,
      pageSize: params.pageSize,
    },
    headers: {
      handleAuthError: false,
src/assets/logo/XDRJ.png

src/assets/styles/sidebar.scss
@@ -216,33 +216,38 @@
    & .nest-menu .el-sub-menu > .el-sub-menu__title,
    & .el-sub-menu .el-menu-item {
      min-width: 0 !important;
      width: calc(100% - 24px) !important;
      margin: 0 12px 8px !important;
      height: 46px;
      line-height: 46px;
      padding-left: 14px !important;
      padding-right: 14px !important;
      border-radius: 12px;
      width: calc(100% - 32px) !important;
      margin: 0 16px 6px !important;
      height: 40px;
      line-height: 40px;
      padding-left: 12px !important;
      padding-right: 12px !important;
      border-radius: 8px;
      transition: all 0.24s ease;
      color: var(--sidebar-text);
      border: 1px solid rgba(255, 255, 255, 0.06) !important;
      background: linear-gradient(128deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.01));
      border: none !important;
      background: transparent;
      font-size: 13px;
      &:hover {
        background: linear-gradient(128deg, rgba(var(--el-color-primary-rgb, 37, 99, 235), 0.24), rgba(var(--el-color-primary-rgb, 37, 99, 235), 0.07)) !important;
        border-color: rgba(var(--el-color-primary-rgb, 37, 99, 235), 0.3) !important;
        transform: translateX(2px);
        background: rgba(var(--el-color-primary-rgb, 37, 99, 235), 0.12) !important;
        transform: translateX(4px);
      }
      &.is-active {
        background: var(--menu-active-bg, linear-gradient(135deg, var(--el-color-primary), var(--el-color-primary-light-3))) !important;
        background-size: 180% 180%;
        background: rgba(var(--el-color-primary-rgb, 37, 99, 235), 0.85) !important;
        color: #fff !important;
        font-weight: 500;
        box-shadow: var(--menu-active-glow, 0 10px 24px rgba(var(--el-color-primary-rgb, 37, 99, 235), 0.34));
        animation: sidebarActiveFlow 4.6s ease infinite;
        box-shadow: 0 4px 12px rgba(var(--el-color-primary-rgb, 37, 99, 235), 0.25);
      }
    }
    // å­èœå•容器样式 - å¢žåŠ ç¼©è¿›å’Œè§†è§‰å±‚æ¬¡
    & .el-sub-menu .el-menu {
      padding: 4px 0 8px;
      margin-left: 8px;
      border-left: 2px solid rgba(255, 255, 255, 0.08);
    }
  }
  .hideSidebar {
src/components/PIMTable/PIMTable.vue
@@ -11,7 +11,7 @@
            :row-key="rowKey"
            :style="tableStyle"
            tooltip-effect="dark"
            :tooltip-options="{ appendTo: 'body' }"
            :tooltip-options="{ popperOptions: { strategy: 'absolute' } }"
            :expand-row-keys="expandRowKeys"
            :show-summary="isShowSummary"
            :summary-method="summaryMethod"
src/layout/components/Sidebar/SidebarItem.vue
@@ -102,57 +102,70 @@
</script>
<style lang="scss" scoped>
.sidebar-item-wrapper {
  :deep(.menu-icon) {
    width: 26px;
    height: 26px;
    margin-right: 12px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    transition: color 0.2s ease;
    color: var(--sidebar-text);
    opacity: 0.88;
  }
  :deep(.el-menu-item:hover .menu-icon),
  :deep(.el-sub-menu__title:hover .menu-icon) {
    color: #ffffff;
    opacity: 1;
  }
.sidebar-item-wrapper {
  :deep(.menu-icon) {
    width: 26px;
    height: 26px;
    margin-right: 12px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    transition: color 0.2s ease;
    color: var(--sidebar-text);
    opacity: 0.88;
  }
  :deep(.el-menu-item:hover .menu-icon),
  :deep(.el-sub-menu__title:hover .menu-icon) {
    color: #ffffff;
    opacity: 1;
  }
  
  :deep(.el-menu-item.is-active .menu-icon) {
    color: var(--menu-active-text) !important;
    opacity: 1;
  }
  
  :deep(.menu-title) {
    font-weight: 500;
    transition: color 0.2s ease;
    color: var(--sidebar-text);
    opacity: 0.82;
  }
  :deep(.el-menu-item:hover .menu-title),
  :deep(.el-sub-menu__title:hover .menu-title) {
    color: #ffffff;
    opacity: 1;
  }
  :deep(.el-menu-item.is-active .menu-title) {
    color: var(--menu-active-text) !important;
    opacity: 1;
  }
  :deep(.nest-menu) {
    .menu-icon {
      width: 22px;
      height: 22px;
      margin-right: 10px;
    }
    .menu-title { font-size: 13px; }
  }
}
  :deep(.menu-title) {
    font-weight: 500;
    transition: color 0.2s ease;
    color: var(--sidebar-text);
    opacity: 0.82;
  }
  :deep(.el-menu-item:hover .menu-title),
  :deep(.el-sub-menu__title:hover .menu-title) {
    color: #ffffff;
    opacity: 1;
  }
  :deep(.el-menu-item.is-active .menu-title) {
    color: var(--menu-active-text) !important;
    opacity: 1;
  }
  :deep(.nest-menu) {
    .menu-icon {
      width: 18px;
      height: 18px;
      margin-right: 8px;
      opacity: 0.7;
    }
    .menu-title {
      font-size: 13px;
      opacity: 0.85;
    }
    .el-menu-item.is-active {
      .menu-icon {
        opacity: 1;
      }
      .menu-title {
        opacity: 1;
      }
    }
  }
}
</style>
src/router/index.js
@@ -97,6 +97,44 @@
      },
    ],
  },
  // æ•™å­¦æ¼”示模块
  {
    path: "/productionManagement/teachingDemo",
    component: Layout,
    hidden: true,
    children: [
      {
        path: "",
        component: () => import("@/views/productionManagement/teachingDemo/index.vue"),
        name: "TeachingDemo",
        meta: { title: "工艺路线与BOM教学演示", icon: "education" },
      },
      {
        path: "bom",
        component: () => import("@/views/productionManagement/teachingDemo/BomDemo.vue"),
        name: "BomDemo",
        meta: { title: "BOM结构演示", activeMenu: "/productionManagement/teachingDemo" },
      },
      {
        path: "processRoute",
        component: () => import("@/views/productionManagement/teachingDemo/ProcessRouteDemo.vue"),
        name: "ProcessRouteDemo",
        meta: { title: "工艺路线演示", activeMenu: "/productionManagement/teachingDemo" },
      },
      {
        path: "combined",
        component: () => import("@/views/productionManagement/teachingDemo/CombinedDemo.vue"),
        name: "CombinedDemo",
        meta: { title: "联动演示", activeMenu: "/productionManagement/teachingDemo" },
      },
      {
        path: "factory",
        component: () => import("@/views/productionManagement/teachingDemo/FactoryDemo.vue"),
        name: "FactoryDemo",
        meta: { title: "动态工厂演示", activeMenu: "/productionManagement/teachingDemo" },
      },
    ],
  },
  {
    path: "/user",
    component: Layout,
src/views/basicData/customerFile/index.vue
@@ -800,7 +800,12 @@
        {
          name: "编辑",
          type: "text",
          showHide: row => !isCustomerEditLocked(row),
          clickFun: row => {
            if (isCustomerEditLocked(row)) {
              proxy.$modal.msgWarning("已被销售模块引用的私海客户不能编辑");
              return;
            }
            openForm("edit", row);
          },
        },
@@ -1001,6 +1006,10 @@
  }
  // æ‰“开弹框
  const openForm = (type, row) => {
    if (type === "edit" && isCustomerEditLocked(row)) {
      proxy.$modal.msgWarning("已被销售模块引用的私海客户不能编辑");
      return;
    }
    operationType.value = type;
    form.value = {};
    form.value.maintainer = userStore.nickName;
@@ -1030,6 +1039,12 @@
    }
    dialogFormVisible.value = true;
  };
  const isCustomerEditLocked = row => {
    const salesReferenced = Number(row?.salesReferenceFlag || 0) === 1;
    const customerType = Number(row?.type);
    const usageStatus = Number(row?.usageStatus || 0);
    return salesReferenced && (customerType === 0 || (customerType === 1 && usageStatus === 1));
  };
  // æäº¤è¡¨å•
  const submitForm = () => {
    proxy.$refs["formRef"].validate(valid => {
src/views/basicData/customerFileOpenSea/index.vue
@@ -917,8 +917,13 @@
        {
          name: "回收",
          type: "text",
          disabled: row => isCustomerRecoveryLocked(row),
          showHide: row => row.usageStatus == 1,
          clickFun: row => {
            if (isCustomerRecoveryLocked(row)) {
              proxy.$modal.msgWarning("已领用并且被销售模块引用的公海客户不能回收");
              return;
            }
            recycle(row);
          },
        },
@@ -933,7 +938,12 @@
                {
                    name: "编辑",
                    type: "text",
                    clickFun: row => {
          disabled: row => isCustomerEditLocked(row),
          clickFun: row => {
            if (isCustomerEditLocked(row)) {
              proxy.$modal.msgWarning("已领用并且被销售模块引用的公海客户不能编辑");
              return;
            }
                        openForm("edit", row);
                    },
                },
@@ -1121,6 +1131,10 @@
  }
  // æ‰“开弹框
  const openForm = (type, row) => {
    if (type === "edit" && isCustomerEditLocked(row)) {
      proxy.$modal.msgWarning("已领用并且被销售模块引用的公海客户不能编辑");
      return;
    }
    operationType.value = type;
    form.value = {};
    form.value.maintainer = userStore.nickName;
@@ -1150,6 +1164,11 @@
    }
    dialogFormVisible.value = true;
  };
  const isCustomerSalesReferenced = row => Number(row?.salesReferenceFlag || 0) === 1;
  const isCustomerRecoveryLocked = row => Number(row?.usageStatus || 0) === 1 && isCustomerSalesReferenced(row);
  const isCustomerEditLocked = row => isCustomerSalesReferenced(row) && (
    Number(row?.type) === 0 || (Number(row?.type) === 1 && Number(row?.usageStatus || 0) === 1)
  );
  // æäº¤è¡¨å•
  const submitForm = () => {
    proxy.$refs["formRef"].validate(valid => {
src/views/basicData/parameterMaintenance/index.vue
@@ -84,7 +84,7 @@
        </el-form-item> -->
        <el-form-item label="创建时间"
                      prop="createTime">
          <el-date-picker v-model="formData.createTime"
          <el-date-picker v-model="formCreateTimeDate"
                          type="date"
                          placeholder="选择日期"
                          value-format="YYYY-MM-DD"
@@ -215,7 +215,8 @@
</template>
<script setup>
  import { onMounted, ref, reactive } from "vue";
  import { onMounted, ref, reactive, computed } from "vue";
  import dayjs from "dayjs";
  import {
    parameterListPage,
    addParameter,
@@ -369,6 +370,12 @@
        },
      },
    ],
  });
  const formCreateTimeDate = computed({
    get: () => (formData.createTime ? String(formData.createTime).split(" ")[0] : ""),
    set: (value) => {
      formData.createTime = value ? `${value} ${dayjs().format("HH:mm:ss")}` : "";
    },
  });
  // const productTypes = ref([]);
  const isEdit = ref(false);
@@ -528,7 +535,7 @@
    formData.unit = "";
    formData.remark = "";
    formData.isRequired = 0;
    formData.createTime = new Date().toISOString().split("T")[0];
    formData.createTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
    dialogVisible.value = true;
  };
@@ -547,6 +554,7 @@
    formData.remark = row.remark || "";
    formData.paramFormat = row.paramFormat || "";
    formData.isRequired = row.isRequired || 0;
    formData.createTime = row.createTime || "";
    dialogVisible.value = true;
  };
src/views/collaborativeApproval/knowledgeBase/index.vue
@@ -225,18 +225,167 @@
        </div>
      </div>
    </FormDialog>
    <!-- æ–‡ä»¶ç®¡ç†å¼¹çª— -->
    <FormDialog
      v-model="filesDialogVisible"
      title="文件管理"
      :width="'900px'"
      @close="closeFilesDialog"
      @confirm="closeFilesDialog"
      @cancel="closeFilesDialog"
    >
      <div class="file-manager">
        <!-- æ–‡ä»¶ä¸Šä¼  -->
        <div class="upload-section">
          <el-upload
            :action="uploadUrl"
            :headers="uploadHeaders"
            :on-success="handleUploadSuccess"
            :on-error="handleUploadError"
            :before-upload="beforeUpload"
            name="files"
            multiple
            :show-file-list="false"
            accept=".txt,.md,.docx,.xlsx,.xls,.pdf"
          >
            <el-button type="primary">上传文件</el-button>
          </el-upload>
          <el-button
            type="success"
            @click="saveFiles"
            :disabled="uploadedBlobIds.length === 0"
            :loading="savingFiles"
            style="margin-left: 10px"
          >
            ä¿å­˜æ–‡ä»¶å…³è”
          </el-button>
          <el-button
            v-if="uploadedBlobIds.length > 0"
            type="text"
            @click="clearUploadedFiles"
            style="margin-left: 10px"
          >
            æ¸…空待保存列表
          </el-button>
        </div>
        <!-- å¾…保存的文件列表 -->
        <div v-if="uploadedBlobIds.length > 0" class="uploaded-list">
          <div class="uploaded-tip">
            <el-icon style="color: #409eff"><InfoFilled /></el-icon>
            <span>已上传 {{ uploadedBlobIds.length }} ä¸ªæ–‡ä»¶,请点击"保存文件关联"按钮触发向量化处理</span>
          </div>
        </div>
        <!-- æ–‡ä»¶åˆ—表与向量化状态 -->
        <el-table :data="fileList" style="margin-top: 20px" border>
          <el-table-column prop="fileName" label="文件名" show-overflow-tooltip />
          <el-table-column prop="fileType" label="文件类型" width="100" />
          <el-table-column label="向量化状态" width="120">
            <template #default="{ row }">
              <el-tag :type="getStatusType(row.vectorStatus)">
                {{ getStatusText(row.vectorStatus) }}
              </el-tag>
            </template>
          </el-table-column>
          <el-table-column prop="chunkCount" label="切片数" width="100" align="center" />
          <el-table-column label="错误信息" width="200" show-overflow-tooltip>
            <template #default="{ row }">
              <span v-if="row.vectorError" style="color: #f56c6c">{{ row.vectorError }}</span>
              <span v-else style="color: #909399">-</span>
            </template>
          </el-table-column>
          <el-table-column prop="createTime" label="上传时间" width="180" />
          <el-table-column label="操作" width="150" align="center">
            <template #default="{ row }">
              <el-button
                v-if="row.vectorStatus === 3"
                type="text"
                @click="reprocessFile(row)"
              >
                é‡æ–°å¤„理
              </el-button>
              <el-button type="text" @click="deleteFile(row)" style="color: #f56c6c">
                åˆ é™¤
              </el-button>
            </template>
          </el-table-column>
        </el-table>
      </div>
    </FormDialog>
    <!-- çŸ¥è¯†åº“问答弹窗 -->
    <FormDialog
      v-model="chatDialogVisible"
      title="知识库问答"
      :width="'800px'"
      @close="closeChatDialog"
      @confirm="closeChatDialog"
      @cancel="closeChatDialog"
    >
      <div class="knowledge-chat">
        <div class="chat-header">
          <el-tag type="success">当前知识库: {{ currentKnowledgeBase?.title }}</el-tag>
        </div>
        <!-- å¯¹è¯åŒºåŸŸ -->
        <div class="chat-messages" ref="chatMessagesRef">
          <div
            v-for="(msg, index) in messages"
            :key="index"
            :class="['message', msg.role]"
          >
            <div class="message-role">{{ msg.role === 'user' ? '我' : 'AI助手' }}</div>
            <div class="message-content">{{ msg.content }}</div>
          </div>
          <div v-if="chatLoading" class="message assistant">
            <div class="message-role">AI助手</div>
            <div class="message-content typing">正在思考中...</div>
          </div>
        </div>
        <!-- è¾“入框 -->
        <div class="chat-input">
          <el-input
            v-model="inputQuestion"
            placeholder="请输入问题,按回车发送(Ctrl+Enter快捷发送)"
            @keyup.enter="sendMessage"
            :disabled="chatLoading"
          >
            <template #append>
              <el-button @click="sendMessage" :loading="chatLoading">发送</el-button>
            </template>
          </el-input>
          <div class="chat-actions">
            <el-button type="text" size="small" @click="clearMessages">清空对话</el-button>
          </div>
        </div>
      </div>
    </FormDialog>
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import { onMounted, ref, reactive, toRefs, getCurrentInstance, computed, watch } from "vue";
import { Search, InfoFilled } from "@element-plus/icons-vue";
import { onMounted, ref, reactive, toRefs, getCurrentInstance, computed, watch, nextTick } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import PIMTable from "@/components/PIMTable/PIMTable.vue";
import FormDialog from '@/components/Dialog/FormDialog.vue';
import { listKnowledgeBase, delKnowledgeBase,addKnowledgeBase,updateKnowledgeBase } from "@/api/collaborativeApproval/knowledgeBase.js";
import {
  listKnowledgeBase,
  delKnowledgeBase,
  addKnowledgeBase,
  updateKnowledgeBase,
  getVectorStatus,
  reprocessVector,
  saveKnowledgeBaseFiles,
  deleteKnowledgeBaseFile,
  knowledgeChat
} from "@/api/collaborativeApproval/knowledgeBase.js";
import useUserStore from '@/store/modules/user';
import { userListNoPageByTenantId } from '@/api/system/user.js';
import { getToken } from "@/utils/auth";
// è¡¨å•验证规则
const rules = {
@@ -283,7 +432,18 @@
  dialogTitle: "",
  dialogType: "add",
  viewDialogVisible: false,
  currentKnowledge: {}
  currentKnowledge: {},
  filesDialogVisible: false,
  currentKnowledgeBase: null,
  fileList: [],
  uploadedBlobIds: [],
  savingFiles: false,
  vectorStatusTimer: null, // å‘量化状态轮询定时器
  chatDialogVisible: false,
  messages: [],
  inputQuestion: "",
  chatLoading: false,
  memoryId: ""
});
const {
@@ -297,7 +457,18 @@
  dialogTitle,
  dialogType,
  viewDialogVisible,
  currentKnowledge
  currentKnowledge,
  filesDialogVisible,
  currentKnowledgeBase,
  fileList,
  uploadedBlobIds,
  savingFiles,
  vectorStatusTimer,
  chatDialogVisible,
  messages,
  inputQuestion,
  chatLoading,
  memoryId
} = toRefs(data);
// è¡¨å•引用
@@ -305,6 +476,12 @@
// ç”¨æˆ·ç›¸å…³
const userStore = useUserStore();
const userList = ref([]);
// èŠå¤©æ¶ˆæ¯å®¹å™¨å¼•用
const chatMessagesRef = ref();
// æ–‡ä»¶ä¸Šä¼ ç›¸å…³
const uploadUrl = import.meta.env.VITE_APP_BASE_API + "/common/upload";
const uploadHeaders = { Authorization: "Bearer " + getToken() };
// è¡¨æ ¼åˆ—配置
const tableColumn = ref([
@@ -352,6 +529,18 @@
    }
  },
  {
    label: "文件数量",
    prop: "fileCount",
    width: 100,
    align: "center"
  },
  {
    label: "切片数量",
    prop: "totalChunkCount",
    width: 100,
    align: "center"
  },
  {
    label: "使用次数",
    prop: "usageCount",
    width: 100,
@@ -379,6 +568,20 @@
        type: "text",
        clickFun: (row) => {
          openForm("edit", row);
        }
      },
      {
        name: "文件",
        type: "text",
        clickFun: (row) => {
          openFilesDialog(row);
        }
      },
      {
        name: "问答",
        type: "text",
        clickFun: (row) => {
          openChatDialog(row);
        }
      },
      {
@@ -422,21 +625,31 @@
const getList = () => {
  tableLoading.value = true;
  listKnowledgeBase({...page.value, ...searchForm.value})
  // âœ… GET请求使用params传参
  listKnowledgeBase({
    current: page.value.current,
    size: page.value.size,
    title: searchForm.value.title,
    type: searchForm.value.type
  })
  .then(res => {
    tableLoading.value = false;
    page.value.total = res.data.total;
    // å¦‚果当前页数超过总页数,重置到第1页并重新查询
    // å¦‚果当前页数超过总页数,重置到第1页并重新查询
    const maxPage = Math.ceil(res.data.total / page.value.size) || 1;
    if (page.value.current > maxPage && maxPage > 0) {
      page.value.current = 1;
      // é‡æ–°æŸ¥è¯¢ç¬¬1页数据
      return getList();
    }
    tableData.value = res.data.records;
  }).catch(err => {
    tableLoading.value = false;
  })
  .catch(err => {
    tableLoading.value = false;
    console.error("查询知识库列表失败:", err);
  });
};
// åˆ†é¡µå¤„理
@@ -611,27 +824,47 @@
const submitForm = async () => {
  try {
    await formRef.value.validate();
    // âœ… POST请求使用data传参,明确参数结构
    const formData = {
      title: form.value.title,
      type: form.value.type,
      scenario: form.value.scenario || "",
      efficiency: form.value.efficiency || "",
      problem: form.value.problem,
      solution: form.value.solution,
      keyPoints: form.value.keyPoints || "",
      creator: form.value.creator || "",
      usageCount: form.value.usageCount || 0
    };
    if (dialogType.value === "add") {
      // æ–°å¢žçŸ¥è¯†
      addKnowledgeBase({...form.value}).then(res => {
      addKnowledgeBase(formData).then(res => {
        if(res.code == 200){
          ElMessage.success("添加成功");
          closeKnowledgeDialog();
          getList();
        }
      }).catch(err => {
        ElMessage.error(err.msg);
      })
        console.error("添加知识库失败:", err);
        ElMessage.error(err.msg || "添加失败");
      });
    } else {
      updateKnowledgeBase({...form.value}).then(res => {
      // æ›´æ–°çŸ¥è¯† - æ·»åŠ id参数
      updateKnowledgeBase({
        id: form.value.id,
        ...formData
      }).then(res => {
        if(res.code == 200){
          ElMessage.success("更新成功");
          closeKnowledgeDialog();
          getList();
        }
      }).catch(err => {
        ElMessage.error(err.msg);
      })
        console.error("更新知识库失败:", err);
        ElMessage.error(err.msg || "更新失败");
      });
    }
  } catch (error) {
    console.error("表单验证失败:", error);
@@ -645,19 +878,22 @@
    return;
  }
  ElMessageBox.confirm("选中的内容将被删除,是否确认删除?", "删除", {
  ElMessageBox.confirm("选中的内容将被删除,是否确认删除?", "删除", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning",
  }).then(() => {
    // console.log(selectedIds.value);
    // âœ… DELETE请求使用data传递ID数组
    delKnowledgeBase(selectedIds.value).then(res => {
      if(res.code == 200){
        ElMessage.success("删除成功");
        selectedIds.value = [];
        getList();
      }
    })
    }).catch(err => {
      console.error("删除知识库失败:", err);
      ElMessage.error(err.msg || "删除失败");
    });
  }).catch(() => {
    // ç”¨æˆ·å–消
  });
@@ -680,6 +916,364 @@
const handleExport = () => {
  proxy.download('/knowledgeBase/export', { ...searchForm.value }, '知识库.xlsx')
}
// ============ æ–‡ä»¶ç®¡ç†ç›¸å…³ ============
// æ‰“开文件管理弹窗
const openFilesDialog = (row) => {
  currentKnowledgeBase.value = row;
  filesDialogVisible.value = true;
  loadFileList();
};
// åŠ è½½æ–‡ä»¶åˆ—è¡¨
const loadFileList = async () => {
  if (!currentKnowledgeBase.value?.id) return;
  try {
    const res = await getVectorStatus(currentKnowledgeBase.value.id);
    fileList.value = res.data || [];
    // æ£€æŸ¥æ˜¯å¦æœ‰å¤„理中的文件,如果有则启动轮询
    const hasProcessing = res.data.some(item => item.vectorStatus === 1);
    if (hasProcessing && !vectorStatusTimer.value) {
      startVectorStatusPolling();
    } else if (!hasProcessing && vectorStatusTimer.value) {
      stopVectorStatusPolling();
    }
  } catch (error) {
    console.error("加载文件列表失败:", error);
    ElMessage.error("加载文件列表失败");
  }
};
// å¼€å§‹è½®è¯¢å‘量化状态
const startVectorStatusPolling = () => {
  vectorStatusTimer.value = setInterval(async () => {
    try {
      const res = await getVectorStatus(currentKnowledgeBase.value.id);
      fileList.value = res.data || [];
      // æ£€æŸ¥æ˜¯å¦è¿˜æœ‰å¤„理中的文件
      const hasProcessing = res.data.some(item => item.vectorStatus === 1);
      if (!hasProcessing) {
        stopVectorStatusPolling();
        ElMessage.success("所有文件向量化处理完成");
      }
    } catch (error) {
      console.error("轮询向量化状态失败:", error);
      stopVectorStatusPolling();
    }
  }, 3000); // æ¯3秒轮询一次
};
// åœæ­¢è½®è¯¢å‘量化状态
const stopVectorStatusPolling = () => {
  if (vectorStatusTimer.value) {
    clearInterval(vectorStatusTimer.value);
    vectorStatusTimer.value = null;
  }
};
// ä¸Šä¼ å‰æ ¡éªŒ
const beforeUpload = (file) => {
  const allowedTypes = ['.txt', '.md', '.docx', '.xlsx', '.xls', '.pdf'];
  const fileName = file.name.toLowerCase();
  const isAllowed = allowedTypes.some(type => fileName.endsWith(type));
  if (!isAllowed) {
    ElMessage.error('只支持 txt、md、docx、xlsx、xls、pdf æ ¼å¼çš„æ–‡ä»¶');
    return false;
  }
  const isLt50M = file.size / 1024 / 1024 < 50;
  if (!isLt50M) {
    ElMessage.error('文件大小不能超过 50MB');
    return false;
  }
  return true;
};
// ä¸Šä¼ æˆåŠŸ
const handleUploadSuccess = (response, file) => {
  console.log("上传响应:", response);  // è°ƒè¯•日志
  if (response.code === 200) {
    // âœ… åŽç«¯è¿”回的是 List<StorageBlobVO>,所以data是数组
    if (Array.isArray(response.data) && response.data.length > 0) {
      // å–数组第一个元素的id
      const blobId = response.data[0].id;
      if (blobId) {
        uploadedBlobIds.value.push(blobId);
        ElMessage.success(`文件 ${file.name} ä¸Šä¼ æˆåŠŸ`);
      } else {
        console.error("上传响应中未找到id:", response.data[0]);
        ElMessage.error("上传失败: æœªèŽ·å–åˆ°æ–‡ä»¶ID");
      }
    } else {
      console.error("上传响应格式错误:", response);
      ElMessage.error("上传失败: å“åº”格式错误");
    }
  } else {
    ElMessage.error(response.msg || "上传失败");
  }
};
// ä¸Šä¼ å¤±è´¥
const handleUploadError = (error, file) => {
  ElMessage.error(`文件 ${file.name} ä¸Šä¼ å¤±è´¥`);
};
// ä¿å­˜æ–‡ä»¶å…³è”
const saveFiles = async () => {
  // å‚数校验
  if (!currentKnowledgeBase.value?.id) {
    ElMessage.error("知识库信息异常");
    return;
  }
  if (uploadedBlobIds.value.length === 0) {
    ElMessage.warning("请先上传文件");
    return;
  }
  savingFiles.value = true;
  try {
    // âœ… POST请求使用data传参,明确参数结构
    await saveKnowledgeBaseFiles({
      knowledgeBaseId: currentKnowledgeBase.value.id,  // çŸ¥è¯†åº“ID
      storageBlobIds: uploadedBlobIds.value             // æ–‡ä»¶blob ID数组
    });
    ElMessage.success("文件关联保存成功,正在后台处理向量化");
    uploadedBlobIds.value = [];
    // å»¶è¿Ÿåˆ·æ–°æ–‡ä»¶åˆ—表,给后台处理时间
    setTimeout(() => {
      loadFileList();
    }, 1000);
  } catch (error) {
    console.error("保存文件关联失败:", error);
    ElMessage.error("保存文件关联失败");
  } finally {
    savingFiles.value = false;
  }
};
// é‡æ–°å¤„理向量化的文件
const reprocessFile = async (row) => {
  try {
    await reprocessVector(row.id);
    ElMessage.success("已重新提交向量化任务");
    // å»¶è¿Ÿåˆ·æ–°
    setTimeout(() => {
      loadFileList();
    }, 1000);
  } catch (error) {
    console.error("重新处理失败:", error);
    ElMessage.error("重新处理失败");
  }
};
// æ¸…空待保存的文件列表
const clearUploadedFiles = () => {
  uploadedBlobIds.value = [];
  ElMessage.success("已清空待保存文件列表");
};
// åˆ é™¤æ–‡ä»¶
const deleteFile = async (row) => {
  try {
    await ElMessageBox.confirm(
      "确定要删除该文件吗?删除后将无法恢复向量数据",
      "删除确认",
      {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning"
      }
    );
    // âœ… DELETE请求使用data传递ID数组
    await deleteKnowledgeBaseFile([row.id]);  // æ³¨æ„: row.id是向量记录ID,不是storageBlobId
    ElMessage.success("删除成功");
    loadFileList();
  } catch (error) {
    if (error !== 'cancel') {
      console.error("删除文件失败:", error);
      ElMessage.error("删除文件失败");
    }
  }
};
// çŠ¶æ€æ–‡æœ¬æ˜ å°„
const getStatusText = (status) => {
  const map = {
    0: '待处理',
    1: '处理中',
    2: '已完成',
    3: '失败'
  };
  return map[status] || '未知';
};
// çŠ¶æ€æ ‡ç­¾ç±»åž‹æ˜ å°„
const getStatusType = (status) => {
  const map = {
    0: 'info',
    1: 'warning',
    2: 'success',
    3: 'danger'
  };
  return map[status] || 'info';
};
// å…³é—­æ–‡ä»¶ç®¡ç†å¼¹çª—
const closeFilesDialog = () => {
  filesDialogVisible.value = false;
  currentKnowledgeBase.value = null;
  fileList.value = [];
  uploadedBlobIds.value = [];
  stopVectorStatusPolling(); // åœæ­¢è½®è¯¢
  getList(); // åˆ·æ–°ä¸»åˆ—表,更新文件数量
};
// ============ çŸ¥è¯†åº“问答相关 ============
// ç”ŸæˆUUID的fallback方案
const generateUUID = () => {
  if (typeof crypto !== 'undefined' && crypto.randomUUID) {
    return crypto.randomUUID();
  }
  // fallback: å…¼å®¹ä¸æ”¯æŒ crypto.randomUUID çš„环境
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    const r = Math.random() * 16 | 0;
    const v = c === 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
};
// æ‰“开问答弹窗
const openChatDialog = (row) => {
  currentKnowledgeBase.value = row;
  chatDialogVisible.value = true;
  memoryId.value = generateUUID();
  messages.value = [];
  inputQuestion.value = "";
};
// å‘送消息
const sendMessage = async () => {
  // å‚数校验
  if (!inputQuestion.value.trim()) {
    ElMessage.warning("请输入问题");
    return;
  }
  if (!currentKnowledgeBase.value?.id) {
    ElMessage.error("知识库信息异常");
    return;
  }
  const question = inputQuestion.value.trim();
  // æ·»åŠ ç”¨æˆ·æ¶ˆæ¯
  messages.value.push({
    role: 'user',
    content: question
  });
  inputQuestion.value = "";
  chatLoading.value = true;
  // æ»šåŠ¨åˆ°åº•éƒ¨
  await nextTick();
  scrollToBottom();
  try {
    // âœ… æµå¼è¯·æ±‚使用Fetch API
    const response = await knowledgeChat({
      knowledgeBaseId: currentKnowledgeBase.value.id,  // çŸ¥è¯†åº“ID
      memoryId: memoryId.value,                         // ä¼šè¯ID
      question: question                                // ç”¨æˆ·é—®é¢˜
    });
    if (!response.ok) {
      const errorText = await response.text();
      throw new Error(errorText || '请求失败');
    }
    // âœ… åŽç«¯è¿”回 text/stream;charset=utf-8
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let aiContent = '';
    messages.value.push({ role: 'assistant', content: '' });
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      const text = decoder.decode(value, { stream: true });  // âœ… æ·»åŠ stream选项
      aiContent += text;
      messages.value[messages.value.length - 1].content = aiContent;
      // æ»šåŠ¨åˆ°åº•éƒ¨
      await nextTick();
      scrollToBottom();
    }
    // å¦‚æžœAI返回空内容,显示提示
    if (!aiContent.trim()) {
      messages.value[messages.value.length - 1].content = '抱歉,知识库中未找到相关内容,请尝试其他问题。';
    }
  } catch (error) {
    console.error("问答请求失败:", error);
    ElMessage.error("问答请求失败,请稍后重试");
    messages.value.push({
      role: 'assistant',
      content: '抱歉,发生了错误,请稍后重试'
    });
  } finally {
    chatLoading.value = false;
  }
};
// æ¸…空对话
const clearMessages = () => {
  ElMessageBox.confirm(
    "确定要清空所有对话记录吗?",
    "清空确认",
    {
      confirmButtonText: "确定",
      cancelButtonText: "取消",
      type: "warning"
    }
  ).then(() => {
    messages.value = [];
    memoryId.value = generateUUID(); // é‡æ–°ç”Ÿæˆä¼šè¯ID
    ElMessage.success("对话已清空");
  }).catch(() => {
    // ç”¨æˆ·å–消
  });
};
// æ»šåŠ¨åˆ°åº•éƒ¨
const scrollToBottom = () => {
  if (chatMessagesRef.value) {
    chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight;
  }
};
// å…³é—­é—®ç­”弹窗
const closeChatDialog = () => {
  chatDialogVisible.value = false;
  currentKnowledgeBase.value = null;
  messages.value = [];
  inputQuestion.value = "";
};
</script>
<style scoped>
@@ -755,4 +1349,113 @@
  font-size: 14px;
  color: #909399;
}
/* æ–‡ä»¶ç®¡ç†æ ·å¼ */
.file-manager {
  padding: 20px 0;
}
.upload-section {
  display: flex;
  align-items: center;
}
.uploaded-list {
  margin-top: 16px;
  padding: 12px;
  background: #f0f9ff;
  border-radius: 6px;
  border: 1px solid #b3d8ff;
}
.uploaded-tip {
  display: flex;
  align-items: center;
  gap: 8px;
  color: #409eff;
  font-size: 14px;
}
/* çŸ¥è¯†åº“问答样式 */
.knowledge-chat {
  display: flex;
  flex-direction: column;
  height: 500px;
}
.chat-header {
  margin-bottom: 16px;
}
.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  background: #f5f7fa;
  border-radius: 8px;
  margin-bottom: 16px;
}
.message {
  margin-bottom: 16px;
  max-width: 80%;
}
.message.user {
  margin-left: auto;
  text-align: right;
}
.message.assistant {
  margin-right: auto;
}
.message-role {
  font-size: 12px;
  color: #909399;
  margin-bottom: 4px;
}
.message-content {
  display: inline-block;
  padding: 10px 14px;
  border-radius: 8px;
  line-height: 1.6;
  word-wrap: break-word;
  white-space: pre-wrap;
}
.message.user .message-content {
  background: #409eff;
  color: white;
}
.message.assistant .message-content {
  background: white;
  color: #303133;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.typing {
  animation: typing 1.5s infinite;
}
@keyframes typing {
  0%, 50%, 100% {
    opacity: 1;
  }
  25%, 75% {
    opacity: 0.5;
  }
}
.chat-input {
  margin-top: auto;
}
.chat-actions {
  margin-top: 8px;
  text-align: right;
}
</style>
src/views/collaborativeApproval/notificationManagement/meetApplication/index.vue
@@ -1,154 +1,148 @@
<template>
  <div>
    <!-- ç”³è¯·ç±»åž‹é€‰æ‹© -->
    <el-card class="type-card">
      <div class="type-selector">
        <div
            v-for="type in applicationTypes"
            :key="type.value"
            class="type-item"
            :class="{ active: currentType === type.value }"
            @click="changeType(type.value)"
        >
          <div class="type-icon">
            <el-icon :size="24"><component :is="type.icon"/></el-icon>
          </div>
          <div class="type-info">
            <div class="type-name">{{ type.name }}</div>
            <div class="type-desc">{{ type.desc }}</div>
          </div>
        </div>
      </div>
    </el-card>
    <!-- ä¼šè®®ç”³è¯·è¡¨å• -->
    <el-card>
      <div class="form-header">
        <h3>{{ getCurrentTypeName() }}申请</h3>
      </div>
      <el-form
          ref="meetingFormRef"
          :model="meetingForm"
          :rules="rules"
          label-width="100px"
      >
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="会议主题" prop="title">
              <el-input v-model="meetingForm.title" placeholder="请输入会议主题"/>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="会议室" prop="roomId">
              <el-select v-model="meetingForm.roomId" placeholder="请选择会议室" style="width: 100%">
                <el-option
                    v-for="room in meetingRooms"
                    :key="room.id"
                    :label="`${room.name} (${room.location})`"
                    :value="room.id"
                />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="主持人" prop="host">
              <el-input v-model="meetingForm.host" placeholder="请输入主持人姓名"/>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="会议日期" prop="meetingDate">
              <el-date-picker
                  v-model="meetingForm.meetingDate"
                  type="date"
                  placeholder="请选择会议日期"
                  value-format="YYYY-MM-DD"
                  format="YYYY-MM-DD"
                  :disabled-date="disabledDate"
                  style="width: 100%"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <!-- ç©ºåˆ—,保持布局 -->
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="开始时间" prop="startTime">
              <el-select
                  v-model="meetingForm.startTime"
                  placeholder="请选择开始时间"
                  style="width: 100%"
              >
                <el-option
                    v-for="time in startTimeOptions"
                    :key="time.value"
                    :label="time.label"
                    :value="time.value"
                />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="结束时间" prop="endTime">
              <el-select
                  v-model="meetingForm.endTime"
                  placeholder="请选择结束时间"
                  style="width: 100%"
              >
                <el-option
                    v-for="time in endTimeOptions"
                    :key="time.value"
                    :label="time.label"
                    :value="time.value"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="参会人员" prop="participants">
          <el-select
              v-model="meetingForm.participants"
              multiple
              filterable
              placeholder="请选择参会人员"
              style="width: 100%"
          >
            <el-option
                v-for="person in employees"
                :key="person.id"
                :label="`${person.staffName}${person.postName ? ` (${person.postName})` : ''}`"
                :value="person.id"
            />
          </el-select>
        </el-form-item>
        <el-form-item label="会议说明" prop="description">
          <el-input
              v-model="meetingForm.description"
              type="textarea"
              :rows="4"
              placeholder="请输入会议说明"
          />
        </el-form-item>
      </el-form>
      <div class="form-footer">
        <el-button @click="resetForm">重置</el-button>
        <el-button type="primary" @click="submitForm">提交</el-button>
      </div>
    </el-card>
  </div>
    <div>
        <!-- ç”³è¯·ç±»åž‹é€‰æ‹© -->
        <el-card class="type-card">
            <div class="type-selector">
                <div
                    v-for="type in applicationTypes"
                    :key="type.value"
                    class="type-item"
                    :class="{ active: currentType === type.value }"
                    @click="changeType(type.value)"
                >
                    <div class="type-icon">
                        <el-icon :size="24"><component :is="type.icon"/></el-icon>
                    </div>
                    <div class="type-info">
                        <div class="type-name">{{ type.name }}</div>
                        <div class="type-desc">{{ type.desc }}</div>
                    </div>
                </div>
            </div>
        </el-card>
        <!-- ä¼šè®®ç”³è¯·è¡¨å• -->
        <el-card>
            <div class="form-header">
                <h3>{{ getCurrentTypeName() }}申请</h3>
            </div>
            <el-form
                ref="meetingFormRef"
                :model="meetingForm"
                :rules="rules"
                label-width="100px"
            >
                <el-row :gutter="20">
                    <el-col :span="12">
                        <el-form-item label="会议主题" prop="title">
                            <el-input v-model="meetingForm.title" placeholder="请输入会议主题"/>
                        </el-form-item>
                    </el-col>
                </el-row>
                <el-row :gutter="20">
                    <el-col :span="12">
                        <el-form-item label="会议室" prop="roomId">
                            <el-select v-model="meetingForm.roomId" placeholder="请选择会议室" style="width: 100%">
                                <el-option
                                    v-for="room in meetingRooms"
                                    :key="room.id"
                                    :label="`${room.name} (${room.location})`"
                                    :value="room.id"
                                />
                            </el-select>
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="主持人" prop="host">
                            <el-input v-model="meetingForm.host" placeholder="请输入主持人姓名"/>
                        </el-form-item>
                    </el-col>
                </el-row>
                <el-row :gutter="20">
                    <el-col :span="12">
                        <el-form-item label="会议日期" prop="meetingDate">
                            <el-date-picker
                                v-model="meetingForm.meetingDate"
                                type="date"
                                placeholder="请选择会议日期"
                                value-format="YYYY-MM-DD"
                                format="YYYY-MM-DD"
                                :disabled-date="disabledDate"
                                style="width: 100%"
                            />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <!-- ç©ºåˆ—,保持布局 -->
                    </el-col>
                </el-row>
                <el-row :gutter="20">
                    <el-col :span="12">
                        <el-form-item label="开始时间" prop="startTime">
                            <el-time-picker
                                v-model="meetingForm.startTime"
                                placeholder="请选择开始时间"
                                format="HH:mm"
                                value-format="HH:mm"
                                :disabled-hours="disabledStartHours"
                                :disabled-minutes="disabledStartMinutes"
                                style="width: 100%"
                            />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="结束时间" prop="endTime">
                            <el-time-picker
                                v-model="meetingForm.endTime"
                                placeholder="请选择结束时间"
                                format="HH:mm"
                                value-format="HH:mm"
                                :disabled-hours="disabledEndHours"
                                :disabled-minutes="disabledEndMinutes"
                                style="width: 100%"
                            />
                        </el-form-item>
                    </el-col>
                </el-row>
                <el-form-item label="参会人员" prop="participants">
                    <el-select
                        v-model="meetingForm.participants"
                        multiple
                        filterable
                        placeholder="请选择参会人员"
                        style="width: 100%"
                    >
                        <el-option
                            v-for="person in employees"
                            :key="person.id"
                            :label="`${person.staffName}${person.postName ? ` (${person.postName})` : ''}`"
                            :value="person.id"
                        />
                    </el-select>
                </el-form-item>
                <el-form-item label="会议说明" prop="description">
                    <el-input
                        v-model="meetingForm.description"
                        type="textarea"
                        :rows="4"
                        placeholder="请输入会议说明"
                    />
                </el-form-item>
            </el-form>
            <div class="form-footer">
                <el-button @click="resetForm">重置</el-button>
                <el-button type="primary" @click="submitForm">提交</el-button>
            </div>
        </el-card>
    </div>
</template>
<script setup>
@@ -163,37 +157,37 @@
// ç”³è¯·ç±»åž‹é€‰é¡¹
const applicationTypes = ref([
  {
    value: 'approval',
    name: '审批流程会议',
    desc: '需要经过多级审批的会议申请',
    icon: Document
  },
  {
    value: 'department',
    name: '部门级会议',
    desc: '部门内部会议申请流程',
    icon: Promotion
  },
  {
    value: 'notification',
    name: '会议通知',
    desc: '无需审批直接发布的会议通知',
    icon: Bell
  }
    {
        value: 'approval',
        name: '审批流程会议',
        desc: '需要经过多级审批的会议申请',
        icon: Document
    },
    {
        value: 'department',
        name: '部门级会议',
        desc: '部门内部会议申请流程',
        icon: Promotion
    },
    {
        value: 'notification',
        name: '会议通知',
        desc: '无需审批直接发布的会议通知',
        icon: Bell
    }
])
// è¡¨å•数据
const meetingForm = reactive({
  title: '',
  type: '',
  roomId: '',
  host: '',
  meetingDate: '',
  startTime: '',
  endTime: '',
  participants: [],
  description: ''
    title: '',
    type: '',
    roomId: '',
    host: '',
    meetingDate: '',
    startTime: '',
    endTime: '',
    participants: [],
    description: ''
})
// è¡¨å•引用
@@ -205,307 +199,270 @@
// å‘˜å·¥åˆ—表
const employees = ref([])
// æ—¶é—´é€‰é¡¹ï¼ˆä»¥åŠå°æ—¶ä¸ºé—´éš”)
const timeOptions = ref([])
const getTimeInMinutes = (time) => {
  if (!time) return -1
  const [hour, minute] = time.split(':').map(Number)
  return hour * 60 + minute
    if (!time) return -1
    const [hour, minute] = time.split(':').map(Number)
    return hour * 60 + minute
}
const isToday = (dateText) => {
  if (!dateText) return false
  const [year, month, day] = dateText.split('-').map(Number)
  const now = new Date()
  return year === now.getFullYear() && month === now.getMonth() + 1 && day === now.getDate()
    if (!dateText) return false
    const [year, month, day] = dateText.split('-').map(Number)
    const now = new Date()
    return year === now.getFullYear() && month === now.getMonth() + 1 && day === now.getDate()
}
const validateStartTime = (_rule, value, callback) => {
  if (!value) {
    callback()
    return
  }
  if (isToday(meetingForm.meetingDate)) {
    const now = new Date()
    const currentMinutes = now.getHours() * 60 + now.getMinutes()
    if (getTimeInMinutes(value) > currentMinutes) {
      callback(new Error('当天开始时间不能晚于当前时间'))
      return
    }
  }
  callback()
    if (!value) {
        callback()
        return
    }
    callback()
}
const validateEndTime = (_rule, value, callback) => {
  if (!value || !meetingForm.startTime) {
    callback()
    return
  }
  if (getTimeInMinutes(value) <= getTimeInMinutes(meetingForm.startTime)) {
    callback(new Error('结束时间必须大于开始时间'))
    return
  }
  callback()
    if (!value || !meetingForm.startTime) {
        callback()
        return
    }
    if (getTimeInMinutes(value) <= getTimeInMinutes(meetingForm.startTime)) {
        callback(new Error('结束时间必须大于开始时间'))
        return
    }
    callback()
}
// è¡¨å•校验规则
const rules = {
  title: [{required: true, message: '请输入会议主题', trigger: 'blur'}],
  roomId: [{required: true, message: '请选择会议室', trigger: 'change'}],
  host: [{required: true, message: '请输入主持人', trigger: 'blur'}],
  meetingDate: [{required: true, message: '请选择会议日期', trigger: 'change'}],
  startTime: [
    {required: true, message: '请选择开始时间', trigger: 'change'},
    {validator: validateStartTime, trigger: 'change'}
  ],
  endTime: [
    {required: true, message: '请选择结束时间', trigger: 'change'},
    {validator: validateEndTime, trigger: 'change'}
  ],
  participants: [{required: true, message: '请选择参会人员', trigger: 'change'}]
    title: [{required: true, message: '请输入会议主题', trigger: 'blur'}],
    roomId: [{required: true, message: '请选择会议室', trigger: 'change'}],
    host: [{required: true, message: '请输入主持人', trigger: 'blur'}],
    meetingDate: [{required: true, message: '请选择会议日期', trigger: 'change'}],
    startTime: [
        {required: true, message: '请选择开始时间', trigger: 'change'},
        {validator: validateStartTime, trigger: 'change'}
    ],
    endTime: [
        {required: true, message: '请选择结束时间', trigger: 'change'},
        {validator: validateEndTime, trigger: 'change'}
    ],
    participants: [{required: true, message: '请选择参会人员', trigger: 'change'}]
}
const startTimeOptions = computed(() => {
  if (!isToday(meetingForm.meetingDate)) {
    return timeOptions.value
  }
  const now = new Date()
  const currentMinutes = now.getHours() * 60 + now.getMinutes()
  return timeOptions.value.filter(item => getTimeInMinutes(item.value) <= currentMinutes)
})
const endTimeOptions = computed(() => {
  if (!meetingForm.startTime) {
    return timeOptions.value
  }
  const startMinutes = getTimeInMinutes(meetingForm.startTime)
  return timeOptions.value.filter(item => getTimeInMinutes(item.value) > startMinutes)
})
// åˆå§‹åŒ–时间选项
const initTimeOptions = () => {
  const options = []
  const now = new Date()
  const currentHour = now.getHours()
  const currentMinute = now.getMinutes()
  // meetingDate æ˜¯ "yyyy-MM-dd"
  const meetingDate = new Date(meetingForm.meetingDate)
  const isSameDay =
    now.getFullYear() === meetingDate.getFullYear() &&
    now.getMonth() === meetingDate.getMonth() &&
    now.getDate() === meetingDate.getDate()
  console.log('是否同一天:', isSameDay)
  for (let hour = 8; hour <= 18; hour++) {
    // å¼€å§‹æ—¶é—´å¿…须晚于当前时间
    if (hour < currentHour && isSameDay) {
      continue
    }
    if (hour === currentHour && currentMinute > 30 && isSameDay) {
      continue
    }
    // æ¯ä¸ªå°æ—¶æ·»åŠ ä¸¤ä¸ªé€‰é¡¹ï¼šæ•´ç‚¹å’ŒåŠç‚¹
    options.push({
      value: `${hour.toString().padStart(2, '0')}:00`,
      label: `${hour.toString().padStart(2, '0')}:00`
    })
    if (hour < 18) { // 18:00之后没有半点选项
      options.push({
        value: `${hour.toString().padStart(2, '0')}:30`,
        label: `${hour.toString().padStart(2, '0')}:30`
      })
    }
  }
  timeOptions.value = options
// æ—¶é—´é€‰æ‹©å™¨ç¦ç”¨é€»è¾‘
const disabledStartHours = () => {
    const hours = []
    for (let h = 0; h < 24; h++) {
        if (h < 8 || h > 18) hours.push(h)
    }
    if (isToday(meetingForm.meetingDate)) {
        const now = new Date()
        for (let h = 8; h < now.getHours(); h++) {
            if (!hours.includes(h)) hours.push(h)
        }
    }
    return hours
}
watch(() => meetingForm.meetingDate, () => {
  if (meetingForm.startTime && !startTimeOptions.value.some(item => item.value === meetingForm.startTime)) {
    meetingForm.startTime = ''
  }
  if (meetingForm.endTime && !endTimeOptions.value.some(item => item.value === meetingForm.endTime)) {
    meetingForm.endTime = ''
  }
  if (meetingForm.startTime) {
    meetingFormRef.value?.validateField('startTime')
  }
  if (meetingForm.endTime) {
    meetingFormRef.value?.validateField('endTime')
  }
  initTimeOptions()
})
const disabledStartMinutes = (hour) => {
    const minutes = []
    for (let m = 0; m < 60; m++) {
        if (m !== 0 && m !== 30) minutes.push(m)
    }
    if (isToday(meetingForm.meetingDate)) {
        const now = new Date()
        if (hour === now.getHours()) {
            if (now.getMinutes() >= 30) {
                minutes.push(0)
            }
        }
    }
    return minutes
}
const disabledEndHours = () => {
    const hours = []
    for (let h = 0; h < 24; h++) {
        if (h < 8 || h > 18) hours.push(h)
    }
    if (meetingForm.startTime) {
        const startHour = parseInt(meetingForm.startTime.split(':')[0])
        for (let h = 8; h < startHour; h++) {
            if (!hours.includes(h)) hours.push(h)
        }
    }
    return hours
}
const disabledEndMinutes = (hour) => {
    const minutes = []
    for (let m = 0; m < 60; m++) {
        if (m !== 0 && m !== 30) minutes.push(m)
    }
    if (meetingForm.startTime) {
        const startHour = parseInt(meetingForm.startTime.split(':')[0])
        const startMinute = parseInt(meetingForm.startTime.split(':')[1])
        if (hour === startHour) {
            if (startMinute >= 0) minutes.push(0)
            if (startMinute >= 30) minutes.push(30)
            // only keep minutes > startMinute
            for (let m = 0; m <= startMinute; m++) {
                if (!minutes.includes(m)) minutes.push(m)
            }
        }
    }
    return minutes
}
watch(() => meetingForm.startTime, () => {
  if (meetingForm.endTime && getTimeInMinutes(meetingForm.endTime) <= getTimeInMinutes(meetingForm.startTime)) {
    meetingForm.endTime = ''
  }
  if (meetingForm.endTime) {
    meetingFormRef.value?.validateField('endTime')
  }
    if (meetingForm.endTime && getTimeInMinutes(meetingForm.endTime) <= getTimeInMinutes(meetingForm.startTime)) {
        meetingForm.endTime = ''
    }
    if (meetingForm.endTime) {
        meetingFormRef.value?.validateField('endTime')
    }
})
// ç¦ç”¨æ—¥æœŸï¼ˆç¦ç”¨ä»Šå¤©ä¹‹å‰çš„æ—¥æœŸï¼‰
const disabledDate = (time) => {
  // ç¦ç”¨ä»Šå¤©ä¹‹å‰çš„æ—¥æœŸ
  return time.getTime() < Date.now() - 86400000
    // ç¦ç”¨ä»Šå¤©ä¹‹å‰çš„æ—¥æœŸ
    return time.getTime() < Date.now() - 86400000
}
// åˆ‡æ¢ç”³è¯·ç±»åž‹
const changeType = (type) => {
  currentType.value = type
    currentType.value = type
}
// èŽ·å–å½“å‰ç±»åž‹åç§°
const getCurrentTypeName = () => {
  const type = applicationTypes.value.find(t => t.value === currentType.value)
  return type ? type.name : ''
    const type = applicationTypes.value.find(t => t.value === currentType.value)
    return type ? type.name : ''
}
// é‡ç½®è¡¨å•
const resetForm = () => {
  meetingFormRef.value?.resetFields()
    meetingFormRef.value?.resetFields()
}
// æäº¤è¡¨å•
const submitForm = () => {
  meetingFormRef.value?.validate((valid) => {
    if (valid) {
      let formData = {...meetingForm}
      formData.applicationType = currentType.value
      formData.startTime = `${meetingForm.meetingDate} ${meetingForm.startTime}:00`
      formData.endTime = `${meetingForm.meetingDate} ${meetingForm.endTime}:00`
      formData.participants = JSON.stringify(formData.participants)
      console.log(formData)
      saveMeetingApplication(formData).then(() => {
        // æ¨¡æ‹Ÿæäº¤æ“ä½œ
        ElMessage.success(`${getCurrentTypeName()}提交成功`)
        // æ ¹æ®ä¸åŒç±»åž‹æ‰§è¡Œä¸åŒæ“ä½œ
        switch (currentType.value) {
          case 'approval':
            ElMessage.info('会议已提交审批流程')
            break
          case 'department':
            ElMessage.info('部门级会议申请已提交')
            break
          case 'notification':
            ElMessage.info('会议通知已发布')
            break
        }
        resetForm()
      })
    }
  })
    meetingFormRef.value?.validate((valid) => {
        if (valid) {
            let formData = {...meetingForm}
            formData.applicationType = currentType.value
            formData.startTime = `${meetingForm.meetingDate} ${meetingForm.startTime}:00`
            formData.endTime = `${meetingForm.meetingDate} ${meetingForm.endTime}:00`
            formData.participants = JSON.stringify(formData.participants)
            console.log(formData)
            saveMeetingApplication(formData).then(() => {
                // æ¨¡æ‹Ÿæäº¤æ“ä½œ
                ElMessage.success(`${getCurrentTypeName()}提交成功`)
                resetForm()
            })
        }
    })
}
// é¡µé¢åŠ è½½æ—¶åˆå§‹åŒ–
onMounted(() => {
  initTimeOptions()
  getRoomEnum().then(res => {
    meetingRooms.value = res.data
  })
  staffOnJobListPage({
    current: -1,
    size: -1,
    staffState: 1
  }).then(res => {
    employees.value = res.data.records.sort((a, b) => (a.postName || '').localeCompare(b.postName || ''))
  })
    getRoomEnum().then(res => {
        meetingRooms.value = res.data
    })
    staffOnJobListPage({
        current: -1,
        size: -1,
        staffState: 1
    }).then(res => {
        employees.value = res.data.records.sort((a, b) => (a.postName || '').localeCompare(b.postName || ''))
    })
})
</script>
<style scoped>
.app-container {
  padding: 20px;
    padding: 20px;
}
.page-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 20px;
}
.page-header h2 {
  margin: 0;
  color: #303133;
    margin: 0;
    color: #303133;
}
.type-card {
  margin-bottom: 20px;
    margin-bottom: 20px;
}
.type-selector {
  display: flex;
  gap: 20px;
    display: flex;
    gap: 20px;
}
.type-item {
  flex: 1;
  display: flex;
  align-items: center;
  padding: 20px;
  border: 1px solid #ebeef5;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.3s;
    flex: 1;
    display: flex;
    align-items: center;
    padding: 20px;
    border: 1px solid #ebeef5;
    border-radius: 8px;
    cursor: pointer;
    transition: all 0.3s;
}
.type-item:hover {
  border-color: #409eff;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
    border-color: #409eff;
    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.type-item.active {
  border-color: #409eff;
  background-color: #ecf5ff;
    border-color: #409eff;
    background-color: #ecf5ff;
}
.type-icon {
  margin-right: 15px;
  color: #409eff;
    margin-right: 15px;
    color: #409eff;
}
.type-name {
  font-size: 16px;
  font-weight: 500;
  color: #303133;
  margin-bottom: 5px;
    font-size: 16px;
    font-weight: 500;
    color: #303133;
    margin-bottom: 5px;
}
.type-desc {
  font-size: 14px;
  color: #909399;
    font-size: 14px;
    color: #909399;
}
.form-header {
  margin-bottom: 20px;
  padding-bottom: 15px;
  border-bottom: 1px solid #ebeef5;
    margin-bottom: 20px;
    padding-bottom: 15px;
    border-bottom: 1px solid #ebeef5;
}
.form-header h3 {
  margin: 0;
  color: #303133;
    margin: 0;
    color: #303133;
}
.form-footer {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  margin-top: 30px;
  padding-top: 20px;
  border-top: 1px solid #ebeef5;
    display: flex;
    justify-content: flex-end;
    gap: 10px;
    margin-top: 30px;
    padding-top: 20px;
    border-top: 1px solid #ebeef5;
}
</style>
src/views/collaborativeApproval/notificationManagement/meetExamine/index.vue
@@ -36,7 +36,7 @@
        <el-table-column prop="location" label="会议地点" align="center" width="150"/>
        <el-table-column prop="participants" label="参会人数" align="center" width="100">
          <template #default="scope">
            {{ scope.row.participants.length }}人
            {{ scope.row.staffCount }}人
          </template>
        </el-table-column>
        <el-table-column prop="status" label="审批状态" align="center" width="120">
@@ -233,9 +233,9 @@
  let resp = await getExamineList({...searchForm, ...queryParams})
  approvalList.value = resp.data.records.map(it => {
    let room = roomEnum.value.find(room => it.roomId === room.id)
    it.location = `${room.name}(${room.location})`
    it.location = room ? `${room.name}(${room.location})` : ''
    let staffs = JSON.parse(it.participants)
    it.staffCount = staffs.size
    it.staffCount = staffs.length
    it.meetingTime = `${it.meetingDate} ${dayjs(it.startTime).format('HH:mm:ss')} ~ ${dayjs(it.endTime).format('HH:mm:ss')}`
    it.participants = staffList.value.filter(staff => staffs.some(id=>id === staff.id)).map(staff => {
      return {
src/views/collaborativeApproval/notificationManagement/meetIndex/index.vue
@@ -145,10 +145,11 @@
    if (endIdx === -1) {
      endIdx = timeSlots.value.length
    }
    console.log('endIdx111', endIdx)
    if (startIdx !== -1 && endIdx !== -1) {
    // å¾€åŽå»¶ä¼¸ä¸€æ ¼ï¼Œè®©ä¼šè®®æ ¼å­è¦†ç›–到结束时间列上
    const displayEndIdx = Math.min(endIdx + 1, timeSlots.value.length)
    if (startIdx !== -1) {
      // æ ‡è®°è¢«å ç”¨çš„æ—¶é—´æ®µ
      for (let i = startIdx; i < endIdx; i++) {
      for (let i = startIdx; i < displayEndIdx; i++) {
        occupiedSlots.add(timeSlots.value[i].value)
      }
@@ -156,7 +157,7 @@
      cells.push({
        type: 'meeting',
        meeting: meeting,
        span: endIdx - startIdx,
        span: displayEndIdx - startIdx,
        startTime: meeting.startTime,
        endTime: meeting.endTime
      })
src/views/collaborativeApproval/notificationManagement/meetPublish/index.vue
@@ -34,7 +34,7 @@
        <el-table-column prop="location" label="会议地点" align="center" width="150"/>
        <el-table-column prop="participants" label="参会人数" align="center" width="100">
          <template #default="scope">
            {{ scope.row.participants.length }}人
            {{ scope.row.staffCount }}人
          </template>
        </el-table-column>
        <el-table-column prop="status" label="发布状态" align="center" width="120">
@@ -231,9 +231,9 @@
  let resp = await getMeetingPublish({...searchForm, ...queryParams})
  approvalList.value = resp.data.records.map(it => {
    let room = roomEnum.value.find(room => it.roomId === room.id)
    it.location = `${room.name}(${room.location})`
    it.location = room ? `${room.name}(${room.location})` : ''
    let staffs = JSON.parse(it.participants)
    it.staffCount = staffs.size
    it.staffCount = staffs.length
    it.status = it.publishStatus
    it.meetingTime = `${it.meetingDate} ${dayjs(it.startTime).format('HH:mm:ss')} ~ ${dayjs(it.endTime).format('HH:mm:ss')}`
    it.participants = staffList.value.filter(staff => staffs.some(id=>id === staff.id)).map(staff => {
src/views/collaborativeApproval/notificationManagement/summary/index.vue
@@ -28,7 +28,7 @@
        <el-table-column prop="location" label="会议地点" align="center" width="150" />
        <el-table-column prop="participants" label="参会人数" align="center" width="100">
          <template #default="scope">
            {{ scope.row.participants.length }}人
            {{ scope.row.staffCount }}人
          </template>
        </el-table-column>
        <el-table-column label="操作" align="center" width="200" fixed="right">
@@ -207,9 +207,9 @@
  let resp = await getMeetingPublish({ ...searchForm, ...queryParams })
  meetingList.value = resp.data.records.map(it => {
    let room = roomEnum.value.find(room => it.roomId === room.id)
    it.location = `${room.name}(${room.location})`
    it.location = room ? `${room.name}(${room.location})` : ''
    let staffs = JSON.parse(it.participants)
    it.staffCount = staffs.size
    it.staffCount = staffs.length
    it.meetingTime = `${it.meetingDate} ${dayjs(it.startTime).format('HH:mm:ss')} ~ ${dayjs(it.endTime).format('HH:mm:ss')}`
    it.participants = staffList.value.filter(staff => staffs.some(id => id === staff.id)).map(staff => {
      return {
@@ -260,7 +260,7 @@
<p><strong>主持人:</strong>${row.host}</p>
<p><strong>参会人员:</strong></p>
<ol>
  ${row.participants.map(p => `<li>${p.name}</li>`).join('')}
  ${(row.participants || []).map(p => `<li>${p?.name || ''}</li>`).join('')}
</ol>
<p><strong>会议内容:</strong></p>
<ol>
src/views/collaborativeApproval/sealManagement/index.vue
@@ -7,7 +7,6 @@
        </div>
      </template>
   <!-- ç”¨å°ç”³è¯·ç®¡ç† -->
        <div class="tab-content">
            <el-row :gutter="20" class="mb-20 ">
@@ -45,7 +44,7 @@
              :isShowPagination="true"
              @pagination="paginationChange"
            />
        </div>
        </div>
    </el-card>
    <!-- ç”¨å°ç”³è¯·å¯¹è¯æ¡† -->
@@ -177,6 +176,7 @@
const sealFormRef = ref()
const userList = ref([])
const sealForm = reactive({
  id: null,
  applicationNum: '',
  title: '',
  sealType: '',
@@ -241,9 +241,9 @@
// ç”¨å°ç”³è¯·è¡¨æ ¼åˆ—配置(需在 getStatusText/getSealTypeText ç­‰ä¹‹åŽå®šä¹‰ï¼‰
const sealTableColumn = ref([
  { label: '申请编号', prop: 'applicationNum',},
  { label: '申请编号', prop: 'applicationNum' },
  { label: '申请标题', prop: 'title', showOverflowTooltip: true },
  { label: '申请人', prop: 'createUserName', },
  { label: '申请人', prop: 'createUserName' },
  { label: '所属部门', prop: 'department', width: 150 },
  {
    label: '用印类型',
@@ -261,34 +261,38 @@
    formatData: (v) => getStatusText(v),
    formatType: (v) => getStatusType(v)
  },
  { label: '审批人', prop: 'approveUserName', width: 100 },
  {
    dataType: 'action',
    label: '操作',
    width: 200,
    width: 250,
    fixed: 'right',
    align: 'center',
    operation: [
      {
        name: '审批',
        clickFun: (row) => approveSeal(row),
        showHide: (row) => row.status === 'pending'
        showHide: (row) => row.status === 'pending' && Number(userStore.id) === row.approveUserId
      },
      {
        name: '拒绝',
        clickFun: (row) => rejectSeal(row),
        showHide: (row) => row.status === 'pending'
        showHide: (row) => row.status === 'pending' && Number(userStore.id) === row.approveUserId
      },
            { name: '详情', clickFun: (row) => viewSealDetail(row) }
      {
        name: '重新申请',
        clickFun: (row) => reapplySeal(row),
        showHide: (row) => row.status === 'rejected' && Number(userStore.id) === row.createUser
      },
      { name: '详情', clickFun: (row) => viewSealDetail(row) }
    ]
  }
])
// æœç´¢å°ç« ç”³è¯·
const searchSealApplications = () => {
  page.current=1
  page.current = 1
  getSealApplicationList()
  // ElMessage.success('搜索完成')
}
// é‡ç½®å°ç« ç”³è¯·æœç´¢
const resetSealSearch = () => {
@@ -297,37 +301,58 @@
  sealSearchForm.applicationNum = ''
  searchSealApplications()
}
// é‡æ–°ç”³è¯·ç”¨å°
const reapplySeal = (row) => {
  // é¢„填表单数据
  Object.assign(sealForm, {
    id: row.id,
    applicationNum: row.applicationNum,
    title: row.title,
    sealType: row.sealType,
    reason: row.reason,
    approveUserId: row.approveUserId,
    urgency: row.urgency || 'normal',
    status: 'pending',
    storageBlobDTOs: row.storageBlobVOList || []
  })
  showSealApplyDialog.value = true
}
// æäº¤ç”¨å°ç”³è¯·
const submitSealApplication = async () => {
  try {
    await sealFormRef.value.validate()
    addSealApplication(sealForm).then(res => {
      if(res.code == 200){
        ElMessage.success('申请提交成功')
    const request = sealForm.id ? updateSealApplication : addSealApplication
    request(sealForm).then(res => {
      if (res.code == 200) {
        ElMessage.success(sealForm.id ? '重新申请成功' : '申请提交成功')
        closeSealApplyDialog()
        getSealApplicationList()
        Object.assign(sealForm, {
        applicationNum: '',
        title: '',
        sealType: '',
        reason: '',
        approveUserId: '',
        urgency: 'normal',
        status: 'pending',
        storageBlobDTOs: []
      })
          id: null,
          applicationNum: '',
          title: '',
          sealType: '',
          reason: '',
          approveUserId: '',
          urgency: 'normal',
          status: 'pending',
          storageBlobDTOs: []
        })
      }
    }).catch(err => {
      console.log(err.msg)
    })
  } catch (error) {
  }
}
// å…³é—­ç”¨å°ç”³è¯·å¯¹è¯æ¡†
const closeSealApplyDialog = () => {
  // æ¸…空表单数据
  Object.assign(sealForm, {
    id: null,
    applicationNum: '',
    title: '',
    sealType: '',
@@ -383,7 +408,7 @@
  }).then(() => {
    row.status = 'approved'
    updateSealApplication(row).then(res => {
      if(res.code == 200){
      if (res.code == 200) {
        ElMessage.success('审批通过')
        getSealApplicationList()
      }
@@ -399,8 +424,9 @@
    inputErrorMessage: '拒绝原因不能为空'
  }).then(({ value }) => {
    row.status = 'rejected'
    row.reason = value
    updateSealApplication(row).then(res => {
      if(res.code == 200){
      if (res.code == 200) {
        ElMessage.success('已拒绝申请')
        getSealApplicationList()
      }
@@ -418,13 +444,13 @@
const getSealApplicationList = async () => {
  tableLoading.value = true
  listSealApplication(page, sealSearchForm)
  .then(res => {
    sealApplications.value = res.data.records
    page.total = res.data.total
    tableLoading.value = false
  }).catch(err => {
    tableLoading.value = false
  })
    .then(res => {
      sealApplications.value = res.data.records
      page.total = res.data.total
      tableLoading.value = false
    }).catch(err => {
      tableLoading.value = false
    })
}
// åˆ†é¡µå˜åŒ–处理
const paginationChange = (obj) => {
@@ -491,4 +517,4 @@
.attachment-table {
  border-radius: 4px;
}
</style>
</style>
src/views/financialManagement/assets/fixedAssets.vue
@@ -178,7 +178,7 @@
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="创建时间" prop="createTime">
              <el-date-picker v-model="form.createTime" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 100%;" />
              <el-date-picker v-model="createTimeDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 100%;" />
            </el-form-item>
          </el-col>
        </el-row>
@@ -196,6 +196,7 @@
<script setup>
import { ref, reactive, onMounted, computed } from "vue";
import dayjs from "dayjs";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import {
@@ -270,6 +271,12 @@
const form = reactive({
  ...createDefaultForm(),
});
const createTimeDate = computed({
  get: () => (form.createTime ? String(form.createTime).split(" ")[0] : ""),
  set: (value) => {
    form.createTime = value ? `${value} ${dayjs().format("HH:mm:ss")}` : "";
  },
});
const rules = {
@@ -371,7 +378,7 @@
  dialogTitle.value = "新增固定资产";
  Object.assign(form, createDefaultForm(), {
    purchaseDate: new Date().toISOString().split('T')[0],
    createTime: new Date().toISOString().split('T')[0],
    createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
  });
  dialogVisible.value = true;
};
src/views/financialManagement/assets/intangibleAssets.vue
@@ -72,7 +72,7 @@
        </template>
        <template #operation="{ row }">
          <el-button type="primary" link @click="view(row)">查看</el-button>
          <el-button type="primary" link @click="edit(row)">编辑</el-button>
          <el-button v-if="row.status !== 'amortized'" type="primary" link @click="edit(row)">编辑</el-button>
          <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
        </template>
      </PIMTable>
@@ -160,7 +160,7 @@
              <el-select v-model="form.status" placeholder="请选择状态" style="width: 100%;">
                <el-option label="在用" value="in_use" />
                <el-option label="闲置" value="idle" />
                <el-option label="已摊销完毕" value="amortized" />
                <el-option v-if="isView" label="已摊销完毕" value="amortized" />
              </el-select>
            </el-form-item>
          </el-col>
@@ -168,7 +168,7 @@
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="创建时间" prop="createTime">
              <el-date-picker v-model="form.createTime" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 100%;" />
              <el-date-picker v-model="createTimeDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 100%;" />
            </el-form-item>
          </el-col>
        </el-row>
@@ -189,6 +189,7 @@
<script setup>
import { ref, reactive, onMounted, computed } from "vue";
import dayjs from "dayjs";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import {
@@ -243,6 +244,7 @@
);
const createDefaultForm = () => ({
  id: null,
  assetCode: "",
  assetName: "",
  category: "",
@@ -262,6 +264,12 @@
const form = reactive({
  ...createDefaultForm(),
});
const createTimeDate = computed({
  get: () => (form.createTime ? String(form.createTime).split(" ")[0] : ""),
  set: (value) => {
    form.createTime = value ? `${value} ${dayjs().format("HH:mm:ss")}` : "";
  },
});
const rules = {
@@ -369,7 +377,7 @@
  dialogTitle.value = "新增无形资产";
  Object.assign(form, createDefaultForm(), {
    acquisitionDate: new Date().toISOString().split('T')[0],
    createTime: new Date().toISOString().split('T')[0],
    createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
  });
  dialogVisible.value = true;
};
src/views/financialManagement/payable/input-invoice.vue
@@ -51,6 +51,8 @@
        :column="columns"
        :tableData="dataList"
        :tableLoading="tableLoading"
        isShowSummary
        :summaryMethod="getSummaries"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
@@ -375,6 +377,26 @@
  amount: [{ required: true, message: "请输入金额", trigger: "blur" }],
};
const summaryProps = ["amount", "taxAmount", "totalAmount"];
const getSummaries = ({ columns, data }) => {
  const sums = [];
  columns.forEach((col, index) => {
    if (index === 0) {
      sums[index] = "合计";
    } else if (summaryProps.includes(col.property)) {
      const total = data.reduce((prev, cur) => {
        const v = Number(cur[col.property]);
        return prev + (isNaN(v) ? 0 : v);
      }, 0);
      sums[index] = Number(total.toFixed(2)).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
    } else {
      sums[index] = "";
    }
  });
  return sums;
};
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
src/views/financialManagement/payable/payment.vue
@@ -65,6 +65,8 @@
                :column="columns"
                :tableData="dataList"
                :tableLoading="tableLoading"
                isShowSummary
                :summaryMethod="getSummaries"
                :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
@@ -149,7 +151,25 @@
    dataList.value.reduce((sum, item) => sum + Number(item.amount ?? 0), 0)
  );
  const formatMoney = value => {
  const getSummaries = ({ columns, data }) => {
  const sums = [];
  columns.forEach((col, index) => {
    if (index === 0) {
      sums[index] = "合计";
    } else if (col.property === "amount") {
      const total = data.reduce((prev, cur) => {
        const v = Number(cur.amount);
        return prev + (isNaN(v) ? 0 : v);
      }, 0);
      sums[index] = total.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
    } else {
      sums[index] = "";
    }
  });
  return sums;
};
const formatMoney = value => {
    if (value === undefined || value === null) return "0.00";
    return Number(value)
      .toFixed(2)
src/views/financialManagement/payable/paymentApply.vue
@@ -52,6 +52,8 @@
        :column="columns"
        :tableData="dataList"
        :tableLoading="tableLoading"
        isShowSummary
        :summaryMethod="getSummaries"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
@@ -160,7 +162,7 @@
          <el-col :span="12">
            <el-form-item label="创建时间" prop="createTime">
              <el-date-picker
                v-model="form.createTime"
                v-model="formCreateTimeDate"
                type="date"
                placeholder="选择日期"
                value-format="YYYY-MM-DD"
@@ -260,7 +262,7 @@
          <el-col :span="12">
            <el-form-item label="创建时间" prop="createTime">
              <el-date-picker
                v-model="paymentForm.createTime"
                v-model="paymentFormCreateTimeDate"
                type="date"
                placeholder="选择日期"
                value-format="YYYY-MM-DD"
@@ -353,6 +355,7 @@
<script setup>
import { ref, reactive, computed, onMounted, nextTick, getCurrentInstance } from "vue";
import dayjs from "dayjs";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import { getOptions } from "@/api/procurementManagement/procurementLedger.js";
@@ -455,6 +458,18 @@
  status: 0,
  createTime: "",
});
const formCreateTimeDate = computed({
  get: () => (form.createTime ? String(form.createTime).split(" ")[0] : ""),
  set: (value) => {
    form.createTime = value ? `${value} ${dayjs().format("HH:mm:ss")}` : "";
  },
});
const paymentFormCreateTimeDate = computed({
  get: () => (paymentForm.createTime ? String(paymentForm.createTime).split(" ")[0] : ""),
  set: (value) => {
    paymentForm.createTime = value ? `${value} ${dayjs().format("HH:mm:ss")}` : "";
  },
});
const rules = {
  supplierId: [{ required: true, message: "请选择供应商", trigger: "change" }],
@@ -462,6 +477,24 @@
  paymentAmount: [{ required: true, message: "请输入付款金额", trigger: "blur" }],
  paymentMethod: [{ required: true, message: "请选择付款方式", trigger: "change" }],
  applyDate: [{ required: true, message: "请选择申请日期", trigger: "change" }],
};
const getSummaries = ({ columns, data }) => {
  const sums = [];
  columns.forEach((col, index) => {
    if (index === 0) {
      sums[index] = "合计";
    } else if (col.property === "amount") {
      const total = data.reduce((prev, cur) => {
        const v = Number(cur.amount);
        return prev + (isNaN(v) ? 0 : v);
      }, 0);
      sums[index] = total.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
    } else {
      sums[index] = "";
    }
  });
  return sums;
};
const formatMoney = (value) => {
@@ -620,6 +653,7 @@
    remark: form.remark || "",
    status: 0,
    paymentAmount: form.paymentAmount,
    createTime: form.createTime,
  };
  if (forUpdate) {
    payload.id = currentId.value;
@@ -730,7 +764,7 @@
    stockInRecordIds: [],
    inboundBatches: "",
    status: 0,
    createTime: new Date().toISOString().split("T")[0],
    createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
  });
  inboundBatchList.value = [];
  inboundBatchOptions.value = [];
@@ -809,7 +843,7 @@
    bankAccount: row.bankAccountNum ?? row.bankAccount ?? "",
    bankName: row.bankAccountName ?? row.bankName ?? "",
    remark: "",
    createTime: new Date().toISOString().split("T")[0],
    createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
  });
  paymentDialogVisible.value = true;
  nextTick(() => {
src/views/financialManagement/payable/purchaseIn.vue
@@ -49,6 +49,8 @@
                :column="columns"
                :tableData="dataList"
                :tableLoading="tableLoading"
                isShowSummary
                :summaryMethod="getSummaries"
                :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
@@ -189,7 +191,25 @@
    getTableData();
  };
  const handleOut = () => {
  const getSummaries = ({ columns, data }) => {
  const sums = [];
  columns.forEach((col, index) => {
    if (index === 0) {
      sums[index] = "合计";
    } else if (col.property === "inboundAmount") {
      const total = data.reduce((prev, cur) => {
        const v = Number(cur.inboundAmount);
        return prev + (isNaN(v) ? 0 : v);
      }, 0);
      sums[index] = total.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
    } else {
      sums[index] = "";
    }
  });
  return sums;
};
const handleOut = () => {
    proxy.download(
      "/accountPurchase/exportAccountPurchaseInbound",
      buildFilterParams(),
src/views/financialManagement/payable/purchaseReturn.vue
@@ -44,6 +44,8 @@
        :column="columns"
        :tableData="dataList"
        :tableLoading="tableLoading"
        isShowSummary
        :summaryMethod="getSummaries"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
@@ -175,6 +177,24 @@
  getTableData();
};
const getSummaries = ({ columns, data }) => {
  const sums = [];
  columns.forEach((col, index) => {
    if (index === 0) {
      sums[index] = "合计";
    } else if (col.property === "totalAmount") {
      const total = data.reduce((prev, cur) => {
        const v = Number(cur.totalAmount);
        return prev + (isNaN(v) ? 0 : v);
      }, 0);
      sums[index] = total.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
    } else {
      sums[index] = "";
    }
  });
  return sums;
};
const handleOut = () => {
  proxy.download(
    "/accountPurchase/exportAccountPurchaseReturn",
src/views/financialManagement/payable/reconciliation.vue
@@ -35,6 +35,8 @@
        :column="columns"
        :tableData="dataList"
        :tableLoading="tableLoading"
        isShowSummary
        :summaryMethod="getSummaries"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
@@ -405,6 +407,26 @@
  accountStatementDetails: selectedPurchases.value.map(buildDetailSubmitItem),
});
const summaryProps = ["openingBalance", "currentPlan", "currentActually", "closingBalance"];
const getSummaries = ({ columns, data }) => {
  const sums = [];
  columns.forEach((col, index) => {
    if (index === 0) {
      sums[index] = "合计";
    } else if (summaryProps.includes(col.property)) {
      const total = data.reduce((prev, cur) => {
        const v = Number(cur[col.property]);
        return prev + (isNaN(v) ? 0 : v);
      }, 0);
      sums[index] = Number(total.toFixed(2)).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
    } else {
      sums[index] = "";
    }
  });
  return sums;
};
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
src/views/financialManagement/receivable/invoiceApply.vue
@@ -48,6 +48,8 @@
        v-loading="tableLoading"
        :column="columns"
        :tableData="dataList"
        isShowSummary
        :summaryMethod="getSummaries"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
@@ -193,7 +195,7 @@
          <el-col :span="12">
            <el-form-item label="创建时间" prop="createTime">
              <el-date-picker
                v-model="form.createTime"
                v-model="formCreateTimeDate"
                type="date"
                placeholder="选择日期"
                value-format="YYYY-MM-DD"
@@ -267,6 +269,7 @@
<script setup>
import { ref, reactive, computed, onMounted, nextTick, getCurrentInstance, defineAsyncComponent } from "vue";
import dayjs from "dayjs";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import { listCustomer } from "@/api/basicData/customer.js";
@@ -540,6 +543,12 @@
  remark: "",
  createTime: "",
});
const formCreateTimeDate = computed({
  get: () => (form.createTime ? String(form.createTime).split(" ")[0] : ""),
  set: (value) => {
    form.createTime = value ? `${value} ${dayjs().format("HH:mm:ss")}` : "";
  },
});
const rules = {
  customerId: [{ required: true, message: "请选择客户", trigger: "change" }],
@@ -641,6 +650,27 @@
  proxy.download("/accountInvoiceApplication/exportAccountInvoiceApplication", params, filename);
};
const getSummaries = ({ columns, data }) => {
  const sums = [];
  columns.forEach((col, index) => {
    if (index === 0) {
      sums[index] = "合计";
    } else if (col.property === "amount") {
      const total = data.reduce((prev, cur) => {
        const v = Number(cur.amount);
        return prev + (isNaN(v) ? 0 : v);
      }, 0);
      sums[index] = total.toLocaleString("zh-CN", {
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
      });
    } else {
      sums[index] = "";
    }
  });
  return sums;
};
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
@@ -721,6 +751,7 @@
    status: normalizeStatus(row.status ?? row.auditStatus),
    outboundBatchNos,
    outboundBatches: formatOutboundBatches(row.outboundBatches),
    createTime: row.createTime ?? "",
  });
};
@@ -739,7 +770,7 @@
    applyDate: new Date().toISOString().split("T")[0],
    content: "",
    remark: "",
    createTime: new Date().toISOString().split("T")[0],
    createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
  });
  outboundBatchList.value = [];
  outboundBatchOptions.value = [];
src/views/financialManagement/receivable/outputInvoice.vue
@@ -46,6 +46,8 @@
        v-loading="tableLoading"
        :column="columns"
        :tableData="dataList"
        isShowSummary
        :summaryMethod="getSummaries"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
@@ -324,6 +326,27 @@
  amount: [{ required: true, message: "请输入金额", trigger: "blur" }],
};
const getSummaries = ({ columns, data }) => {
  const sums = [];
  columns.forEach((col, index) => {
    if (index === 0) {
      sums[index] = "合计";
    } else if (col.property === "amount") {
      const total = data.reduce((prev, cur) => {
        const v = Number(cur.amount);
        return prev + (isNaN(v) ? 0 : v);
      }, 0);
      sums[index] = total.toLocaleString("zh-CN", {
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
      });
    } else {
      sums[index] = "";
    }
  });
  return sums;
};
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
src/views/financialManagement/receivable/receipt.vue
@@ -196,7 +196,7 @@
          <el-col :span="12">
            <el-form-item label="创建时间"
                          prop="createTime">
              <el-date-picker v-model="form.createTime"
              <el-date-picker v-model="formCreateTimeDate"
                              type="date"
                              placeholder="选择日期"
                              value-format="YYYY-MM-DD"
@@ -299,6 +299,7 @@
    nextTick,
    getCurrentInstance,
  } from "vue";
  import dayjs from "dayjs";
  import { ElMessage, ElMessageBox } from "element-plus";
  import FormDialog from "@/components/Dialog/FormDialog.vue";
  import { listCustomer } from "@/api/basicData/customer.js";
@@ -391,6 +392,12 @@
    outboundBatches: "",
    remark: "",
    createTime: "",
  });
  const formCreateTimeDate = computed({
    get: () => (form.createTime ? String(form.createTime).split(" ")[0] : ""),
    set: (value) => {
      form.createTime = value ? `${value} ${dayjs().format("HH:mm:ss")}` : "";
    },
  });
  const rules = {
@@ -691,6 +698,7 @@
      stockOutRecordIds,
      outboundBatches: formatOutboundBatches(row.outboundBatches),
      remark: row.remark ?? "",
      createTime: row.createTime ?? "",
    });
  };
@@ -767,7 +775,7 @@
      stockOutRecordIds: [],
      outboundBatches: "",
      remark: "",
      createTime: new Date().toISOString().split("T")[0],
      createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
    });
    outboundBatchList.value = [];
    outboundBatchOptions.value = [];
src/views/financialManagement/receivable/reconciliation.vue
@@ -30,6 +30,8 @@
        :column="columns"
        :tableData="dataList"
        :tableLoading="tableLoading"
        isShowSummary
        :summaryMethod="getSummaries"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
@@ -388,6 +390,29 @@
  accountStatementDetails: selectedSales.value.map(buildDetailSubmitItem),
});
const summaryProps = ["openingBalance", "currentPlan", "currentActually", "closingBalance"];
const getSummaries = ({ columns, data }) => {
  const sums = [];
  columns.forEach((col, index) => {
    if (index === 0) {
      sums[index] = "合计";
    } else if (summaryProps.includes(col.property)) {
      const total = data.reduce((prev, cur) => {
        const v = Number(cur[col.property]);
        return prev + (isNaN(v) ? 0 : v);
      }, 0);
      sums[index] = Number(total.toFixed(2)).toLocaleString("zh-CN", {
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
      });
    } else {
      sums[index] = "";
    }
  });
  return sums;
};
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
src/views/financialManagement/receivable/salesOut.vue
@@ -43,6 +43,8 @@
                :column="columns"
                :tableData="dataList"
                :tableLoading="tableLoading"
                isShowSummary
                :summaryMethod="getSummaries"
                :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
@@ -86,7 +88,6 @@
      label: "金额",
      prop: "outboundAmount",
      minWidth: "120",
      align: "right",
      formatData: val =>
        val === null || val === undefined || val === ""
          ? ""
@@ -158,6 +159,27 @@
    getTableData();
  };
  const getSummaries = ({ columns, data }) => {
    const sums = [];
    columns.forEach((col, index) => {
      if (index === 0) {
        sums[index] = "合计";
      } else if (col.property === "outboundAmount") {
        const total = data.reduce((prev, cur) => {
          const v = Number(cur.outboundAmount);
          return prev + (isNaN(v) ? 0 : v);
        }, 0);
        sums[index] = total.toLocaleString("zh-CN", {
          minimumFractionDigits: 2,
          maximumFractionDigits: 2,
        });
      } else {
        sums[index] = "";
      }
    });
    return sums;
  };
  const handleOut = () => {
    proxy.download(
      "/accountSales/exportAccountSalesOutbound",
src/views/financialManagement/receivable/salesReturn.vue
@@ -37,6 +37,8 @@
        :column="columns"
        :tableData="dataList"
        :tableLoading="tableLoading"
        isShowSummary
        :summaryMethod="getSummaries"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
@@ -80,7 +82,6 @@
    label: "退款总额",
    prop: "refundAmount",
    minWidth: "120",
    align: "right",
    formatData: (val) =>
      val === null || val === undefined || val === ""
        ? ""
@@ -149,6 +150,27 @@
  getTableData();
};
const getSummaries = ({ columns, data }) => {
  const sums = [];
  columns.forEach((col, index) => {
    if (index === 0) {
      sums[index] = "合计";
    } else if (col.property === "refundAmount") {
      const total = data.reduce((prev, cur) => {
        const v = Number(cur.refundAmount);
        return prev + (isNaN(v) ? 0 : v);
      }, 0);
      sums[index] = total.toLocaleString("zh-CN", {
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
      });
    } else {
      sums[index] = "";
    }
  });
  return sums;
};
const handleOut = () => {
  proxy.download(
    "/accountSales/exportAccountSalesReturn",
src/views/financialManagement/voucher/index.vue
@@ -25,7 +25,7 @@
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="getTableData">搜索</el-button>
        <el-button type="primary" @click="onSearch">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
@@ -453,6 +453,11 @@
  return map[key] || "";
};
const onSearch = () => {
  pagination.currentPage = 1;
  getTableData();
};
// è”调约定:分页参数使用 current/size,日期范围拆分为 startDate/endDate
const getTableData = async () => {
  try {
@@ -518,9 +523,9 @@
  getTableData();
};
const changePage = ({ current, size }) => {
  pagination.currentPage = current;
  pagination.pageSize = size;
const changePage = ({ page, limit }) => {
  pagination.currentPage = page;
  pagination.pageSize = limit;
  getTableData();
};
src/views/index.vue
@@ -120,33 +120,35 @@
              <el-radio-button :value="'paused'">已暂停({{ orderProgressMeta.pausedCount }})</el-radio-button>
            </el-radio-group>
          </div>
          <el-table :data="filteredOrders" stripe>
            <el-table-column prop="orderNo" label="订单编号" min-width="150" />
            <el-table-column prop="productName" label="产品名称" min-width="120" />
            <el-table-column prop="planQty" label="计划数量" min-width="90" />
            <el-table-column prop="completedQty" label="已完成" min-width="90" />
            <el-table-column label="完成率" min-width="180">
              <template #default="{ row }">
                <div class="table-progress">
                  <el-progress
                      :stroke-width="8"
                      :percentage="row.completionRate"
                      :show-text="false"
                      status="success"
                  />
                  <span>{{ row.completionRate }}%</span>
                </div>
              </template>
            </el-table-column>
            <el-table-column prop="deliveryDate" label="交期" min-width="110" />
            <el-table-column label="状态" min-width="90">
              <template #default="{ row }">
                <el-tag :type="getOrderStatusType(row.status)" effect="light">
                  {{ row.statusLabel || getOrderStatusText(row.status) }}
                </el-tag>
              </template>
            </el-table-column>
          </el-table>
          <div class="order-table-wrap">
            <el-table :data="filteredOrders" stripe max-height="440">
              <el-table-column prop="orderNo" label="订单编号" min-width="150" />
              <el-table-column prop="productName" label="产品名称" min-width="120" />
              <el-table-column prop="planQty" label="计划数量" min-width="90" />
              <el-table-column prop="completedQty" label="已完成" min-width="90" />
              <el-table-column label="完成率" min-width="180">
                <template #default="{ row }">
                  <div class="table-progress">
                    <el-progress
                        :stroke-width="8"
                        :percentage="row.completionRate"
                        :show-text="false"
                        status="success"
                    />
                    <span>{{ row.completionRate }}%</span>
                  </div>
                </template>
              </el-table-column>
              <el-table-column prop="deliveryDate" label="交期" min-width="110" />
              <el-table-column label="状态" min-width="90">
                <template #default="{ row }">
                  <el-tag :type="getOrderStatusType(row.status)" effect="light">
                    {{ row.statusLabel || getOrderStatusText(row.status) }}
                  </el-tag>
                </template>
              </el-table-column>
            </el-table>
          </div>
        </div>
        <div v-if="visiblePanels.contract" class="cockpit-panel contract-panel">
@@ -1232,8 +1234,8 @@
    const res = await productionOrderProgress({
      status: orderFilter.value,
      tab: orderFilter.value,
      pageNum: 1,
      pageSize: 10,
      pageNum: -1,
      pageSize: -1,
    });
    const data = res?.data || {};
    const statusValue = normalizeOrderFilter(data.status, orderFilter.value);
@@ -2194,6 +2196,13 @@
.order-panel {
  min-height: 0;
  display: flex;
  flex-direction: column;
}
.order-table-wrap {
  flex: 1;
  min-height: 0;
}
.quick-panel {
src/views/inventoryManagement/dispatchLog/Record.vue
@@ -76,6 +76,8 @@
    <div class="actions">
      <el-button type="primary"
                 @click="handleBatchApprove">审批</el-button>
      <el-button :disabled="!canReverseApprove"
                 @click="handleReverseApprove">反审</el-button>
      <el-button @click="handleOut">导出</el-button>
      <el-button type="danger"
                 plain
@@ -163,6 +165,7 @@
    getStockOutPage,
    delPendingStockOut,
    batchApproveStockOutRecords,
    batchUnapproveStockOutRecords,
  } from "@/api/inventoryManagement/stockOut.js";
  import {
    findAllQualifiedStockOutRecordTypeOptions,
@@ -306,6 +309,44 @@
    selectedRows.value = selection.filter(item => item.id);
    console.log("selection", selectedRows.value);
  };
  const isRejectedApproval = status => {
    return status === 2 || status === "2" || status === "rejected" || status === "REJECTED";
  };
  const canReverseApprove = computed(() => {
    return (
      selectedRows.value.length > 0 &&
      selectedRows.value.every(row => isRejectedApproval(row.approvalStatus))
    );
  });
  const handleReverseApprove = () => {
    if (!canReverseApprove.value) {
      proxy.$modal.msgWarning("请选择驳回状态的数据");
      return;
    }
    const ids = selectedRows.value.map(item => item.id);
    ElMessageBox.confirm("反审后记录将恢复为待审批状态,是否确认反审?", "反审", {
      confirmButtonText: "确认",
      cancelButtonText: "取消",
      type: "warning",
    })
      .then(() => {
        batchUnapproveStockOutRecords({ ids })
          .then(() => {
            proxy.$modal.msgSuccess("反审成功");
            getList();
          })
          .catch(() => {
            proxy.$modal.msgError("反审失败");
          });
      })
      .catch(() => {
        proxy.$modal.msg("已取消");
      });
  };
  const expandedRowKeys = ref([]);
  const handleBatchApprove = () => {
src/views/inventoryManagement/stockManagement/New.vue
@@ -52,8 +52,9 @@
        <el-form-item label="库存数量"
                      prop="qualitity">
          <el-input-number v-model="formState.qualitity"
                           :step="1"
                           :min="1"
                           :step="0.01"
                           :min="0.01"
                           :precision="2"
                           style="width: 100%" />
        </el-form-item>
        <el-form-item label="批号"
@@ -65,9 +66,10 @@
                      label="库存预警数量"
                      prop="warnNum">
          <el-input-number v-model="formState.warnNum"
                           :step="1"
                           :step="0.01"
                           :min="0"
                           :max="formState.qualitity"
                           :precision="2"
                           style="width: 100%" />
        </el-form-item>
        <el-form-item label="备注"
src/views/mock/dataCheck/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,241 @@
<template>
  <div class="app-container">
    <el-card header="基础数据检测">
      <el-checkbox-group v-model="selectedModules">
        <el-checkbox label="sales">销售模块</el-checkbox>
        <el-checkbox label="purchase">采购模块</el-checkbox>
        <el-checkbox label="quality">质量模块</el-checkbox>
        <el-checkbox label="production">生产模块</el-checkbox>
      </el-checkbox-group>
      <div style="margin-top: 16px">
        <el-button type="primary" @click="handleCheck" :loading="checking">
          å¼€å§‹æ£€æµ‹
        </el-button>
      </div>
    </el-card>
    <el-card v-if="checkResult" header="检测结果" style="margin-top: 16px">
      <el-alert
        :title="`通过 ${checkResult.passedItems} / ${checkResult.totalItems} é¡¹`"
        :type="checkResult.passedItems === checkResult.totalItems ? 'success' : 'warning'"
        :closable="false"
        show-icon
      />
      <el-table :data="checkResult.items" style="margin-top: 12px" border>
        <el-table-column prop="module" label="模块" width="100" />
        <el-table-column prop="itemName" label="检测项" width="160" />
        <el-table-column prop="minRequired" label="最低要求" width="80" align="center" />
        <el-table-column prop="currentCount" label="当前数量" width="80" align="center" />
        <el-table-column label="状态" width="80" align="center">
          <template #default="{ row }">
            <el-tag :type="row.passed ? 'success' : 'danger'">
              {{ row.passed ? "通过" : "未通过" }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="message" label="提示" min-width="200" />
      </el-table>
    </el-card>
    <!-- AI æ•°æ®ç”Ÿæˆ -->
    <el-card header="AI æ•°æ®ç”Ÿæˆ" style="margin-top: 16px">
      <el-form :model="generateForm" label-width="100px">
        <el-form-item label="选择模块">
          <el-checkbox-group v-model="generateForm.modules">
            <el-checkbox label="sales">销售</el-checkbox>
            <el-checkbox label="purchase">采购</el-checkbox>
            <el-checkbox label="quality">质量</el-checkbox>
            <el-checkbox label="production">生产</el-checkbox>
            <el-checkbox label="stock">库存</el-checkbox>
          </el-checkbox-group>
        </el-form-item>
        <el-form-item label="行业">
          <el-select
            v-model="generateForm.industries"
            multiple
            filterable
            allow-create
            placeholder="输入或选择行业"
          >
            <el-option label="机械制造" value="机械制造" />
            <el-option label="食品加工" value="食品加工" />
            <el-option label="电子装配" value="电子装配" />
            <el-option label="汽车零部件" value="汽车零部件" />
            <el-option label="医疗器械" value="医疗器械" />
            <el-option label="化工材料" value="化工材料" />
            <el-option label="纺织服装" value="纺织服装" />
            <el-option label="家具制造" value="家具制造" />
          </el-select>
        </el-form-item>
        <el-row :gutter="16">
          <el-col :span="12">
            <el-form-item label="生成条数">
              <el-slider
                v-model="generateForm.countRange"
                range
                :min="1"
                :max="50"
                :marks="{ 1: '1', 10: '10', 20: '20', 50: '50' }"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="时间范围">
              <el-date-picker
                v-model="generateForm.dateRange"
                type="daterange"
                range-separator="至"
                start-placeholder="开始日期"
                end-placeholder="结束日期"
                value-format="YYYY-MM-DD"
                unlink-panels
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="补充信息">
          <el-input
            v-model="generateForm.additionalInfo"
            type="textarea"
            :rows="2"
            placeholder="如:产品以金属零部件为主,客户集中在华东地区"
          />
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleGenerate" :loading="generating">
            å¼€å§‹ç”Ÿæˆ
          </el-button>
          <el-button @click="handleCheckFirst" :loading="checking">
            å…ˆæ£€æµ‹å†ç”Ÿæˆ
          </el-button>
        </el-form-item>
      </el-form>
    </el-card>
    <!-- ç”Ÿæˆç»“æžœ -->
    <el-card v-if="generateResult" header="生成结果" style="margin-top: 16px">
      <el-alert
        :title="`共生成 ${generateResult.totalGenerated} æ¡æ•°æ®`"
        type="success"
        :closable="false"
        show-icon
      />
      <el-table :data="generateResult.moduleSummaries" style="margin-top: 12px" border>
        <el-table-column prop="module" label="模块" width="100" />
        <el-table-column prop="entityName" label="实体名称" width="160" />
        <el-table-column prop="generatedCount" label="生成条数" width="80" align="center" />
        <el-table-column prop="successCount" label="成功" width="80" align="center">
          <template #default="{ row }">
            <el-tag type="success">{{ row.successCount }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="failCount" label="失败" width="80" align="center">
          <template #default="{ row }">
            <el-tag v-if="row.failCount > 0" type="danger">{{ row.failCount }}</el-tag>
            <span v-else>0</span>
          </template>
        </el-table-column>
      </el-table>
      <div v-if="generateResult.errors && generateResult.errors.length > 0" style="margin-top: 12px">
        <el-alert
          v-for="(err, i) in generateResult.errors"
          :key="i"
          :title="err"
          type="error"
          :closable="false"
          style="margin-bottom: 4px"
        />
      </div>
    </el-card>
  </div>
</template>
<script setup>
import { ref, reactive } from "vue";
import { ElMessage } from "element-plus";
import { dataCheck } from "@/api/mock/dataCheck.js";
import { mockGenerate } from "@/api/mock/generate.js";
const selectedModules = ref(["sales", "purchase", "quality", "production"]);
const checking = ref(false);
const checkResult = ref(null);
const generateForm = reactive({
  modules: [],
  industries: [],
  countRange: [3, 10],
  dateRange: [],
  additionalInfo: "",
});
const generating = ref(false);
const generateResult = ref(null);
const handleCheck = async () => {
  if (selectedModules.value.length === 0) {
    ElMessage.warning("请至少选择一个模块");
    return;
  }
  checking.value = true;
  try {
    const res = await dataCheck(selectedModules.value);
    checkResult.value = res.data;
  } finally {
    checking.value = false;
  }
};
// ç›´æŽ¥ç”Ÿæˆï¼ˆå†…部自动检测)
const handleGenerate = async () => {
  if (generateForm.modules.length === 0) {
    ElMessage.warning("请至少选择一个模块");
    return;
  }
  generating.value = true;
  try {
    const res = await mockGenerate({
      modules: generateForm.modules,
      industries: generateForm.industries,
      countMin: generateForm.countRange[0],
      countMax: generateForm.countRange[1],
      dateStart: generateForm.dateRange[0] || null,
      dateEnd: generateForm.dateRange[1] || null,
      additionalInfo: generateForm.additionalInfo,
    });
    generateResult.value = res.data;
    ElMessage.success(`成功生成 ${res.data.totalGenerated} æ¡æ•°æ®`);
  } catch (err) {
    // code 500 æ—¶å“åº”拦截器会弹错误提示,此处不再重复提示
    // è‹¥éœ€è¦å±•示检测结果,需要后端在非200时也返回数据,此处暂不处理
  } finally {
    generating.value = false;
  }
};
// å…ˆæ£€æµ‹å†ç”Ÿæˆ
const handleCheckFirst = async () => {
  if (generateForm.modules.length === 0) {
    ElMessage.warning("请至少选择一个模块");
    return;
  }
  checking.value = true;
  try {
    const res = await dataCheck(generateForm.modules);
    const result = res.data;
    if (result.passedItems === result.totalItems) {
      await handleGenerate();
    } else {
      checkResult.value = result;
      ElMessage.warning("请先补齐基础数据后再生成");
    }
  } finally {
    checking.value = false;
  }
};
</script>
<style scoped></style>
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
@@ -457,6 +457,9 @@
    currentNodeIndex: 0,
    approvalRecords,
    rejectReason: approvalRecords.find(r => r.result === "rejected")?.opinion || "",
    purchaseContractNumber: row.purchaseContractNumber || "",
    quotationNo: row.quotationNo || "",
    shippingNo: row.shippingNo || "",
  };
}
src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue
@@ -19,9 +19,9 @@
            {{ approvalTypeLabel(row.approvalType) }}
          </span>
        </el-descriptions-item>
        <el-descriptions-item label="申请人编号">{{ row.applicantNo || "—" }}</el-descriptions-item>
        <el-descriptions-item label="申请人名称">{{ row.applicantName || "—" }}</el-descriptions-item>
        <el-descriptions-item label="申请摘要">{{ row.summary || "—" }}</el-descriptions-item>
        <el-descriptions-item label="发起人编号">{{ row.applicantNo || "—" }}</el-descriptions-item>
        <el-descriptions-item label="发起人名称">{{ row.applicantName || "—" }}</el-descriptions-item>
        <el-descriptions-item label="摘要">{{ row.summary || "—" }}</el-descriptions-item>
        <el-descriptions-item v-if="row.rejectReason"
                              label="驳回原因"
                              :span="2">
@@ -35,9 +35,116 @@
    </div>
    <div class="detail-block">
      <div class="detail-block-title">填报内容</div>
      <FormPayloadFields :fields="formResolved.fields"
      <!-- é»˜è®¤è¡¨å•展示 -->
      <FormPayloadFields v-if="!isSpecialApprovalType"
                         :fields="formResolved.fields"
                         :form-payload="formResolved.formPayload"
                         readonly />
      <!-- å‘货审批详情 -->
      <template v-else-if="row.businessType === 7">
        <div v-if="detailData.shippingInfo" class="shipment-detail">
          <el-divider content-position="left">发货详情</el-divider>
          <el-descriptions :column="2" border>
            <el-descriptions-item label="销售订单">{{ detailData.shippingInfo.salesContractNo || "--" }}</el-descriptions-item>
            <el-descriptions-item label="发货订单号">{{ detailData.shippingInfo.shippingNo || "--" }}</el-descriptions-item>
            <el-descriptions-item label="客户名称">{{ detailData.shippingInfo.customerName || "--" }}</el-descriptions-item>
            <el-descriptions-item label="发货类型">{{ detailData.shippingInfo.type || "--" }}</el-descriptions-item>
            <el-descriptions-item label="发货日期">{{ detailData.shippingInfo.shippingDateDate || "--" }}</el-descriptions-item>
            <el-descriptions-item label="审核状态">{{ detailData.shippingInfo.status || "--" }}</el-descriptions-item>
            <el-descriptions-item label="发货车牌号">{{ detailData.shippingInfo.shippingCarNumber || "--" }}</el-descriptions-item>
            <el-descriptions-item label="快递公司">{{ detailData.shippingInfo.expressCompany || "--" }}</el-descriptions-item>
            <el-descriptions-item label="快递单号" :span="2">{{ detailData.shippingInfo.expressNumber || "--" }}</el-descriptions-item>
          </el-descriptions>
          <div v-if="detailData.shippingProductDetailDtoList.length" style="margin-top: 20px;">
            <h4>产品明细</h4>
            <el-table :data="detailData.shippingProductDetailDtoList"
                      border
                      size="small"
                      style="width: 100%">
              <el-table-column label="批号" prop="batchNo" min-width="160" show-overflow-tooltip />
              <el-table-column label="产品名称" prop="productName" min-width="160" show-overflow-tooltip />
              <el-table-column label="规格型号" prop="specificationModel" min-width="160" show-overflow-tooltip />
              <el-table-column label="发货数量" prop="deliveryQuantity" min-width="120" align="center" />
            </el-table>
          </div>
        </div>
      </template>
      <!-- é‡‡è´­å®¡æ‰¹è¯¦æƒ… -->
      <template v-else-if="row.businessType === 5">
        <div v-if="detailData" class="procurement-detail">
          <el-divider content-position="left">采购详情</el-divider>
          <el-descriptions :column="2" border>
            <el-descriptions-item label="采购合同号">{{ detailData.purchaseContractNumber || "--" }}</el-descriptions-item>
            <el-descriptions-item label="供应商名称">{{ detailData.supplierName || "--" }}</el-descriptions-item>
            <el-descriptions-item label="项目名称">{{ detailData.projectName || "--" }}</el-descriptions-item>
            <el-descriptions-item label="销售合同号">{{ detailData.salesContractNo || "--" }}</el-descriptions-item>
            <el-descriptions-item label="签订日期">{{ detailData.executionDate || "--" }}</el-descriptions-item>
            <el-descriptions-item label="录入日期">{{ detailData.entryDate || "--" }}</el-descriptions-item>
            <el-descriptions-item label="付款方式">{{ detailData.paymentMethod || "--" }}</el-descriptions-item>
            <el-descriptions-item label="合同金额" :span="2">
              <span style="font-size: 18px; color: #e6a23c; font-weight: bold;">
                Â¥{{ Number(detailData.contractAmount ?? 0).toFixed(2) }}
              </span>
            </el-descriptions-item>
          </el-descriptions>
          <div v-if="detailData.productData.length" style="margin-top: 20px;">
            <h4>产品明细</h4>
            <el-table :data="detailData.productData"
                      border
                      style="width: 100%">
              <el-table-column prop="productCategory" label="产品名称" />
              <el-table-column prop="specificationModel" label="规格型号" />
              <el-table-column prop="unit" label="单位" />
              <el-table-column prop="quantity" label="数量" />
              <el-table-column prop="taxInclusiveUnitPrice" label="含税单价">
                <template #default="scope">Â¥{{ Number(scope.row.taxInclusiveUnitPrice ?? 0).toFixed(2) }}</template>
              </el-table-column>
              <el-table-column prop="taxInclusiveTotalPrice" label="含税总价">
                <template #default="scope">Â¥{{ Number(scope.row.taxInclusiveTotalPrice ?? 0).toFixed(2) }}</template>
              </el-table-column>
            </el-table>
          </div>
        </div>
      </template>
      <!-- æŠ¥ä»·å®¡æ‰¹è¯¦æƒ… -->
      <template v-else-if="row.businessType === 6">
        <div v-if="detailData" class="quotation-detail">
          <el-divider content-position="left">报价详情</el-divider>
          <el-descriptions :column="2" border>
            <el-descriptions-item label="报价单号">{{ detailData.quotationNo || "--" }}</el-descriptions-item>
            <el-descriptions-item label="客户名称">{{ detailData.customer || "--" }}</el-descriptions-item>
            <el-descriptions-item label="业务员">{{ detailData.salesperson || "--" }}</el-descriptions-item>
            <el-descriptions-item label="报价日期">{{ detailData.quotationDate || "--" }}</el-descriptions-item>
            <el-descriptions-item label="有效期至">{{ detailData.validDate || "--" }}</el-descriptions-item>
            <el-descriptions-item label="付款方式">{{ detailData.paymentMethod || "--" }}</el-descriptions-item>
            <el-descriptions-item label="报价总额" :span="2">
              <span style="font-size: 18px; color: #e6a23c; font-weight: bold;">
                Â¥{{ Number(detailData.totalAmount ?? 0).toFixed(2) }}
              </span>
            </el-descriptions-item>
          </el-descriptions>
          <div v-if="detailData.products.length" style="margin-top: 20px;">
            <h4>产品明细</h4>
            <el-table :data="detailData.products"
                      border
                      style="width: 100%">
              <el-table-column prop="product" label="产品名称" />
              <el-table-column prop="specification" label="规格型号" />
              <el-table-column prop="unit" label="单位" />
              <el-table-column prop="unitPrice" label="单价">
                <template #default="scope">Â¥{{ Number(scope.row.unitPrice ?? 0).toFixed(2) }}</template>
              </el-table-column>
            </el-table>
          </div>
          <div v-if="detailData.remark" style="margin-top: 20px;">
            <h4>备注</h4>
            <p>{{ detailData.remark }}</p>
          </div>
        </div>
      </template>
    </div>
    <div v-if="attachmentList.length"
         class="detail-block">
@@ -86,9 +193,21 @@
  const props = defineProps({
    row: { type: Object, default: () => ({}) },
    detailData: { type: Object, default: () => ({}) },
  });
  const formResolved = computed(() => resolveInstanceFormFields(props.row));
  // æ˜¯å¦ä¸ºç‰¹æ®Šå®¡æ‰¹ç±»åž‹ï¼ˆé‡‡è´­ã€å‘货、报价)
  const isSpecialApprovalType = computed(() => {
    return [5, 7, 6].includes(props.row.businessType);
  });
  // è¯¦æƒ…数据(直接使用传入的 detail-data å‚数)
  const detailData = computed(() => {
    return props.detailData || {};
  });
  const attachmentList = computed(() => {
    const list = props.row.storageBlobVOList || props.row.storageBlobDTOs || [];
@@ -164,4 +283,4 @@
    align-items: center;
    flex-shrink: 0;
  }
</style>
</style>
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
@@ -172,7 +172,7 @@
      class="approve-detail-dialog"
    >
      <div class="approve-detail-body">
        <ApproveDetailPanel :row="detailRow" />
        <ApproveDetailPanel :row="detailRow" :detail-data="detailData" />
        <div class="detail-block">
          <div class="detail-block-title">
            å®¡æ‰¹æµç¨‹ï¼ˆ{{ detailRow.tasks?.length || detailRow.flowNodes?.length || 0 }} é¡¹ï¼‰
@@ -290,7 +290,7 @@
      destroy-on-close
      @closed="approveOpinion = ''"
    >
      <ApproveDetailPanel :row="approveDialog.row" />
      <ApproveDetailPanel :row="approveDialog.row" :detail-data="detailData" />
      <div class="detail-block mt16">
        <div class="detail-block-title">
          å®¡æ‰¹æµç¨‹ï¼ˆ{{ approveDialog.row?.tasks?.length || approveDialog.row?.flowNodes?.length || 0 }} é¡¹ï¼‰
@@ -369,6 +369,7 @@
  tableColumn,
  detailDialog,
  detailRow,
  detailData,
  reimburseDialog,
  approveDialog,
  approveOpinion,
@@ -610,4 +611,4 @@
  border-left: 3px solid var(--el-color-primary);
  line-height: 1.4;
}
</style>
</style>
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
@@ -10,6 +10,9 @@
  saveApprovalInstance,
  updateApprovalInstance,
} from "@/api/officeProcessAutomation/approvalInstance.js";
import { getQuotationList } from "@/api/salesManagement/salesQuotation.js";
import { getPurchaseByCode } from "@/api/procurementManagement/procurementLedger.js";
import { getDeliveryDetailByShippingNo } from "@/api/salesManagement/deliveryLedger.js";
import useUserStore from "@/store/modules/user";
import { Search } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
@@ -77,6 +80,7 @@
  const detailDialog = reactive({ visible: false });
  const detailRow = ref({});
  const detailData = ref({});
  const approveDialog = reactive({ visible: false, row: null });
  const approveOpinion = ref("");
@@ -135,8 +139,8 @@
  }));
  const tableColumn = ref([
    { label: "申请人编号", prop: "applicantNo", width: 110 },
    { label: "申请人名称", prop: "applicantName", minWidth: 100 },
    // { label: "申请人编号", prop: "applicantNo", width: 110 },
    { label: "发起人", prop: "applicantName", minWidth: 100 },
    { label: "模板类型", prop: "businessName", minWidth: 120 },
    {
      label: "审批类型",
@@ -150,7 +154,9 @@
      prop: "unread",
      width: 90,
      align: "center",
      dataType: "tag",
      formatData: (v) => (v ? "是" : "否"),
      formatType: (v) => (v ? "success" : "danger"),
    },
    {
      label: "审批状态",
@@ -186,11 +192,11 @@
          disabled: (row) => row.approvalStatus !== "pending" || !row.isApprove,
          clickFun: (row) => openApprove(row),
        },
        {
          name: "删除",
          type: "danger",
          clickFun: (row) => removeInstance(row),
        },
        // {
        //   name: "删除",
        //   type: "danger",
        //   clickFun: (row) => removeInstance(row),
        // },
      ],
    },
  ]);
@@ -220,7 +226,7 @@
        fetchBusinessTypeOptions(),
        listApprovalTemplate(TEMPLATE_TYPE_CUSTOM),
      ]);
      submitBusinessTypeOptions.value = typeOptions;
      submitBusinessTypeOptions.value = typeOptions.filter((x) => ![5, 6, 7].includes(x.value));
      allSubmitTemplates.value = unwrapTemplateList(customRes)
        .filter((row) => mapEnabledFromApi(row.enabled))
        .map(mapSubmitTemplateCard);
@@ -288,11 +294,46 @@
      await openReimburseDetail(row, "detail");
      return;
    }
    detailRow.value = { ...row };
    // æŠ¥ä»·å®¡æ‰¹ï¼šç”¨å®¡æ‰¹äº‹ç”±å­—段承载的"报价单号"去查报价列表
    if (row.businessType === 6) {
      const quotationNo = row?.quotationNo;
      if (quotationNo) {
        const res = await getQuotationList({ quotationNo });
        const records = res?.data?.records || [];
        detailData.value = records[0] || {};
      }
    }
    // é‡‡è´­å®¡æ‰¹ï¼šç”¨å®¡æ‰¹äº‹ç”±å­—段承载的"采购合同号"去查采购详情
    else if (row.businessType === 5) {
      const purchaseContractNumber = row?.purchaseContractNumber;
      if (purchaseContractNumber) {
        const res = await getPurchaseByCode({ purchaseContractNumber });
        detailData.value = res || {};
      }
    }
    // å‘货审批:用审批事由字段承载的"发货单号"去查发货详情
    else if (row.businessType === 7) {
      const shippingNo = row?.shippingNo;
      if (shippingNo) {
        const res = await getDeliveryDetailByShippingNo({ shippingNo });
        detailData.value = res?.data || res || {};
      }
    }
    // å…¶ä»–审批类型
    else {
      detailData.value = {};
    }
    detailDialog.visible = true;
  }
  async function openApprove(row) {
    if (inferReimburseModuleKeyFromInstance(row)) {
      approveOpinion.value = "";
      await openReimburseDetail(row, "approve");
@@ -300,6 +341,38 @@
    }
    approveDialog.row = { ...row };
    approveOpinion.value = "";
    // æŠ¥ä»·å®¡æ‰¹ï¼šç”¨å®¡æ‰¹äº‹ç”±å­—段承载的"报价单号"去查报价列表
    if (row.businessType === 6) {
      const quotationNo = row?.quotationNo;
      if (quotationNo) {
        const res = await getQuotationList({ quotationNo });
        const records = res?.data?.records || [];
        detailData.value = records[0] || {};
      }
    }
    // é‡‡è´­å®¡æ‰¹ï¼šç”¨å®¡æ‰¹äº‹ç”±å­—段承载的"采购合同号"去查采购详情
    else if (row.businessType === 5) {
      const purchaseContractNumber = row?.purchaseContractNumber;
      if (purchaseContractNumber) {
        const res = await getPurchaseByCode({ purchaseContractNumber });
        detailData.value = res || {};
      }
    }
    // å‘货审批:用审批事由字段承载的"发货单号"去查发货详情
    else if (row.businessType === 7) {
      const shippingNo = row?.shippingNo;
      if (shippingNo) {
        const res = await getDeliveryDetailByShippingNo({ shippingNo });
        detailData.value = res?.data || res || {};
      }
    }
    // å…¶ä»–审批类型
    else {
      detailData.value = {};
    }
    approveDialog.visible = true;
  }
@@ -586,6 +659,7 @@
    tableColumn,
    detailDialog,
    detailRow,
    detailData,
    reimburseDialog,
    approveDialog,
    approveOpinion,
@@ -625,4 +699,4 @@
    openApprove,
    fetchApprovalList,
  };
}
}
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalInstanceModule.js
@@ -357,11 +357,11 @@
        disabled: (row) => !canEditBusinessInstanceRow(row),
        clickFun: (row) => openEdit(row),
      },
      {
        name: "删除",
        type: "danger",
        clickFun: (row) => removeInstance(row),
      },
      // {
      //   name: "删除",
      //   type: "danger",
      //   clickFun: (row) => removeInstance(row),
      // },
      ...extraOperations,
    ];
  }
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
@@ -380,23 +380,18 @@
      <template v-if="detailAttachments.length">
        <el-tag
          v-for="(f, i) in detailAttachments"
          :key="i"
          class="detail-attachment-tag"
          type="info"
          effect="plain"
        >
          {{ attachmentDisplayName(f) }}
        </el-tag>
        <div class="detail-attachment-list">
          <div
            v-for="(f, i) in detailAttachments"
            :key="i"
            class="detail-attachment-item"
            @click="openAttachmentFile(f)"
          >
            <el-icon class="attachment-icon"><Document /></el-icon>
            <span class="attachment-name">{{ attachmentDisplayName(f) }}</span>
            <el-icon class="attachment-download"><Download /></el-icon>
          </div>
        </div>
      </template>
@@ -422,7 +417,7 @@
<script setup>
import { ArrowRight, Plus, RefreshRight } from "@element-plus/icons-vue";
import { ArrowRight, Document, Download, Plus, RefreshRight } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
@@ -525,6 +520,14 @@
}
function openAttachmentFile(file) {
  const url = file?.url || file?.previewURL || file?.downloadURL || file?.previewUrl || "";
  if (url) {
    window.open(url, "_blank");
  } else {
    ElMessage.warning("无法打开该附件");
  }
}
function unwrapArray(payload) {
@@ -795,10 +798,40 @@
}
.detail-attachment-tag {
.detail-attachment-list {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
  margin: 0 8px 8px 0;
.detail-attachment-item {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 12px;
  background: var(--el-fill-color-light);
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.2s;
}
.detail-attachment-item:hover {
  background: var(--el-fill-color);
}
.attachment-icon {
  font-size: 16px;
  color: var(--el-text-color-regular);
}
.attachment-name {
  font-size: 14px;
  color: var(--el-text-color-primary);
}
.attachment-download {
  font-size: 14px;
  color: var(--el-text-color-secondary);
}
.text-muted {
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
@@ -214,10 +214,21 @@
    });
  }
  function openFormDialog(mode, row) {
  async function openFormDialog(mode, row) {
    formDialog.mode = mode;
    formDialog.title = mode === "add" ? "新建审批模板" : "编辑审批模板";
    resetForm(mode === "edit" ? row : null);
    if (mode === "edit" && row?.id) {
      // ç¼–辑时先查询详情获取完整数据(包含附件)
      try {
        const res = await getApprovalTemplateDetail(row.id);
        const detailData = mapTemplateFromApi(unwrapTemplateDetail(res));
        resetForm(detailData);
      } catch {
        resetForm(row);
      }
    } else {
      resetForm(mode === "edit" ? row : null);
    }
    formDialog.visible = true;
  }
src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue
@@ -24,7 +24,7 @@
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="warning" plain @click="handleExport">导出</el-button>
<!--        <el-button type="warning" plain @click="handleExport">导出</el-button>-->
        <el-button type="primary" @click="openAddWithTemplate">新增加班申请</el-button>
      </div>
    </div>
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue
@@ -16,8 +16,8 @@
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="success" plain @click="handleImportClick">导入</el-button>
        <el-button type="warning" plain @click="handleExport">导出</el-button>
<!--        <el-button type="success" plain @click="handleImportClick">导入</el-button>-->
<!--        <el-button type="warning" plain @click="handleExport">导出</el-button>-->
        <el-button type="primary" @click="openFormDialog('add')">新增费用报销</el-button>
      </div>
    </div>
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue
@@ -33,7 +33,7 @@
    <el-table-column type="index" label="序号" width="55" align="center" />
    <el-table-column prop="invoiceDate" label="发票日期" width="120" />
    <el-table-column label="费用科目" width="100">
      <template #default="{ row: d }">{{ expenseSubjectLabel(d.expenseSubject) }}</template>
      <template #default="{ row: d }">{{ d.expenseCategory || "—" }}</template>
    </el-table-column>
    <el-table-column prop="amount" label="金额" width="100" />
    <el-table-column prop="description" label="描述" min-width="140" show-overflow-tooltip />
@@ -51,7 +51,7 @@
<script setup>
import { computed } from "vue";
import { expenseSubjectLabel, statusLabel, statusTagType } from "../travelReimburseUtils.js";
import { statusLabel, statusTagType } from "../travelReimburseUtils.js";
const props = defineProps({
  row: { type: Object, default: () => ({}) },
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue
@@ -16,8 +16,8 @@
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="success" plain @click="handleImportClick">导入</el-button>
        <el-button type="warning" plain @click="handleExport">导出</el-button>
<!--        <el-button type="success" plain @click="handleImportClick">导入</el-button>-->
<!--        <el-button type="warning" plain @click="handleExport">导出</el-button>-->
        <el-button type="primary" @click="openFormDialog('add')">新增差旅报销</el-button>
      </div>
    </div>
src/views/personnelManagement/attendanceCheckin/checkinRules/components/form.vue
@@ -186,6 +186,11 @@
      { required: true, message: "请选择下班时间", trigger: "change" },
      {
        validator: (rule, value, callback) => {
          // å¦‚果是夜班,不限制时间顺序
          if (form.shift && ['夜班', '夜'].includes(form.shift)) {
            callback();
            return;
          }
          if (form.startAt && value) {
            const startParts = form.startAt.split(":");
            const endParts = value.split(":");
src/views/personnelManagement/employeeRecord/index.vue
@@ -145,6 +145,10 @@
    prop: "staffNo",
  },
  {
    label: "部门",
    prop: "deptName",
  },
  {
    label: "姓名",
    prop: "staffName",
  },
src/views/personnelManagement/monthlyStatistics/components/auditDia.vue
@@ -86,7 +86,7 @@
<script setup>
import { ref, computed, reactive, toRefs, getCurrentInstance, watch } from "vue";
import { ElMessage } from "element-plus";
import Cookies from "js-cookie";
import useUserStore from "@/store/modules/user";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import { staffSalaryMainUpdate } from "@/api/personnelManagement/staffSalaryMain.js";
@@ -98,6 +98,7 @@
});
const { proxy } = getCurrentInstance();
const userStore = useUserStore();
const dialogVisible = computed({
  get: () => props.modelValue,
@@ -176,15 +177,21 @@
      return;
    }
    
    const username = Cookies.get("username") || "";
    const userIdRaw = Cookies.get("userId");
    const auditUserId = userIdRaw ? Number(userIdRaw) : undefined;
    const username = userStore.name || "";
    const currentUserId = Number(userStore.id);
    const assignedAuditUserId = Number(row?.auditUserId);
    // æƒé™éªŒè¯ï¼šåªæœ‰æŒ‡å®šçš„审核人才能进行审核
    if (!currentUserId || currentUserId !== assignedAuditUserId) {
      ElMessage.warning("您不是指定的审核人,无法进行审核操作");
      return;
    }
    
    // æž„建审核数据
    const submitData = {
      id: row.id,
      status: Number(auditResult.value) === 2 ? 2 : 4, // 2=不通过 4=通过(待发放)
      auditUserId,
      auditUserId: currentUserId,
      auditUserName: username,
    };
    loading.value = true;
src/views/personnelManagement/monthlyStatistics/components/formDia.vue
@@ -1,505 +1,520 @@
<template>
  <FormDialog
    v-model="dialogVisible"
    :title="operationType === 'add' ? '新建工资表' : '编辑工资表'"
    width="90%"
    @close="closeDia"
  >
  <FormDialog v-model="dialogVisible"
              :title="operationType === 'add' ? '新建工资表' : '编辑工资表'"
              width="90%"
              @close="closeDia">
    <template #footer>
      <el-button type="info" @click="saveDraft">保存草稿</el-button>
      <el-button type="primary" @click="submitForm">确认提交</el-button>
      <el-button type="info"
                 @click="saveDraft">保存草稿</el-button>
      <el-button type="primary"
                 @click="submitForm">确认提交</el-button>
      <el-button @click="closeDia">取消</el-button>
    </template>
    <div class="form-dia-body">
      <!-- åŸºç¡€èµ„æ–™ -->
      <el-card class="form-card" shadow="never">
      <el-card class="form-card"
               shadow="never">
        <template #header>
          <span class="card-title"><span class="card-title-line">|</span> åŸºç¡€èµ„æ–™</span>
          <el-icon class="card-collapse"><ArrowUp /></el-icon>
          <el-icon class="card-collapse">
            <ArrowUp />
          </el-icon>
        </template>
        <el-form ref="formRef" :model="form" :rules="rules" label-position="top">
        <el-form ref="formRef"
                 :model="form"
                 :rules="rules"
                 label-position="top">
          <el-row :gutter="24">
            <el-col :span="6">
              <el-form-item label="工资主题" prop="salaryTitle">
                <el-input
                  v-model="form.salaryTitle"
                  placeholder="请输入"
                  clearable
                  maxlength="20"
                  show-word-limit
                />
              <el-form-item label="工资主题"
                            prop="salaryTitle">
                <el-input v-model="form.salaryTitle"
                          placeholder="请输入"
                          clearable
                          maxlength="20"
                          show-word-limit />
              </el-form-item>
            </el-col>
            <el-col :span="6">
              <el-form-item label="选择部门" prop="deptIds">
                <el-select
                  v-model="form.deptIds"
                  placeholder="请选择"
                  clearable
                  multiple
                  collapse-tags-tooltip
                  style="width: 100%"
                >
                  <el-option
                    v-for="item in deptOptions"
                    :key="item.deptId"
                    :label="item.deptName"
                    :value="item.deptId"
                  />
              <el-form-item label="选择工资月份"
                            prop="salaryMonth">
                <el-date-picker v-model="form.salaryMonth"
                                type="month"
                                value-format="YYYY-MM"
                                format="YYYY-MM"
                                placeholder="请选择工资月份"
                                style="width: 100%"
                                clearable />
              </el-form-item>
            </el-col>
            <el-col :span="6">
              <el-form-item label="选择部门"
                            prop="deptIds">
                <el-select v-model="form.deptIds"
                           placeholder="请选择"
                           clearable
                           multiple
                           collapse-tags-tooltip
                           style="width: 100%">
                  <el-option v-for="item in deptOptions"
                             :key="item.deptId"
                             :label="item.deptName"
                             :value="item.deptId"
                             :disabled="isDeptDisabled(item.deptId)" />
                </el-select>
              </el-form-item>
            </el-col>
            <el-col :span="6">
              <el-form-item label="选择工资月份" prop="salaryMonth">
                <el-date-picker
                  v-model="form.salaryMonth"
                  type="month"
                  value-format="YYYY-MM"
                  format="YYYY-MM"
                  placeholder="请选择工资月份"
                  style="width: 100%"
                  clearable
                />
              </el-form-item>
            </el-col>
            <el-col :span="6">
              <el-form-item label="备注" prop="remark">
                <el-input
                  v-model="form.remark"
                  placeholder="请输入"
                  clearable
                />
              <el-form-item label="备注"
                            prop="remark">
                <el-input v-model="form.remark"
                          placeholder="请输入"
                          clearable />
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="24">
            <el-col :span="6">
              <el-form-item label="支付银行" prop="payBank">
                <el-select
                  v-model="form.payBank"
                  placeholder="请选择"
                  clearable
                  filterable
                  style="width: 100%"
                >
                  <el-option
                    v-for="b in bankOptions"
                    :key="b"
                    :label="b"
                    :value="b"
                  />
              <el-form-item label="支付银行"
                            prop="payBank">
                <el-select v-model="form.payBank"
                           placeholder="请选择"
                           clearable
                           filterable
                           style="width: 100%">
                  <el-option v-for="b in bankOptions"
                             :key="b"
                             :label="b"
                             :value="b" />
                </el-select>
              </el-form-item>
            </el-col>
            <el-col :span="6">
              <el-form-item label="审核人" prop="auditUserId">
                <el-select
                  v-model="form.auditUserId"
                  placeholder="请选择审核人"
                  clearable
                  filterable
                  style="width: 100%"
                >
                  <el-option
                    v-for="item in userList"
                    :key="item.userId"
                    :label="item.nickName"
                    :value="item.userId"
                  />
              <el-form-item label="审核人"
                            prop="auditUserId">
                <el-select v-model="form.auditUserId"
                           placeholder="请选择审核人"
                           clearable
                           filterable
                           style="width: 100%">
                  <el-option v-for="item in userList"
                             :key="item.userId"
                             :label="item.nickName"
                             :value="item.userId" />
                </el-select>
              </el-form-item>
            </el-col>
          </el-row>
        </el-form>
      </el-card>
      <!-- æ“ä½œæŒ‰é’® -->
      <div class="toolbar">
        <el-button type="primary" @click="handleGenerate">生成工资表</el-button>
        <el-button type="primary"
                   @click="handleGenerate">生成工资表</el-button>
        <el-button @click="handleClear">清空</el-button>
        <el-button @click="handleBatchDelete">删除</el-button>
        <el-button @click="handleTaxForm">个税表</el-button>
      </div>
      <!-- å‘˜å·¥å·¥èµ„详情表格 -->
      <div class="employee-table-wrap">
        <el-table
          ref="employeeTableRef"
          :data="employeeList"
          border
          max-height="400"
          @selection-change="onEmployeeSelectionChange"
        >
          <el-table-column type="selection" width="55" align="center" />
          <el-table-column label="员工姓名" prop="staffName" minWidth="100" />
          <el-table-column label="部门" prop="deptName" minWidth="100" />
          <el-table-column label="基本工资" minWidth="110">
        <el-table ref="employeeTableRef"
                  :data="employeeList"
                  border
                  max-height="400"
                  @selection-change="onEmployeeSelectionChange">
          <el-table-column type="selection"
                           width="55"
                           align="center" />
          <el-table-column label="员工姓名"
                           prop="staffName"
                           minWidth="100" />
          <el-table-column label="部门"
                           prop="deptName"
                           minWidth="100" />
          <el-table-column label="基本工资"
                           minWidth="110">
            <template #default="{ row }">
              <el-input
                v-model.number="row.basicSalary"
                type="number"
                placeholder="0"
                size="small"
                @input="row.basicSalary = parseNum(row.basicSalary)"
              />
              <el-input v-model.number="row.basicSalary"
                        type="number"
                        placeholder="0"
                        size="small"
                        @input="handleSalaryInput(row)" />
            </template>
          </el-table-column>
          <el-table-column label="计件工资" minWidth="110">
          <el-table-column label="计件工资"
                           minWidth="110">
            <template #default="{ row }">
              <el-input
                v-model.number="row.pieceSalary"
                type="number"
                placeholder="0"
                size="small"
                @input="row.pieceSalary = parseNum(row.pieceSalary)"
              />
              <el-input v-model.number="row.pieceSalary"
                        type="number"
                        placeholder="0"
                        size="small"
                        disabled
                        @input="row.pieceSalary = parseNum(row.pieceSalary)" />
            </template>
          </el-table-column>
          <el-table-column label="计时工资" minWidth="110">
          <el-table-column label="计时工资"
                           minWidth="110">
            <template #default="{ row }">
              <el-input
                v-model.number="row.hourlySalary"
                type="number"
                placeholder="0"
                size="small"
                @input="row.hourlySalary = parseNum(row.hourlySalary)"
              />
              <el-input v-model.number="row.hourlySalary"
                        type="number"
                        placeholder="0"
                        size="small"
                        disabled
                        @input="row.hourlySalary = parseNum(row.hourlySalary)" />
            </template>
          </el-table-column>
          <el-table-column label="其他收入" minWidth="110">
          <el-table-column label="其他收入"
                           minWidth="110">
            <template #default="{ row }">
              <el-input
                v-model.number="row.otherIncome"
                type="number"
                placeholder="0"
                size="small"
                @input="row.otherIncome = parseNum(row.otherIncome)"
              />
              <el-input v-model.number="row.otherIncome"
                        type="number"
                        placeholder="0"
                        size="small"
                        @input="handleSalaryInput(row)" />
            </template>
          </el-table-column>
          <el-table-column label="社保个人" minWidth="110">
          <el-table-column label="社保个人"
                           minWidth="110">
            <template #default="{ row }">
              <el-input
                v-model.number="row.socialPersonal"
                type="number"
                placeholder="0"
                size="small"
                @input="row.socialPersonal = parseNum(row.socialPersonal)"
              />
              <el-input v-model.number="row.socialPersonal"
                        type="number"
                        placeholder="0"
                        size="small"
                        disabled
                        @input="row.socialPersonal = parseNum(row.socialPersonal)" />
            </template>
          </el-table-column>
          <el-table-column label="公积金个人" minWidth="120">
          <el-table-column label="公积金个人"
                           minWidth="120">
            <template #default="{ row }">
              <el-input
                v-model.number="row.fundPersonal"
                type="number"
                placeholder="0"
                size="small"
                @input="row.fundPersonal = parseNum(row.fundPersonal)"
              />
              <el-input v-model.number="row.fundPersonal"
                        type="number"
                        placeholder="0"
                        size="small"
                        disabled
                        @input="row.fundPersonal = parseNum(row.fundPersonal)" />
            </template>
          </el-table-column>
          <el-table-column label="其他支出" minWidth="110">
          <el-table-column label="其他支出"
                           minWidth="110">
            <template #default="{ row }">
              <el-input
                v-model.number="row.otherDeduct"
                type="number"
                placeholder="0"
                size="small"
                @input="row.otherDeduct = parseNum(row.otherDeduct)"
              />
              <el-input v-model.number="row.otherDeduct"
                        type="number"
                        placeholder="0"
                        size="small"
                        disabled
                        @input="row.otherDeduct = parseNum(row.otherDeduct)" />
            </template>
          </el-table-column>
          <el-table-column label="工资个税" minWidth="110">
          <el-table-column label="社保补缴"
                           minWidth="120">
            <template #default="{ row }">
              <el-input
                v-model.number="row.salaryTax"
                type="number"
                placeholder="0"
                size="small"
                @input="row.salaryTax = parseNum(row.salaryTax)"
              />
              <el-input v-model.number="row.socialSupplementAmount"
                        type="number"
                        placeholder="0"
                        size="small"
                        @input="handleSalaryInput(row)" />
            </template>
          </el-table-column>
          <el-table-column label="应发工资" minWidth="110">
          <el-table-column label="工资个税"
                           minWidth="110">
            <template #default="{ row }">
              <el-input
                v-model.number="row.grossSalary"
                type="number"
                placeholder="0"
                size="small"
                @input="row.grossSalary = parseNum(row.grossSalary)"
              />
              <el-input v-model.number="row.salaryTax"
                        type="number"
                        placeholder="0"
                        size="small"
                        disabled
                        @input="row.salaryTax = parseNum(row.salaryTax)" />
            </template>
          </el-table-column>
          <el-table-column label="应扣工资" minWidth="110">
          <el-table-column label="应发工资"
                           minWidth="110">
            <template #default="{ row }">
              <el-input
                v-model.number="row.deductSalary"
                type="number"
                placeholder="0"
                size="small"
                @input="row.deductSalary = parseNum(row.deductSalary)"
              />
              <el-input v-model.number="row.grossSalary"
                        type="number"
                        placeholder="0"
                        size="small"
                        disabled
                        @input="row.grossSalary = parseNum(row.grossSalary)" />
            </template>
          </el-table-column>
          <el-table-column label="实发工资" minWidth="110">
          <el-table-column label="应扣工资"
                           minWidth="110">
            <template #default="{ row }">
              <el-input
                v-model.number="row.netSalary"
                type="number"
                placeholder="0"
                size="small"
                @input="row.netSalary = parseNum(row.netSalary)"
              />
              <el-input v-model.number="row.deductSalary"
                        type="number"
                        placeholder="0"
                        size="small"
                        disabled
                        @input="row.deductSalary = parseNum(row.deductSalary)" />
            </template>
          </el-table-column>
          <el-table-column label="备注" minWidth="120">
          <el-table-column label="实发工资"
                           minWidth="110">
            <template #default="{ row }">
              <el-input
                v-model="row.remark"
                placeholder="请输入"
                size="small"
              />
              <el-input v-model.number="row.netSalary"
                        type="number"
                        placeholder="0"
                        size="small"
                        disabled
                        @input="row.netSalary = parseNum(row.netSalary)" />
            </template>
          </el-table-column>
          <el-table-column label="操作" width="80" align="center" fixed="right">
          <el-table-column label="备注"
                           minWidth="120">
            <template #default="{ row }">
              <el-button type="primary" link @click="removeEmployee(row)">删除</el-button>
              <el-input v-model="row.remark"
                        placeholder="请输入"
                        size="small" />
            </template>
          </el-table-column>
          <el-table-column label="操作"
                           width="80"
                           align="center"
                           fixed="right">
            <template #default="{ row }">
              <el-button type="primary"
                         link
                         @click="removeEmployee(row)">删除</el-button>
            </template>
          </el-table-column>
        </el-table>
        <div v-if="!employeeList.length" class="table-empty">暂无数据</div>
        <div v-else class="salary-total">
        <div v-if="!employeeList.length"
             class="table-empty">暂无数据</div>
        <div v-else
             class="salary-total">
          <span class="total-label">工资总额:</span>
          <span class="total-value">Â¥ {{ totalSalary.toFixed(2) }}</span>
        </div>
      </div>
    </div>
    <!-- æ–°å¢žäººå‘˜å¼¹çª— -->
    <el-dialog
      v-model="addPersonVisible"
      title="新增人员"
      width="400px"
      append-to-body
      @close="addPersonClose"
    >
    <el-dialog v-model="addPersonVisible"
               title="新增人员"
               width="400px"
               append-to-body
               @close="addPersonClose">
      <div class="add-person-tree">
        <el-tree
          ref="personTreeRef"
          :data="deptStaffTree"
          show-checkbox
          node-key="id"
          :props="{ label: 'label', children: 'children' }"
          default-expand-all
        />
        <el-tree ref="personTreeRef"
                 :data="deptStaffTree"
                 show-checkbox
                 node-key="id"
                 :props="{ label: 'label', children: 'children' }"
                 default-expand-all />
      </div>
      <template #footer>
        <el-button @click="addPersonVisible = false">取消</el-button>
        <el-button type="primary" @click="confirmAddPerson">确定</el-button>
        <el-button type="primary"
                   @click="confirmAddPerson">确定</el-button>
      </template>
    </el-dialog>
    <!-- ä¸ªç¨Žè¡¨å¼¹çª— -->
    <el-dialog
      v-model="taxDialogVisible"
      title="个税表"
      width="700px"
      append-to-body
    >
    <el-dialog v-model="taxDialogVisible"
               title="个税表"
               width="700px"
               append-to-body>
      <div class="tax-desc">个人所得税免征额:5000元</div>
      <el-table :data="taxTableData" border style="width: 100%;margin-bottom: 20px;">
        <el-table-column prop="level" label="级数" width="80" align="center" />
        <el-table-column
          prop="range"
          label="全年应纳税所得额/元"
          min-width="220"
        />
        <el-table-column
          prop="rate"
          label="税率(%)"
          width="100"
          align="center"
        />
        <el-table-column
          prop="quickDeduction"
          label="速算扣除数/元"
          width="160"
          align="center"
        />
      <el-table :data="taxTableData"
                border
                style="width: 100%;margin-bottom: 20px;">
        <el-table-column prop="level"
                         label="级数"
                         width="80"
                         align="center" />
        <el-table-column prop="range"
                         label="全年应纳税所得额/元"
                         min-width="220" />
        <el-table-column prop="rate"
                         label="税率(%)"
                         width="100"
                         align="center" />
        <el-table-column prop="quickDeduction"
                         label="速算扣除数/元"
                         width="160"
                         align="center" />
      </el-table>
    </el-dialog>
  </FormDialog>
</template>
<script setup>
import { ref, reactive, toRefs, computed, getCurrentInstance, nextTick } from "vue";
import { ArrowUp } from "@element-plus/icons-vue";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import { listDept } from "@/api/system/dept.js";
import { staffOnJobList } from "@/api/personnelManagement/monthlyStatistics.js";
  import {
    ref,
    reactive,
    toRefs,
    computed,
    getCurrentInstance,
    nextTick,
    watch,
  } from "vue";
  import { ArrowUp } from "@element-plus/icons-vue";
  import FormDialog from "@/components/Dialog/FormDialog.vue";
  import { listDept } from "@/api/system/dept.js";
  import { staffOnJobList } from "@/api/personnelManagement/monthlyStatistics.js";
import { bankList } from "@/api/personnelManagement/bank.js";
import {
  staffSalaryMainAdd,
  staffSalaryMainUpdate,
  staffSalaryMainCalculateSalary,
  staffSalaryMainCalculateByEmployeeId,
  staffSalaryMainAvailableDepts,
} from "@/api/personnelManagement/staffSalaryMain.js";
import { userListNoPageByTenantId } from "@/api/system/user.js";
  import { userListNoPageByTenantId } from "@/api/system/user.js";
  const emit = defineEmits(["update:modelValue", "close"]);
  const props = defineProps({
    modelValue: { type: Boolean, default: false },
    operationType: { type: String, default: "add" },
    row: { type: Object, default: () => ({}) },
  });
const emit = defineEmits(["update:modelValue", "close"]);
const props = defineProps({
  modelValue: { type: Boolean, default: false },
  operationType: { type: String, default: "add" },
  row: { type: Object, default: () => ({}) },
});
  const { proxy } = getCurrentInstance();
const { proxy } = getCurrentInstance();
  const dialogVisible = computed({
    get: () => props.modelValue,
    set: val => emit("update:modelValue", val),
  });
const dialogVisible = computed({
  get: () => props.modelValue,
  set: (val) => emit("update:modelValue", val),
});
  const formRef = ref(null);
  const employeeTableRef = ref(null);
  const personTreeRef = ref(null);
  const addPersonVisible = ref(false);
  const taxDialogVisible = ref(false);
  const deptOptions = ref([]);
  const availableDeptIds = ref(new Set());
  const deptStaffTree = ref([]);
  const employeeList = ref([]);
  const selectedEmployees = ref([]);
  const bankOptions = ref([]);
  const userList = ref([]);
  const recalcTimers = new Map();
  const recalcVersions = new Map();
  const taxTableData = ref([
    { level: 1, range: "不超过36000元", rate: 3, quickDeduction: 0 },
    { level: 2, range: "超过36000-144000元", rate: 10, quickDeduction: 2520 },
    { level: 3, range: "超过144000-300000元", rate: 20, quickDeduction: 16920 },
    { level: 4, range: "超过300000-420000元", rate: 25, quickDeduction: 31920 },
    { level: 5, range: "超过420000-660000元", rate: 30, quickDeduction: 52920 },
    { level: 6, range: "超过660000-960000元", rate: 35, quickDeduction: 85920 },
    { level: 7, range: "超过960000元", rate: 45, quickDeduction: 181920 },
  ]);
const formRef = ref(null);
const employeeTableRef = ref(null);
const personTreeRef = ref(null);
const addPersonVisible = ref(false);
const taxDialogVisible = ref(false);
const deptOptions = ref([]);
const deptStaffTree = ref([]);
const employeeList = ref([]);
const selectedEmployees = ref([]);
const bankOptions = ref([]);
const userList = ref([]);
const taxTableData = ref([
  { level: 1, range: "不超过36000元", rate: 3, quickDeduction: 0 },
  { level: 2, range: "超过36000-144000元", rate: 10, quickDeduction: 2520 },
  { level: 3, range: "超过144000-300000元", rate: 20, quickDeduction: 16920 },
  { level: 4, range: "超过300000-420000元", rate: 25, quickDeduction: 31920 },
  { level: 5, range: "超过420000-660000元", rate: 30, quickDeduction: 52920 },
  { level: 6, range: "超过660000-960000元", rate: 35, quickDeduction: 85920 },
  { level: 7, range: "超过960000元", rate: 45, quickDeduction: 181920 },
]);
  function parseNum(v) {
    if (v === "" || v == null) return 0;
    const n = Number(v);
    return isNaN(n) ? 0 : n;
  }
function parseNum(v) {
  if (v === "" || v == null) return 0;
  const n = Number(v);
  return isNaN(n) ? 0 : n;
}
  function parseNullableNum(v) {
    if (v === "" || v == null) return null;
    const n = Number(v);
    return isNaN(n) ? null : n;
  }
// åŸºç¡€èµ„料表单
const data = reactive({
  form: {
  const createEmployeeRow = () => ({
    staffOnJobId: undefined,
    id: undefined,
    salaryTitle: "",
    deptIds: [],
    salaryMonth: "",
    staffName: "",
    postName: "",
    deptName: "",
    basicSalary: 0,
    pieceSalary: 0,
    hourlySalary: 0,
    otherIncome: 0,
    socialPersonal: 0,
    fundPersonal: 0,
    otherDeduct: 0,
    socialSupplementAmount: null,
    salaryTax: 0,
    grossSalary: 0,
    deductSalary: 0,
    netSalary: 0,
    remark: "",
    payBank: "",
    auditUserId: undefined,
  },
  rules: {
    salaryTitle: [{ required: true, message: "请输入工资主题", trigger: "blur" }],
    deptIds: [{ required: true, message: "请选择部门", trigger: "change" }],
    salaryMonth: [{ required: true, message: "请选择工资月份", trigger: "change" }],
    auditUserId: [{ required: true, message: "请选择审核人", trigger: "change" }],
  },
});
const { form, rules } = toRefs(data);
// è®¡ç®—工资总额(所有员工实发工资之和)
const totalSalary = computed(() => {
  return employeeList.value.reduce((sum, e) => sum + parseNum(e.netSalary), 0);
});
// æ ¹æ®å®¡æ ¸äººID获取审核人名称
const auditUserName = computed(() => {
  if (!form.value.auditUserId) return "";
  const user = userList.value.find(u => u.userId === form.value.auditUserId);
  return user ? user.nickName : "";
});
const loadBankOptions = () => {
  return bankList().then((res) => {
    const list = Array.isArray(res?.data) ? res.data : [];
    bankOptions.value = list
      .map((b) => (b?.bankName == null ? "" : String(b.bankName).trim()))
      .filter((v) => v !== "");
  });
};
const loadUserList = () => {
  return userListNoPageByTenantId().then((res) => {
    userList.value = res.data || [];
  const normalizeEmployeeRow = (source, fallback = {}) => ({
    ...createEmployeeRow(),
    ...fallback,
    ...source,
    staffOnJobId: source?.staffOnJobId ?? source?.staffId ?? source?.id ?? fallback.staffOnJobId ?? fallback.id,
    id: source?.staffOnJobId ?? source?.staffId ?? source?.id ?? fallback.staffOnJobId ?? fallback.id,
    staffName: source?.staffName ?? fallback.staffName ?? "",
    postName: source?.postName ?? fallback.postName ?? "",
    deptName: source?.deptName ?? fallback.deptName ?? "",
    basicSalary: parseNum(source?.basicSalary ?? fallback.basicSalary),
    pieceSalary: parseNum(source?.pieceSalary ?? fallback.pieceSalary),
    hourlySalary: parseNum(source?.hourlySalary ?? fallback.hourlySalary),
    otherIncome: parseNum(source?.otherIncome ?? fallback.otherIncome),
    socialPersonal: parseNum(source?.socialPersonal ?? fallback.socialPersonal),
    fundPersonal: parseNum(source?.fundPersonal ?? fallback.fundPersonal),
    otherDeduct: parseNum(source?.otherDeduct ?? fallback.otherDeduct),
    socialSupplementAmount:
      source?.socialSupplementAmount ?? fallback.socialSupplementAmount ?? null,
    salaryTax: parseNum(source?.salaryTax ?? fallback.salaryTax),
    grossSalary: parseNum(source?.grossSalary ?? fallback.grossSalary),
    deductSalary: parseNum(source?.deductSalary ?? fallback.deductSalary),
    netSalary: parseNum(source?.netSalary ?? fallback.netSalary),
    remark: source?.remark ?? fallback.remark ?? "",
  });
};
// æ‰å¹³åŒ–部门树供下拉使用
function flattenDept(tree, list = []) {
  if (!tree?.length) return list;
  tree.forEach((node) => {
    list.push({ deptId: node.deptId, deptName: node.deptName });
    if (node.children?.length) flattenDept(node.children, list);
  });
  return list;
}
  const applyBackendRow = (targetRow, sourceRow) => {
    Object.assign(targetRow, normalizeEmployeeRow(sourceRow, targetRow));
  };
const loadDeptOptions = () => {
  listDept().then((res) => {
    const tree = res.data ?? [];
    deptOptions.value = flattenDept(tree);
  });
};
  const clearRowRecalcState = row => {
    const key = String(row?.staffOnJobId ?? row?.id ?? "");
    if (!key) return;
    clearTimeout(recalcTimers.get(key));
    recalcTimers.delete(key);
    recalcVersions.delete(key);
  };
// æž„建 éƒ¨é—¨-人员 æ ‘(用于新增人员弹窗)
const loadDeptStaffTree = () => {
  Promise.all([listDept(), staffOnJobList()]).then(([deptRes, staffRes]) => {
    const tree = deptRes.data ?? [];
    const staffList = staffRes.data ?? [];
    const deptMap = new Map();
    function walk(nodes) {
      nodes.forEach((node) => {
        deptMap.set(node.deptId, {
          id: "dept_" + node.deptId,
          deptId: node.deptId,
          label: node.deptName,
          type: "dept",
          children: [],
  const resetRecalcState = () => {
    recalcTimers.forEach(timer => clearTimeout(timer));
    recalcTimers.clear();
    recalcVersions.clear();
  };
  const requestEmployeeRecalculate = row => {
    const key = String(row?.staffOnJobId ?? row?.id ?? "");
    if (!key || !form.value.salaryMonth) return;
    const version = (recalcVersions.get(key) || 0) + 1;
    recalcVersions.set(key, version);
    clearTimeout(recalcTimers.get(key));
    const payload = {
      staffOnJobId: row.staffOnJobId ?? row.id,
      salaryMonth: form.value.salaryMonth,
      staffName: row.staffName ?? "",
      postName: row.postName ?? "",
      deptName: row.deptName ?? "",
      basicSalary: parseNum(row.basicSalary),
      pieceSalary: parseNum(row.pieceSalary),
      hourlySalary: parseNum(row.hourlySalary),
      otherIncome: parseNum(row.otherIncome),
      otherDeduct: parseNum(row.otherDeduct),
      socialSupplementAmount: parseNullableNum(row.socialSupplementAmount),
      remark: row.remark ?? "",
    };
    recalcTimers.set(
      key,
      setTimeout(() => {
        staffSalaryMainCalculateByEmployeeId(payload).then(res => {
          if (recalcVersions.get(key) !== version) return;
          applyBackendRow(row, res?.data || {});
        });
        if (node.children?.length) walk(node.children);
      });
    }
    walk(tree);
    staffList.forEach((s) => {
      const deptId = s.deptId ?? s.dept_id;
      const node = deptMap.get(deptId);
      if (node) {
        node.children.push({
          id: s.id ?? s.staffId,
          staffId: s.id ?? s.staffId,
          label: s.staffName ?? s.name,
          type: "staff",
          ...s,
        });
      }
    });
    deptStaffTree.value = Array.from(deptMap.values()).filter(
      (n) => n.children && n.children.length > 0
      }, 250)
    );
  });
};
  };
const openDialog = (type, row) => {
  nextTick(() => {
    loadDeptOptions();
    loadBankOptions();
    loadUserList();
    employeeList.value = [];
    Object.assign(form.value, {
  const handleSalaryInput = row => {
    row.basicSalary = parseNum(row.basicSalary);
    row.otherIncome = parseNum(row.otherIncome);
    requestEmployeeRecalculate(row);
  };
  // åŸºç¡€èµ„料表单
  const data = reactive({
    form: {
      id: undefined,
      salaryTitle: "",
      deptIds: [],
@@ -507,298 +522,413 @@
      remark: "",
      payBank: "",
      auditUserId: undefined,
    },
    rules: {
      salaryTitle: [
        { required: true, message: "请输入工资主题", trigger: "blur" },
      ],
      deptIds: [{ required: true, message: "请选择部门", trigger: "change" }],
      salaryMonth: [
        { required: true, message: "请选择工资月份", trigger: "change" },
      ],
      auditUserId: [
        { required: true, message: "请选择审核人", trigger: "change" },
      ],
    },
  });
  const { form, rules } = toRefs(data);
  // è®¡ç®—工资总额(所有员工实发工资之和)
  const totalSalary = computed(() => {
    return employeeList.value.reduce((sum, e) => sum + parseNum(e.netSalary), 0);
  });
  // æ ¹æ®å®¡æ ¸äººID获取审核人名称
  const auditUserName = computed(() => {
    if (!form.value.auditUserId) return "";
    const user = userList.value.find(u => u.userId === form.value.auditUserId);
    return user ? user.nickName : "";
  });
  const loadBankOptions = () => {
    return bankList().then(res => {
      const list = Array.isArray(res?.data) ? res.data : [];
      bankOptions.value = list
        .map(b => (b?.bankName == null ? "" : String(b.bankName).trim()))
        .filter(v => v !== "");
    });
    // ç¼–辑:列表页已返回主表字段;这里只做回显(明细由“生成工资表/计算工资”得到)
    if (type === "edit" && row?.id) {
      form.value.id = row.id;
      form.value.salaryTitle = row.salaryTitle ?? "";
      // deptIds åŽç«¯æ˜¯å­—符串(多个用逗号分隔);当前表单仍是单选 deptId
      form.value.deptIds = row.deptIds
        ? String(row.deptIds).split(",").map((id) => Number(id.trim())).filter(Boolean)
        : [];
      form.value.salaryMonth = row.salaryMonth ?? "";
      form.value.remark = row.remark ?? "";
      form.value.payBank = row.payBank ?? "";
      form.value.auditUserId = row.auditUserId ?? undefined;
      // å¦‚果有员工明细数据,直接反显
      if (row.staffSalaryDetailList && row.staffSalaryDetailList.length > 0) {
        employeeList.value = row.staffSalaryDetailList.map((e) => ({
          staffOnJobId: e.staffOnJobId ?? e.staffId ?? e.id,
          id: e.staffOnJobId ?? e.staffId ?? e.id,
          staffName: e.staffName ?? "",
          postName: e.postName ?? "",
          deptName: e.deptName ?? "",
          basicSalary: parseNum(e.basicSalary),
          pieceSalary: parseNum(e.pieceSalary),
          hourlySalary: parseNum(e.hourlySalary),
          otherIncome: parseNum(e.otherIncome),
          socialPersonal: parseNum(e.socialPersonal),
          fundPersonal: parseNum(e.fundPersonal),
          otherDeduct: parseNum(e.otherDeduct),
          salaryTax: parseNum(e.salaryTax),
          grossSalary: parseNum(e.grossSalary),
          deductSalary: parseNum(e.deductSalary),
          netSalary: parseNum(e.netSalary),
          remark: e.remark ?? "",
        }));
      }
    }
  });
};
const openAddPerson = () => {
  loadDeptStaffTree();
  addPersonVisible.value = true;
  nextTick(() => {
    personTreeRef.value?.setCheckedKeys([]);
  });
};
const addPersonClose = () => {};
const confirmAddPerson = () => {
  const tree = personTreeRef.value;
  if (!tree) {
    addPersonVisible.value = false;
    return;
  }
  const checked = tree.getCheckedNodes();
  const staffNodes = checked.filter((n) => n.type === "staff");
  const existIds = new Set(employeeList.value.map((e) => e.staffId || e.id));
  staffNodes.forEach((node) => {
    const id = node.staffId ?? node.id;
    if (existIds.has(id)) return;
    existIds.add(id);
    employeeList.value.push({
      staffOnJobId: id,
      id,
      staffName: node.label,
      postName: node.postName ?? node.post ?? "",
      deptName: node.deptName ?? "",
      basicSalary: 0,
      pieceSalary: 0,
      hourlySalary: 0,
      otherIncome: 0,
      socialPersonal: 0,
      fundPersonal: 0,
      otherDeduct: 0,
      salaryTax: 0,
      grossSalary: 0,
      deductSalary: 0,
      netSalary: 0,
      remark: "",
    });
  });
  addPersonVisible.value = false;
};
const removeEmployee = (row) => {
  employeeList.value = employeeList.value.filter(
    (e) => (e.staffOnJobId || e.id) !== (row.staffOnJobId || row.id)
  );
};
const onEmployeeSelectionChange = (selection) => {
  selectedEmployees.value = selection;
};
const handleBatchDelete = () => {
  if (!selectedEmployees.value?.length) {
    proxy.$modal.msgWarning("请先勾选要删除的员工");
    return;
  }
  const ids = new Set(selectedEmployees.value.map((e) => e.staffOnJobId || e.id));
  employeeList.value = employeeList.value.filter(
    (e) => !ids.has(e.staffOnJobId || e.id)
  );
};
const handleGenerate = () => {
  if (!form.value.deptIds?.length) {
    proxy.$modal.msgWarning("请先选择部门");
    return;
  }
  if (!form.value.salaryMonth) {
    proxy.$modal.msgWarning("请先选择工资月份");
    return;
  }
  const payload = {
    ids: form.value.deptIds,
    date: form.value.salaryMonth,
  };
  staffSalaryMainCalculateSalary(payload).then((res) => {
    const list = Array.isArray(res?.data) ? res.data : [];
    if (!list.length) {
      proxy.$modal.msgWarning("未计算到工资数据");
  const loadUserList = () => {
    return userListNoPageByTenantId().then(res => {
      userList.value = res.data || [];
    });
  };
  // æ‰å¹³åŒ–部门树供下拉使用
  function flattenDept(tree, list = []) {
    if (!tree?.length) return list;
    tree.forEach(node => {
      list.push({ deptId: node.deptId, deptName: node.deptName });
      if (node.children?.length) flattenDept(node.children, list);
    });
    return list;
  }
  // åŠ è½½æ‰€æœ‰éƒ¨é—¨
  const loadAllDeptOptions = () => {
    listDept().then(res => {
      const tree = res.data ?? [];
      deptOptions.value = flattenDept(tree);
    });
  };
  // åŠ è½½å¯ç”¨éƒ¨é—¨åˆ—è¡¨ï¼ˆç”¨äºŽåˆ¤æ–­éƒ¨é—¨æ˜¯å¦å¯é€‰ï¼‰
  const loadAvailableDepts = (salaryMonth) => {
    if (!salaryMonth) {
      availableDeptIds.value = new Set();
      return;
    }
    employeeList.value = list.map((e) => ({
      ...e,
      staffOnJobId: e.staffOnJobId ?? e.staffId ?? e.id,
      staffName: e.staffName,
      postName: e.postName,
      deptName: e.deptName,
      basicSalary: parseNum(e.basicSalary),
      pieceSalary: parseNum(e.pieceSalary),
      hourlySalary: parseNum(e.hourlySalary),
      otherIncome: parseNum(e.otherIncome),
      socialPersonal: parseNum(e.socialPersonal),
      fundPersonal: parseNum(e.fundPersonal),
      otherDeduct: parseNum(e.otherDeduct),
      salaryTax: parseNum(e.salaryTax),
      grossSalary: parseNum(e.grossSalary),
      deductSalary: parseNum(e.deductSalary),
      netSalary: parseNum(e.netSalary),
      remark: e.remark ?? "",
    }));
    proxy.$modal.msgSuccess("生成成功");
  });
};
const handleClear = () => {
  proxy.$modal.confirm("确定清空当前员工列表吗?").then(() => {
    employeeList.value = [];
  }).catch(() => {});
};
const handleTaxForm = () => {
  taxDialogVisible.value = true;
};
const submitForm = () => {
  formRef.value?.validate((valid) => {
    if (!valid) return;
    saveData(3); // ç¡®è®¤æäº¤ï¼ŒçŠ¶æ€ä¸º3(待审核)
  });
};
const saveDraft = () => {
  formRef.value?.validate((valid) => {
    if (!valid) return;
    saveData(1); // ä¿å­˜è‰ç¨¿ï¼ŒçŠ¶æ€ä¸º1(草稿)
  });
};
const saveData = (status) => {
  const payload = {
    id: form.value.id,
    salaryTitle: form.value.salaryTitle,
    deptIds: form.value.deptIds?.length ? form.value.deptIds.join(",") : "",
    salaryMonth: form.value.salaryMonth,
    remark: form.value.remark,
    payBank: form.value.payBank,
    auditUserId: form.value.auditUserId,
    auditUserName: auditUserName.value,
    totalSalary: totalSalary.value,
    staffSalaryDetailList: employeeList.value.map((e) => ({
      staffOnJobId: e.staffOnJobId ?? e.staffId ?? e.id,
      staffName: e.staffName,
      postName: e.postName ?? "",
      deptName: e.deptName ?? "",
      basicSalary: parseNum(e.basicSalary),
      pieceSalary: parseNum(e.pieceSalary),
      hourlySalary: parseNum(e.hourlySalary),
      otherIncome: parseNum(e.otherIncome),
      socialPersonal: parseNum(e.socialPersonal),
      fundPersonal: parseNum(e.fundPersonal),
      otherDeduct: parseNum(e.otherDeduct),
      salaryTax: parseNum(e.salaryTax),
      grossSalary: parseNum(e.grossSalary),
      deductSalary: parseNum(e.deductSalary),
      netSalary: parseNum(e.netSalary),
      remark: e.remark ?? "",
    })),
    staffSalaryMainAvailableDepts(salaryMonth).then(res => {
      const availableDepts = Array.isArray(res.data) ? res.data : [];
      availableDeptIds.value = new Set(availableDepts.map(d => d.deptId));
    });
  };
  if (props.operationType === "add") {
    staffSalaryMainAdd({ ...payload, status }).then(() => {
      proxy.$modal.msgSuccess(status === 1 ? "草稿保存成功" : "提交成功");
      closeDia();
    });
  } else {
    staffSalaryMainUpdate({ ...payload, status }).then(() => {
      proxy.$modal.msgSuccess(status === 1 ? "草稿保存成功" : "提交成功");
      closeDia();
    });
  }
};
const closeDia = () => {
  dialogVisible.value = false;
  emit("close");
};
  // åˆ¤æ–­éƒ¨é—¨æ˜¯å¦ç¦ç”¨
  const isDeptDisabled = (deptId) => {
    // å¦‚果没有选择月份,所有部门都可选
    if (!form.value.salaryMonth) return false;
    // å¦‚果已选择月份,只有在可用部门列表中的部门才可选
    return !availableDeptIds.value.has(deptId);
  };
defineExpose({ openDialog });
  // æž„建 éƒ¨é—¨-人员 æ ‘(用于新增人员弹窗)
  const loadDeptStaffTree = () => {
    Promise.all([listDept(), staffOnJobList()]).then(([deptRes, staffRes]) => {
      const tree = deptRes.data ?? [];
      const staffList = staffRes.data ?? [];
      const deptMap = new Map();
      function walk(nodes) {
        nodes.forEach(node => {
          deptMap.set(node.deptId, {
            id: "dept_" + node.deptId,
            deptId: node.deptId,
            label: node.deptName,
            type: "dept",
            children: [],
          });
          if (node.children?.length) walk(node.children);
        });
      }
      walk(tree);
      staffList.forEach(s => {
        const deptId = s.deptId ?? s.dept_id;
        const node = deptMap.get(deptId);
        if (node) {
          node.children.push({
            id: s.id ?? s.staffId,
            staffId: s.id ?? s.staffId,
            label: s.staffName ?? s.name,
            type: "staff",
            ...s,
          });
        }
      });
      deptStaffTree.value = Array.from(deptMap.values()).filter(
        n => n.children && n.children.length > 0
      );
    });
  };
  const openDialog = (type, row) => {
    nextTick(() => {
      resetRecalcState();
      loadBankOptions();
      loadUserList();
      loadAllDeptOptions();
      employeeList.value = [];
      Object.assign(form.value, {
        id: undefined,
        salaryTitle: "",
        deptIds: [],
        salaryMonth: "",
        remark: "",
        payBank: "",
        auditUserId: undefined,
      });
      // ç¼–辑:列表页已返回主表字段;这里只做回显(明细由“生成工资表/计算工资”得到)
      if (type === "edit" && row?.id) {
        form.value.id = row.id;
        form.value.salaryTitle = row.salaryTitle ?? "";
        // deptIds åŽç«¯æ˜¯å­—符串(多个用逗号分隔);当前表单仍是单选 deptId
        form.value.deptIds = row.deptIds
          ? String(row.deptIds)
              .split(",")
              .map(id => Number(id.trim()))
              .filter(Boolean)
          : [];
        form.value.salaryMonth = row.salaryMonth ?? "";
        form.value.remark = row.remark ?? "";
        form.value.payBank = row.payBank ?? "";
        form.value.auditUserId = row.auditUserId ?? undefined;
        // å¦‚果有员工明细数据,直接反显
        if (row.staffSalaryDetailList && row.staffSalaryDetailList.length > 0) {
          employeeList.value = row.staffSalaryDetailList.map(e => normalizeEmployeeRow(e));
        }
      }
      // ç¼–辑状态且有月份时,加载可用部门列表
      if (type === "edit" && form.value.salaryMonth) {
        loadAvailableDepts(form.value.salaryMonth);
      }
    });
  };
  // ç›‘听工资月份变化,清空部门选择并加载可用部门列表
  watch(() => form.value.salaryMonth, (newMonth, oldMonth) => {
    // åˆ‡æ¢æœˆä»½æ—¶æ¸…空已选择的部门
    if (newMonth !== oldMonth) {
      form.value.deptIds = [];
    }
    loadAvailableDepts(newMonth);
  });
  const openAddPerson = () => {
    loadDeptStaffTree();
    addPersonVisible.value = true;
    nextTick(() => {
      personTreeRef.value?.setCheckedKeys([]);
    });
  };
  const addPersonClose = () => {};
  const confirmAddPerson = () => {
    const tree = personTreeRef.value;
    if (!tree) {
      addPersonVisible.value = false;
      return;
    }
    const checked = tree.getCheckedNodes();
    const staffNodes = checked.filter(n => n.type === "staff");
    const existIds = new Set(employeeList.value.map(e => e.staffId || e.id));
    staffNodes.forEach(node => {
      const id = node.staffId ?? node.id;
      if (existIds.has(id)) return;
      existIds.add(id);
      const newRow = normalizeEmployeeRow({
        staffOnJobId: id,
        id,
        staffName: node.label,
        postName: node.postName ?? node.post ?? "",
        deptName: node.deptName ?? "",
        remark: "",
        socialSupplementAmount: null,
      });
      employeeList.value.push(newRow);
      requestEmployeeRecalculate(newRow);
    });
    addPersonVisible.value = false;
  };
  const removeEmployee = row => {
    clearRowRecalcState(row);
    employeeList.value = employeeList.value.filter(
      e => (e.staffOnJobId || e.id) !== (row.staffOnJobId || row.id)
    );
  };
  const onEmployeeSelectionChange = selection => {
    selectedEmployees.value = selection;
  };
  const handleBatchDelete = () => {
    if (!selectedEmployees.value?.length) {
      proxy.$modal.msgWarning("请先勾选要删除的员工");
      return;
    }
    const ids = new Set(selectedEmployees.value.map(e => e.staffOnJobId || e.id));
    selectedEmployees.value.forEach(item => clearRowRecalcState(item));
    employeeList.value = employeeList.value.filter(
      e => !ids.has(e.staffOnJobId || e.id)
    );
  };
  const handleGenerate = () => {
    if (!form.value.deptIds?.length) {
      proxy.$modal.msgWarning("请先选择部门");
      return;
    }
    if (!form.value.salaryMonth) {
      proxy.$modal.msgWarning("请先选择工资月份");
      return;
    }
    const payload = {
      ids: form.value.deptIds,
      date: form.value.salaryMonth,
    };
    staffSalaryMainCalculateSalary(payload).then(res => {
      const list = Array.isArray(res?.data) ? res.data : [];
      if (!list.length) {
        proxy.$modal.msgWarning("未计算到工资数据");
        return;
      }
      resetRecalcState();
      employeeList.value = list.map(e => normalizeEmployeeRow(e));
      proxy.$modal.msgSuccess("生成成功");
    });
  };
  const handleClear = () => {
    proxy.$modal
      .confirm("确定清空当前员工列表吗?")
      .then(() => {
        resetRecalcState();
        employeeList.value = [];
      })
      .catch(() => {});
  };
  const handleTaxForm = () => {
    taxDialogVisible.value = true;
  };
  const submitForm = () => {
    formRef.value?.validate(valid => {
      if (!valid) return;
      saveData(3); // ç¡®è®¤æäº¤ï¼ŒçŠ¶æ€ä¸º3(待审核)
    });
  };
  const saveDraft = () => {
    formRef.value?.validate(valid => {
      if (!valid) return;
      saveData(1); // ä¿å­˜è‰ç¨¿ï¼ŒçŠ¶æ€ä¸º1(草稿)
    });
  };
  const saveData = status => {
    const payload = {
      id: form.value.id,
      salaryTitle: form.value.salaryTitle,
      deptIds: form.value.deptIds?.length ? form.value.deptIds.join(",") : "",
      salaryMonth: form.value.salaryMonth,
      remark: form.value.remark,
      payBank: form.value.payBank,
      auditUserId: form.value.auditUserId,
      auditUserName: auditUserName.value,
      totalSalary: totalSalary.value,
      staffSalaryDetailList: employeeList.value.map(e => ({
        staffOnJobId: e.staffOnJobId ?? e.staffId ?? e.id,
        staffName: e.staffName,
        postName: e.postName ?? "",
        deptName: e.deptName ?? "",
        basicSalary: parseNum(e.basicSalary),
        pieceSalary: parseNum(e.pieceSalary),
        hourlySalary: parseNum(e.hourlySalary),
        otherIncome: parseNum(e.otherIncome),
        socialPersonal: parseNum(e.socialPersonal),
        fundPersonal: parseNum(e.fundPersonal),
        otherDeduct: parseNum(e.otherDeduct),
        socialSupplementAmount: parseNum(e.socialSupplementAmount),
        salaryTax: parseNum(e.salaryTax),
        grossSalary: parseNum(e.grossSalary),
        deductSalary: parseNum(e.deductSalary),
        netSalary: parseNum(e.netSalary),
        remark: e.remark ?? "",
      })),
    };
    if (props.operationType === "add") {
      staffSalaryMainAdd({ ...payload, status }).then(() => {
        proxy.$modal.msgSuccess(status === 1 ? "草稿保存成功" : "提交成功");
        closeDia();
      });
    } else {
      staffSalaryMainUpdate({ ...payload, status }).then(() => {
        proxy.$modal.msgSuccess(status === 1 ? "草稿保存成功" : "提交成功");
        closeDia();
      });
    }
  };
  const closeDia = () => {
    resetRecalcState();
    dialogVisible.value = false;
    emit("close");
  };
  defineExpose({ openDialog });
</script>
<style scoped>
.form-dia-body {
  padding: 0;
}
.card-title-line {
  color: #f56c6c;
  margin-right: 4px;
}
.form-card {
  margin-bottom: 16px;
}
.form-card :deep(.el-card__header) {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
}
.card-title {
  font-weight: 500;
}
.card-collapse {
  color: #999;
  cursor: pointer;
}
.toolbar {
  margin-bottom: 16px;
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}
.employee-table-wrap {
  position: relative;
  min-height: 120px;
}
.table-empty {
  text-align: center;
  padding: 24px;
  color: #999;
  font-size: 14px;
}
.add-person-tree {
  max-height: 360px;
  overflow-y: auto;
  padding: 8px 0;
}
.tax-desc {
  margin-bottom: 12px;
  font-size: 14px;
  color: #606266;
}
.dialog-footer {
  text-align: right;
}
.salary-total {
  margin-top: 16px;
  padding: 12px 16px;
  background-color: #f5f7fa;
  border-radius: 4px;
  text-align: right;
  font-size: 16px;
}
.salary-total .total-label {
  color: #606266;
  margin-right: 8px;
}
.salary-total .total-value {
  color: #f56c6c;
  font-weight: bold;
  font-size: 18px;
}
  .form-dia-body {
    padding: 0;
  }
  .card-title-line {
    color: #f56c6c;
    margin-right: 4px;
  }
  .form-card {
    margin-bottom: 16px;
  }
  .form-card :deep(.el-card__header) {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 16px;
  }
  .card-title {
    font-weight: 500;
  }
  .card-collapse {
    color: #999;
    cursor: pointer;
  }
  .toolbar {
    margin-bottom: 16px;
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
  }
  .employee-table-wrap {
    position: relative;
    min-height: 120px;
  }
  .table-empty {
    text-align: center;
    padding: 24px;
    color: #999;
    font-size: 14px;
  }
  .add-person-tree {
    max-height: 360px;
    overflow-y: auto;
    padding: 8px 0;
  }
  .tax-desc {
    margin-bottom: 12px;
    font-size: 14px;
    color: #606266;
  }
  .dialog-footer {
    text-align: right;
  }
  .salary-total {
    margin-top: 16px;
    padding: 12px 16px;
    background-color: #f5f7fa;
    border-radius: 4px;
    text-align: right;
    font-size: 16px;
  }
  .salary-total .total-label {
    color: #606266;
    margin-right: 8px;
  }
  .salary-total .total-value {
    color: #f56c6c;
    font-weight: bold;
    font-size: 18px;
  }
</style>
src/views/personnelManagement/monthlyStatistics/index.vue
@@ -107,7 +107,7 @@
  nextTick,
} from "vue";
import { ElMessageBox } from "element-plus";
import Cookies from "js-cookie";
import useUserStore from "@/store/modules/user";
import FormDia from "./components/formDia.vue";
import BankSettingDia from "./components/bankSettingDia.vue";
import AuditDia from "./components/auditDia.vue";
@@ -167,7 +167,12 @@
      {
        name: "审核",
        type: "text",
        disabled: (row) => Number(row?.status) !== 3,
        disabled: (row) => {
          const currentUserId = Number(userStore.id);
          const auditUserId = Number(row?.auditUserId);
          // çŠ¶æ€ä¸æ˜¯å¾…å®¡æ ¸ æˆ– å½“前用户不是指定的审核人,则禁用审核按钮
          return Number(row?.status) !== 3 || !currentUserId || currentUserId !== auditUserId;
        },
        clickFun: (row) => openAudit(row),
      },
      {
@@ -193,6 +198,7 @@
const operationType = ref("add");
const currentRow = ref({});
const { proxy } = getCurrentInstance();
const userStore = useUserStore();
const bankSetting = ref({});
const bankDialogVisible = ref(false);
const bankDiaRef = ref(null);
@@ -378,8 +384,8 @@
const handleExport = () => {
  proxy.download(
    "/compensationPerformance/export",
    { ...searchForm.value, current: page.current, size: page.size },
    "/staffSalaryMain/export",
    { ...searchForm.value },
    "工资表.xlsx"
  );
};
@@ -404,4 +410,4 @@
.table_list {
  margin-top: 20px;
}
</style>
</style>
src/views/personnelManagement/socialSecuritySet/components/formDia.vue
@@ -1,34 +1,31 @@
<template>
  <div>
    <FormDialog
      v-model="dialogFormVisible"
      :operation-type="operationType"
      :title="dialogTitle"
      width="80%"
      @close="closeDia"
      @confirm="submitForm"
      @cancel="closeDia"
    >
      <el-form ref="formRef" :model="form" :rules="rules" label-position="top">
    <FormDialog v-model="dialogFormVisible"
                :operation-type="operationType"
                :title="dialogTitle"
                width="80%"
                @close="closeDia"
                @confirm="submitForm"
                @cancel="closeDia">
      <el-form ref="formRef"
               :model="form"
               :rules="rules"
               label-position="top">
        <el-row :gutter="24">
          <!-- å·¦ä¾§ï¼šé€‚用人员 -->
          <el-col :span="8">
            <el-form-item label="适用人员:" prop="deptIds">
            <el-form-item label="适用人员:"
                          prop="deptIds">
              <div class="dept-checkbox-wrap">
                <el-checkbox-group
                  v-model="form.deptIds"
                  :disabled="isDetail"
                >
                  <div
                    v-for="dept in deptList"
                    :key="dept.deptId"
                    class="dept-checkbox-item"
                  >
                <el-checkbox-group v-model="form.deptIds"
                                   :disabled="isDetail">
                  <div v-for="dept in deptList"
                       :key="dept.deptId"
                       class="dept-checkbox-item">
                    <el-checkbox :value="dept.deptId">
                      {{ dept.deptName }}
                      <span v-if="dept.personCount != null" class="dept-count"
                        >{{ dept.personCount }}人</span
                      >
                      <span v-if="dept.personCount != null"
                            class="dept-count">{{ dept.personCount }}人</span>
                    </el-checkbox>
                  </div>
                </el-checkbox-group>
@@ -38,124 +35,117 @@
          <!-- å³ä¾§ï¼šåŸºç¡€ä¿¡æ¯ + ä¿é™©ç±»åž‹ -->
          <el-col :span="16">
            <!-- åŸºç¡€ä¿¡æ¯ -->
            <el-card class="form-card" shadow="never">
            <el-card class="form-card"
                     shadow="never">
              <template #header>
                <span class="card-title"><span class="card-title-line">|</span> åŸºç¡€ä¿¡æ¯</span>
                <el-icon class="card-collapse"><ArrowUp /></el-icon>
                <el-icon class="card-collapse">
                  <ArrowUp />
                </el-icon>
              </template>
              <el-form-item label="方案标题:" prop="title">
                <el-input
                  v-model="form.title"
                  placeholder="请输入"
                  clearable
                  :disabled="isDetail"
                />
              <el-form-item label="方案标题:"
                            prop="title">
                <el-input v-model="form.title"
                          placeholder="请输入"
                          clearable
                          :disabled="isDetail" />
              </el-form-item>
              <el-form-item label="备注:" prop="remark">
                <el-input
                  v-model="form.remark"
                  type="textarea"
                  :rows="2"
                  placeholder="请输入"
                  clearable
                  :disabled="isDetail"
                />
              <el-form-item label="备注:"
                            prop="remark">
                <el-input v-model="form.remark"
                          type="textarea"
                          :rows="2"
                          placeholder="请输入"
                          clearable
                          :disabled="isDetail" />
              </el-form-item>
            </el-card>
            <!-- ä¿é™©ç±»åž‹ -->
            <el-card class="form-card" shadow="never">
            <el-card class="form-card"
                     shadow="never">
              <template #header>
                <span class="card-title"><span class="card-title-line">|</span> ä¿é™©ç±»åž‹</span>
                <el-button
                  v-if="!isDetail"
                  type="primary"
                  size="small"
                  @click="addInsuranceBenefit"
                >
                <el-button v-if="!isDetail"
                           type="primary"
                           size="small"
                           @click="addInsuranceBenefit">
                  æ·»åŠ ä¿é™©ç¦åˆ©
                </el-button>
              </template>
              <el-row :gutter="16">
                <el-col
                  v-for="(item, index) in form.insuranceBenefits"
                  :key="item._key"
                  :span="12"
                >
                <el-col v-for="(item, index) in form.insuranceBenefits"
                        :key="item._key"
                        :span="12">
                  <div class="insurance-benefit-card">
                    <div class="insurance-benefit-title">
                      ä¿é™©ç¦åˆ©{{ index + 1 }}
                      <el-button
                        v-if="!isDetail && form.insuranceBenefits.length > 1"
                        type="danger"
                        link
                        size="small"
                        class="card-delete-btn"
                        @click="removeInsuranceBenefit(index)"
                      >
                      <el-button v-if="!isDetail && form.insuranceBenefits.length > 1"
                                 type="danger"
                                 link
                                 size="small"
                                 class="card-delete-btn"
                                 @click="removeInsuranceBenefit(index)">
                        åˆ é™¤
                      </el-button>
                    </div>
                    <el-form-item
                      :prop="'insuranceBenefits.' + index + '.insuranceType'"
                      label="保险类型:"
                      label-width="100px"
                    <el-form-item :prop="'insuranceBenefits.' + index + '.insuranceType'"
                                  label="保险类型:"
                                  label-width="100px"
                                  :rules="[{ required: true, message: '请选择保险类型', trigger: 'blur' }]"
                    >
                      <el-select
                        v-model="item.insuranceType"
                        placeholder="请选择"
                        clearable
                        style="width: 100%"
                        :disabled="isDetail"
                      >
                        <el-option
                          v-for="opt in insuranceTypeOptions"
                          :key="opt.value"
                          :label="opt.label"
                          :value="opt.value"
                        />
                      <el-select v-model="item.insuranceType"
                                 placeholder="请选择"
                                 clearable
                                 style="width: 100%"
                                 :disabled="isDetail">
                        <el-option v-for="opt in insuranceTypeOptions"
                                   :key="opt.value"
                                   :label="opt.label"
                                   :value="opt.value" />
                      </el-select>
                    </el-form-item>
                    <el-form-item label="缴费基数:" label-width="100px">
                    <el-form-item label="缴费基数:"
                                  label-width="100px"
                                  :prop="'insuranceBenefits.' + index + '.paymentBase'"
                                  :rules="[{ validator: validatePaymentBase, trigger: 'blur' }]">
                      <div class="base-salary-wrap">
                        <el-input
                          v-model="item.paymentBase"
                          placeholder="根据基本工资缴纳"
                          clearable
                          style="width: 120px"
                          type="number"
                          :disabled="isDetail || item.useBasicSalary"
                          @input="handlePaymentBaseInput(item)"
                        />
                        <el-checkbox
                          v-model="item.useBasicSalary"
                          @change="handleUseBasicSalaryChange(item)"
                          :disabled="isDetail"
                        >
                        <el-input v-model="item.paymentBase"
                                  placeholder="根据基本工资缴纳"
                                  clearable
                                  style="width: 120px"
                                  type="number"
                                  :disabled="isDetail || item.useBasicSalary"
                                  @input="handlePaymentBaseInput(item)" />
                        <el-checkbox v-model="item.useBasicSalary"
                                     @change="(val) => handleUseBasicSalaryChange(item, index)"
                                     :disabled="isDetail">
                          è°ƒç”¨åŸºæœ¬å·¥èµ„
                        </el-checkbox>
                      </div>
                    </el-form-item>
                    <el-form-item label="个人缴费比例:" label-width="100px">
                    <el-form-item label="个人缴费比例:"
                                  label-width="100px"
                                  :prop="'insuranceBenefits.' + index + '.personalRatio'"
                                  :rules="[{ required: true, message: '请输入个人缴费比例', trigger: 'blur' }]">
                      <div class="personal-ratio-wrap">
                        <el-input
                          v-model="item.personalRatio"
                          placeholder="请输入"
                          clearable
                          style="width: 100px"
                          type="number"
                          :disabled="isDetail"
                        />
                        <el-input v-model="item.personalRatio"
                                  placeholder="请输入"
                                  clearable
                                  style="width: 100px"
                                  type="number"
                                  :disabled="isDetail"
                                  :min="0"
                                  @input="handlePersonalRatioInput(item)" />
                        <span class="ratio-unit">(%)</span>
                        <span class="ratio-plus">+</span>
                        <el-input
                          v-model="item.personalFixed"
                          placeholder="请输入"
                          clearable
                          style="width: 100px"
                          type="number"
                          :disabled="isDetail"
                        />
                        <el-input v-model="item.personalFixed"
                                  placeholder="请输入"
                                  clearable
                                  style="width: 100px"
                                  type="number"
                                  :disabled="isDetail"
                                  :min="0"
                                  @input="handlePersonalFixedInput(item)" />
                      </div>
                    </el-form-item>
                  </div>
@@ -170,301 +160,345 @@
</template>
<script setup>
import { ref, reactive, toRefs, getCurrentInstance, nextTick, computed } from "vue";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import { ArrowUp } from "@element-plus/icons-vue";
import { listDept } from "@/api/system/dept.js";
import { socialSecurityAdd, socialSecurityUpdate } from "@/api/personnelManagement/socialSecuritySet.js";
  import {
    ref,
    reactive,
    toRefs,
    getCurrentInstance,
    nextTick,
    computed,
  } from "vue";
  import FormDialog from "@/components/Dialog/FormDialog.vue";
  import { ArrowUp } from "@element-plus/icons-vue";
  import { listDept } from "@/api/system/dept.js";
  import {
    socialSecurityAdd,
    socialSecurityUpdate,
  } from "@/api/personnelManagement/socialSecuritySet.js";
const emit = defineEmits(["close"]);
const { proxy } = getCurrentInstance();
  const emit = defineEmits(["close"]);
  const { proxy } = getCurrentInstance();
const dialogFormVisible = ref(false);
const operationType = ref("add");
const formRef = ref(null);
const deptList = ref([]);
  const dialogFormVisible = ref(false);
  const operationType = ref("add");
  const formRef = ref(null);
  const deptList = ref([]);
const isDetail = computed(() => operationType.value === "detail");
  const isDetail = computed(() => operationType.value === "detail");
const dialogTitle = () =>
  operationType.value === "add"
    ? "新增方案"
    : operationType.value === "edit"
    ? "编辑方案"
    : "方案详情";
  const dialogTitle = () =>
    operationType.value === "add"
      ? "新增方案"
      : operationType.value === "edit"
      ? "编辑方案"
      : "方案详情";
// ä¿é™©ç±»åž‹é€‰é¡¹ï¼ˆå¯æŒ‰å­—典替换)
const insuranceTypeOptions = [
  { label: "养老保险", value: "养老保险" },
  { label: "医疗保险", value: "医疗保险" },
  { label: "失业保险", value: "失业保险" },
  { label: "工伤保险", value: "工伤保险" },
  { label: "生育保险", value: "生育保险" },
  { label: "公积金", value: "公积金" },
];
  // ä¿é™©ç±»åž‹é€‰é¡¹ï¼ˆå¯æŒ‰å­—典替换)
  const insuranceTypeOptions = [
    { label: "养老保险", value: "养老保险" },
    { label: "医疗保险", value: "医疗保险" },
    { label: "失业保险", value: "失业保险" },
    { label: "工伤保险", value: "工伤保险" },
    { label: "生育保险", value: "生育保险" },
    { label: "公积金", value: "公积金" },
  ];
const defaultBenefit = () => ({
  _key: Math.random().toString(36).slice(2),
  insuranceType: "",
  paymentBase: "",
  useBasicSalary: false,
  personalRatio: "",
  personalFixed: "",
});
  const defaultBenefit = () => ({
    _key: Math.random().toString(36).slice(2),
    insuranceType: "",
    paymentBase: "",
    useBasicSalary: false,
    personalRatio: "",
    personalFixed: "",
  });
const data = reactive({
  form: {
    id: undefined,
    title: "",
    remark: "",
    deptIds: [],
    insuranceBenefits: [defaultBenefit()],
  },
  rules: {
    title: [{ required: true, message: "请输入方案标题", trigger: "blur" }],
    deptIds: [
      {
        required: true,
        type: "array",
        min: 1,
        message: "请至少选择一个适用部门",
        trigger: "change",
      },
    ],
  },
});
const { form, rules } = toRefs(data);
  const data = reactive({
    form: {
      id: undefined,
      title: "",
      remark: "",
      deptIds: [],
      insuranceBenefits: [defaultBenefit()],
    },
    rules: {
      title: [{ required: true, message: "请输入方案标题", trigger: "blur" }],
      deptIds: [
        {
          required: true,
          type: "array",
          min: 1,
          message: "请至少选择一个适用部门",
          trigger: "change",
        },
      ],
    },
  });
  const { form, rules } = toRefs(data);
function flattenDept(tree, list = []) {
  if (!tree || !tree.length) return list;
  tree.forEach((node) => {
    list.push({
      deptId: node.deptId,
      deptName: node.deptName,
      personCount: node.personCount ?? null,
  function flattenDept(tree, list = []) {
    if (!tree || !tree.length) return list;
    tree.forEach(node => {
      list.push({
        deptId: node.deptId,
        deptName: node.deptName,
        personCount: node.personCount ?? null,
      });
      if (node.children && node.children.length) {
        flattenDept(node.children, list);
      }
    });
    if (node.children && node.children.length) {
      flattenDept(node.children, list);
    }
  });
  return list;
}
const loadDeptList = () => {
  listDept().then((res) => {
    const tree = res.data ?? [];
    deptList.value = flattenDept(tree);
  });
};
const addInsuranceBenefit = () => {
  form.value.insuranceBenefits.push(defaultBenefit());
};
const removeInsuranceBenefit = (index) => {
  form.value.insuranceBenefits.splice(index, 1);
};
const handleUseBasicSalaryChange = (item) => {
  if (item.useBasicSalary) {
    item.paymentBase = "";
    return list;
  }
};
const handlePaymentBaseInput = (item) => {
  if (item.paymentBase !== "" && item.paymentBase != null) {
    item.useBasicSalary = false;
  }
};
const resetForm = () => {
  form.value = {
    id: undefined,
    title: "",
    remark: "",
    deptIds: [],
    insuranceBenefits: [defaultBenefit()],
  const loadDeptList = () => {
    listDept().then(res => {
      const tree = res.data ?? [];
      deptList.value = flattenDept(tree);
    });
  };
};
const openDialog = (type, row) => {
  operationType.value = type;
  dialogFormVisible.value = true;
  loadDeptList();
  resetForm();
  if ((type === "edit" || type === "detail") && row) {
    const d = row || {};
    form.value.id = d.id;
    form.value.title = d.title;
    form.value.remark = d.remark ?? "";
    // deptIds åŽç«¯å¯èƒ½æ˜¯é€—号分隔字符串或数组,这里统一转为数组并尽量还原数值类型
    if (d.deptIds) {
      form.value.deptIds = String(d.deptIds)
        .split(",")
        .filter((v) => v !== "")
        .map((v) => {
          const num = Number(v);
          return Number.isNaN(num) ? v : num;
        });
    } else {
      form.value.deptIds = [];
  const addInsuranceBenefit = () => {
    form.value.insuranceBenefits.push(defaultBenefit());
  };
  const removeInsuranceBenefit = index => {
    form.value.insuranceBenefits.splice(index, 1);
  };
  const handleUseBasicSalaryChange = (item, index) => {
    if (item.useBasicSalary) {
      item.paymentBase = "";
    }
    const detailList = d.schemeInsuranceDetailList || [];
    form.value.insuranceBenefits =
      detailList.length > 0
        ? detailList.map((b) => ({
            _key: Math.random().toString(36).slice(2),
            insuranceType: b.insuranceType || "",
            paymentBase: b.paymentBase ?? "",
            useBasicSalary: b.useBasicSalary === 2,
            personalRatio: b.personalRatio ?? "",
            personalFixed: b.personalFixed ?? "",
          }))
        : [defaultBenefit()];
  }
};
    nextTick(() => {
      formRef.value?.validateField(`insuranceBenefits.${index}.paymentBase`);
    });
  };
const submitForm = () => {
  // è¯¦æƒ…模式下不提交,只关闭弹窗
  if (operationType.value === "detail") {
    closeDia();
    return;
  }
  formRef.value?.validate((valid) => {
    if (!valid) return;
    const deptIds =
      Array.isArray(form.value.deptIds) && form.value.deptIds.length
        ? form.value.deptIds.join(",")
        : "";
    const schemeInsuranceDetailList = (form.value.insuranceBenefits || []).map(
      ({ _key, ...rest }) => ({
        ...rest,
        useBasicSalary: rest.useBasicSalary ? 2 : 1,
      })
    );
    const insuranceTypes = schemeInsuranceDetailList
      .map((item) => item.insuranceType)
      .filter((v) => v)
      .join(",");
    // éƒ¨é—¨åç§°ï¼Œå¤šä¸ªä½¿ç”¨é€—号隔开(根据选中的 deptIds ä¸Ž deptList è®¡ç®—)
    const deptNames = (deptList.value || [])
      .filter((d) =>
        (form.value.deptIds || []).some(
          (id) => String(id) === String(d.deptId)
        )
      )
      .map((d) => d.deptName)
      .join(",");
    const submitData = {
      id: form.value.id,
      title: form.value.title,
      remark: form.value.remark ?? "",
      deptIds,
      insuranceTypes,
      deptNames,
      schemeInsuranceDetailList,
  const handlePaymentBaseInput = item => {
    if (item.paymentBase !== "" && item.paymentBase != null) {
      item.useBasicSalary = false;
    }
  };
  const handlePersonalRatioInput = item => {
    if (item.personalRatio !== "" && item.personalRatio != null) {
      const value = Number(item.personalRatio);
      if (value < 0) {
        item.personalRatio = "";
      }
    }
  };
  const handlePersonalFixedInput = item => {
    if (item.personalFixed !== "" && item.personalFixed != null) {
      const value = Number(item.personalFixed);
      if (value < 0) {
        item.personalFixed = "";
      }
    }
  };
  const validatePaymentBase = (rule, value, callback) => {
    const match = rule.field.match(/insuranceBenefits\.(\d+)\.paymentBase/);
    if (match) {
      const index = parseInt(match[1], 10);
      const item = form.value.insuranceBenefits[index];
      if (item && !item.useBasicSalary && (!value || value === "")) {
        callback(new Error("请输入缴费基数"));
      } else {
        callback();
      }
    } else {
      callback();
    }
  };
  const resetForm = () => {
    form.value = {
      id: undefined,
      title: "",
      remark: "",
      deptIds: [],
      insuranceBenefits: [defaultBenefit()],
    };
    if (operationType.value === "add") {
      socialSecurityAdd(submitData).then(() => {
        proxy.$modal.msgSuccess("新增成功");
        closeDia();
      });
    } else {
      socialSecurityUpdate(submitData).then(() => {
        proxy.$modal.msgSuccess("修改成功");
        closeDia();
      });
  };
  const openDialog = (type, row) => {
    operationType.value = type;
    dialogFormVisible.value = true;
    loadDeptList();
    resetForm();
    if ((type === "edit" || type === "detail") && row) {
      const d = row || {};
      form.value.id = d.id;
      form.value.title = d.title;
      form.value.remark = d.remark ?? "";
      // deptIds åŽç«¯å¯èƒ½æ˜¯é€—号分隔字符串或数组,这里统一转为数组并尽量还原数值类型
      if (d.deptIds) {
        form.value.deptIds = String(d.deptIds)
          .split(",")
          .filter(v => v !== "")
          .map(v => {
            const num = Number(v);
            return Number.isNaN(num) ? v : num;
          });
      } else {
        form.value.deptIds = [];
      }
      const detailList = d.schemeInsuranceDetailList || [];
      form.value.insuranceBenefits =
        detailList.length > 0
          ? detailList.map(b => ({
              _key: Math.random().toString(36).slice(2),
              insuranceType: b.insuranceType || "",
              paymentBase: b.paymentBase ?? "",
              useBasicSalary: b.useBasicSalary === 2,
              personalRatio: b.personalRatio ?? "",
              personalFixed: b.personalFixed ?? "",
            }))
          : [defaultBenefit()];
    }
  });
};
  };
const closeDia = () => {
  proxy.resetForm?.("formRef");
  dialogFormVisible.value = false;
  emit("close");
};
  const submitForm = () => {
    // è¯¦æƒ…模式下不提交,只关闭弹窗
    if (operationType.value === "detail") {
      closeDia();
      return;
    }
    formRef.value?.validate(valid => {
      if (!valid) return;
      const deptIds =
        Array.isArray(form.value.deptIds) && form.value.deptIds.length
          ? form.value.deptIds.join(",")
          : "";
      const schemeInsuranceDetailList = (form.value.insuranceBenefits || []).map(
        ({ _key, ...rest }) => ({
          ...rest,
          useBasicSalary: rest.useBasicSalary ? 2 : 1,
        })
      );
      const insuranceTypes = schemeInsuranceDetailList
        .map(item => item.insuranceType)
        .filter(v => v)
        .join(",");
      // éƒ¨é—¨åç§°ï¼Œå¤šä¸ªä½¿ç”¨é€—号隔开(根据选中的 deptIds ä¸Ž deptList è®¡ç®—)
      const deptNames = (deptList.value || [])
        .filter(d =>
          (form.value.deptIds || []).some(id => String(id) === String(d.deptId))
        )
        .map(d => d.deptName)
        .join(",");
      const submitData = {
        id: form.value.id,
        title: form.value.title,
        remark: form.value.remark ?? "",
        deptIds,
        insuranceTypes,
        deptNames,
        schemeInsuranceDetailList,
      };
      if (operationType.value === "add") {
        socialSecurityAdd(submitData).then(() => {
          proxy.$modal.msgSuccess("新增成功");
          closeDia();
        });
      } else {
        socialSecurityUpdate(submitData).then(() => {
          proxy.$modal.msgSuccess("修改成功");
          closeDia();
        });
      }
    });
  };
defineExpose({ openDialog });
  const closeDia = () => {
    proxy.resetForm?.("formRef");
    dialogFormVisible.value = false;
    emit("close");
  };
  defineExpose({ openDialog });
</script>
<style scoped>
.card-title-line {
  color: #f56c6c;
  margin-right: 4px;
}
.form-card {
  margin-bottom: 16px;
}
.form-card :deep(.el-card__header) {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
}
.card-title {
  font-weight: 500;
}
.card-collapse {
  color: #999;
  cursor: pointer;
}
.dept-checkbox-wrap {
  max-height: 320px;
  overflow-y: auto;
  padding: 8px 0;
  border: 1px solid var(--el-border-color);
  border-radius: 4px;
  background: #fff;
}
.dept-checkbox-item {
  padding: 6px 12px;
}
.dept-count {
  color: #909399;
  font-size: 12px;
  margin-left: 4px;
}
.insurance-benefit-card {
  border: 1px solid var(--el-border-color-lighter);
  border-radius: 4px;
  padding: 12px 16px;
  margin-bottom: 12px;
  background: #fafafa;
}
.insurance-benefit-title {
  font-size: 14px;
  margin-bottom: 12px;
  font-weight: 500;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.card-delete-btn {
  margin-left: auto;
}
.checkbox-group-inline {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
}
.base-salary-wrap {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}
.base-salary-text {
  color: #606266;
  font-size: 14px;
}
.personal-ratio-wrap {
  display: flex;
  align-items: center;
  gap: 8px;
}
.ratio-unit,
.ratio-plus {
  color: #606266;
  font-size: 14px;
}
</style>
  .card-title-line {
    color: #f56c6c;
    margin-right: 4px;
  }
  .form-card {
    margin-bottom: 16px;
  }
  .form-card :deep(.el-card__header) {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 16px;
  }
  .card-title {
    font-weight: 500;
  }
  .card-collapse {
    color: #999;
    cursor: pointer;
  }
  .dept-checkbox-wrap {
    max-height: 320px;
    overflow-y: auto;
    padding: 8px 0;
    border: 1px solid var(--el-border-color);
    border-radius: 4px;
    background: #fff;
  }
  .dept-checkbox-item {
    padding: 6px 12px;
  }
  .dept-count {
    color: #909399;
    font-size: 12px;
    margin-left: 4px;
  }
  .insurance-benefit-card {
    border: 1px solid var(--el-border-color-lighter);
    border-radius: 4px;
    padding: 12px 16px;
    margin-bottom: 12px;
    background: #fafafa;
  }
  .insurance-benefit-title {
    font-size: 14px;
    margin-bottom: 12px;
    font-weight: 500;
    display: flex;
    align-items: center;
    justify-content: space-between;
  }
  .card-delete-btn {
    margin-left: auto;
  }
  .checkbox-group-inline {
    display: flex;
    flex-wrap: wrap;
    gap: 16px;
  }
  .base-salary-wrap {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
  }
  .base-salary-text {
    color: #606266;
    font-size: 14px;
  }
  .personal-ratio-wrap {
    display: flex;
    align-items: center;
    gap: 8px;
  }
  .ratio-unit,
  .ratio-plus {
    color: #606266;
    font-size: 14px;
  }
</style>
src/views/procurementManagement/procurementLedger/index.vue
@@ -66,6 +66,9 @@
    </div>
    <div class="table_list">
      <div style="display: flex;justify-content: flex-end;margin-bottom: 20px;">
        <el-button type="success"
                   plain
                   @click="handleBatchGenerate">批量生成数据</el-button>
        <el-button type="primary"
                   @click="openForm('add')">新增台账</el-button>
        <el-button type="primary"
@@ -106,7 +109,7 @@
                               prop="specificationModel" />
              <el-table-column label="单位"
                               prop="unit" />
                               <el-table-column label="入库审核状态"
              <el-table-column label="入库审核状态"
                               prop="stockInApprovalStatus"
                               width="120">
                <template #default="scope">
@@ -121,7 +124,7 @@
              <el-table-column label="可用数量"
                               prop="availableQuality" />
              <el-table-column label="退货数量"
                               prop="returnQuality" />
                               prop="returnQuantity" />
              <el-table-column label="税率(%)"
                               prop="taxRate" />
              <el-table-column label="含税单价(元)"
@@ -721,6 +724,7 @@
    getOptions,
    getPurchaseTemplateList,
    delPurchaseTemplate,
    batchGeneratePurchaseInboundSteps,
  } from "@/api/procurementManagement/procurementLedger.js";
  import useFormData from "@/hooks/useFormData.js";
  const FileList = defineAsyncComponent(() =>
@@ -776,9 +780,9 @@
  // èŽ·å–å…¥åº“çŠ¶æ€æ ‡ç­¾ç±»åž‹
  const getStockInStatusType = status => {
    const typeMap = {
      "待入库": "info", // å¾…入库 - ç°è‰²
      "入库中": "warning", // å…¥åº“中 - æ©™è‰²
      "完全入库": "success", // å®Œå…¨å…¥åº“ - ç»¿è‰²
      å¾…入库: "info", // å¾…入库 - ç°è‰²
      å…¥åº“中: "warning", // å…¥åº“中 - æ©™è‰²
      å®Œå…¨å…¥åº“: "success", // å®Œå…¨å…¥åº“ - ç»¿è‰²
    };
    return typeMap[status] || "";
  };
@@ -786,9 +790,9 @@
  // èŽ·å–å…¥åº“å®¡æ ¸çŠ¶æ€æ ‡ç­¾ç±»åž‹
  const getStockInApprovalStatusType = status => {
    const typeMap = {
      "待入库": "info", // å¾…入库 - ç°è‰²
      "入库中": "warning", // å…¥åº“中 - æ©™è‰²
      "完全入库": "success", // å®Œå…¨å…¥åº“ - ç»¿è‰²
      å¾…入库: "info", // å¾…入库 - ç°è‰²
      å…¥åº“中: "warning", // å…¥åº“中 - æ©™è‰²
      å®Œå…¨å…¥åº“: "success", // å®Œå…¨å…¥åº“ - ç»¿è‰²
    };
    return typeMap[status] || "";
  };
@@ -1274,7 +1278,7 @@
  const openForm = async (type, row) => {
    // ç¼–辑时检查入库状态,完全入库时不能编辑
    if (type === "edit" && row) {
      if (row.stockInStatus === '完全入库') {
      if (row.stockInStatus === "完全入库") {
        proxy.$modal.msgWarning("完全入库状态的记录不能编辑");
        return;
      }
@@ -1314,7 +1318,10 @@
          form.value = { ...purchaseRes, stockInStatus: row.stockInStatus };
          fileList.value = purchaseRes.storageBlobVOS || [];
          // ä½¿ç”¨ productList æŽ¥å£èŽ·å–äº§å“åˆ—è¡¨ï¼Œä»¥èŽ·å–å…¥åº“å®¡æ ¸çŠ¶æ€
          const productRes = await productList({ salesLedgerId: row.id, type: 2 });
          const productRes = await productList({
            salesLedgerId: row.id,
            type: 2,
          });
          productData.value = productRes.data || [];
        } catch (error) {
          console.error("加载采购台账数据失败:", error);
@@ -1447,7 +1454,7 @@
  // æ‰“开产品弹框
  const openProductForm = async (type, row, index) => {
    // ç¼–辑时检查产品入库审核状态,完全入库时不能编辑
    if (type === "edit" && row && row.stockInApprovalStatus === '完全入库') {
    if (type === "edit" && row && row.stockInApprovalStatus === "完全入库") {
      proxy.$modal.msgWarning("完全入库状态的产品不能编辑");
      return;
    }
@@ -1636,7 +1643,7 @@
    }
    // æ£€æŸ¥é€‰ä¸­çš„产品中是否有完全入库的
    const hasFullyStocked = productSelectedRows.value.some(
      row => row.stockInApprovalStatus === '完全入库'
      row => row.stockInApprovalStatus === "完全入库"
    );
    if (hasFullyStocked) {
      proxy.$modal.msgWarning("选中的产品中包含完全入库的产品,无法删除");
@@ -1712,6 +1719,37 @@
          proxy.$modal.msgSuccess("删除成功");
          getList();
        });
      })
      .catch(() => {
        proxy.$modal.msg("已取消");
      });
  };
  const handleBatchGenerate = async () => {
    if (selectedRows.value.length === 0) {
      proxy.$modal.msgWarning("请选择数据");
      return;
    }
    const ids = selectedRows.value.map((item) => item.id);
    ElMessageBox.confirm("确认批量生成数据?", "批量生成", {
      confirmButtonText: "确认",
      cancelButtonText: "取消",
      type: "info",
    })
      .then(() => {
        proxy.$modal.loading("正在批量生成数据,请稍候...");
        batchGeneratePurchaseInboundSteps({ ids })
          .then((res) => {
            proxy.$modal.msgSuccess("批量生成成功");
            getList();
          })
          .catch(() => {
            proxy.$modal.msgError("批量生成失败");
          })
          .finally(() => {
            proxy.$modal.closeLoading();
          });
      })
      .catch(() => {
        proxy.$modal.msg("已取消");
@@ -1855,7 +1893,7 @@
      contractNo: form.value.salesLedgerId,
    });
    if (code == 200) {
      productData.value = data;
      productData.value = data || [];
    }
  };
src/views/procurementManagement/procurementPlan/index.vue
@@ -183,7 +183,7 @@
              </el-col>
              <el-col :span="12">
                <el-form-item label="创建时间" prop="createTime">
                  <el-date-picker v-model="formData.createTime"
                  <el-date-picker v-model="formCreateTimeDate"
                                  type="date"
                                  placeholder="选择日期"
                                  value-format="YYYY-MM-DD"
@@ -377,7 +377,8 @@
<script setup>
  import FormDialog from "@/components/Dialog/FormDialog.vue";
  import { ref, reactive, onMounted, getCurrentInstance } from "vue";
  import { ref, reactive, onMounted, getCurrentInstance, computed } from "vue";
  import dayjs from "dayjs";
  import { ElMessage, ElMessageBox } from "element-plus";
  import { Search, Refresh, Plus, Download } from "@element-plus/icons-vue";
  import {
@@ -432,6 +433,12 @@
    summaryMaterial: false,
    summaryAuxAttributes: false,
    summaryDemandDate: false,
  });
  const formCreateTimeDate = computed({
    get: () => (formData.createTime ? String(formData.createTime).split(" ")[0] : ""),
    set: (value) => {
      formData.createTime = value ? `${value} ${dayjs().format("HH:mm:ss")}` : "";
    },
  });
  // å½“前激活的标签页
@@ -510,7 +517,7 @@
  const handleAdd = () => {
    dialogType.value = "add";
    resetForm();
    formData.createTime = new Date().toISOString().split("T")[0];
    formData.createTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
    dialogVisible.value = true;
  };
src/views/procurementManagement/purchaseOrder/index.vue
@@ -57,7 +57,7 @@
          </el-select>
        </el-form-item>
        <el-form-item label="创建时间">
          <el-date-picker v-model="formData.createTime"
          <el-date-picker v-model="formCreateTimeDate"
                          type="date"
                          placeholder="选择日期"
                          value-format="YYYY-MM-DD"
@@ -73,7 +73,8 @@
<script setup>
import FormDialog from '@/components/Dialog/FormDialog.vue';
import { ref, reactive } from 'vue'
import { ref, reactive, computed } from 'vue'
import dayjs from 'dayjs'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
@@ -90,6 +91,12 @@
  supplierName: '',
  remark: '',
  createTime: ''
})
const formCreateTimeDate = computed({
  get: () => (formData.createTime ? String(formData.createTime).split(' ')[0] : ''),
  set: (value) => {
    formData.createTime = value ? `${value} ${dayjs().format('HH:mm:ss')}` : ''
  }
})
const mockData = [
@@ -130,9 +137,9 @@
const openDialog = (type, row = {}) => {
  dialogType.value = type
  if (type === 'edit' && row.id) {
    Object.assign(formData, { supplierName: row.supplierName, remark: row.remark })
    Object.assign(formData, { supplierName: row.supplierName, remark: row.remark, createTime: row.createTime || '' })
  } else {
    Object.assign(formData, { supplierName: '', remark: '', createTime: new Date().toISOString().split('T')[0] })
    Object.assign(formData, { supplierName: '', remark: '', createTime: dayjs().format('YYYY-MM-DD HH:mm:ss') })
  }
  dialogVisible.value = true
}
src/views/procurementManagement/qualityInspection/index.vue
@@ -119,7 +119,7 @@
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="创建时间">
              <el-date-picker v-model="formData.createTime"
              <el-date-picker v-model="formCreateTimeDate"
                              type="date"
                              placeholder="选择日期"
                              value-format="YYYY-MM-DD"
@@ -138,7 +138,8 @@
<script setup>
import FormDialog from '@/components/Dialog/FormDialog.vue';
import { ref, reactive } from 'vue'
import { ref, reactive, computed } from 'vue'
import dayjs from 'dayjs'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
@@ -158,6 +159,12 @@
  inspector: '',
  remark: '',
  createTime: ''
})
const formCreateTimeDate = computed({
  get: () => (formData.createTime ? String(formData.createTime).split(' ')[0] : ''),
  set: (value) => {
    formData.createTime = value ? `${value} ${dayjs().format('HH:mm:ss')}` : ''
  }
})
const mockData = [
@@ -203,7 +210,8 @@
      arrivalNo: row.arrivalNo, 
      supplierName: row.supplierName, 
      inspector: row.inspector,
      remark: row.remark
      remark: row.remark,
      createTime: row.createTime || ''
    })
  } else {
    Object.assign(formData, {
@@ -212,7 +220,7 @@
      products: [],
      inspector: '',
      remark: '',
      createTime: new Date().toISOString().split('T')[0]
      createTime: dayjs().format('YYYY-MM-DD HH:mm:ss')
    })
  }
  dialogVisible.value = true
src/views/productManagement/productIdentifier/index.vue
@@ -240,7 +240,7 @@
          <span>{{ currentProduct.identifierType }}</span>
        </el-form-item>
        <el-form-item label="创建时间">
          <el-date-picker v-model="createTime"
          <el-date-picker v-model="createTimeDate"
                          type="date"
                          placeholder="选择日期"
                          value-format="YYYY-MM-DD"
@@ -363,6 +363,7 @@
<script setup>
  import { ref, reactive, computed } from "vue";
  import dayjs from "dayjs";
  import { ElMessage, ElMessageBox } from "element-plus";
  import { Plus, Search, Loading, Download } from "@element-plus/icons-vue";
  import Pagination from "@/components/PIMTable/Pagination.vue";
@@ -449,7 +450,13 @@
  const generateQuantity = ref(1);
  const codeRule = ref("");
  const customPrefix = ref("");
  const createTime = ref(new Date().toISOString().split('T')[0]);
  const createTime = ref(dayjs().format("YYYY-MM-DD HH:mm:ss"));
  const createTimeDate = computed({
    get: () => (createTime.value ? String(createTime.value).split(" ")[0] : ""),
    set: (value) => {
      createTime.value = value ? `${value} ${dayjs().format("HH:mm:ss")}` : "";
    },
  });
  const newBatchNo = ref("");
  const reassignReason = ref("");
  const formRef = ref();
src/views/productionManagement/productStructure/DetailNew/MaterialCard.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,236 @@
<template>
  <div class="material-node">
    <!-- å½“前节点卡片 -->
    <div :class="['node-card', isRoot ? 'root-card' : (row.nodeType === 'semiFinished' ? 'semi-finished-card' : 'child-card')]">
      <div class="node-header">
        <div class="node-label">
          <el-tag :type="isRoot ? '' : (row.nodeType === 'semiFinished' ? 'warning' : 'success')" size="small" effect="dark">
            {{ isRoot ? '成品' : (row.nodeType === 'semiFinished' ? '半成品' : '原料') }}
          </el-tag>
          <span class="node-title">{{ row.productName || '未选择产品' }}</span>
          <span v-if="row.model" class="node-sub">规格: {{ row.model }}</span>
          <span v-if="row.unit" class="node-sub">单位: {{ row.unit }}</span>
        </div>
        <div class="node-actions">
          <template v-if="editable && (isRoot || row.nodeType === 'semiFinished')">
            <el-button type="primary"
                       text
                       size="small"
                       @click="handleAdd('semiFinished')">
              + æ·»åŠ åŠæˆå“
            </el-button>
            <el-button type="primary"
                       text
                       size="small"
                       @click="handleAdd('rawMaterial')">
              + æ·»åŠ åŽŸæ–™
            </el-button>
          </template>
          <el-button v-if="editable"
                     type="danger"
                     text
                     size="small"
                     @click="$emit('remove', row.tempId)">
            åˆ é™¤
          </el-button>
        </div>
      </div>
      <!-- ç¼–辑模式下的表单 -->
      <div v-if="editable" class="node-body">
        <el-row :gutter="12">
          <el-col :span="7">
            <el-form-item label="产品" :rules="[{ required: true, message: '请选择产品' }]" style="margin:0">
              <el-input :model-value="row.productName || ''"
                        readonly
                        placeholder="点击选择产品"
                        @click="openSelect"
                        style="width:100%">
                <template #suffix>
                  <el-icon><component :is="SearchIcon" /></el-icon>
                </template>
              </el-input>
            </el-form-item>
          </el-col>
          <el-col :span="5">
            <el-form-item label="规格" style="margin:0">
              <el-select v-model="row.model"
                         placeholder="请选择规格"
                         clearable
                         style="width:100%"
                         @visible-change="(v:boolean) => { if (v) openSelect() }">
                <el-option v-if="row.model" :label="row.model" :value="row.model" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col v-if="!isRoot" :span="5">
            <el-form-item label="工序" :rules="[{ required: true, message: '请选择工序' }]" style="margin:0">
              <el-select v-model="row.processId"
                         placeholder="请选择"
                         filterable
                         clearable
                         style="width:100%"
                         @change="(v:any) => $emit('processChange', row, v)">
                <el-option v-for="item in processOptions"
                           :key="item.id"
                           :label="item.name"
                           :value="item.id" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="4">
            <el-form-item label="数量" :rules="[{ required: true, message: '请填写数量' }]" style="margin:0">
              <el-input-number v-model="row.unitQuantity"
                               :min="0"
                               :precision="2"
                               :step="1"
                               controls-position="right"
                               style="width:100%"
                               @change="$emit('quantityChange')" />
            </el-form-item>
          </el-col>
          <el-col :span="3">
            <el-form-item label="单位" style="margin:0">
              <el-input v-model="row.unit" placeholder="单位" clearable style="width:100%" />
            </el-form-item>
          </el-col>
        </el-row>
      </div>
      <!-- éžç¼–辑模式:简洁显示 -->
      <div v-else class="node-view">
        <span v-if="!isRoot && row.processName">工序: {{ row.processName }} | </span>
        <span>数量: {{ row.unitQuantity || '-' }}</span>
      </div>
    </div>
    <!-- é€’归渲染子节点 -->
    <div v-if="row.children && row.children.length > 0" class="node-children">
      <MaterialCard
        v-for="child in row.children"
        :key="child.tempId"
        :row="child"
        :depth="depth + 1"
        :editable="editable"
        :process-options="processOptions"
        @remove="(id:string) => $emit('remove', id)"
        @add="(id:string, nodeType:string) => $emit('add', id, nodeType)"
        @select-product="(tempId: string, data: any) => $emit('selectProduct', tempId, data)"
        @process-change="(row: any, v: any) => $emit('processChange', row, v)"
        @quantity-change="$emit('quantityChange')"
      />
    </div>
  </div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Search } from '@element-plus/icons-vue'
const SearchIcon = Search
const props = defineProps<{
  row: any
  depth: number
  editable: boolean
  processOptions: any[]
}>()
const emit = defineEmits<{
  remove: [tempId: string]
  add: [tempId: string, nodeType: string]
  selectProduct: [tempId: string, data: any]
  processChange: [row: any, value: any]
  quantityChange: []
}>()
const isRoot = computed(() => props.depth === 0)
const openSelect = () => {
  emit('selectProduct', props.row.tempId, null)
}
const handleAdd = (nodeType: string) => {
  emit('add', props.row.tempId, nodeType)
}
</script>
<script lang="ts">
export default { name: 'MaterialCard' }
</script>
<style scoped>
.material-node {
  margin: 4px 0;
}
.node-card {
  border: 1px solid #e4e7ed;
  border-radius: 8px;
  overflow: hidden;
}
.root-card {
  border-color: #409eff;
  border-left: 4px solid #409eff;
  background-color: #f0f5ff;
}
.child-card {
  border-left: 4px solid #67c23a;
  background-color: #f0f9eb;
}
.semi-finished-card {
  border-left: 4px solid #e6a23c;
  background-color: #fdf6ec;
}
.node-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px;
  background-color: rgba(0,0,0,0.03);
  flex-wrap: wrap;
  gap: 4px;
}
.node-label {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}
.node-title {
  font-weight: 600;
  color: #303133;
}
.node-sub {
  font-size: 12px;
  color: #909399;
}
.node-actions {
  display: flex;
  gap: 4px;
}
.node-body {
  padding: 10px 12px;
}
.node-view {
  padding: 6px 12px;
  font-size: 13px;
  color: #606266;
}
.node-children {
  margin-left: 36px;
  padding-left: 16px;
  border-left: 2px dashed #dcdfe6;
}
</style>
src/views/productionManagement/productStructure/DetailNew/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,626 @@
<template>
  <div class="app-container">
    <PageHeader content="产品结构详情">
      <template #right-button>
        <el-button v-if="!dataValue.isEdit && !isOrderPage"
                   type="primary"
                   @click="dataValue.isEdit = true">编辑
        </el-button>
        <el-button v-if="dataValue.isEdit && !isOrderPage"
                   type="primary"
                   @click="cancelEdit">取消
        </el-button>
        <el-button v-if="!isOrderPage"
                   type="primary"
                   :loading="dataValue.loading"
                   @click="submit"
                   :disabled="!dataValue.isEdit">确认
        </el-button>
      </template>
    </PageHeader>
    <el-table :data="tableData"
              border
              :preserve-expanded-content="false"
              :default-expand-all="true"
              style="width: 100%">
      <el-table-column type="expand">
        <template #default="props">
          <el-form ref="form" :model="dataValue">
            <div class="tree-container">
              <div class="tree-legend">
                <el-tag type="" size="small" effect="dark">成品</el-tag>
                <span style="margin:0 4px">← æœ€ä¸Šå±‚(产出物)</span>
                <el-divider direction="vertical" />
                <el-tag type="warning" size="small" effect="dark">半成品</el-tag>
                <span style="margin:0 4px">(可继续展开)</span>
                <el-divider direction="vertical" />
                <span style="margin:0 4px">最下层(投入物)→</span>
                <el-tag type="success" size="small" effect="dark">原料</el-tag>
              </div>
              <div v-if="dataValue.dataList.length === 0 && dataValue.isEdit" class="empty-hint">
                è¯·ç‚¹å‡»ä¸‹æ–¹æŒ‰é’®æ·»åŠ æˆå“
              </div>
              <MaterialCard
                v-for="(item, index) in dataValue.dataList"
                :key="item.tempId"
                :row="item"
                :depth="0"
                :editable="dataValue.isEdit"
                :process-options="dataValue.processOptions"
                @remove="(id: string) => removeItem(id)"
                @add="(id: string, nodeType: string) => addChildItem(id, nodeType)"
                @select-product="(tempId: string, _data: any) => { dataValue.currentRowName = tempId; dataValue.showProductDialog = true }"
                @process-change="(row: any, v: any) => handleProcessChange(row, v)"
                @quantity-change="handleUnitQuantityChange"
              />
              <el-button v-if="dataValue.isEdit"
                         type="primary"
                         plain
                         style="margin-top:12px"
                         @click="addRootItem">
                + æ·»åŠ æˆå“
              </el-button>
            </div>
          </el-form>
        </template>
      </el-table-column>
      <el-table-column label="BOM编号"
                       prop="bomNo" />
      <el-table-column label="产品名称"
                       prop="productName" />
      <el-table-column label="规格型号"
                       prop="model" />
    </el-table>
    <product-select-dialog v-if="dataValue.showProductDialog"
                           v-model:model-value="dataValue.showProductDialog"
                           :single="true"
                           @confirm="handleProduct" />
  </div>
</template>
<script setup lang="ts">
  import {
    computed,
    defineAsyncComponent,
    defineComponent,
    onMounted,
    reactive,
    ref,
  } from "vue";
  import {
    queryList,
    addBomDetail,
  } from "@/api/productionManagement/productStructure.js";
  import { listProcessBom } from "@/api/productionManagement/productionOrder.js";
  import { list } from "@/api/productionManagement/productionProcess";
  import { ElMessage } from "element-plus";
  import { useRoute, useRouter } from "vue-router";
  defineComponent({
    name: "StructureEdit",
  });
  const ProductSelectDialog = defineAsyncComponent(
    () => import("@/views/basicData/product/ProductSelectDialog.vue")
  );
  import MaterialCard from "./MaterialCard.vue";
  const emit = defineEmits(["update:router"]);
  const form = ref();
  const route = useRoute();
  const router = useRouter();
  const routeId = computed({
    get() {
      return route.query.id;
    },
    set(val) {
      emit("update:router", val);
    },
  });
  // ä»Žè·¯ç”±å‚数获取产品信息
  const routeBomNo = computed(() => route.query.bomNo || "");
  const routeProductName = computed(() => route.query.productName || "");
  const routeProductModelName = computed(
    () => route.query.productModelName || ""
  );
  const routeOrderId = computed(() => route.query.orderId);
  const pageType = computed(() => route.query.type);
  const isOrderPage = computed(
    () => pageType.value === "order" && routeOrderId.value
  );
  const dataValue = reactive({
    dataList: [],
    productOptions: [],
    processOptions: [],
    showProductDialog: false,
    currentRowIndex: null,
    currentRowName: null,
    loading: false,
    isEdit: false,
  });
  const normalizeListData = (source: any) => {
    if (Array.isArray(source)) {
      return source;
    }
    if (Array.isArray(source?.records)) {
      return source.records;
    }
    return [];
  };
  const getProcessOptionById = (id: any) => {
    if (id === undefined || id === null || id === "") {
      return null;
    }
    return (
      normalizeListData(dataValue.processOptions).find(
        option => String(option.id) === String(id)
      ) || null
    );
  };
  const syncProcessOperationFields = (item: any) => {
    const processId = item.processId ?? item.operationId ?? "";
    if (!processId) {
      item.processId = "";
      item.operationId = "";
      item.processName = "";
      item.operationName = "";
      return;
    }
    const option = getProcessOptionById(processId);
    const processName =
      option?.name || item.processName || item.operationName || "";
    item.processId = processId;
    item.operationId = processId;
    item.processName = processName;
    item.operationName = processName;
  };
  const normalizeTreeData = (items: any[], depth: number = 0) => {
    items.forEach((item: any) => {
      item.tempId = item.tempId || item.id || `${Date.now()}_${Math.random()}`;
      syncProcessOperationFields(item);
      if (depth > 0 && !item.nodeType) {
        item.nodeType = Array.isArray(item.children) && item.children.length > 0
          ? 'semiFinished'
          : 'rawMaterial';
      }
      if (Array.isArray(item.children) && item.children.length > 0) {
        normalizeTreeData(item.children, depth + 1);
      }
    });
  };
  const toQuantityNumber = (value: any) => {
    const numberValue = Number(value);
    if (!Number.isFinite(numberValue)) {
      return 0;
    }
    return Number(numberValue.toFixed(2));
  };
  const syncDemandedQuantityTree = (
    items: any[],
    parentDemandedQuantity: number | null = null
  ) => {
    items.forEach((item: any) => {
      if (parentDemandedQuantity !== null) {
        item.demandedQuantity = toQuantityNumber(
          parentDemandedQuantity * toQuantityNumber(item.unitQuantity)
        );
      }
      if (Array.isArray(item.children) && item.children.length > 0) {
        syncDemandedQuantityTree(
          item.children,
          toQuantityNumber(item.demandedQuantity)
        );
      }
    });
  };
  const recalculateDemandedQuantities = () => {
    if (!isOrderPage.value) {
      return;
    }
    syncDemandedQuantityTree(dataValue.dataList);
  };
  const buildSubmitTree = (items: any[]) => {
    return items.map((item: any) => {
      const current = { ...item };
      syncProcessOperationFields(current);
      current.children = Array.isArray(current.children)
        ? buildSubmitTree(current.children)
        : [];
      return current;
    });
  };
  const findSiblings = (items: any[], tempId: string): any[] | null => {
    if (!items || items.length === 0) return null;
    // æ£€æŸ¥å½“前层级
    if (items.some(item => item.tempId === tempId)) {
      return items;
    }
    // é€’归查找子级
    for (const item of items) {
      if (item.children && item.children.length > 0) {
        const result = findSiblings(item.children, tempId);
        if (result) return result;
      }
    }
    return null;
  };
  const handleProcessChange = (row: any, value: any) => {
    row.processId = value || "";
    syncProcessOperationFields(row);
    // æ£€æŸ¥åŒä¸€å±‚级是否已经有其他不同的工序被选中
    const siblings = findSiblings(dataValue.dataList, row.tempId);
    if (siblings && value) {
      const hasDifferentProcess = siblings.some(sibling => {
        return sibling.tempId !== row.tempId && sibling.processId && sibling.processId !== value;
      });
      if (hasDifferentProcess) {
        ElMessage.warning("同一层级已存在不同的工序,请先统一工序后再进行修改");
      }
    }
  };
  const handleUnitQuantityChange = () => {
    recalculateDemandedQuantities();
  };
  const tableData = reactive([
    {
      productName: "",
      model: "",
      bomNo: "",
    },
  ]);
  const openDialog = (tempId: any) => {
    console.log(tempId, "tempId");
    dataValue.currentRowName = tempId;
    dataValue.showProductDialog = true;
  };
  const fetchData = async () => {
    if (isOrderPage.value) {
      // è®¢å•情况:使用订单的产品结构接口
      const { data } = await listProcessBom({ orderId: routeOrderId.value });
      dataValue.dataList = (data as any) || [];
      normalizeTreeData(dataValue.dataList);
      recalculateDemandedQuantities();
    } else {
      // éžè®¢å•情况:使用原来的接口
      const { data } = await queryList(routeId.value);
      dataValue.dataList = (data as any) || [];
      console.log(dataValue);
      normalizeTreeData(dataValue.dataList);
      console.log(dataValue.dataList, "dataValue.dataList");
    }
  };
  const fetchProcessOptions = async () => {
    const { data } = await list({});
    console.log(data, "dataValue.dataList");
    dataValue.processOptions = normalizeListData(data);
  };
  const handleProduct = (row: any) => {
    if (!Array.isArray(row) || row.length === 0) {
      ElMessage.warning("请选择一个产品");
      return;
    }
    // åªå…è®¸ä¸€ä¸ªï¼šå¦‚果上游返回了多个,默认使用最后一次选择并覆盖当前值
    const productData = row[row.length - 1];
    //  æœ€å¤–层组件中,与当前产品相同的产品只能有一个
    const isTopLevel = dataValue.dataList.some(
      item => (item as any).tempId === dataValue.currentRowName
    );
    if (isTopLevel) {
      if (
        productData.productName === tableData[0].productName &&
        productData.model === tableData[0].model
      ) {
        //  æŸ¥æ‰¾æ˜¯å¦å·²ç»æœ‰å…¶ä»–顶层行已经是这个产品
        const hasOther = dataValue.dataList.some(
          item =>
            (item as any).tempId !== dataValue.currentRowName &&
            (item as any).productName === tableData[0].productName &&
            (item as any).model === tableData[0].model
        );
        if (hasOther) {
          ElMessage.warning("最外层和当前产品一样的一级只能有一个");
          return;
        }
      }
    }
    // dataValue.dataList[dataValue.currentRowIndex].productName =
    //   row[0].productName;
    // dataValue.dataList[dataValue.currentRowIndex].model = row[0].model;
    // dataValue.dataList[dataValue.currentRowIndex].productModelId = row[0].id;
    // dataValue.dataList[dataValue.currentRowIndex].unit = row[0].unit || "";
    dataValue.dataList.map(item => {
      if (item.tempId === dataValue.currentRowName) {
        item.productName = productData.productName;
        item.model = productData.model;
        item.productModelId = productData.id;
        item.unit = productData.unit || "";
        return;
      }
      childItem(item, dataValue.currentRowName, productData);
    });
    dataValue.showProductDialog = false;
  };
  const childItem = (item: any, tempId: any, productData: any) => {
    if (item.tempId === tempId) {
      item.productName = productData.productName;
      item.model = productData.model;
      item.productModelId = productData.id;
      item.unit = productData.unit || "";
      return true;
    }
    if (item.children && item.children.length > 0) {
      for (let child of item.children) {
        if (childItem(child, tempId, productData)) {
          return true;
        }
      }
    }
    return false;
  };
  // é€’归校验所有层级的表单数据
  const validateAll = () => {
    let isValid = true;
    // æ ¡éªŒä¸€ç»„兄弟节点的工序是否都相同
    const checkProcessUniqueness = (items: any[]) => {
      if (!items || items.length === 0 || !isValid) return;
      // èŽ·å–ç¬¬ä¸€ä¸ªéžç©ºçš„å·¥åºID作为参考
      const firstProcessId = items.find(item => item.processId)?.processId;
      // å¦‚果有工序ID,检查所有项是否都使用相同的工序
      if (firstProcessId) {
        for (const item of items) {
          if (item.processId && item.processId !== firstProcessId) {
            const option1 = getProcessOptionById(firstProcessId);
            const option2 = getProcessOptionById(item.processId);
            const processName1 = option1?.name || "未知工序";
            const processName2 = option2?.name || "未知工序";
            ElMessage.error(
              `当前层级下工序不一致,请使用相同的工序。存在「${processName1}」和「${processName2}」`
            );
            isValid = false;
            return;
          }
        }
      }
      // é€’归校验子级的兄弟节点
      for (const item of items) {
        if (item.children && item.children.length > 0) {
          checkProcessUniqueness(item.children);
        }
      }
    };
    // æ ¡éªŒå‡½æ•°
    const validateItem = (item: any, isTopLevel = false) => {
      if (!isValid) return;
      // æ ¡éªŒå½“前项的必填字段
      if (!item.model) {
        ElMessage.error("请选择规格");
        isValid = false;
        return;
      }
      if (!isTopLevel && !item.processId) {
        ElMessage.error("请选择消耗工序");
        isValid = false;
        return;
      }
      if (!item.unitQuantity) {
        ElMessage.error("请输入单位产出所需数量");
        isValid = false;
        return;
      }
      if (isOrderPage.value && !item.demandedQuantity) {
        ElMessage.error("请输入需求总量");
        isValid = false;
        return;
      }
      // if (!item.unit) {
      //   ElMessage.error("请输入单位");
      //   isValid = false;
      //   return;
      // }
      // é€’归校验子项字段
      if (item.children && item.children.length > 0) {
        item.children.forEach(child => {
          validateItem(child, false);
        });
      }
    };
    // 1. é¦–先校验同一父级下的同层消耗工序是否唯一
    checkProcessUniqueness(dataValue.dataList);
    if (!isValid) return false;
    // 2. ç„¶åŽéåŽ†æ ¡éªŒæ‰€æœ‰é¡¶å±‚é¡¹çš„å­—æ®µå¿…å¡«æƒ…å†µ
    dataValue.dataList.forEach(item => {
      validateItem(item, true);
    });
    return isValid;
  };
  const submit = () => {
    dataValue.loading = true;
    normalizeTreeData(dataValue.dataList);
    recalculateDemandedQuantities();
    // å…ˆè¿›è¡Œè¡¨å•校验
    const valid = validateAll();
    console.log(dataValue.dataList, "dataValue.dataList");
    if (valid) {
      addBomDetail({
        bomId: routeId.value,
        children: buildSubmitTree(dataValue.dataList || []),
      })
        .then(res => {
          router.go(-1);
          ElMessage.success("保存成功");
          dataValue.loading = false;
        })
        .catch(() => {
          dataValue.loading = false;
        });
    } else {
      dataValue.loading = false;
    }
  };
  const removeItem = (tempId: string) => {
    const topIndex = dataValue.dataList.findIndex(item => item.tempId === tempId);
    if (topIndex !== -1) {
      dataValue.dataList.splice(topIndex, 1);
      return;
    }
    const delchildItem = (items: any[], tempId: any) => {
      for (let i = 0; i < items.length; i++) {
        const item = items[i];
        if (item.tempId === tempId) {
          items.splice(i, 1);
          return true;
        }
        if (item.children && item.children.length > 0) {
          if (delchildItem(item.children, tempId)) {
            return true;
          }
        }
      }
      return false;
    };
    dataValue.dataList.forEach(item => {
      if (item.children && item.children.length > 0) {
        delchildItem(item.children, tempId);
      }
    });
  };
  const newChildNode = (parentItem: any, nodeType: string = 'rawMaterial') => ({
    parentId: parentItem.id || "",
    parentTempId: parentItem.tempId || "",
    productName: "",
    productId: "",
    model: undefined,
    productModelId: undefined,
    processId: "",
    processName: "",
    operationId: "",
    operationName: "",
    unitQuantity: 1,
    demandedQuantity: 0,
    unit: "",
    nodeType,
    children: [],
    tempId: new Date().getTime(),
  });
  const addRootItem = () => {
    dataValue.dataList.push(newChildNode({ id: "", tempId: "" }));
  };
  const addChildItem = (parentTempId: string, nodeType: string = 'rawMaterial') => {
    const addToItem = (items: any[]): boolean => {
      for (const item of items) {
        if (item.tempId === parentTempId) {
          if (!item.children) item.children = [];
          item.children.push(newChildNode(item, nodeType));
          recalculateDemandedQuantities();
          return true;
        }
        if (item.children?.length > 0) {
          if (addToItem(item.children)) return true;
        }
      }
      return false;
    };
    addToItem(dataValue.dataList);
  };
  const getPropPath = (row, field) => {
    // ä¸ºæ¯ä¸ªrow生成唯一的路径
    // ä½¿ç”¨row.id或索引作为唯一标识
    let path = "dataList";
    // ç®€å•实现:使用row的id或一个唯一标识
    const uniqueId = row.id || Math.floor(Math.random() * 10000);
    path += `.${uniqueId}`;
    return path + `.${field}`;
  };
  const cancelEdit = () => {
    dataValue.isEdit = false;
    // dataValue.dataList = dataValue.dataList.filter(item => item.id !== undefined);
    fetchData();
  };
  onMounted(async () => {
    // ä»Žè·¯ç”±å‚数回显数据
    tableData[0].productName = routeProductName.value as string;
    tableData[0].model = routeProductModelName.value as string;
    tableData[0].bomNo = routeBomNo.value as string;
    // è®¢å•情况下禁用编辑
    if (isOrderPage.value) {
      dataValue.isEdit = false;
    }
    // å…ˆåŠ è½½å·¥åºé€‰é¡¹ï¼Œå†åŠ è½½æ•°æ®ï¼Œç¡®ä¿el-select能够正确回显
    await fetchProcessOptions();
    await fetchData();
  });
</script>
<style scoped>
.tree-container {
  padding: 8px 0;
}
.tree-legend {
  display: flex;
  align-items: center;
  margin-bottom: 12px;
  padding: 8px 12px;
  background: #f5f7fa;
  border-radius: 6px;
  font-size: 13px;
  color: #606266;
}
.empty-hint {
  text-align: center;
  color: #909399;
  padding: 24px 0;
}
</style>
src/views/productionManagement/productionOrder/index.vue
@@ -487,7 +487,7 @@
    if (!Number.isFinite(n)) return 0;
    if (n <= 0) return 0;
    if (n >= 100) return 100;
    return Math.round(n);
    return parseFloat(n.toFixed(2));
  };
  // 30/50/80/100 åˆ†æ®µé¢œè‰²ï¼šçº¢/橙/蓝/绿
src/views/productionManagement/teachingDemo/BomDemo.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,430 @@
<template>
  <div class="app-container bom-demo">
    <PageHeader :content="`BOM结构演示 - ${bomInfo.productName || ''}`">
      <template #right-button>
        <el-button @click="goBack">返回</el-button>
      </template>
    </PageHeader>
    <DemoControls
      ref="controlsRef"
      :steps="demoSteps"
      :explanations="demoExplanations"
      @play="handlePlay"
      @pause="handlePause"
      @reset="handleReset"
      @stepChange="handleStepChange"
      @speedChange="handleSpeedChange"
    />
    <div class="demo-container" v-loading="loading">
      <div class="bom-visualization">
        <div class="tree-container" v-if="bomData.length > 0">
          <TreeNode
            v-for="(item, index) in bomData"
            :key="item.tempId || item.id || index"
            :node="item"
            :level="0"
            :delay="0"
            :animation-speed="animationSpeed"
            :auto-expand="autoExpand"
            :show-lines="true"
            :highlight-ids="highlightIds"
            :active-id="activeNodeId"
            :animating="isAnimating"
            @node-click="handleNodeClick"
            @expand="handleExpand"
          />
        </div>
        <el-empty v-else description="暂无BOM数据" />
        <div class="visualization-sidebar">
          <el-card class="info-card" shadow="hover">
            <template #header>
              <span>当前节点信息</span>
            </template>
            <el-descriptions :column="1" border size="small" v-if="activeNode">
              <el-descriptions-item label="产品名称">{{ activeNode.productName }}</el-descriptions-item>
              <el-descriptions-item label="规格型号">{{ activeNode.model || '-' }}</el-descriptions-item>
              <el-descriptions-item label="单位用量">{{ activeNode.unitQuantity || 1 }}</el-descriptions-item>
              <el-descriptions-item label="单位">{{ activeNode.unit || '-' }}</el-descriptions-item>
              <el-descriptions-item label="消耗工序">{{ activeNode.processName || '-' }}</el-descriptions-item>
              <el-descriptions-item label="层级深度">{{ getNodeDepth(activeNode) }}</el-descriptions-item>
              <el-descriptions-item label="子项数量">{{ activeNode.children?.length || 0 }}</el-descriptions-item>
            </el-descriptions>
            <div v-else class="empty-info">点击节点查看详细信息</div>
          </el-card>
          <el-card class="legend-card" shadow="hover">
            <template #header>
              <span>图例说明</span>
            </template>
            <div class="legend-item">
              <span class="legend-icon root"></span>
              <span class="legend-text">根节点(成品)</span>
            </div>
            <div class="legend-item">
              <span class="legend-icon child"></span>
              <span class="legend-text">子节点(零部件)</span>
            </div>
            <div class="legend-item">
              <span class="legend-icon leaf"></span>
              <span class="legend-text">叶子节点(原材料)</span>
            </div>
            <div class="legend-item">
              <span class="legend-icon highlight"></span>
              <span class="legend-text">高亮状态</span>
            </div>
            <div class="legend-item">
              <span class="legend-icon active"></span>
              <span class="legend-text">选中状态</span>
            </div>
          </el-card>
        </div>
      </div>
    </div>
  </div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import TreeNode from './components/TreeNode.vue'
import DemoControls from './components/DemoControls.vue'
import { queryList } from '@/api/productionManagement/productStructure.js'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const bomData = ref([])
const controlsRef = ref(null)
const animationSpeed = ref(1)
const autoExpand = ref(false)
const isAnimating = ref(false)
const highlightIds = ref([])
const activeNodeId = ref(null)
const activeNode = ref(null)
const playTimer = ref(null)
const bomId = computed(() => route.query.id)
const bomInfo = computed(() => ({
  bomNo: route.query.bomNo || '',
  productName: route.query.productName || '',
  productModelName: route.query.productModelName || ''
}))
const demoSteps = [
  { title: '根节点', description: '展示成品' },
  { title: '展开子项', description: '逐层展开' },
  { title: '物料组成', description: '显示关系' },
  { title: '用量信息', description: '数量关系' },
  { title: '工序关联', description: '消耗工序' }
]
const demoExplanations = [
  {
    title: '第1步:认识成品根节点',
    content: 'BOM的根节点代表最终成品。这里是我们要生产的产品,所有子节点都是为了生产这个成品所需要的物料。'
  },
  {
    title: '第2步:展开查看子项',
    content: '点击节点可以展开查看其子项。子项代表组成父项的零部件或原材料。BOM是一个树形结构,体现了产品的组成层次。'
  },
  {
    title: '第3步:理解物料组成关系',
    content: '连线表示父子关系。每个子节点都是其父节点的组成部分。例如,一个电脑由主板、CPU、内存等组成。'
  },
  {
    title: '第4步:查看用量信息',
    content: '每个节点上的数字表示"单位产出所需数量",即生产一个父项需要多少子项。例如,生产一台电脑需要1块主板。'
  },
  {
    title: '第5步:了解工序关联',
    content: '子节点上的工序名称表示该物料在哪道工序中被消耗。这连接了BOM与工艺路线,说明物料在何时被使用。'
  }
]
const fetchBomData = async () => {
  if (!bomId.value) return
  loading.value = true
  try {
    const res = await queryList(bomId.value)
    bomData.value = res?.data || []
    normalizeBomData(bomData.value)
  } catch (error) {
    console.error('获取BOM数据失败:', error)
  } finally {
    loading.value = false
  }
}
const normalizeBomData = (data) => {
  data.forEach(item => {
    item.tempId = item.tempId || item.id || `${Date.now()}_${Math.random()}`
    if (item.children && item.children.length > 0) {
      normalizeBomData(item.children)
    }
  })
}
const goBack = () => {
  router.push('/productionManagement/teachingDemo')
}
const handleNodeClick = (node) => {
  activeNode.value = node
  activeNodeId.value = node.tempId || node.id
  highlightIds.value = getRelatedIds(node)
}
const handleExpand = (node, expanded) => {
  // å¤„理展开事件
}
const getRelatedIds = (node) => {
  const ids = []
  // æ·»åŠ æ‰€æœ‰çˆ¶èŠ‚ç‚¹ID
  const addParentIds = (currentNode, targetNode) => {
    if (currentNode.children) {
      for (const child of currentNode.children) {
        if (child.tempId === targetNode.tempId || child.id === targetNode.id) {
          ids.push(currentNode.tempId || currentNode.id)
          return true
        }
        if (addParentIds(child, targetNode)) {
          ids.push(currentNode.tempId || currentNode.id)
          return true
        }
      }
    }
    return false
  }
  for (const root of bomData.value) {
    addParentIds(root, node)
  }
  // æ·»åŠ æ‰€æœ‰å­èŠ‚ç‚¹ID
  const addChildIds = (currentNode) => {
    ids.push(currentNode.tempId || currentNode.id)
    if (currentNode.children) {
      for (const child of currentNode.children) {
        addChildIds(child)
      }
    }
  }
  if (node.children) {
    for (const child of node.children) {
      addChildIds(child)
    }
  }
  return ids
}
const getNodeDepth = (node) => {
  let depth = 0
  const findDepth = (currentNode, targetNode, currentDepth) => {
    if (currentNode.tempId === targetNode.tempId || currentNode.id === targetNode.id) {
      depth = currentDepth
      return true
    }
    if (currentNode.children) {
      for (const child of currentNode.children) {
        if (findDepth(child, targetNode, currentDepth + 1)) {
          return true
        }
      }
    }
    return false
  }
  for (const root of bomData.value) {
    findDepth(root, node, 0)
  }
  return depth
}
const handlePlay = () => {
  isAnimating.value = true
  autoExpand.value = true
  runAnimation()
}
const handlePause = () => {
  isAnimating.value = false
  autoExpand.value = false
  if (playTimer.value) {
    clearTimeout(playTimer.value)
  }
}
const handleReset = () => {
  isAnimating.value = false
  autoExpand.value = false
  activeNodeId.value = null
  activeNode.value = null
  highlightIds.value = []
  if (playTimer.value) {
    clearTimeout(playTimer.value)
  }
}
const handleStepChange = (step) => {
  // æ ¹æ®æ­¥éª¤æ‰§è¡Œç›¸åº”操作
  switch (step) {
    case 0:
      // å±•示根节点
      if (bomData.value.length > 0) {
        activeNode.value = bomData.value[0]
        activeNodeId.value = bomData.value[0].tempId || bomData.value[0].id
      }
      break
    case 1:
      // å±•开子项
      autoExpand.value = true
      break
    case 2:
      // æ˜¾ç¤ºå…³ç³»
      if (activeNode.value) {
        highlightIds.value = getRelatedIds(activeNode.value)
      }
      break
    case 3:
      // ç”¨é‡ä¿¡æ¯
      break
    case 4:
      // å·¥åºå…³è”
      break
  }
}
const handleSpeedChange = (speed) => {
  animationSpeed.value = speed
}
const runAnimation = () => {
  if (!isAnimating.value) return
  const totalSteps = demoSteps.length
  let currentStep = controlsRef.value?.currentStep || 0
  const animate = () => {
    if (!isAnimating.value) return
    if (currentStep < totalSteps) {
      controlsRef.value?.setStep(currentStep)
      handleStepChange(currentStep)
      currentStep++
      playTimer.value = setTimeout(animate, 3000 / animationSpeed.value)
    } else {
      isAnimating.value = false
      controlsRef.value?.setIsPlaying(false)
    }
  }
  animate()
}
onMounted(() => {
  fetchBomData()
})
onUnmounted(() => {
  if (playTimer.value) {
    clearTimeout(playTimer.value)
  }
})
</script>
<style scoped lang="scss">
.bom-demo {
  .demo-container {
    background: #f5f7fa;
    padding: 20px;
    border-radius: 12px;
    min-height: 400px;
    .bom-visualization {
      display: flex;
      gap: 20px;
      .tree-container {
        flex: 1;
        background: #fff;
        padding: 30px;
        border-radius: 12px;
        box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
        overflow: auto;
        max-height: 600px;
      }
      .visualization-sidebar {
        width: 300px;
        display: flex;
        flex-direction: column;
        gap: 16px;
        .info-card {
          .empty-info {
            color: #909399;
            text-align: center;
            padding: 20px;
          }
        }
        .legend-card {
          .legend-item {
            display: flex;
            align-items: center;
            gap: 8px;
            margin-bottom: 8px;
            .legend-icon {
              width: 16px;
              height: 16px;
              border-radius: 4px;
              border: 2px solid;
              &.root {
                background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
                border-color: #409eff;
              }
              &.child {
                background: #fff;
                border-color: #e4e7ed;
              }
              &.leaf {
                background: #fff;
                border-color: #67c23a;
              }
              &.highlight {
                background: #fff;
                border-color: #e6a23c;
                box-shadow: 0 0 8px rgba(230, 162, 60, 0.4);
              }
              &.active {
                background: #fff;
                border-color: #409eff;
                box-shadow: 0 0 12px rgba(64, 158, 255, 0.5);
              }
            }
            .legend-text {
              font-size: 12px;
              color: #666;
            }
          }
        }
      }
    }
  }
}
</style>
src/views/productionManagement/teachingDemo/CombinedDemo.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,518 @@
<template>
  <div class="app-container combined-demo">
    <PageHeader content="BOM与工艺路线联动演示">
      <template #right-button>
        <el-button @click="goBack">返回</el-button>
      </template>
    </PageHeader>
    <DemoControls
      ref="controlsRef"
      :steps="demoSteps"
      :explanations="demoExplanations"
      @play="handlePlay"
      @pause="handlePause"
      @reset="handleReset"
      @stepChange="handleStepChange"
      @speedChange="handleSpeedChange"
    />
    <div class="selection-panel" v-if="!showDemo">
      <el-card shadow="hover">
        <el-form label-width="100px">
          <el-form-item label="选择BOM">
            <el-select v-model="selectedBomId" placeholder="请选择BOM" filterable style="width: 100%">
              <el-option v-for="item in bomList" :key="item.id" :label="`${item.bomNo} - ${item.productName}`" :value="item.id" />
            </el-select>
          </el-form-item>
          <el-form-item label="选择工艺路线">
            <el-select v-model="selectedRouteId" placeholder="请选择工艺路线" filterable style="width: 100%">
              <el-option v-for="item in routeList" :key="item.id" :label="`${item.processRouteCode} - ${item.productName}`" :value="item.id" />
            </el-select>
          </el-form-item>
          <el-form-item>
            <el-button type="primary" :disabled="!selectedBomId || !selectedRouteId" @click="startDemo">
              å¼€å§‹æ¼”示
            </el-button>
          </el-form-item>
        </el-form>
      </el-card>
    </div>
    <div class="demo-container" v-loading="loading" v-if="showDemo">
      <div class="combined-visualization">
        <div class="bom-panel">
          <div class="panel-header">
            <h4>BOM ç‰©æ–™ç»“æž„</h4>
            <el-button size="small" @click="toggleBomExpand">{{ bomExpanded ? '全部折叠' : '全部展开' }}</el-button>
          </div>
          <div class="tree-container">
            <TreeNode
              v-for="(item, index) in bomData"
              :key="item.tempId || item.id || index"
              :node="item"
              :level="0"
              :delay="0"
              :animation-speed="animationSpeed"
              :auto-expand="bomExpanded"
              :show-lines="true"
              :highlight-ids="highlightBomIds"
              :active-id="activeBomId"
              :animating="isAnimating"
              @node-click="handleBomClick"
            />
          </div>
        </div>
        <div class="connection-panel">
          <svg class="connection-svg" ref="connectionSvg">
            <!-- åŠ¨æ€ç»˜åˆ¶è¿žæŽ¥çº¿ -->
          </svg>
          <div class="connection-info" v-if="connectionInfo">
            <el-tag type="success" effect="plain">
              {{ connectionInfo }}
            </el-tag>
          </div>
        </div>
        <div class="route-panel">
          <div class="panel-header">
            <h4>工艺路线流程</h4>
          </div>
          <div class="process-container">
            <ProcessNode
              v-for="(process, index) in processList"
              :key="process.id || index"
              :process="process"
              :index="index"
              :delay="index * 200"
              :animation-speed="animationSpeed"
              :active="activeProcessId === process.id"
              :completed="completedProcesses.includes(process.id)"
              :current="currentProcessId === process.id"
              :animating="isAnimating && currentProcessId === process.id"
              :show-arrow="index < processList.length - 1"
              @click="handleProcessClick"
            />
          </div>
        </div>
      </div>
      <div class="demo-explanation">
        <el-card shadow="hover">
          <div class="explanation-content">
            <h5>联动说明</h5>
            <p>点击右侧的工序节点,左侧会高亮显示该工序消耗的物料。</p>
            <p>点击左侧的物料节点,右侧会高亮显示消耗该物料的工序。</p>
            <p>绿色连线表示物料在对应工序中被消耗的关系。</p>
          </div>
        </el-card>
      </div>
    </div>
  </div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import TreeNode from './components/TreeNode.vue'
import ProcessNode from './components/ProcessNode.vue'
import DemoControls from './components/DemoControls.vue'
import { listPage as getBomList } from '@/api/productionManagement/productBom.js'
import { listPage as getRouteList } from '@/api/productionManagement/processRoute.js'
import { queryList } from '@/api/productionManagement/productStructure.js'
import { findProcessRouteItemList } from '@/api/productionManagement/processRouteItem.js'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const showDemo = ref(false)
const bomList = ref([])
const routeList = ref([])
const selectedBomId = ref(null)
const selectedRouteId = ref(null)
const bomData = ref([])
const processList = ref([])
const controlsRef = ref(null)
const animationSpeed = ref(1)
const isAnimating = ref(false)
const bomExpanded = ref(true)
const highlightBomIds = ref([])
const activeBomId = ref(null)
const activeProcessId = ref(null)
const activeProcess = ref(null)
const completedProcesses = ref([])
const currentProcessId = ref(null)
const connectionInfo = ref('')
const playTimer = ref(null)
const demoSteps = [
  { title: 'BOM结构', description: '查看物料组成' },
  { title: '工艺路线', description: '查看工序流程' },
  { title: '物料消耗', description: '关联关系' },
  { title: '生产过程', description: '模拟流程' },
  { title: '联动演示', description: '交互体验' }
]
const demoExplanations = [
  {
    title: '第1步:认识BOM结构',
    content: '左侧展示了产品的BOM结构,树形图显示了产品由哪些物料组成。根节点是成品,子节点是组成成品的零部件和原材料。'
  },
  {
    title: '第2步:了解工艺路线',
    content: '右侧展示了工艺路线,横向流程图显示了产品需要经过哪些工序。每个工序节点代表一个独立的加工步骤。'
  },
  {
    title: '第3步:理解物料消耗关系',
    content: 'BOM中的每个物料节点都有一个"消耗工序"属性,表示该物料在哪道工序中被使用。这是BOM与工艺路线的核心关联。'
  },
  {
    title: '第4步:观看生产过程模拟',
    content: '点击播放可以观看产品在各工序间流转的过程。随着工序的进行,BOM中的物料被逐步消耗。'
  },
  {
    title: '第5步:体验联动交互',
    content: '点击工序节点,BOM中对应物料会高亮;点击物料节点,对应工序会高亮。这种联动帮助理解物料在何时何地被消耗。'
  }
]
const fetchBomList = async () => {
  try {
    const res = await getBomList({ current: 1, size: 100 })
    bomList.value = res?.data?.records || []
    // å¦‚果路由有预设bomId,自动选择
    if (route.query.bomId) {
      selectedBomId.value = Number(route.query.bomId)
    }
  } catch (error) {
    console.error('获取BOM列表失败:', error)
  }
}
const fetchRouteList = async () => {
  try {
    const res = await getRouteList({ current: 1, size: 100 })
    routeList.value = res?.data?.records || []
    // å¦‚果路由有预设routeId,自动选择
    if (route.query.routeId) {
      selectedRouteId.value = Number(route.query.routeId)
    }
  } catch (error) {
    console.error('获取工艺路线列表失败:', error)
  }
}
const fetchBomData = async () => {
  if (!selectedBomId.value) return
  try {
    const res = await queryList(selectedBomId.value)
    bomData.value = res?.data || []
    normalizeBomData(bomData.value)
  } catch (error) {
    console.error('获取BOM数据失败:', error)
  }
}
const fetchProcessList = async () => {
  if (!selectedRouteId.value) return
  try {
    const res = await findProcessRouteItemList({ routeId: selectedRouteId.value })
    processList.value = res?.data || []
    processList.value.sort((a, b) => (a.dragSort || 0) - (b.dragSort || 0))
  } catch (error) {
    console.error('获取工艺路线数据失败:', error)
  }
}
const normalizeBomData = (data) => {
  data.forEach(item => {
    item.tempId = item.tempId || item.id || `${Date.now()}_${Math.random()}`
    if (item.children && item.children.length > 0) {
      normalizeBomData(item.children)
    }
  })
}
const startDemo = async () => {
  loading.value = true
  try {
    await Promise.all([fetchBomData(), fetchProcessList()])
    showDemo.value = true
  } finally {
    loading.value = false
  }
}
const goBack = () => {
  router.push('/productionManagement/teachingDemo')
}
const toggleBomExpand = () => {
  bomExpanded.value = !bomExpanded.value
}
const handleBomClick = (node) => {
  activeBomId.value = node.tempId || node.id
  // æ‰¾åˆ°æ¶ˆè€—该物料的工序
  const processName = node.processName
  if (processName) {
    const matchingProcess = processList.value.find(p =>
      p.technologyOperationName === processName || p.operationName === processName
    )
    if (matchingProcess) {
      activeProcessId.value = matchingProcess.id
      connectionInfo.value = `${node.productName} åœ¨ "${processName}" å·¥åºä¸­æ¶ˆè€—`
    }
  }
}
const handleProcessClick = (process) => {
  activeProcess.value = process
  activeProcessId.value = process.id
  // æ‰¾åˆ°è¯¥å·¥åºæ¶ˆè€—的所有物料
  const processName = process.technologyOperationName || process.operationName
  highlightBomIds.value = findBomIdsByProcess(processName)
  if (highlightBomIds.value.length > 0) {
    connectionInfo.value = `"${processName}" å·¥åºæ¶ˆè€—了 ${highlightBomIds.value.length} ç§ç‰©æ–™`
  }
}
const findBomIdsByProcess = (processName) => {
  const ids = []
  const traverse = (nodes) => {
    for (const node of nodes) {
      if (node.processName === processName) {
        ids.push(node.tempId || node.id)
      }
      if (node.children) {
        traverse(node.children)
      }
    }
  }
  traverse(bomData.value)
  return ids
}
const handlePlay = () => {
  isAnimating.value = true
  bomExpanded.value = true
  runAnimation()
}
const handlePause = () => {
  isAnimating.value = false
  if (playTimer.value) {
    clearTimeout(playTimer.value)
  }
}
const handleReset = () => {
  isAnimating.value = false
  highlightBomIds.value = []
  activeBomId.value = null
  activeProcessId.value = null
  activeProcess.value = null
  currentProcessId.value = null
  completedProcesses.value = []
  connectionInfo.value = ''
  if (playTimer.value) {
    clearTimeout(playTimer.value)
  }
}
const handleStepChange = (step) => {
  switch (step) {
    case 0:
      // BOM结构
      break
    case 1:
      // å·¥è‰ºè·¯çº¿
      break
    case 2:
      // ç‰©æ–™æ¶ˆè€—
      if (processList.value.length > 0) {
        const firstProcess = processList.value[0]
        handleProcessClick(firstProcess)
      }
      break
    case 3:
      // ç”Ÿäº§è¿‡ç¨‹æ¨¡æ‹Ÿ
      simulateProduction()
      break
    case 4:
      // è”动演示
      break
  }
}
const handleSpeedChange = (speed) => {
  animationSpeed.value = speed
}
const simulateProduction = () => {
  completedProcesses.value = []
  currentProcessId.value = null
  let index = 0
  const interval = 1500 / animationSpeed.value
  playTimer.value = setInterval(() => {
    if (index >= processList.value.length) {
      clearInterval(playTimer.value)
      currentProcessId.value = null
      isAnimating.value = false
      return
    }
    if (index > 0) {
      completedProcesses.value.push(processList.value[index - 1].id)
    }
    currentProcessId.value = processList.value[index].id
    activeProcessId.value = processList.value[index].id
    // é«˜äº®å¯¹åº”物料
    const processName = processList.value[index].technologyOperationName || processList.value[index].operationName
    highlightBomIds.value = findBomIdsByProcess(processName)
    connectionInfo.value = `正在执行 "${processName}" å·¥åº`
    index++
  }, interval)
}
const runAnimation = () => {
  if (!isAnimating.value) return
  const totalSteps = demoSteps.length
  let currentStep = controlsRef.value?.currentStep || 0
  const animate = () => {
    if (!isAnimating.value) return
    if (currentStep < totalSteps) {
      controlsRef.value?.setStep(currentStep)
      handleStepChange(currentStep)
      currentStep++
      playTimer.value = setTimeout(animate, 4000 / animationSpeed.value)
    } else {
      isAnimating.value = false
      controlsRef.value?.setIsPlaying(false)
    }
  }
  animate()
}
onMounted(() => {
  fetchBomList()
  fetchRouteList()
  // å¦‚果预设了数据,自动开始演示
  if (route.query.bomId && route.query.routeId) {
    nextTick(() => {
      startDemo()
    })
  }
})
onUnmounted(() => {
  if (playTimer.value) {
    clearTimeout(playTimer.value)
  }
})
</script>
<style scoped lang="scss">
.combined-demo {
  .selection-panel {
    margin-bottom: 20px;
  }
  .demo-container {
    background: #f5f7fa;
    padding: 20px;
    border-radius: 12px;
    min-height: 400px;
    .combined-visualization {
      display: flex;
      gap: 16px;
      min-height: 300px;
      .bom-panel,
      .route-panel {
        flex: 1;
        background: #fff;
        padding: 20px;
        border-radius: 12px;
        box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
        .panel-header {
          display: flex;
          justify-content: space-between;
          align-items: center;
          margin-bottom: 16px;
          h4 {
            color: #303133;
            font-size: 16px;
            font-weight: 600;
            margin: 0;
          }
        }
        .tree-container,
        .process-container {
          overflow: auto;
          max-height: 400px;
        }
        .process-container {
          display: flex;
          align-items: center;
          gap: 0;
          padding: 10px 0;
        }
      }
      .connection-panel {
        width: 200px;
        background: #fff;
        padding: 20px;
        border-radius: 12px;
        box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
        .connection-svg {
          width: 100%;
          height: 100px;
        }
        .connection-info {
          text-align: center;
          margin-top: 16px;
        }
      }
    }
    .demo-explanation {
      margin-top: 20px;
      .explanation-content {
        h5 {
          color: #409eff;
          margin-bottom: 12px;
        }
        p {
          color: #666;
          line-height: 1.8;
          margin-bottom: 8px;
        }
      }
    }
  }
}
</style>
src/views/productionManagement/teachingDemo/FactoryDemo.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1153 @@
<template>
  <div class="app-container factory-demo">
    <PageHeader content="动态工厂生产线演示">
      <template #right-button>
        <el-button @click="goBack">返回</el-button>
      </template>
    </PageHeader>
    <div class="control-panel">
      <el-row :gutter="20" align="middle">
        <el-col :span="6">
          <el-select v-model="selectedRouteId" placeholder="选择工艺路线" filterable style="width: 100%" @change="handleRouteChange">
            <el-option v-for="item in routeList" :key="item.id" :label="`${item.processRouteCode} - ${item.productName}`" :value="item.id" />
          </el-select>
        </el-col>
        <el-col :span="6">
          <el-button-group>
            <el-button :type="isPlaying ? 'warning' : 'primary'" @click="togglePlay">
              <el-icon v-if="isPlaying"><VideoPause /></el-icon>
              <el-icon v-else><VideoPlay /></el-icon>
              {{ isPlaying ? '暂停' : '播放' }}
            </el-button>
            <el-button @click="handleReset">
              <el-icon><RefreshRight /></el-icon>
              é‡ç½®
            </el-button>
          </el-button-group>
        </el-col>
        <el-col :span="6">
          <div class="speed-control">
            <span>速度:</span>
            <el-radio-group v-model="speed" size="small">
              <el-radio-button label="0.5">慢</el-radio-button>
              <el-radio-button label="1">正常</el-radio-button>
              <el-radio-button label="2">å¿«</el-radio-button>
            </el-radio-group>
          </div>
        </el-col>
        <el-col :span="6">
          <div class="progress-info">
            <span>进度:</span>
            <el-progress :percentage="productionProgress" :stroke-width="8" style="width: 120px" />
          </div>
        </el-col>
      </el-row>
    </div>
    <div class="factory-container" v-loading="loading">
      <div class="factory-scene">
        <svg class="factory-svg" viewBox="0 0 1100 500" preserveAspectRatio="xMidYMid meet">
          <defs>
            <!-- æ¸å˜å®šä¹‰ -->
            <linearGradient id="beltGradient" x1="0%" y1="0%" x2="100%" y2="0%">
              <stop offset="0%" stop-color="#409eff" stop-opacity="0.8" />
              <stop offset="50%" stop-color="#66b1ff" stop-opacity="0.6" />
              <stop offset="100%" stop-color="#409eff" stop-opacity="0.8" />
            </linearGradient>
            <linearGradient id="stationGradient" x1="0%" y1="0%" x2="0%" y2="100%">
              <stop offset="0%" stop-color="#f8f9fa" />
              <stop offset="100%" stop-color="#e9ecef" />
            </linearGradient>
            <linearGradient id="activeGradient" x1="0%" y1="0%" x2="0%" y2="100%">
              <stop offset="0%" stop-color="#ecf5ff" />
              <stop offset="100%" stop-color="#d9ecff" />
            </linearGradient>
            <filter id="stationShadow" x="-20%" y="-20%" width="140%" height="140%">
              <feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000" flood-opacity="0.15" />
            </filter>
            <filter id="glowEffect" x="-50%" y="-50%" width="200%" height="200%">
              <feGaussianBlur stdDeviation="4" result="blur" />
              <feMerge>
                <feMergeNode in="blur" />
                <feMergeNode in="SourceGraphic" />
              </feMerge>
            </filter>
          </defs>
          <!-- èƒŒæ™¯åœ°æ¿ -->
          <rect x="0" y="380" width="1100" height="120" fill="#f5f7fa" />
          <rect x="0" y="380" width="1100" height="2" fill="#e4e7ed" />
          <!-- å±¥å¸¦ -->
          <g class="conveyor-system">
            <!-- å±¥å¸¦ä¸»ä½“ -->
            <rect x="40" y="320" width="1020" height="60" rx="8" fill="url(#beltGradient)" opacity="0.3" />
            <!-- å±¥å¸¦è¾¹ç¼˜ -->
            <rect x="40" y="320" width="1020" height="4" rx="2" fill="#409eff" opacity="0.5" />
            <rect x="40" y="376" width="1020" height="4" rx="2" fill="#409eff" opacity="0.5" />
            <!-- æµåŠ¨çº¿æ¡ -->
            <path class="belt-flow" d="M50 350 H1050" stroke="#fff" stroke-width="3" stroke-dasharray="15 20" stroke-linecap="round" opacity="0.8" />
            <!-- å±¥å¸¦æ»šè½® -->
            <circle v-for="i in 12" :key="'roller'+i" :cx="50 + i * 85" cy="350" r="8" fill="#fff" opacity="0.6" />
          </g>
          <!-- å…¥åº“区 -->
          <g class="input-area" @click="showInputDialog">
            <rect x="20" y="200" width="80" height="80" rx="12" fill="#f0f9eb" stroke="#67c23a" stroke-width="2" filter="url(#stationShadow)" />
            <text x="60" y="245" text-anchor="middle" font-size="14" fill="#67c23a" font-weight="600">原材料</text>
            <text x="60" y="265" text-anchor="middle" font-size="12" fill="#67c23a">入库</text>
            <!-- å…¥åº“图标 -->
            <g transform="translate(45, 215)">
              <rect width="30" height="20" rx="4" fill="#67c23a" opacity="0.8" />
              <path d="M15 -8 L15 24" stroke="#67c23a" stroke-width="3" stroke-dasharray="4 4" />
            </g>
          </g>
          <!-- å‡ºåº“区 -->
          <g class="output-area" @click="showOutputDialog">
            <rect x="1000" y="200" width="80" height="80" rx="12" fill="#ecf5ff" stroke="#409eff" stroke-width="2" filter="url(#stationShadow)" />
            <text x="1040" y="245" text-anchor="middle" font-size="14" fill="#409eff" font-weight="600">成品</text>
            <text x="1040" y="265" text-anchor="middle" font-size="12" fill="#409eff">出库</text>
            <!-- å‡ºåº“图标 -->
            <g transform="translate(1015, 215)">
              <rect width="30" height="20" rx="4" fill="#409eff" opacity="0.8" />
              <path d="M15 28 L15 0" stroke="#409eff" stroke-width="3" stroke-dasharray="4 4" />
              <path d="M8 -5 L15 -12 L22 -5" stroke="#409eff" stroke-width="2" fill="none" />
            </g>
          </g>
          <!-- å·¥åºå·¥ä½œç«™ -->
          <g
            v-for="(process, index) in processList"
            :key="process.id || index"
            class="work-station"
            :class="{ 'is-active': currentProcessIndex === index, 'is-completed': completedIndex > index }"
            :transform="`translate(${getStationX(index)}, 0)`"
            @click="showProcessDetail(process)"
          >
            <!-- æœºå™¨å¤–壳 -->
            <rect x="0" y="80" width="120" height="140" rx="16" :fill="currentProcessIndex === index ? 'url(#activeGradient)' : 'url(#stationGradient)'" :stroke="completedIndex > index ? '#67c23a' : currentProcessIndex === index ? '#409eff' : '#e4e7ed'" stroke-width="2" filter="url(#stationShadow)" />
            <!-- å·¥åºåºå· -->
            <circle cx="60" cy="60" r="24" :fill="completedIndex > index ? '#67c23a' : currentProcessIndex === index ? '#409eff' : '#909399'" />
            <text x="60" y="65" text-anchor="middle" font-size="14" fill="#fff" font-weight="bold">{{ index + 1 }}</text>
            <!-- å·¥åºåç§° -->
            <text x="60" y="110" text-anchor="middle" font-size="13" :fill="currentProcessIndex === index ? '#409eff' : '#303133'" font-weight="600" class="station-name">
              {{ truncateText(process.technologyOperationName || process.operationName, 8) }}
            </text>
            <!-- äº§å“ä¿¡æ¯ -->
            <text x="60" y="130" text-anchor="middle" font-size="10" fill="#909399">
              {{ truncateText(process.productName, 10) || '无产品' }}
            </text>
            <!-- çŠ¶æ€æ ‡ç­¾ -->
            <g transform="translate(20, 145)">
              <rect width="80" height="20" rx="4" :fill="process.isQuality ? '#fdf6ec' : '#f5f7fa'" />
              <text x="40" y="14" text-anchor="middle" font-size="10" :fill="process.isQuality ? '#e6a23c' : '#909399'">
                {{ process.isQuality ? '质检点' : '加工' }}
              </text>
            </g>
            <!-- æŠ•料口 -->
            <g class="feed-point" :transform="`translate(50, 218)`">
              <circle r="10" fill="#fff" stroke="#409eff" stroke-width="2" :class="{ 'is-feeding': feedingIndex === index }" />
              <text y="4" text-anchor="middle" font-size="10" fill="#409eff">投</text>
            </g>
            <!-- çŠ¶æ€æŒ‡ç¤ºç¯ -->
            <circle cx="110" cy="90" r="6" :fill="completedIndex > index ? '#67c23a' : currentProcessIndex === index ? '#e6a23c' : '#c0c4cc'" :class="{ 'is-pulsing': currentProcessIndex === index }" />
            <!-- æŠ•料动画小球 -->
            <g v-if="feedingIndex === index" class="feed-ball">
              <circle cx="60" cy="200" r="8" fill="#67c23a">
                <animate attributeName="cy" values="200;280" dur="0.3s" fill="freeze" />
                <animate attributeName="opacity" values="1;0" dur="0.3s" fill="freeze" />
              </circle>
            </g>
          </g>
          <!-- ç§»åŠ¨çš„äº§å“ç®±å­ -->
          <g class="product-boxes">
            <g v-for="(box, bIndex) in visibleBoxes" :key="bIndex" class="product-box" :transform="`translate(${box.x}, ${box.y})`">
              <!-- ç®±å­ä¸»ä½“ - æ”¾å¤§å°ºå¯¸ -->
              <rect x="0" y="0" width="70" height="45" rx="8" :fill="box.color" opacity="0.95" />
              <rect x="0" y="15" width="70" height="3" fill="#fff" opacity="0.3" />
              <!-- äº§å“åç§° -->
              <text x="35" y="35" text-anchor="middle" font-size="12" fill="#fff" font-weight="600">{{ box.label }}</text>
              <!-- æ‚¬åœæç¤ºæ¡† -->
              <title>{{ box.fullName || box.label }}</title>
            </g>
          </g>
          <!-- æŠ•料动画:原料从工序节点落下到产品上 -->
          <g v-if="showFeedingAnimation && feedingProcessIndex >= 0" class="feeding-animation">
            <!-- æŠ•入的原料小球 -->
            <circle
              :cx="getStationX(feedingProcessIndex) + 60"
              :cy="feedingAnimY"
              r="12"
              fill="#67c23a"
              opacity="0.9"
            >
              <animate
                attributeName="cy"
                :values="`${feedingStartY};${feedingEndY}`"
                :dur="`${0.4 / parseFloat(speed)}s`"
                fill="freeze"
              />
              <animate
                attributeName="opacity"
                values="1;0.6"
                :dur="`${0.4 / parseFloat(speed)}s`"
                fill="freeze"
              />
            </circle>
            <!-- åŽŸæ–™åç§° -->
            <text
              :x="getStationX(feedingProcessIndex) + 60"
              :y="feedingAnimY - 18"
              text-anchor="middle"
              font-size="10"
              fill="#67c23a"
              font-weight="600"
              opacity="0.9"
            >
              {{ truncateText(feedingMaterialName, 5) || '原料' }}
            </text>
          </g>
          <!-- äº§å‡ºåŠ¨ç”»ï¼šåŠ å·¥å®ŒæˆåŽçš„é—ªå…‰æ•ˆæžœ -->
          <g v-if="showOutputAnimation && outputProcessIndex >= 0" class="output-animation">
            <circle
              :cx="getStationX(outputProcessIndex) + 60"
              cy="285"
              r="30"
              fill="none"
              stroke="#409eff"
              stroke-width="3"
              opacity="0.8"
            >
              <animate
                attributeName="r"
                values="20;40"
                dur="0.5s"
                fill="freeze"
              />
              <animate
                attributeName="opacity"
                values="0.8;0"
                dur="0.5s"
                fill="freeze"
              />
            </circle>
            <!-- äº§å‡ºåç§°æç¤º -->
            <text
              :x="getStationX(outputProcessIndex) + 60"
              y="340"
              text-anchor="middle"
              font-size="11"
              fill="#409eff"
              font-weight="600"
            >
              äº§å‡º: {{ truncateText(outputProductName, 6) || '产品' }}
            </text>
          </g>
          <!-- è¿›åº¦æŒ‡ç¤ºçº¿ -->
          <path class="progress-line" :d="getProgressPath()" stroke="#67c23a" stroke-width="3" fill="none" opacity="0.6" />
          <!-- è¯´æ˜Žæ–‡å­—区域 -->
          <g class="info-text" v-if="currentProcess">
            <rect x="200" y="10" width="300" height="50" rx="8" fill="#ecf5ff" stroke="#409eff" stroke-width="1" opacity="0.9" />
            <text x="350" y="35" text-anchor="middle" font-size="14" fill="#409eff" font-weight="600">
              å½“前工序:{{ currentProcess.technologyOperationName || currentProcess.operationName }}
            </text>
            <text x="350" y="50" text-anchor="middle" font-size="11" fill="#606266">
              {{ feedingIndex >= 0 ? '正在投料...' : '产品流转中...' }}
            </text>
          </g>
        </svg>
      </div>
      <div class="factory-sidebar">
        <el-card class="info-card" shadow="hover">
          <template #header>
            <span>生产线状态</span>
          </template>
          <div class="status-info">
            <div class="status-item">
              <span class="status-label">工序总数</span>
              <span class="status-value">{{ processList.length }}</span>
            </div>
            <div class="status-item">
              <span class="status-label">已完成</span>
              <span class="status-value">{{ completedIndex }} / {{ processList.length }}</span>
            </div>
            <div class="status-item">
              <span class="status-label">当前工序</span>
              <span class="status-value highlight">{{ currentProcess?.technologyOperationName || currentProcess?.operationName || '-' }}</span>
            </div>
            <div class="status-item">
              <span class="status-label">生产状态</span>
              <el-tag :type="isPlaying ? 'success' : 'info'" size="small">{{ isPlaying ? '运行中' : '已暂停' }}</el-tag>
            </div>
          </div>
        </el-card>
        <el-card class="legend-card" shadow="hover">
          <template #header>
            <span>状态说明</span>
          </template>
          <div class="legend-item">
            <span class="legend-dot completed"></span>
            <span class="legend-text">已完成</span>
          </div>
          <div class="legend-item">
            <span class="legend-dot current"></span>
            <span class="legend-text">当前工序</span>
          </div>
          <div class="legend-item">
            <span class="legend-dot pending"></span>
            <span class="legend-text">待加工</span>
          </div>
          <div class="legend-item">
            <span class="legend-dot quality"></span>
            <span class="legend-text">质检点</span>
          </div>
        </el-card>
        <el-card class="tips-card" shadow="hover">
          <template #header>
            <span>操作提示</span>
          </template>
          <div class="tips-content">
            <p>点击工序节点查看详细信息</p>
            <p>点击"原材料入库"查看投料清单</p>
            <p>点击"成品出库"查看产出信息</p>
            <p>使用控制面板调节演示速度</p>
          </div>
        </el-card>
      </div>
    </div>
    <!-- å·¥åºè¯¦æƒ…对话框 -->
    <el-dialog v-model="processDialogVisible" :title="`工序详情 - ${selectedProcess?.technologyOperationName || selectedProcess?.operationName}`" width="650px">
      <el-descriptions :column="2" border size="small" v-if="selectedProcess">
        <el-descriptions-item label="工序名称">{{ selectedProcess.technologyOperationName || selectedProcess.operationName }}</el-descriptions-item>
        <el-descriptions-item label="工序序号">{{ selectedProcess.dragSort || selectedProcessIndex + 1 }}</el-descriptions-item>
        <el-descriptions-item label="产品名称">{{ selectedProcess.productName || '-' }}</el-descriptions-item>
        <el-descriptions-item label="规格型号">{{ selectedProcess.model || '-' }}</el-descriptions-item>
        <el-descriptions-item label="单位">{{ selectedProcess.unit || '-' }}</el-descriptions-item>
        <el-descriptions-item label="计费类型">
          <el-tag :type="selectedProcess.type === 1 ? 'primary' : 'success'" size="small">
            {{ selectedProcess.type === 1 ? '计件' : '计时' }}
          </el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="是否质检">
          <el-tag :type="selectedProcess.isQuality ? 'warning' : 'info'" size="small">{{ selectedProcess.isQuality ? '是' : '否' }}</el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="是否生产">
          <el-tag :type="selectedProcess.isProduction ? 'success' : 'info'" size="small">{{ selectedProcess.isProduction ? '是' : '否' }}</el-tag>
        </el-descriptions-item>
      </el-descriptions>
      <!-- æŠ•入物料 -->
      <div class="material-section" v-if="inputMaterials.length > 0">
        <div class="section-header">
          <el-icon style="color: #67c23a;"><Bottom /></el-icon>
          <h4>需要投入的物料({{ inputMaterials.length }} ç§ï¼‰</h4>
        </div>
        <el-table :data="inputMaterials" border size="small" max-height="200">
          <el-table-column prop="productName" label="物料名称" min-width="120" />
          <el-table-column prop="model" label="规格型号" min-width="100" />
          <el-table-column prop="unitQuantity" label="单位用量" width="90" align="right">
            <template #default="{ row }">
              <span class="quantity-value">{{ row.unitQuantity }}</span>
            </template>
          </el-table-column>
          <el-table-column prop="unit" label="单位" width="70" align="center" />
        </el-table>
        <div class="material-summary">
          <el-tag type="success" size="small" effect="plain">
            ç”Ÿäº§1个单位产品需要投入以上 {{ inputMaterials.length }} ç§ç‰©æ–™
          </el-tag>
        </div>
      </div>
      <div class="material-section empty-section" v-else>
        <el-empty description="该工序暂无需要投入的物料" :image-size="60" />
      </div>
      <!-- äº§å‡ºäº§å“ -->
      <div class="material-section" v-if="outputProduct">
        <div class="section-header">
          <el-icon style="color: #409eff;"><Top /></el-icon>
          <h4>产出产品(本工序产出)</h4>
        </div>
        <el-descriptions :column="2" border size="small">
          <el-descriptions-item label="产品名称">{{ outputProduct.productName || '-' }}</el-descriptions-item>
          <el-descriptions-item label="规格型号">{{ outputProduct.model || '-' }}</el-descriptions-item>
          <el-descriptions-item label="单位">{{ outputProduct.unit || '-' }}</el-descriptions-item>
          <el-descriptions-item label="产出类型">{{ selectedProcess?.isProduction ? '生产产出' : '加工产出' }}</el-descriptions-item>
        </el-descriptions>
      </div>
      <!-- ä¸Šä¸‹æ¸¸å·¥åº -->
      <div class="material-section" v-if="selectedProcessIndex >= 0">
        <div class="section-header">
          <el-icon style="color: #909399;"><Sort /></el-icon>
          <h4>工序流转</h4>
        </div>
        <div class="process-flow">
          <div class="flow-item" v-if="prevProcess">
            <span class="flow-label">上一道工序</span>
            <el-tag type="info" size="small">{{ prevProcess.technologyOperationName || prevProcess.operationName }}</el-tag>
          </div>
          <div class="flow-item current">
            <span class="flow-label">当前工序</span>
            <el-tag type="primary">{{ selectedProcess?.technologyOperationName || selectedProcess?.operationName }}</el-tag>
          </div>
          <div class="flow-item" v-if="nextProcess">
            <span class="flow-label">下一道工序</span>
            <el-tag type="info" size="small">{{ nextProcess.technologyOperationName || nextProcess.operationName }}</el-tag>
          </div>
        </div>
      </div>
      <template #footer>
        <el-button @click="processDialogVisible = false">关闭</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { VideoPlay, VideoPause, RefreshRight, Bottom, Top, Sort } from '@element-plus/icons-vue'
import { listPage as getRouteList } from '@/api/productionManagement/processRoute.js'
import { findProcessRouteItemList } from '@/api/productionManagement/processRouteItem.js'
import { queryList } from '@/api/productionManagement/productStructure.js'
const router = useRouter()
const loading = ref(false)
const routeList = ref([])
const selectedRouteId = ref(null)
const selectedBomId = ref(null)
const processList = ref([])
const bomData = ref([])
const isPlaying = ref(false)
const speed = ref('1')
const currentProcessIndex = ref(-1)
const completedIndex = ref(0)
const feedingIndex = ref(-1)
const processDialogVisible = ref(false)
const selectedProcess = ref(null)
const selectedProcessIndex = ref(-1)
const boxPosition = ref(0)
const animationTimer = ref(null)
const boxColors = ['#ffb15f', '#28d9cd', '#8b5cf6', '#409eff', '#67c23a']
// æŠ•料动画相关
const showFeedingAnimation = ref(false)
const feedingProcessIndex = ref(-1)
const feedingMaterialName = ref('')
const feedingAnimY = ref(200)
const feedingStartY = 200
const feedingEndY = 285
// äº§å‡ºåŠ¨ç”»ç›¸å…³
const showOutputAnimation = ref(false)
const outputProcessIndex = ref(-1)
const outputProductName = ref('')
// ä¸Šä¸€æ¬¡å®Œæˆçš„工序索引(用于判断是否需要触发产出动画)
const lastCompletedIndex = ref(-1)
const productionProgress = computed(() => {
  if (processList.value.length === 0) return 0
  return Math.round((completedIndex.value / processList.value.length) * 100)
})
const currentProcess = computed(() => {
  if (currentProcessIndex.value >= 0 && currentProcessIndex.value < processList.value.length) {
    return processList.value[currentProcessIndex.value]
  }
  return null
})
const processMaterials = computed(() => {
  if (!selectedProcess.value) return []
  const processName = selectedProcess.value.technologyOperationName || selectedProcess.value.operationName
  const materials = []
  const traverse = (nodes) => {
    for (const node of nodes) {
      if (node.processName === processName) {
        materials.push(node)
      }
      if (node.children) traverse(node.children)
    }
  }
  traverse(bomData.value)
  return materials
})
// æŠ•入物料(需要投入的物料 - ä»ŽBOM中匹配工序)
const inputMaterials = computed(() => {
  if (!selectedProcess.value) return []
  const processId = selectedProcess.value.technologyOperationId || selectedProcess.value.operationId
  const processName = selectedProcess.value.technologyOperationName || selectedProcess.value.operationName
  const materials = []
  const traverse = (nodes) => {
    for (const node of nodes) {
      // ä¼˜å…ˆç”¨ID匹配,其次用名称匹配
      const nodeProcessId = node.processId || node.operationId || node.technologyOperationId
      const nodeProcessName = node.processName || node.operationName || node.technologyOperationName
      const isMatch = (processId && nodeProcessId === processId) ||
                      (processName && nodeProcessName === processName)
      if (isMatch) {
        materials.push({
          productName: node.productName || '未命名物料',
          model: node.model || '-',
          unitQuantity: node.unitQuantity || 1,
          unit: node.unit || '-',
          processName: nodeProcessName || '-'
        })
      }
      if (node.children && node.children.length > 0) {
        traverse(node.children)
      }
    }
  }
  traverse(bomData.value)
  return materials
})
// äº§å‡ºäº§å“ï¼ˆå½“前工序关联的产品信息)
const outputProduct = computed(() => {
  if (!selectedProcess.value) return null
  return {
    productName: selectedProcess.value.productName || null,
    model: selectedProcess.value.model || null,
    unit: selectedProcess.value.unit || null
  }
})
// ä¸Šä¸€é“工序
const prevProcess = computed(() => {
  if (selectedProcessIndex.value <= 0) return null
  return processList.value[selectedProcessIndex.value - 1]
})
// ä¸‹ä¸€é“工序
const nextProcess = computed(() => {
  if (selectedProcessIndex.value < 0 || selectedProcessIndex.value >= processList.value.length - 1) return null
  return processList.value[selectedProcessIndex.value + 1]
})
const visibleBoxes = computed(() => {
  const boxes = []
  const baseX = 60 + boxPosition.value
  // è®¡ç®—产品当前所在的工序索引
  const totalWidth = 900
  const progressRatio = Math.min(1, boxPosition.value / totalWidth)
  const currentStep = Math.floor(progressRatio * processList.value.length)
  // å½“前工序
  const currentProc = processList.value[currentStep] || null
  // ä¸Šä¸€é“工序(已完成)
  const prevProc = processList.value[currentStep - 1] || null
  // ç¡®å®šäº§å“åç§°
  let productLabel = '原料'
  let fullName = '原材料(待加工)'
  if (currentStep > 0 && prevProc) {
    // å·²ç»è¿‡äº†ä¸€äº›å·¥åºï¼Œæ˜¾ç¤ºä¸Šä¸€ä¸ªå·¥åºçš„产出
    productLabel = truncateText(prevProc.productName, 5) || '半成品'
    fullName = prevProc.productName || '半成品'
  }
  if (currentStep >= processList.value.length) {
    // å…¨éƒ¨å®Œæˆ
    const lastProc = processList.value[processList.value.length - 1]
    productLabel = truncateText(lastProc?.productName, 5) || '成品'
    fullName = lastProc?.productName || '成品'
  }
  if (baseX > 40) {
    boxes.push({
      x: baseX,
      y: 275, // è°ƒæ•´ä½ç½®ï¼Œè®©ç®±å­åœ¨å±¥å¸¦ä¸Š
      color: '#409eff',
      label: productLabel,
      fullName: fullName,
      processIndex: currentStep,
      isFeeding: feedingIndex.value === currentStep && currentStep < processList.value.length
    })
  }
  return boxes
})
// å½“前工序的投入物料名称
const currentInputMaterial = computed(() => {
  if (currentProcessIndex.value < 0 || currentProcessIndex.value >= processList.value.length) return null
  const process = processList.value[currentProcessIndex.value]
  if (!process) return null
  const processId = process.technologyOperationId || process.operationId
  const processName = process.technologyOperationName || process.operationName
  // ä»ŽBOM中找第一个匹配的物料
  let material = null
  const traverse = (nodes) => {
    for (const node of nodes) {
      const nodeProcessId = node.processId || node.operationId || node.technologyOperationId
      const nodeProcessName = node.processName || node.operationName || node.technologyOperationName
      if ((processId && nodeProcessId === processId) || (processName && nodeProcessName === processName)) {
        material = node
        return true
      }
      if (node.children && node.children.length > 0) {
        if (traverse(node.children)) return true
      }
    }
    return false
  }
  traverse(bomData.value)
  return material
})
const getStationX = (index) => {
  const totalStations = processList.value.length
  if (totalStations === 0) return 0
  const startX = 140
  const spacing = Math.min(180, (900 - startX) / totalStations)
  return startX + index * spacing
}
const truncateText = (text, maxLen) => {
  if (!text) return ''
  return text.length > maxLen ? text.substring(0, maxLen) + '..' : text
}
const getProgressPath = () => {
  if (completedIndex.value === 0) return ''
  const endX = getStationX(Math.min(completedIndex.value, processList.value.length) - 1) + 60
  return `M60 350 L${endX} 350`
}
const fetchRouteList = async () => {
  try {
    const res = await getRouteList({ current: 1, size: 100 })
    routeList.value = res?.data?.records || []
  } catch (error) {
    console.error('获取工艺路线列表失败:', error)
  }
}
const fetchProcessList = async () => {
  if (!selectedRouteId.value) return
  loading.value = true
  try {
    const res = await findProcessRouteItemList({ routeId: selectedRouteId.value })
    processList.value = res?.data || []
    processList.value.sort((a, b) => (a.dragSort || 0) - (b.dragSort || 0))
    // èŽ·å–å…³è”çš„BOM数据
    const routeInfo = routeList.value.find(r => r.id === selectedRouteId.value)
    if (routeInfo?.bomId) {
      selectedBomId.value = routeInfo.bomId
      const bomRes = await queryList(routeInfo.bomId)
      bomData.value = bomRes?.data || []
    }
  } catch (error) {
    console.error('获取工艺路线数据失败:', error)
  } finally {
    loading.value = false
  }
}
const handleRouteChange = () => {
  handleReset()
  fetchProcessList()
}
const togglePlay = () => {
  isPlaying.value = !isPlaying.value
  if (isPlaying.value) {
    startAnimation()
  } else {
    stopAnimation()
  }
}
const handleReset = () => {
  stopAnimation()
  isPlaying.value = false
  currentProcessIndex.value = -1
  completedIndex.value = 0
  feedingIndex.value = -1
  boxPosition.value = 0
  showFeedingAnimation.value = false
  showOutputAnimation.value = false
  feedingProcessIndex.value = -1
  outputProcessIndex.value = -1
  lastCompletedIndex.value = -1
}
const startAnimation = () => {
  const speedFactor = parseFloat(speed.value)
  const interval = 2000 / speedFactor
  const moveStep = 20 / speedFactor
  animationTimer.value = setInterval(() => {
    if (!isPlaying.value) return
    // æ›´æ–°ç®±å­ä½ç½®
    boxPosition.value += moveStep
    // è®¡ç®—当前应该在哪个工序
    const totalWidth = 900
    const progressRatio = boxPosition.value / totalWidth
    const newIndex = Math.floor(progressRatio * processList.value.length)
    // è¿›å…¥æ–°å·¥åºæ—¶è§¦å‘投料动画
    if (newIndex !== currentProcessIndex.value && newIndex < processList.value.length) {
      // å…ˆè§¦å‘产出动画(上一道工序完成)
      if (currentProcessIndex.value >= 0 && currentProcessIndex.value < processList.value.length) {
        const prevProcess = processList.value[currentProcessIndex.value]
        triggerOutputAnimation(currentProcessIndex.value, prevProcess?.productName || '产品')
      }
      currentProcessIndex.value = newIndex
      // è§¦å‘投料动画
      const process = processList.value[newIndex]
      const inputMaterial = getInputMaterialForProcess(process)
      triggerFeedingAnimation(newIndex, inputMaterial?.productName || '原料')
      feedingIndex.value = newIndex
      setTimeout(() => {
        feedingIndex.value = -1
      }, 500 / speedFactor)
    }
    // åˆ¤æ–­æ˜¯å¦å®Œæˆ
    if (boxPosition.value >= totalWidth) {
      // æœ€åŽä¸€ä¸ªå·¥åºå®Œæˆï¼Œè§¦å‘产出动画
      if (currentProcessIndex.value >= 0 && currentProcessIndex.value < processList.value.length) {
        const lastProcess = processList.value[currentProcessIndex.value]
        triggerOutputAnimation(currentProcessIndex.value, lastProcess?.productName || '成品')
      }
      completedIndex.value = processList.value.length
      currentProcessIndex.value = -1
      // å¾ªçŽ¯æ’­æ”¾
      setTimeout(() => {
        boxPosition.value = 0
        completedIndex.value = 0
        currentProcessIndex.value = -1
        lastCompletedIndex.value = -1
        showFeedingAnimation.value = false
        showOutputAnimation.value = false
      }, 1500 / speedFactor)
    } else if (newIndex >= 0) {
      completedIndex.value = newIndex
    }
  }, interval)
}
// èŽ·å–å·¥åºéœ€è¦æŠ•å…¥çš„ç‰©æ–™
const getInputMaterialForProcess = (process) => {
  if (!process) return null
  const processId = process.technologyOperationId || process.operationId
  const processName = process.technologyOperationName || process.operationName
  let material = null
  const traverse = (nodes) => {
    for (const node of nodes) {
      const nodeProcessId = node.processId || node.operationId || node.technologyOperationId
      const nodeProcessName = node.processName || node.operationName || node.technologyOperationName
      if ((processId && nodeProcessId === processId) || (processName && nodeProcessName === processName)) {
        material = node
        return true
      }
      if (node.children && node.children.length > 0) {
        if (traverse(node.children)) return true
      }
    }
    return false
  }
  traverse(bomData.value)
  return material
}
// è§¦å‘投料动画
const triggerFeedingAnimation = (processIndex, materialName) => {
  showFeedingAnimation.value = true
  feedingProcessIndex.value = processIndex
  feedingMaterialName.value = materialName
  feedingAnimY.value = feedingStartY
  setTimeout(() => {
    showFeedingAnimation.value = false
  }, 600 / parseFloat(speed.value))
}
// è§¦å‘产出动画
const triggerOutputAnimation = (processIndex, productName) => {
  showOutputAnimation.value = true
  outputProcessIndex.value = processIndex
  outputProductName.value = productName
  setTimeout(() => {
    showOutputAnimation.value = false
  }, 800 / parseFloat(speed.value))
}
const stopAnimation = () => {
  if (animationTimer.value) {
    clearInterval(animationTimer.value)
    animationTimer.value = null
  }
}
const showProcessDetail = (process) => {
  selectedProcess.value = process
  // æŸ¥æ‰¾å·¥åºç´¢å¼•
  selectedProcessIndex.value = processList.value.findIndex(p =>
    p.id === process.id ||
    (p.technologyOperationName === process.technologyOperationName && p.productName === process.productName)
  )
  processDialogVisible.value = true
}
const showInputDialog = () => {
  // å¯ä»¥å±•示原材料入库信息
}
const showOutputDialog = () => {
  // å¯ä»¥å±•示成品出库信息
}
const goBack = () => {
  router.push('/productionManagement/teachingDemo')
}
watch(speed, () => {
  if (isPlaying.value) {
    stopAnimation()
    startAnimation()
  }
})
onMounted(() => {
  fetchRouteList()
})
onUnmounted(() => {
  stopAnimation()
})
</script>
<style scoped lang="scss">
.factory-demo {
  .control-panel {
    background: #fff;
    padding: 20px;
    border-radius: 12px;
    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
    margin-bottom: 20px;
    .speed-control {
      display: flex;
      align-items: center;
      gap: 8px;
    }
    .progress-info {
      display: flex;
      align-items: center;
      gap: 8px;
    }
  }
  .factory-container {
    display: flex;
    gap: 20px;
    min-height: 500px;
    .factory-scene {
      flex: 1;
      background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
      border-radius: 16px;
      padding: 20px;
      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
      overflow: hidden;
      .factory-svg {
        width: 100%;
        height: auto;
        min-height: 450px;
        .conveyor-system {
          .belt-flow {
            animation: beltFlow 0.8s linear infinite;
          }
        }
        .work-station {
          cursor: pointer;
          &:hover {
            filter: brightness(1.1) drop-shadow(0 0 12px rgba(64, 158, 255, 0.4));
          }
          &.is-active {
            .station-name {
              animation: textGlow 1s ease-in-out infinite;
            }
          }
          .feed-point {
            transition: all 0.3s ease;
            &.is-feeding {
              animation: feedPulse 0.3s ease-out;
            }
          }
          .is-pulsing {
            animation: statusPulse 1s ease-in-out infinite;
          }
        }
        .product-box {
          transition: transform 0.1s linear;
        }
        .product-boxes {
          animation: boxesMove calc(8s / var(--speed, 1)) linear infinite;
        }
        .info-text {
          animation: fadeInOut 0.5s ease;
        }
      }
    }
    .factory-sidebar {
      width: 280px;
      display: flex;
      flex-direction: column;
      gap: 16px;
      .info-card {
        .status-info {
          .status-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 8px 0;
            border-bottom: 1px solid #f0f0f0;
            &:last-child {
              border-bottom: none;
            }
            .status-label {
              color: #909399;
              font-size: 13px;
            }
            .status-value {
              color: #303133;
              font-weight: 600;
              &.highlight {
                color: #409eff;
              }
            }
          }
        }
      }
      .legend-card {
        .legend-item {
          display: flex;
          align-items: center;
          gap: 8px;
          margin-bottom: 8px;
          .legend-dot {
            width: 12px;
            height: 12px;
            border-radius: 50%;
            &.completed {
              background: #67c23a;
            }
            &.current {
              background: #e6a23c;
              animation: legendPulse 1s ease-in-out infinite;
            }
            &.pending {
              background: #c0c4cc;
            }
            &.quality {
              background: #fdf6ec;
              border: 2px solid #e6a23c;
            }
          }
          .legend-text {
            font-size: 12px;
            color: #666;
          }
        }
      }
      .tips-card {
        .tips-content {
          p {
            font-size: 12px;
            color: #909399;
            margin-bottom: 8px;
            line-height: 1.6;
            &:before {
              content: '• ';
              color: #409eff;
            }
          }
        }
      }
    }
  }
  .material-section {
    margin-top: 16px;
    .section-header {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-bottom: 12px;
      h4 {
        color: #303133;
        font-size: 14px;
        margin: 0;
        font-weight: 600;
      }
    }
    &.empty-section {
      margin-top: 12px;
    }
    .quantity-value {
      font-weight: 600;
      color: #67c23a;
    }
    .material-summary {
      margin-top: 8px;
    }
    .process-flow {
      display: flex;
      align-items: center;
      gap: 16px;
      flex-wrap: wrap;
      .flow-item {
        display: flex;
        align-items: center;
        gap: 8px;
        .flow-label {
          font-size: 12px;
          color: #909399;
        }
        &.current {
          .flow-label {
            color: #409eff;
            font-weight: 600;
          }
        }
      }
    }
  }
}
@keyframes beltFlow {
  to {
    stroke-dashoffset: -35;
  }
}
@keyframes statusPulse {
  0%, 100% {
    opacity: 0.6;
    transform: scale(1);
  }
  50% {
    opacity: 1;
    transform: scale(1.2);
  }
}
@keyframes feedPulse {
  0% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.3);
    fill: #67c23a;
  }
  100% {
    transform: scale(1);
  }
}
@keyframes textGlow {
  0%, 100% {
    opacity: 1;
  }
  50% {
    opacity: 0.8;
  }
}
@keyframes legendPulse {
  0%, 100% {
    box-shadow: 0 0 4px rgba(230, 162, 60, 0.4);
  }
  50% {
    box-shadow: 0 0 8px rgba(230, 162, 60, 0.6);
  }
}
@keyframes fadeInOut {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
</style>
src/views/productionManagement/teachingDemo/ProcessRouteDemo.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,535 @@
<template>
  <div class="app-container process-route-demo">
    <PageHeader :content="`工艺路线演示 - ${routeInfo.productName || ''}`">
      <template #right-button>
        <el-button @click="goBack">返回</el-button>
      </template>
    </PageHeader>
    <DemoControls
      ref="controlsRef"
      :steps="demoSteps"
      :explanations="demoExplanations"
      @play="handlePlay"
      @pause="handlePause"
      @reset="handleReset"
      @stepChange="handleStepChange"
      @speedChange="handleSpeedChange"
    />
    <div class="demo-container" v-loading="loading">
      <div class="route-visualization">
        <div class="process-flow-container" v-if="processList.length > 0">
          <div class="flow-wrapper">
            <ProcessNode
              v-for="(process, index) in processList"
              :key="process.id || index"
              :process="process"
              :index="index"
              :delay="index * 200"
              :animation-speed="animationSpeed"
              :active="activeProcessId === process.id"
              :completed="completedProcesses.includes(process.id)"
              :current="currentProcessId === process.id"
              :animating="isAnimating && currentProcessId === process.id"
              :show-arrow="index < processList.length - 1"
              :show-progress="showProgress"
              :progress="getProcessProgress(process)"
              @click="handleProcessClick"
            />
          </div>
          <div class="flow-start-end">
            <div class="flow-node start">
              <el-icon><Goods /></el-icon>
              <span>原材料</span>
            </div>
            <div class="flow-connector">
              <svg width="60" height="24">
                <line x1="0" y1="12" x2="50" y2="12" stroke="#67c23a" stroke-width="2" stroke-dasharray="5,5" />
                <polygon points="50,8 60,12 50,16" fill="#67c23a" />
              </svg>
            </div>
            <div class="flow-node end">
              <el-icon><Box /></el-icon>
              <span>成品</span>
            </div>
          </div>
        </div>
        <el-empty v-else description="暂无工艺路线数据" />
        <div class="visualization-sidebar">
          <el-card class="info-card" shadow="hover">
            <template #header>
              <span>当前工序信息</span>
            </template>
            <el-descriptions :column="1" border size="small" v-if="activeProcess">
              <el-descriptions-item label="工序名称">{{ activeProcess.technologyOperationName || activeProcess.operationName }}</el-descriptions-item>
              <el-descriptions-item label="产品名称">{{ activeProcess.productName || '-' }}</el-descriptions-item>
              <el-descriptions-item label="规格型号">{{ activeProcess.model || '-' }}</el-descriptions-item>
              <el-descriptions-item label="单位">{{ activeProcess.unit || '-' }}</el-descriptions-item>
              <el-descriptions-item label="计费类型">{{ activeProcess.type === 1 ? '计件' : '计时' }}</el-descriptions-item>
              <el-descriptions-item label="是否质检">{{ activeProcess.isQuality ? '是' : '否' }}</el-descriptions-item>
              <el-descriptions-item label="是否生产">{{ activeProcess.isProduction ? '是' : '否' }}</el-descriptions-item>
              <el-descriptions-item label="工序序号">{{ activeProcess.dragSort || getProcessIndex(activeProcess) + 1 }}</el-descriptions-item>
            </el-descriptions>
            <div v-else class="empty-info">点击工序节点查看详细信息</div>
          </el-card>
          <el-card class="progress-card" shadow="hover">
            <template #header>
              <span>生产进度模拟</span>
            </template>
            <div class="progress-info">
              <div class="progress-stat">
                <span class="stat-label">已完成工序</span>
                <span class="stat-value">{{ completedProcesses.length }} / {{ processList.length }}</span>
              </div>
              <el-progress
                :percentage="processList.length > 0 ? (completedProcesses.length / processList.length) * 100 : 0"
                :stroke-width="10"
                status="success"
              />
            </div>
            <div class="progress-actions">
              <el-button type="primary" size="small" @click="simulateProgress">
                æ¨¡æ‹Ÿè¿›åº¦
              </el-button>
              <el-button size="small" @click="resetProgress">
                é‡ç½®
              </el-button>
            </div>
          </el-card>
          <el-card class="legend-card" shadow="hover">
            <template #header>
              <span>状态说明</span>
            </template>
            <div class="legend-item">
              <span class="legend-icon default"></span>
              <span class="legend-text">待加工</span>
            </div>
            <div class="legend-item">
              <span class="legend-icon current"></span>
              <span class="legend-text">当前工序</span>
            </div>
            <div class="legend-item">
              <span class="legend-icon completed"></span>
              <span class="legend-text">已完成</span>
            </div>
            <div class="legend-item">
              <span class="legend-icon active"></span>
              <span class="legend-text">选中状态</span>
            </div>
          </el-card>
        </div>
      </div>
    </div>
  </div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Goods, Box } from '@element-plus/icons-vue'
import ProcessNode from './components/ProcessNode.vue'
import DemoControls from './components/DemoControls.vue'
import { findProcessRouteItemList } from '@/api/productionManagement/processRouteItem.js'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const processList = ref([])
const controlsRef = ref(null)
const animationSpeed = ref(1)
const isAnimating = ref(false)
const activeProcessId = ref(null)
const activeProcess = ref(null)
const currentProcessId = ref(null)
const completedProcesses = ref([])
const showProgress = ref(false)
const playTimer = ref(null)
const progressTimer = ref(null)
const routeId = computed(() => route.query.id)
const routeInfo = computed(() => ({
  processRouteCode: route.query.processRouteCode || '',
  productName: route.query.productName || '',
  model: route.query.model || '',
  bomId: route.query.bomId || ''
}))
const demoSteps = [
  { title: '工序概览', description: '查看全部工序' },
  { title: '工序顺序', description: '了解流程' },
  { title: '工序详情', description: '查看参数' },
  { title: '进度模拟', description: '生产流程' },
  { title: '完成状态', description: '生产完成' }
]
const demoExplanations = [
  {
    title: '第1步:工序概览',
    content: '工艺路线展示了产品从原材料到成品需要经过的所有工序。每个节点代表一道独立的加工步骤。'
  },
  {
    title: '第2步:工序顺序',
    content: '箭头表示工序的执行顺序。产品按照箭头方向依次经过每道工序,直到最终完成。'
  },
  {
    title: '第3步:工序详情',
    content: '点击工序节点可以查看详细信息,包括产品名称、规格、计费类型、是否需要质检等。'
  },
  {
    title: '第4步:进度模拟',
    content: '点击"模拟进度"可以观看产品在各工序间流转的过程。绿色表示已完成,黄色表示当前工序。'
  },
  {
    title: '第5步:生产完成',
    content: '当所有工序都完成后,产品就生产出来了。实际生产中,每道工序可能会有更详细的参数和操作要求。'
  }
]
const fetchProcessList = async () => {
  if (!routeId.value) return
  loading.value = true
  try {
    const res = await findProcessRouteItemList({ routeId: routeId.value })
    processList.value = res?.data || []
    // æŒ‰åºå·æŽ’序
    processList.value.sort((a, b) => (a.dragSort || 0) - (b.dragSort || 0))
  } catch (error) {
    console.error('获取工艺路线数据失败:', error)
  } finally {
    loading.value = false
  }
}
const goBack = () => {
  router.push('/productionManagement/teachingDemo')
}
const handleProcessClick = (process) => {
  activeProcess.value = process
  activeProcessId.value = process.id
}
const getProcessIndex = (process) => {
  return processList.value.findIndex(p => p.id === process.id)
}
const getProcessProgress = (process) => {
  if (completedProcesses.value.includes(process.id)) {
    return 100
  }
  if (currentProcessId.value === process.id) {
    return 50
  }
  return 0
}
const handlePlay = () => {
  isAnimating.value = true
  runAnimation()
}
const handlePause = () => {
  isAnimating.value = false
  if (playTimer.value) {
    clearTimeout(playTimer.value)
  }
}
const handleReset = () => {
  isAnimating.value = false
  activeProcessId.value = null
  activeProcess.value = null
  currentProcessId.value = null
  completedProcesses.value = []
  showProgress.value = false
  if (playTimer.value) {
    clearTimeout(playTimer.value)
  }
  if (progressTimer.value) {
    clearInterval(progressTimer.value)
  }
}
const handleStepChange = (step) => {
  switch (step) {
    case 0:
      // å·¥åºæ¦‚览
      break
    case 1:
      // å·¥åºé¡ºåº
      if (processList.value.length > 0) {
        activeProcess.value = processList.value[0]
        activeProcessId.value = processList.value[0].id
      }
      break
    case 2:
      // å·¥åºè¯¦æƒ…
      break
    case 3:
      // è¿›åº¦æ¨¡æ‹Ÿ
      simulateProgress()
      break
    case 4:
      // å®ŒæˆçŠ¶æ€
      completedProcesses.value = processList.value.map(p => p.id)
      currentProcessId.value = null
      break
  }
}
const handleSpeedChange = (speed) => {
  animationSpeed.value = speed
}
const simulateProgress = () => {
  showProgress.value = true
  completedProcesses.value = []
  currentProcessId.value = null
  let index = 0
  const interval = 1500 / animationSpeed.value
  progressTimer.value = setInterval(() => {
    if (index >= processList.value.length) {
      clearInterval(progressTimer.value)
      currentProcessId.value = null
      isAnimating.value = false
      controlsRef.value?.setIsPlaying(false)
      return
    }
    if (index > 0) {
      completedProcesses.value.push(processList.value[index - 1].id)
    }
    currentProcessId.value = processList.value[index].id
    activeProcess.value = processList.value[index]
    activeProcessId.value = processList.value[index].id
    index++
  }, interval)
}
const resetProgress = () => {
  if (progressTimer.value) {
    clearInterval(progressTimer.value)
  }
  completedProcesses.value = []
  currentProcessId.value = null
  showProgress.value = false
}
const runAnimation = () => {
  if (!isAnimating.value) return
  const totalSteps = demoSteps.length
  let currentStep = controlsRef.value?.currentStep || 0
  const animate = () => {
    if (!isAnimating.value) return
    if (currentStep < totalSteps) {
      controlsRef.value?.setStep(currentStep)
      handleStepChange(currentStep)
      currentStep++
      playTimer.value = setTimeout(animate, 4000 / animationSpeed.value)
    } else {
      isAnimating.value = false
      controlsRef.value?.setIsPlaying(false)
    }
  }
  animate()
}
onMounted(() => {
  fetchProcessList()
})
onUnmounted(() => {
  if (playTimer.value) {
    clearTimeout(playTimer.value)
  }
  if (progressTimer.value) {
    clearInterval(progressTimer.value)
  }
})
</script>
<style scoped lang="scss">
.process-route-demo {
  .demo-container {
    background: #f5f7fa;
    padding: 20px;
    border-radius: 12px;
    min-height: 400px;
    .route-visualization {
      display: flex;
      gap: 20px;
      .process-flow-container {
        flex: 1;
        background: #fff;
        padding: 30px;
        border-radius: 12px;
        box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
        .flow-wrapper {
          display: flex;
          align-items: center;
          gap: 0;
          padding: 20px 0;
          overflow-x: auto;
          min-height: 200px;
        }
        .flow-start-end {
          display: flex;
          align-items: center;
          justify-content: center;
          margin-top: 30px;
          padding-top: 20px;
          border-top: 1px dashed #e4e7ed;
          .flow-node {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 8px;
            padding: 16px 24px;
            border-radius: 12px;
            &.start {
              background: linear-gradient(135deg, #f0f9eb 0%, #e1f3d8 100%);
              border: 2px solid #67c23a;
              .el-icon {
                color: #67c23a;
                font-size: 24px;
              }
              span {
                color: #67c23a;
                font-weight: 600;
              }
            }
            &.end {
              background: linear-gradient(135deg, #ecf5ff 0%, #d9ecff 100%);
              border: 2px solid #409eff;
              .el-icon {
                color: #409eff;
                font-size: 24px;
              }
              span {
                color: #409eff;
                font-weight: 600;
              }
            }
          }
          .flow-connector {
            margin: 0 20px;
          }
        }
      }
      .visualization-sidebar {
        width: 300px;
        display: flex;
        flex-direction: column;
        gap: 16px;
        .info-card,
        .progress-card,
        .legend-card {
          .empty-info {
            color: #909399;
            text-align: center;
            padding: 20px;
          }
        }
        .progress-card {
          .progress-info {
            margin-bottom: 16px;
            .progress-stat {
              display: flex;
              justify-content: space-between;
              margin-bottom: 8px;
              .stat-label {
                color: #666;
                font-size: 14px;
              }
              .stat-value {
                color: #409eff;
                font-weight: 600;
              }
            }
          }
          .progress-actions {
            display: flex;
            gap: 8px;
          }
        }
        .legend-card {
          .legend-item {
            display: flex;
            align-items: center;
            gap: 8px;
            margin-bottom: 8px;
            .legend-icon {
              width: 20px;
              height: 20px;
              border-radius: 4px;
              border: 2px solid;
              &.default {
                background: #fff;
                border-color: #e4e7ed;
              }
              &.current {
                background: linear-gradient(135deg, #fdf6ec 0%, #fff 100%);
                border-color: #e6a23c;
                box-shadow: 0 0 8px rgba(230, 162, 60, 0.4);
              }
              &.completed {
                background: linear-gradient(135deg, #f0f9eb 0%, #fff 100%);
                border-color: #67c23a;
              }
              &.active {
                background: #fff;
                border-color: #409eff;
                box-shadow: 0 0 12px rgba(64, 158, 255, 0.5);
              }
            }
            .legend-text {
              font-size: 12px;
              color: #666;
            }
          }
        }
      }
    }
  }
}
</style>
src/views/productionManagement/teachingDemo/components/DemoControls.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,186 @@
<template>
  <div class="demo-controls">
    <div class="control-buttons">
      <el-button-group>
        <el-button :type="isPlaying ? 'warning' : 'primary'" @click="togglePlay">
          <el-icon v-if="isPlaying"><VideoPause /></el-icon>
          <el-icon v-else><VideoPlay /></el-icon>
          {{ isPlaying ? '暂停' : '播放' }}
        </el-button>
        <el-button @click="handleReset">
          <el-icon><RefreshRight /></el-icon>
          é‡ç½®
        </el-button>
      </el-button-group>
      <div class="speed-control">
        <span class="speed-label">速度:</span>
        <el-radio-group v-model="speed" size="small" @change="handleSpeedChange">
          <el-radio-button label="0.5">慢速</el-radio-button>
          <el-radio-button label="1">正常</el-radio-button>
          <el-radio-button label="2">快速</el-radio-button>
        </el-radio-group>
      </div>
    </div>
    <div class="step-control" v-if="steps.length > 0">
      <span class="step-label">步骤:</span>
      <el-steps :active="currentStep" align-center>
        <el-step
          v-for="(step, index) in steps"
          :key="index"
          :title="step.title"
          :description="step.description"
          @click.native="handleStepClick(index)"
          class="clickable-step"
        />
      </el-steps>
    </div>
    <div class="explanation-box" v-if="currentExplanation">
      <el-alert
        :title="currentExplanation.title"
        type="success"
        :closable="false"
        show-icon
      >
        <template #default>
          {{ currentExplanation.content }}
        </template>
      </el-alert>
    </div>
  </div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { VideoPlay, VideoPause, RefreshRight } from '@element-plus/icons-vue'
const props = defineProps({
  steps: {
    type: Array,
    default: () => []
  },
  explanations: {
    type: Array,
    default: () => []
  }
})
const emit = defineEmits(['play', 'pause', 'reset', 'stepChange', 'speedChange'])
const isPlaying = ref(false)
const speed = ref('1')
const currentStep = ref(0)
const currentExplanation = computed(() => {
  if (props.explanations && props.explanations[currentStep.value]) {
    return props.explanations[currentStep.value]
  }
  return null
})
const togglePlay = () => {
  isPlaying.value = !isPlaying.value
  if (isPlaying.value) {
    emit('play')
  } else {
    emit('pause')
  }
}
const handleReset = () => {
  isPlaying.value = false
  currentStep.value = 0
  emit('reset')
}
const handleStepClick = (index) => {
  currentStep.value = index
  emit('stepChange', index)
}
const handleSpeedChange = (val) => {
  emit('speedChange', parseFloat(val))
}
const nextStep = () => {
  if (currentStep.value < props.steps.length - 1) {
    currentStep.value++
    emit('stepChange', currentStep.value)
  }
}
const setStep = (index) => {
  currentStep.value = index
}
const setIsPlaying = (val) => {
  isPlaying.value = val
}
defineExpose({
  nextStep,
  setStep,
  setIsPlaying,
  currentStep
})
</script>
<style scoped lang="scss">
.demo-controls {
  background: #fff;
  padding: 20px;
  border-radius: 12px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
  margin-bottom: 20px;
  .control-buttons {
    display: flex;
    align-items: center;
    gap: 24px;
    margin-bottom: 20px;
    .speed-control {
      display: flex;
      align-items: center;
      gap: 8px;
      .speed-label {
        color: #666;
        font-size: 14px;
      }
    }
  }
  .step-control {
    margin-bottom: 20px;
    .step-label {
      display: block;
      color: #666;
      font-size: 14px;
      margin-bottom: 12px;
    }
    .clickable-step {
      cursor: pointer;
    }
  }
  .explanation-box {
    animation: fadeIn 0.3s ease;
  }
}
@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
</style>
src/views/productionManagement/teachingDemo/components/ProcessNode.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,354 @@
<template>
  <div
    class="process-node"
    :class="{
      'is-active': active,
      'is-completed': completed,
      'is-current': current,
      'is-animating': animating
    }"
    :style="{ '--delay': delay + 'ms', '--speed': animationSpeed }"
  >
    <div class="node-badge">
      <span class="badge-number">{{ index + 1 }}</span>
    </div>
    <div class="node-card" @click="handleClick">
      <div class="node-header">
        <div class="node-icon">
          <el-icon><SetUp /></el-icon>
        </div>
        <div class="node-title">{{ process.technologyOperationName || process.operationName || '未命名工序' }}</div>
      </div>
      <div class="node-body">
        <div class="node-info-item" v-if="process.productName">
          <span class="info-label">产品:</span>
          <span class="info-value">{{ process.productName }}</span>
        </div>
        <div class="node-info-item" v-if="process.model">
          <span class="info-label">规格:</span>
          <span class="info-value">{{ process.model }}</span>
        </div>
        <div class="node-tags">
          <el-tag :type="process.type === 1 ? 'primary' : 'success'" size="small">
            {{ process.type === 1 ? '计件' : '计时' }}
          </el-tag>
          <el-tag v-if="process.isQuality" type="warning" size="small">质检</el-tag>
          <el-tag v-if="process.isProduction" type="info" size="small">生产</el-tag>
        </div>
      </div>
      <div class="node-progress" v-if="showProgress">
        <el-progress
          :percentage="progress"
          :status="completed ? 'success' : current ? '' : ''"
          :stroke-width="6"
        />
      </div>
    </div>
    <div class="node-arrow" v-if="showArrow">
      <svg width="40" height="24" viewBox="0 0 40 24">
        <defs>
          <marker
            id="arrowhead"
            markerWidth="10"
            markerHeight="7"
            refX="9"
            refY="3.5"
            orient="auto"
          >
            <polygon points="0 0, 10 3.5, 0 7" fill="#409eff" />
          </marker>
        </defs>
        <line
          x1="0"
          y1="12"
          x2="30"
          y2="12"
          stroke="#409eff"
          stroke-width="2"
          marker-end="url(#arrowhead)"
          :class="{ 'is-animating': animating }"
        />
        <circle
          v-if="animating"
          r="4"
          fill="#67c23a"
          class="flow-dot"
        >
          <animate
            attributeName="cx"
            values="0;30"
            :dur="`${1 / animationSpeed}s`"
            repeatCount="indefinite"
          />
          <animate
            attributeName="cy"
            values="12;12"
            dur="1s"
            repeatCount="indefinite"
          />
        </circle>
      </svg>
    </div>
  </div>
</template>
<script setup>
import { computed } from 'vue'
import { SetUp } from '@element-plus/icons-vue'
const props = defineProps({
  process: {
    type: Object,
    required: true
  },
  index: {
    type: Number,
    default: 0
  },
  delay: {
    type: Number,
    default: 0
  },
  animationSpeed: {
    type: Number,
    default: 1
  },
  active: {
    type: Boolean,
    default: false
  },
  completed: {
    type: Boolean,
    default: false
  },
  current: {
    type: Boolean,
    default: false
  },
  animating: {
    type: Boolean,
    default: false
  },
  showArrow: {
    type: Boolean,
    default: true
  },
  showProgress: {
    type: Boolean,
    default: false
  },
  progress: {
    type: Number,
    default: 0
  }
})
const emit = defineEmits(['click'])
const handleClick = () => {
  emit('click', props.process)
}
</script>
<style scoped lang="scss">
.process-node {
  display: flex;
  align-items: center;
  animation: nodeSlideIn calc(var(--delay) * var(--speed)) ease-out both;
  .node-badge {
    position: absolute;
    top: -12px;
    left: 50%;
    transform: translateX(-50%);
    z-index: 1;
    .badge-number {
      display: flex;
      align-items: center;
      justify-content: center;
      width: 28px;
      height: 28px;
      background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
      color: #fff;
      font-weight: bold;
      font-size: 14px;
      border-radius: 50%;
      box-shadow: 0 2px 8px rgba(64, 158, 255, 0.4);
    }
  }
  .node-card {
    position: relative;
    width: 180px;
    background: #fff;
    border-radius: 12px;
    border: 2px solid #e4e7ed;
    padding: 20px 16px 16px;
    cursor: pointer;
    transition: all 0.3s ease;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
    &:hover {
      transform: translateY(-4px);
      box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
      border-color: #409eff;
    }
    .node-header {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-bottom: 12px;
      .node-icon {
        width: 28px;
        height: 28px;
        display: flex;
        align-items: center;
        justify-content: center;
        background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
        border-radius: 6px;
        .el-icon {
          color: #fff;
          font-size: 16px;
        }
      }
      .node-title {
        font-weight: 600;
        color: #303133;
        font-size: 13px;
        flex: 1;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
    }
    .node-body {
      .node-info-item {
        display: flex;
        font-size: 12px;
        margin-bottom: 4px;
        .info-label {
          color: #909399;
          margin-right: 4px;
        }
        .info-value {
          color: #606266;
          flex: 1;
          overflow: hidden;
          text-overflow: ellipsis;
          white-space: nowrap;
        }
      }
      .node-tags {
        display: flex;
        flex-wrap: wrap;
        gap: 4px;
        margin-top: 8px;
      }
    }
    .node-progress {
      margin-top: 12px;
    }
  }
  .node-arrow {
    margin: 0 8px;
    line {
      transition: stroke 0.3s;
      &.is-animating {
        animation: arrowGlow 0.5s ease-in-out infinite alternate;
      }
    }
    .flow-dot {
      filter: drop-shadow(0 0 4px rgba(103, 194, 58, 0.6));
    }
  }
  &.is-active {
    .node-card {
      border-color: #409eff;
      box-shadow: 0 4px 16px rgba(64, 158, 255, 0.3);
    }
  }
  &.is-completed {
    .node-card {
      border-color: #67c23a;
      background: linear-gradient(135deg, #f0f9eb 0%, #fff 100%);
      .node-badge .badge-number {
        background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
      }
    }
  }
  &.is-current {
    .node-card {
      border-color: #e6a23c;
      animation: currentPulse 1.5s ease-in-out infinite;
    }
  }
  &.is-animating {
    .node-card {
      animation: cardPulse calc(1s * var(--speed)) ease-in-out infinite;
    }
  }
}
@keyframes nodeSlideIn {
  from {
    opacity: 0;
    transform: translateX(-30px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}
@keyframes currentPulse {
  0%, 100% {
    box-shadow: 0 4px 16px rgba(230, 162, 60, 0.3);
  }
  50% {
    box-shadow: 0 4px 24px rgba(230, 162, 60, 0.5);
  }
}
@keyframes cardPulse {
  0%, 100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.02);
  }
}
@keyframes arrowGlow {
  from {
    stroke: #409eff;
    filter: drop-shadow(0 0 2px rgba(64, 158, 255, 0.4));
  }
  to {
    stroke: #66b1ff;
    filter: drop-shadow(0 0 6px rgba(64, 158, 255, 0.6));
  }
}
</style>
src/views/productionManagement/teachingDemo/components/TreeNode.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,423 @@
<template>
  <div
    class="tree-node"
    :class="{
      'is-root': isRoot,
      'is-expanded': expanded,
      'is-highlighted': highlighted,
      'is-active': active,
      'is-animating': animating
    }"
    :style="{ '--delay': delay + 'ms', '--speed': animationSpeed }"
  >
    <div class="node-content" @click="handleClick">
      <div class="node-icon">
        <el-icon v-if="hasChildren"><FolderOpened v-if="expanded" /><Folder v-else /></el-icon>
        <el-icon v-else><Document /></el-icon>
      </div>
      <div class="node-info">
        <div class="node-name">{{ node.productName || '未命名产品' }}</div>
        <div class="node-model" v-if="node.model">{{ node.model }}</div>
        <div class="node-meta">
          <span class="node-quantity">
            <el-icon><Goods /></el-icon>
            {{ node.unitQuantity || 1 }} {{ node.unit || '' }}
          </span>
          <span class="node-process" v-if="node.processName && !isRoot">
            <el-icon><Setting /></el-icon>
            {{ node.processName }}
          </span>
        </div>
      </div>
      <div class="node-expand-btn" v-if="hasChildren" @click.stop="toggleExpand">
        <el-icon>
          <ArrowDown v-if="expanded" />
          <ArrowRight v-else />
        </el-icon>
      </div>
    </div>
    <div class="node-children" v-if="hasChildren && expanded">
      <div class="children-container">
        <svg class="connection-lines" v-if="showLines">
          <path
            v-for="(child, index) in node.children"
            :key="child.tempId || child.id || index"
            :d="getLinePath(index)"
            class="connection-line"
            :class="{ 'is-animating': animating }"
            :style="{ '--line-delay': (delay + index * 100) + 'ms' }"
          />
        </svg>
        <TreeNode
          v-for="(child, index) in node.children"
          :key="child.tempId || child.id || index"
          :node="child"
          :level="level + 1"
          :delay="delay + 200 + index * 100"
          :animation-speed="animationSpeed"
          :auto-expand="autoExpand"
          :show-lines="showLines"
          :highlight-ids="highlightIds"
          :active-id="activeId"
          :animating="animating"
          @node-click="(n) => emit('node-click', n)"
          @expand="(n, exp) => emit('expand', n, exp)"
        />
      </div>
    </div>
    <div class="material-flow-ball" v-if="animating && !hasChildren">
      <div class="ball"></div>
    </div>
  </div>
</template>
<script setup>
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import {
  Folder,
  FolderOpened,
  Document,
  ArrowDown,
  ArrowRight,
  Goods,
  Setting
} from '@element-plus/icons-vue'
const props = defineProps({
  node: {
    type: Object,
    required: true
  },
  level: {
    type: Number,
    default: 0
  },
  delay: {
    type: Number,
    default: 0
  },
  animationSpeed: {
    type: Number,
    default: 1
  },
  autoExpand: {
    type: Boolean,
    default: false
  },
  showLines: {
    type: Boolean,
    default: true
  },
  highlightIds: {
    type: Array,
    default: () => []
  },
  activeId: {
    type: [String, Number],
    default: null
  },
  animating: {
    type: Boolean,
    default: false
  }
})
const emit = defineEmits(['node-click', 'expand'])
const expanded = ref(false)
const nodeWidth = ref(200)
const nodeHeight = ref(60)
const isRoot = computed(() => props.level === 0)
const hasChildren = computed(() => props.node.children && props.node.children.length > 0)
const highlighted = computed(() => {
  const nodeId = props.node.tempId || props.node.id
  return props.highlightIds.includes(nodeId)
})
const active = computed(() => {
  const nodeId = props.node.tempId || props.node.id
  return props.activeId === nodeId
})
const toggleExpand = () => {
  expanded.value = !expanded.value
  emit('expand', props.node, expanded.value)
}
const handleClick = () => {
  emit('node-click', props.node)
}
const getLinePath = (index) => {
  const startX = 20
  const startY = 0
  const endX = 20
  const endY = 30 + index * 70
  return `M ${startX} ${startY} L ${startX} ${endY}`
}
watch(() => props.autoExpand, (val) => {
  if (val && hasChildren.value) {
    setTimeout(() => {
      expanded.value = true
    }, props.delay)
  }
})
onMounted(() => {
  if (props.autoExpand && isRoot.value && hasChildren.value) {
    expanded.value = true
  }
})
</script>
<style scoped lang="scss">
.tree-node {
  position: relative;
  margin-left: 20px;
  animation: nodeAppear calc(var(--delay) * var(--speed)) ease-out both;
  &.is-root {
    margin-left: 0;
    .node-content {
      background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
      color: #fff;
      .node-name {
        color: #fff;
      }
      .node-model {
        color: rgba(255, 255, 255, 0.85);
      }
      .node-meta {
        color: rgba(255, 255, 255, 0.9);
        .node-quantity,
        .node-process {
          background: rgba(255, 255, 255, 0.2);
          padding: 2px 8px;
          border-radius: 4px;
        }
      }
    }
  }
  &.is-highlighted {
    .node-content {
      border-color: #e6a23c;
      box-shadow: 0 0 12px rgba(230, 162, 60, 0.4);
    }
  }
  &.is-active {
    .node-content {
      border-color: #409eff;
      box-shadow: 0 0 16px rgba(64, 158, 255, 0.5);
      animation: activePulse 1.5s ease-in-out infinite;
    }
  }
  &.is-animating {
    .node-content {
      animation: nodeFlow calc(2s * var(--speed)) ease-in-out infinite;
    }
  }
  .node-content {
    display: flex;
    align-items: center;
    padding: 12px 16px;
    background: #fff;
    border-radius: 12px;
    border: 2px solid #e4e7ed;
    min-width: 200px;
    cursor: pointer;
    transition: all 0.3s ease;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
    &:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
      border-color: #409eff;
    }
    .node-icon {
      width: 32px;
      height: 32px;
      display: flex;
      align-items: center;
      justify-content: center;
      background: #f5f7fa;
      border-radius: 8px;
      margin-right: 12px;
      .el-icon {
        color: #409eff;
        font-size: 18px;
      }
    }
    .node-info {
      flex: 1;
      .node-name {
        font-weight: 600;
        color: #303133;
        font-size: 14px;
      }
      .node-model {
        color: #909399;
        font-size: 12px;
        margin-top: 2px;
      }
      .node-meta {
        display: flex;
        gap: 8px;
        margin-top: 6px;
        font-size: 12px;
        color: #606266;
        .node-quantity,
        .node-process {
          display: flex;
          align-items: center;
          gap: 4px;
        }
      }
    }
    .node-expand-btn {
      width: 24px;
      height: 24px;
      display: flex;
      align-items: center;
      justify-content: center;
      background: #f5f7fa;
      border-radius: 4px;
      transition: background 0.2s;
      &:hover {
        background: #409eff;
        .el-icon {
          color: #fff;
        }
      }
      .el-icon {
        font-size: 14px;
        color: #909399;
      }
    }
  }
  .node-children {
    margin-top: 8px;
    .children-container {
      position: relative;
      padding-left: 20px;
      .connection-lines {
        position: absolute;
        top: 0;
        left: 0;
        width: 40px;
        height: 100%;
        overflow: visible;
        .connection-line {
          stroke: #c0c4cc;
          stroke-width: 2;
          fill: none;
          stroke-dasharray: 100;
          stroke-dashoffset: 100;
          transition: stroke-dashoffset calc(var(--line-delay) * var(--speed)) ease-out;
          &.is-animating {
            stroke-dashoffset: 0;
            stroke: #409eff;
            animation: lineFlow 1s ease-in-out infinite;
          }
        }
      }
    }
  }
  .material-flow-ball {
    position: absolute;
    right: -20px;
    top: 50%;
    transform: translateY(-50%);
    .ball {
      width: 12px;
      height: 12px;
      background: #67c23a;
      border-radius: 50%;
      box-shadow: 0 0 8px rgba(103, 194, 58, 0.6);
      animation: ballPulse 0.8s ease-in-out infinite;
    }
  }
}
@keyframes nodeAppear {
  from {
    opacity: 0;
    transform: translateY(-20px) scale(0.9);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}
@keyframes activePulse {
  0%, 100% {
    box-shadow: 0 0 16px rgba(64, 158, 255, 0.5);
  }
  50% {
    box-shadow: 0 0 24px rgba(64, 158, 255, 0.8);
  }
}
@keyframes nodeFlow {
  0%, 100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.02);
  }
}
@keyframes lineFlow {
  0% {
    stroke-dashoffset: 100;
  }
  100% {
    stroke-dashoffset: 0;
  }
}
@keyframes ballPulse {
  0%, 100% {
    transform: scale(1);
    opacity: 1;
  }
  50% {
    transform: scale(1.3);
    opacity: 0.8;
  }
}
</style>
src/views/productionManagement/teachingDemo/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,333 @@
<template>
  <div class="app-container teaching-demo-index">
    <PageHeader content="工艺路线与BOM动画教学演示">
      <template #right-button>
        <el-button type="success" @click="goToFactoryDemo">
          <el-icon><Monitor /></el-icon>
          å·¥åŽ‚æ¼”ç¤º
        </el-button>
        <el-button type="primary" @click="goToCombinedDemo">
          è”动演示
        </el-button>
      </template>
    </PageHeader>
    <div class="demo-intro">
      <el-alert
        title="教学演示说明"
        type="info"
        :closable="false"
        show-icon
      >
        <template #default>
          æœ¬æ¨¡å—通过动画演示帮助您理解 <strong>BOM(物料清单)</strong> å’Œ <strong>工艺路线</strong> çš„æ¦‚念。
          é€‰æ‹©ä¸‹æ–¹çš„æ•°æ®åŽç‚¹å‡»è¿›å…¥æ¼”示,即可观看动画讲解。
        </template>
      </el-alert>
    </div>
    <el-row :gutter="20">
      <el-col :span="12">
        <el-card class="demo-card" shadow="hover">
          <template #header>
            <div class="card-header">
              <span class="card-title">
                <el-icon><List /></el-icon>
                BOM ç»“构演示
              </span>
              <el-button type="primary" :disabled="!selectedBom" @click="goToBomDemo">
                è¿›å…¥æ¼”示
              </el-button>
            </div>
          </template>
          <div class="card-content">
            <p class="card-desc">展示物料清单的树形层级结构,理解产品由哪些零部件组成。</p>
            <el-select
              v-model="selectedBom"
              placeholder="请选择一个BOM"
              filterable
              style="width: 100%"
              @change="handleBomChange"
            >
              <el-option
                v-for="item in bomList"
                :key="item.id"
                :label="`${item.bomNo} - ${item.productName}`"
                :value="item.id"
              />
            </el-select>
            <div v-if="selectedBomInfo" class="selected-info">
              <el-descriptions :column="1" border size="small">
                <el-descriptions-item label="BOM编号">{{ selectedBomInfo.bomNo }}</el-descriptions-item>
                <el-descriptions-item label="产品名称">{{ selectedBomInfo.productName }}</el-descriptions-item>
                <el-descriptions-item label="规格型号">{{ selectedBomInfo.productModelName }}</el-descriptions-item>
                <el-descriptions-item label="版本号">{{ selectedBomInfo.version }}</el-descriptions-item>
              </el-descriptions>
            </div>
          </div>
        </el-card>
      </el-col>
      <el-col :span="12">
        <el-card class="demo-card" shadow="hover">
          <template #header>
            <div class="card-header">
              <span class="card-title">
                <el-icon><Connection /></el-icon>
                å·¥è‰ºè·¯çº¿æ¼”示
              </span>
              <el-button type="primary" :disabled="!selectedRoute" @click="goToRouteDemo">
                è¿›å…¥æ¼”示
              </el-button>
            </div>
          </template>
          <div class="card-content">
            <p class="card-desc">展示产品加工的工序流程,理解产品是如何一步步生产出来的。</p>
            <el-select
              v-model="selectedRoute"
              placeholder="请选择一个工艺路线"
              filterable
              style="width: 100%"
              @change="handleRouteChange"
            >
              <el-option
                v-for="item in routeList"
                :key="item.id"
                :label="`${item.processRouteCode} - ${item.productName}`"
                :value="item.id"
              />
            </el-select>
            <div v-if="selectedRouteInfo" class="selected-info">
              <el-descriptions :column="1" border size="small">
                <el-descriptions-item label="路线编号">{{ selectedRouteInfo.processRouteCode }}</el-descriptions-item>
                <el-descriptions-item label="产品名称">{{ selectedRouteInfo.productName }}</el-descriptions-item>
                <el-descriptions-item label="规格名称">{{ selectedRouteInfo.model }}</el-descriptions-item>
                <el-descriptions-item label="BOM编号">{{ selectedRouteInfo.bomNo }}</el-descriptions-item>
              </el-descriptions>
            </div>
          </div>
        </el-card>
      </el-col>
    </el-row>
    <el-card class="concept-card" shadow="hover">
      <template #header>
        <span class="card-title">
          <el-icon><Reading /></el-icon>
          æ¦‚念说明
        </span>
      </template>
      <el-row :gutter="20">
        <el-col :span="12">
          <div class="concept-item">
            <h4>BOM(物料清单)</h4>
            <p>
              BOM(Bill of Materials)是产品结构的技术文件,它详细记录了产品由哪些零部件、原材料组成,
              ä»¥åŠå„组成部分之间的层级关系和数量关系。
            </p>
            <ul>
              <li><strong>父项:</strong>上层产品或部件</li>
              <li><strong>子项:</strong>组成父项的零部件或原材料</li>
              <li><strong>用量:</strong>生产一个父项需要多少子项</li>
            </ul>
          </div>
        </el-col>
        <el-col :span="12">
          <div class="concept-item">
            <h4>工艺路线</h4>
            <p>
              å·¥è‰ºè·¯çº¿æ˜¯æè¿°äº§å“åŠ å·¥è¿‡ç¨‹çš„æ–‡ä»¶ï¼Œå®ƒè§„å®šäº†äº§å“ä»ŽåŽŸææ–™åˆ°æˆå“éœ€è¦ç»è¿‡å“ªäº›å·¥åºï¼Œ
              ä»¥åŠå„工序的先后顺序和操作要求。
            </p>
            <ul>
              <li><strong>工序:</strong>一个独立的加工步骤</li>
              <li><strong>顺序:</strong>工序执行的先后次序</li>
              <li><strong>参数:</strong>工序的具体操作要求</li>
            </ul>
          </div>
        </el-col>
      </el-row>
    </el-card>
  </div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { List, Connection, Reading, Monitor } from '@element-plus/icons-vue'
import { listPage as getBomList } from '@/api/productionManagement/productBom.js'
import { listPage as getRouteList } from '@/api/productionManagement/processRoute.js'
const router = useRouter()
const bomList = ref([])
const routeList = ref([])
const selectedBom = ref(null)
const selectedRoute = ref(null)
const selectedBomInfo = computed(() => {
  return bomList.value.find(item => item.id === selectedBom.value)
})
const selectedRouteInfo = computed(() => {
  return routeList.value.find(item => item.id === selectedRoute.value)
})
const fetchBomList = async () => {
  try {
    const res = await getBomList({ current: 1, size: 100 })
    bomList.value = res?.data?.records || []
  } catch (error) {
    console.error('获取BOM列表失败:', error)
  }
}
const fetchRouteList = async () => {
  try {
    const res = await getRouteList({ current: 1, size: 100 })
    routeList.value = res?.data?.records || []
  } catch (error) {
    console.error('获取工艺路线列表失败:', error)
  }
}
const handleBomChange = (val) => {
  selectedBom.value = val
}
const handleRouteChange = (val) => {
  selectedRoute.value = val
}
const goToBomDemo = () => {
  if (!selectedBom.value) return
  const info = selectedBomInfo.value
  router.push({
    path: '/productionManagement/teachingDemo/bom',
    query: {
      id: selectedBom.value,
      bomNo: info?.bomNo || '',
      productName: info?.productName || '',
      productModelName: info?.productModelName || ''
    }
  })
}
const goToRouteDemo = () => {
  if (!selectedRoute.value) return
  const info = selectedRouteInfo.value
  router.push({
    path: '/productionManagement/teachingDemo/processRoute',
    query: {
      id: selectedRoute.value,
      processRouteCode: info?.processRouteCode || '',
      productName: info?.productName || '',
      model: info?.model || '',
      bomId: info?.bomId || ''
    }
  })
}
const goToCombinedDemo = () => {
  router.push({
    path: '/productionManagement/teachingDemo/combined',
    query: {
      bomId: selectedBom.value || '',
      routeId: selectedRoute.value || ''
    }
  })
}
const goToFactoryDemo = () => {
  router.push({
    path: '/productionManagement/teachingDemo/factory',
    query: {
      routeId: selectedRoute.value || ''
    }
  })
}
onMounted(() => {
  fetchBomList()
  fetchRouteList()
})
</script>
<style scoped lang="scss">
.teaching-demo-index {
  .demo-intro {
    margin-bottom: 20px;
  }
  .demo-card {
    margin-bottom: 20px;
    .card-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      .card-title {
        display: flex;
        align-items: center;
        gap: 8px;
        font-weight: 600;
        font-size: 16px;
      }
    }
    .card-content {
      .card-desc {
        color: #666;
        margin-bottom: 16px;
        line-height: 1.6;
      }
      .selected-info {
        margin-top: 16px;
      }
    }
  }
  .concept-card {
    .card-title {
      display: flex;
      align-items: center;
      gap: 8px;
      font-weight: 600;
      font-size: 16px;
    }
    .concept-item {
      h4 {
        color: #409eff;
        margin-bottom: 12px;
        font-size: 15px;
      }
      p {
        color: #666;
        line-height: 1.8;
        margin-bottom: 12px;
      }
      ul {
        padding-left: 20px;
        color: #888;
        line-height: 1.8;
        li {
          margin-bottom: 4px;
        }
      }
    }
  }
}
</style>
src/views/productionManagement/workOrder/index.vue
@@ -475,7 +475,7 @@
    if (!Number.isFinite(n)) return 0;
    if (n <= 0) return 0;
    if (n >= 100) return 100;
    return Math.round(n);
    return parseFloat(n.toFixed(2));
  };
  const progressColor = percentage => {
    const p = toProgressPercentage(percentage);
src/views/productionManagement/workOrderEdit/index.vue
@@ -297,7 +297,7 @@
    if (!Number.isFinite(n)) return 0;
    if (n <= 0) return 0;
    if (n >= 100) return 100;
    return Math.round(n);
    return parseFloat(n.toFixed(2));
  };
  const progressColor = percentage => {
src/views/productionManagement/workOrderManagement/index.vue
@@ -549,7 +549,7 @@
    if (!Number.isFinite(n)) return 0;
    if (n <= 0) return 0;
    if (n >= 100) return 100;
    return Math.round(n);
    return parseFloat(n.toFixed(2));
  };
  const progressColor = percentage => {
    const p = toProgressPercentage(percentage);
src/views/productionPlan/productionPlan/index.vue
@@ -140,7 +140,7 @@
                           style="width: 100%" />
        </el-form-item>
        <el-form-item label="创建时间">
          <el-date-picker v-model="mergeForm.createTime"
          <el-date-picker v-model="mergeCreateTimeDate"
                          type="date"
                          value-format="YYYY-MM-DD"
                          style="width: 100%" />
@@ -235,7 +235,7 @@
        </el-form-item>
        <el-form-item label="创建时间"
                      prop="createTime">
          <el-date-picker v-model="form.createTime"
          <el-date-picker v-model="formCreateTimeDate"
                          type="date"
                          value-format="YYYY-MM-DD"
                          style="width: 100%"
@@ -444,7 +444,7 @@
              Number(row.qtyRequired || 0) - Number(row.quantityIssued || 0);
            mergeForm.planCompleteTime = row.requiredDate || "";
            mergeForm.productId = row.productId || "";
            mergeForm.createTime = new Date().toISOString().split("T")[0];
            mergeForm.createTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
            mergeForm.ids = [row.id];
            sumAssignedQuantity.value =
              Number(row.qtyRequired || 0) - Number(row.quantityIssued || 0);
@@ -490,6 +490,12 @@
    productId: "",
    createTime: "",
  });
  const mergeCreateTimeDate = computed({
    get: () => (mergeForm.createTime ? String(mergeForm.createTime).split(" ")[0] : ""),
    set: (value) => {
      mergeForm.createTime = value ? `${value} ${dayjs().format("HH:mm:ss")}` : "";
    },
  });
  // å¯¼å…¥ç›¸å…³
  const importDialogRef = ref(null);
@@ -520,6 +526,12 @@
    promisedDeliveryDate: "",
    remark: "",
    createTime: "",
  });
  const formCreateTimeDate = computed({
    get: () => (form.createTime ? String(form.createTime).split(" ")[0] : ""),
    set: (value) => {
      form.createTime = value ? `${value} ${dayjs().format("HH:mm:ss")}` : "";
    },
  });
  const rules = reactive({
    productId: [{ required: true, message: "请选择产品", trigger: "change" }],
@@ -763,6 +775,7 @@
    mergeForm.totalAssignedQuantity = totalAssignedQuantity;
    mergeForm.planCompleteTime = firstRow.requiredDate || "";
    mergeForm.productId = firstRow.productId || "";
    mergeForm.createTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
    mergeForm.ids = selectedRows.value.map(row => row.id);
    // æ‰“开弹窗
@@ -906,7 +919,7 @@
      requiredDate: "",
      promisedDeliveryDate: "",
      remark: "",
      createTime: new Date().toISOString().split("T")[0],
      createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
    });
    dialogVisible.value = true;
    fetchProductOptions();
@@ -927,6 +940,7 @@
      requiredDate: row.requiredDate || "",
      promisedDeliveryDate: row.promisedDeliveryDate || "",
      remark: row.remark || "",
      createTime: row.createTime || "",
    });
    dialogVisible.value = true;
    fetchProductOptions();
src/views/qualityManagement/metricBinding/index.vue
@@ -128,6 +128,7 @@
          <el-tree-select
            v-model="selectedProductIds"
            multiple
            filterable
            collapse-tags
            collapse-tags-tooltip
            placeholder="请选择产品(可多选)"
src/views/safeProduction/safetyTrainingAssessment/index.vue
@@ -85,7 +85,7 @@
            <el-form-item label="创建时间"
                          prop="createTime">
              <el-date-picker style="width: 100%"
                              v-model="form.createTime"
                              v-model="formCreateTimeDate"
                              value-format="YYYY-MM-DD"
                              format="YYYY-MM-DD"
                              type="date"
@@ -466,6 +466,12 @@
    dialogType: "add",
    viewDialogVisible: false,
    currentKnowledge: {},
  });
  const formCreateTimeDate = computed({
    get: () => (form.value.createTime ? String(form.value.createTime).split(" ")[0] : ""),
    set: (value) => {
      form.value.createTime = value ? `${value} ${dayjs().format("HH:mm:ss")}` : "";
    },
  });
  const {
@@ -1040,7 +1046,7 @@
        trainingMode: "", // åŸ¹è®­æ–¹å¼
        placeTraining: "", // åŸ¹è®­åœ°ç‚¹
        classHour: "", // è¯¾æ—¶
        createTime: new Date().toISOString().split("T")[0], // åˆ›å»ºæ—¶é—´
        createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"), // åˆ›å»ºæ—¶é—´
      });
    } else if (type === "edit" && row) {
      dialogTitle.value = "编辑培训";
@@ -1058,6 +1064,7 @@
        trainingMode: row.trainingMode, // åŸ¹è®­æ–¹å¼
        placeTraining: row.placeTraining, // åŸ¹è®­åœ°ç‚¹
        classHour: row.classHour, // è¯¾æ—¶
        createTime: row.createTime || "", // åˆ›å»ºæ—¶é—´
      });
    }
    dialogVisible.value = true;
src/views/salesManagement/deliveryLedger/index.vue
@@ -139,13 +139,13 @@
              @click="openDetail(scope.row)"
              >详情
            </el-button>
            <el-button
              link
              type="danger"
              :disabled="isApproving(scope.row.status)"
              @click="handleDeleteSingle(scope.row)"
              >删除
            </el-button>
            <!--            <el-button-->
            <!--              link-->
            <!--              type="danger"-->
            <!--              :disabled="isApproving(scope.row.status)"-->
            <!--              @click="handleDeleteSingle(scope.row)"-->
            <!--              >删除-->
            <!--            </el-button>-->
          </template>
        </el-table-column>
      </el-table>
src/views/salesManagement/returnOrder/components/formDia.vue
@@ -511,6 +511,22 @@
const submitForm = () => {
  proxy.$refs["formRef"].validate(valid => {
    if (!valid) return;
    if (!tableData.value || tableData.value.length === 0) {
      proxy.$modal.msgWarning("请至少添加一个退货产品");
      return;
    }
    for (let i = 0; i < tableData.value.length; i++) {
      const row = tableData.value[i];
      const qty = Number(row.num ?? row.returnQuantity ?? 0);
      if (!qty || qty <= 0) {
        proxy.$modal.msgWarning(`第${i + 1}行退货数量必须大于0`);
        return;
      }
      if (row.price === "" || row.price === null || row.price === undefined || Number(row.price) < 0) {
        proxy.$modal.msgWarning(`第${i + 1}行退货单价不能为负数`);
        return;
      }
    }
    const returnSaleProducts = (tableData.value || []).map(el => ({
      stockOutRecordId: el.returnSaleLedgerProductId ?? el.id,
      productModelId: el.productModelId,
src/views/salesManagement/salesLedger/index.vue
@@ -117,6 +117,11 @@
                  </el-tag>
                </template>
              </el-table-column>
              <el-table-column label="审批中数量" width="120" align="center">
                <template #default="scope">
                  <span>{{ scope.row.pendingApprovalQuantity || 0 }}</span>
                </template>
              </el-table-column>
              <el-table-column
                label="快递公司"
                prop="expressCompany"
@@ -148,6 +153,7 @@
                </template>
              </el-table-column>
              <el-table-column label="数量" prop="quantity" />
              <el-table-column label="退货数量" prop="returnQuantity" />
              <el-table-column label="待发货数量" prop="noQuantity" />
              <el-table-column label="税率(%)" prop="taxRate" />
              <el-table-column
@@ -214,6 +220,13 @@
        <el-table-column
          label="合同金额(元)"
          prop="contractAmount"
          width="220"
          show-overflow-tooltip
          :formatter="formattedNumber"
        />
        <el-table-column
          label="实际合同金额(元)"
          prop="netContractAmount"
          width="220"
          show-overflow-tooltip
          :formatter="formattedNumber"
@@ -1749,7 +1762,7 @@
    // ç­¾è®¢æ—¥æœŸé»˜è®¤ä¸ºå½“天
    form.value.executionDate = getCurrentDate();
    // åˆ›å»ºæ—¶é—´é»˜è®¤ä¸ºå½“天
    form.value.createTime = getCurrentDate();
    form.value.createTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
    // é»˜è®¤è‡ªåŠ¨ç”Ÿæˆé”€å”®åˆåŒå·
    form.value.autoGenerateContractNo = true;
  } else {
@@ -2007,6 +2020,12 @@
    return;
  }
  // æ£€æŸ¥æ˜¯å¦åªå‰©ä¸€æ¡æ•°æ®ï¼Œé”€å”®å°è´¦è‡³å°‘保留一条产品
  if (productData.value.length <= 1) {
    proxy.$modal.msgWarning("销售台账至少需要保留一条产品数据");
    return;
  }
  if (operationType.value === "add") {
    productData.value = productData.value.filter(
      (item) => !productSelectedRows.value.includes(item)
@@ -2155,7 +2174,7 @@
};
// æ‰“印功能
const handlePrint = async () => {
  const handlePrint = async () => {
  if (selectedRows.value.length === 0) {
    proxy.$modal.msgWarning("请选择要打印的数据");
    return;
@@ -2512,11 +2531,7 @@
// æ ¼å¼åŒ–日期
const formatDate = (dateString) => {
  if (!dateString) return getCurrentDate();
  const date = new Date(dateString);
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, "0");
  const day = String(date.getDate()).padStart(2, "0");
  return `${year}/${month}/${day}`;
  return dayjs(dateString).format("YYYY/MM/DD HH:mm:ss");
};
// æ ¼å¼åŒ–日期时间
const formatDateTime = (date) => {
@@ -2803,7 +2818,7 @@
/**
 * åˆ¤æ–­æ˜¯å¦å¯ä»¥å‘è´§
 * åªæœ‰åœ¨äº§å“çŠ¶æ€æ˜¯å……è¶³ï¼Œå‘è´§çŠ¶æ€æ˜¯å¾…å‘è´§å’Œå®¡æ ¸æ‹’ç»çš„æ—¶å€™æ‰å¯ä»¥å‘è´§
 * åªæœ‰åœ¨äº§å“çŠ¶æ€æ˜¯å……è¶³ï¼Œå‘è´§çŠ¶æ€æ˜¯å¾…å‘è´§ã€å®¡æ‰¹ä¸­ã€å®¡æ ¸æ‹’ç»ã€éƒ¨åˆ†å‘è´§çš„æ—¶å€™æ‰å¯ä»¥å‘è´§
 * @param row è¡Œæ•°æ®
 */
const canShip = (row) => {
@@ -2815,15 +2830,16 @@
  // èŽ·å–å‘è´§çŠ¶æ€
  const shippingStatus = row.shippingStatus;
  // å¦‚果已发货(有发货日期或车牌号),不能再次发货
  // å¦‚果已发货,不能再次发货
  if (shippingStatus === "已发货") {
    return false;
  }
  // å‘货状态必须是"待发货"或"审核拒绝"
  // å‘货状态必须是"待发货"、"审批中"、"审核拒绝"或"部分发货"
  const statusStr = shippingStatus ? String(shippingStatus).trim() : "";
  return (
    statusStr === "待发货" ||
    statusStr === "审批中" ||
    statusStr === "审核拒绝" ||
    statusStr === "部分发货"
  );
@@ -2844,11 +2860,17 @@
  // æ£€æŸ¥æ˜¯å¦å¯ä»¥å‘è´§
  if (!canShip(row)) {
    proxy.$modal.msgWarning(
      "只有在产品状态是充足,发货状态是待发货或审核拒绝的时候才可以发货"
      "只有在产品状态是充足,发货状态是待发货、审批中、审核拒绝或部分发货的时候才可以发货"
    );
    return;
  }
  // å®¡æ‰¹ä¸­æ—¶æç¤ºç”¨æˆ·å·²æœ‰å¾…审批的发货记录
  const statusStr = row.shippingStatus ? String(row.shippingStatus).trim() : "";
  if (statusStr === "审批中") {
    proxy.$modal.msgWarning("该产品存在待审批的发货记录,请注意避免重复提交");
  }
  currentDeliveryRow.value = row;
  const batchNoList = await getDeliveryBatchNoList(
    row.productModelId || row.modelId
vite.config.js
@@ -8,11 +8,11 @@
  const { VITE_APP_ENV } = env;
  const baseUrl =
      env.VITE_APP_ENV === "development"
          ? "http://1.15.17.182:9048"
          ? "http://localhost:7005"
          : env.VITE_BASE_API;
  const javaUrl =
      env.VITE_APP_ENV === "development"
          ? "http://1.15.17.182:9048"
          ? "http://1.15.17.182:9049"
          : env.VITE_JAVA_API;
  return {
    define:{