feat: 报销审批流重构与管家计划全链路贯通
- 重构报销状态注册表、审批流路由与平台风险标记 - 完善管家意图规划器与模型计划构建器全链路 - 新增 OCR Worker 脚本、数据库会话管理与通知状态 - 优化文档中心、日志视图、预算中心与员工管理交互 - 增强工作台摘要、图标资源与全局主题样式 - 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
BIN
web/src/assets/images/cap-analysis.png
Normal file
|
After Width: | Height: | Size: 678 KiB |
BIN
web/src/assets/images/cap-approval.png
Normal file
|
After Width: | Height: | Size: 668 KiB |
BIN
web/src/assets/images/cap-budget.png
Normal file
|
After Width: | Height: | Size: 984 KiB |
BIN
web/src/assets/images/cap-expense.png
Normal file
|
After Width: | Height: | Size: 542 KiB |
BIN
web/src/assets/images/cap-policy.png
Normal file
|
After Width: | Height: | Size: 1022 KiB |
BIN
web/src/assets/images/cap-reimb.png
Normal file
|
After Width: | Height: | Size: 804 KiB |
BIN
web/src/assets/images/exp-cart.png
Normal file
|
After Width: | Height: | Size: 834 KiB |
BIN
web/src/assets/images/exp-dining.png
Normal file
|
After Width: | Height: | Size: 984 KiB |
BIN
web/src/assets/images/exp-flight.png
Normal file
|
After Width: | Height: | Size: 578 KiB |
BIN
web/src/assets/images/exp-meeting.png
Normal file
|
After Width: | Height: | Size: 964 KiB |
BIN
web/src/assets/images/exp-megaphone.png
Normal file
|
After Width: | Height: | Size: 671 KiB |
@@ -1,107 +1,142 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400" width="100%" height="100%">
|
||||
<defs>
|
||||
<!-- Soft, large shadow for the main cards -->
|
||||
<filter id="shadow-lg" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="24" stdDeviation="32" flood-color="#0f172a" flood-opacity="0.08"/>
|
||||
<!-- Background glowing orbs -->
|
||||
<filter id="blur-huge" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="60"/>
|
||||
</filter>
|
||||
|
||||
<!-- Tighter shadow for floating chips -->
|
||||
<filter id="shadow-sm" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="16" flood-color="#3a7ca5" flood-opacity="0.12"/>
|
||||
<!-- Premium drop shadows -->
|
||||
<filter id="glass-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="24" stdDeviation="32" flood-color="#020617" flood-opacity="0.12"/>
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="16" flood-color="#020617" flood-opacity="0.08"/>
|
||||
</filter>
|
||||
|
||||
<!-- Glowing effect for spheres -->
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
<filter id="glow-cyan" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur"/>
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
|
||||
</filter>
|
||||
|
||||
<linearGradient id="glass-base" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95"/>
|
||||
<stop offset="100%" stop-color="#f8fafc" stop-opacity="0.65"/>
|
||||
<!-- Apple Liquid Glass Gradients -->
|
||||
<linearGradient id="glass-bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.85"/>
|
||||
<stop offset="40%" stop-color="#ffffff" stop-opacity="0.45"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.25"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="blue-primary" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3a7ca5" stop-opacity="1"/>
|
||||
<stop offset="100%" stop-color="#255b7d" stop-opacity="1"/>
|
||||
<linearGradient id="glass-border" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="1"/>
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.1"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="amber-accent" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#b58b4c" stop-opacity="1"/>
|
||||
<stop offset="100%" stop-color="#d4a359" stop-opacity="1"/>
|
||||
<!-- Vibrant Data Gradients -->
|
||||
<linearGradient id="grad-blue" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0ea5e9" stop-opacity="1"/>
|
||||
<stop offset="100%" stop-color="#2563eb" stop-opacity="1"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="grad-amber" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#f59e0b" stop-opacity="1"/>
|
||||
<stop offset="100%" stop-color="#ea580c" stop-opacity="1"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="grad-emerald" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#10b981" stop-opacity="1"/>
|
||||
<stop offset="100%" stop-color="#059669" stop-opacity="1"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="area-blue" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0ea5e9" stop-opacity="0.4"/>
|
||||
<stop offset="100%" stop-color="#2563eb" stop-opacity="0.0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Ambient Glowing Orbs Background removed as per user request -->
|
||||
|
||||
<g transform="translate(40, 20)">
|
||||
|
||||
<!-- Background Document (Left) -->
|
||||
<g transform="translate(20, 60) rotate(-12)" filter="url(#shadow-lg)">
|
||||
<rect width="260" height="340" rx="16" fill="url(#glass-base)" stroke="#ffffff" stroke-width="2"/>
|
||||
<!-- UI Skeleton Lines -->
|
||||
<text x="30" y="50" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="20" font-weight="bold" fill="#64748b">支出分析</text>
|
||||
<rect x="30" y="70" width="200" height="8" rx="4" fill="#e2e8f0" opacity="0.7"/>
|
||||
<rect x="30" y="90" width="160" height="8" rx="4" fill="#e2e8f0" opacity="0.7"/>
|
||||
<!-- ========================================== -->
|
||||
<!-- 1. Expense Analysis Document (Left, Back) -->
|
||||
<!-- ========================================== -->
|
||||
<g transform="translate(60, 30) rotate(-10)" filter="url(#glass-shadow)">
|
||||
<!-- Liquid Glass Base -->
|
||||
<rect width="280" height="340" rx="24" fill="url(#glass-bg)" stroke="url(#glass-border)" stroke-width="1.5"/>
|
||||
<rect width="280" height="340" rx="24" fill="none" stroke="#ffffff" stroke-width="4" stroke-opacity="0.4" style="mix-blend-mode: overlay;"/>
|
||||
|
||||
<!-- Bar Chart Component -->
|
||||
<rect x="30" y="140" width="24" height="80" rx="6" fill="#e2e8f0" opacity="0.8"/>
|
||||
<rect x="70" y="100" width="24" height="120" rx="6" fill="#e2e8f0" opacity="0.8"/>
|
||||
<rect x="110" y="160" width="24" height="60" rx="6" fill="#e2e8f0" opacity="0.8"/>
|
||||
<rect x="150" y="80" width="24" height="140" rx="6" fill="url(#blue-primary)"/>
|
||||
<rect x="190" y="120" width="24" height="100" rx="6" fill="#e2e8f0" opacity="0.8"/>
|
||||
<!-- Header -->
|
||||
<circle cx="40" cy="46" r="12" fill="url(#grad-blue)"/>
|
||||
<text x="66" y="52" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="20" font-weight="900" fill="#1e293b" letter-spacing="1">支出分析</text>
|
||||
|
||||
<!-- Floating Glass Donut Chart -->
|
||||
<g transform="translate(140, 150)">
|
||||
<circle cx="0" cy="0" r="50" fill="none" stroke="#e2e8f0" stroke-width="16" opacity="0.6"/>
|
||||
<circle cx="0" cy="0" r="50" fill="none" stroke="url(#grad-blue)" stroke-width="16" stroke-dasharray="200 314" stroke-linecap="round" filter="url(#glow-cyan)"/>
|
||||
<circle cx="0" cy="0" r="50" fill="none" stroke="url(#grad-amber)" stroke-width="16" stroke-dasharray="60 314" stroke-dashoffset="-220" stroke-linecap="round"/>
|
||||
<text x="0" y="8" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="22" font-weight="900" fill="#1e293b" text-anchor="middle">72%</text>
|
||||
</g>
|
||||
|
||||
<!-- Data Bars -->
|
||||
<rect x="30" y="240" width="220" height="40" rx="12" fill="#ffffff" opacity="0.6"/>
|
||||
<rect x="42" y="256" width="120" height="8" rx="4" fill="url(#grad-blue)"/>
|
||||
<rect x="180" y="256" width="50" height="8" rx="4" fill="#94a3b8"/>
|
||||
|
||||
<rect x="30" y="290" width="220" height="40" rx="12" fill="#ffffff" opacity="0.6"/>
|
||||
<rect x="42" y="306" width="80" height="8" rx="4" fill="url(#grad-amber)"/>
|
||||
<rect x="140" y="306" width="90" height="8" rx="4" fill="#94a3b8"/>
|
||||
</g>
|
||||
|
||||
<!-- Floating Element 1 (Left) -->
|
||||
<g transform="translate(40, 20) rotate(12)" filter="url(#glass-shadow)">
|
||||
<rect width="80" height="80" rx="20" fill="url(#glass-bg)" stroke="url(#glass-border)" stroke-width="1.5"/>
|
||||
<path d="M 25 40 L 35 50 L 55 30" fill="none" stroke="url(#grad-emerald)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
|
||||
<!-- Center Floating Sphere -->
|
||||
<circle cx="280" cy="90" r="30" fill="url(#amber-accent)" opacity="0.85" filter="url(#glow)"/>
|
||||
<circle cx="340" cy="110" r="30" fill="url(#grad-amber)" opacity="0.9" filter="url(#glass-shadow)"/>
|
||||
|
||||
<!-- Main Foreground Document (Right Focus) -->
|
||||
<g transform="translate(320, 10) rotate(6)" filter="url(#shadow-lg)">
|
||||
<!-- Main Card Body -->
|
||||
<rect width="320" height="400" rx="20" fill="url(#glass-base)" stroke="#ffffff" stroke-width="3"/>
|
||||
<!-- ========================================== -->
|
||||
<!-- 2. Cost Trend Document (Right, Front) -->
|
||||
<!-- ========================================== -->
|
||||
<g transform="translate(320, 10) rotate(5)" filter="url(#glass-shadow)">
|
||||
<!-- Liquid Glass Base -->
|
||||
<rect width="320" height="380" rx="24" fill="url(#glass-bg)" stroke="url(#glass-border)" stroke-width="1.5"/>
|
||||
<rect width="320" height="380" rx="24" fill="none" stroke="#ffffff" stroke-width="4" stroke-opacity="0.5" style="mix-blend-mode: overlay;"/>
|
||||
|
||||
<!-- Header Section -->
|
||||
<circle cx="44" cy="50" r="18" fill="url(#blue-primary)"/>
|
||||
<text x="76" y="58" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="22" font-weight="800" fill="#3a7ca5">费用趋势</text>
|
||||
<circle cx="44" cy="50" r="14" fill="url(#grad-blue)"/>
|
||||
<text x="74" y="57" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="22" font-weight="900" fill="#1e293b" letter-spacing="1">费用趋势</text>
|
||||
|
||||
<!-- Area Chart Widget -->
|
||||
<rect x="30" y="100" width="260" height="130" rx="12" fill="#f1f5f9" opacity="0.5"/>
|
||||
<path d="M 30 200 L 40 180 Q 90 120 140 150 T 260 90 L 290 90 L 290 230 L 30 230 Z" fill="#3a7ca5" opacity="0.1"/>
|
||||
<path d="M 40 180 Q 90 120 140 150 T 260 90" fill="none" stroke="#3a7ca5" stroke-width="4" stroke-linecap="round"/>
|
||||
<!-- Chart Nodes -->
|
||||
<circle cx="140" cy="150" r="6" fill="#ffffff" stroke="#3a7ca5" stroke-width="3"/>
|
||||
<circle cx="260" cy="90" r="6" fill="#ffffff" stroke="#3a7ca5" stroke-width="3"/>
|
||||
|
||||
<!-- Data List Rows -->
|
||||
<g transform="translate(30, 250)" filter="url(#shadow-sm)">
|
||||
<rect width="260" height="40" rx="10" fill="#ffffff" opacity="0.95"/>
|
||||
<rect x="16" y="16" width="60" height="8" rx="4" fill="#94a3b8"/>
|
||||
<rect x="200" y="16" width="44" height="8" rx="4" fill="url(#blue-primary)"/>
|
||||
</g>
|
||||
<g transform="translate(30, 306)" filter="url(#shadow-sm)">
|
||||
<rect width="260" height="40" rx="10" fill="#ffffff" opacity="0.95"/>
|
||||
<rect x="16" y="16" width="80" height="8" rx="4" fill="#cbd5e1"/>
|
||||
<rect x="210" y="16" width="34" height="8" rx="4" fill="url(#blue-primary)"/>
|
||||
<!-- Beautiful Smooth Area Chart -->
|
||||
<g transform="translate(30, 100)">
|
||||
<rect width="260" height="140" rx="16" fill="#ffffff" opacity="0.5"/>
|
||||
<!-- Grid lines -->
|
||||
<line x1="20" y1="35" x2="240" y2="35" stroke="#cbd5e1" stroke-width="1" stroke-dasharray="4 4"/>
|
||||
<line x1="20" y1="70" x2="240" y2="70" stroke="#cbd5e1" stroke-width="1" stroke-dasharray="4 4"/>
|
||||
<line x1="20" y1="105" x2="240" y2="105" stroke="#cbd5e1" stroke-width="1" stroke-dasharray="4 4"/>
|
||||
|
||||
<!-- Smooth Area -->
|
||||
<path d="M 20 120 C 60 120, 80 40, 130 60 C 180 80, 200 20, 240 30 L 240 140 L 20 140 Z" fill="url(#area-blue)"/>
|
||||
<!-- Smooth Line -->
|
||||
<path d="M 20 120 C 60 120, 80 40, 130 60 C 180 80, 200 20, 240 30" fill="none" stroke="url(#grad-blue)" stroke-width="4" stroke-linecap="round"/>
|
||||
|
||||
<!-- Glowing Data Point -->
|
||||
<circle cx="240" cy="30" r="6" fill="#ffffff" stroke="url(#grad-blue)" stroke-width="3" filter="url(#glow-cyan)"/>
|
||||
<!-- Value Tooltip -->
|
||||
<rect x="190" y="-10" width="60" height="24" rx="12" fill="#1e293b"/>
|
||||
<text x="220" y="6" font-family="sans-serif" font-size="12" font-weight="bold" fill="#ffffff" text-anchor="middle">+14%</text>
|
||||
</g>
|
||||
|
||||
<!-- List Items -->
|
||||
<rect x="30" y="260" width="260" height="44" rx="12" fill="#ffffff" opacity="0.8"/>
|
||||
<circle cx="50" cy="282" r="8" fill="url(#grad-emerald)"/>
|
||||
<rect x="74" y="278" width="100" height="8" rx="4" fill="#64748b"/>
|
||||
<rect x="230" y="278" width="40" height="8" rx="4" fill="url(#grad-blue)"/>
|
||||
|
||||
<rect x="30" y="316" width="260" height="44" rx="12" fill="#ffffff" opacity="0.8"/>
|
||||
<circle cx="50" cy="338" r="8" fill="url(#grad-amber)"/>
|
||||
<rect x="74" y="334" width="80" height="8" rx="4" fill="#64748b"/>
|
||||
<rect x="240" y="334" width="30" height="8" rx="4" fill="url(#grad-amber)"/>
|
||||
</g>
|
||||
|
||||
<!-- Floating UI Chip 1 (Top Left) -->
|
||||
<g transform="translate(140, 0) rotate(-6)" filter="url(#shadow-sm)">
|
||||
<rect width="150" height="48" rx="24" fill="#ffffff" stroke="#f1f5f9" stroke-width="2"/>
|
||||
<circle cx="24" cy="24" r="10" fill="#10b981"/>
|
||||
<rect x="44" y="20" width="80" height="8" rx="4" fill="#334155"/>
|
||||
</g>
|
||||
|
||||
<!-- Floating UI Chip 2 (Bottom Right) -->
|
||||
<g transform="translate(580, 280) rotate(14)" filter="url(#shadow-sm)">
|
||||
<rect width="130" height="52" rx="14" fill="url(#blue-primary)"/>
|
||||
<rect x="20" y="22" width="90" height="8" rx="4" fill="#ffffff" opacity="0.9"/>
|
||||
</g>
|
||||
|
||||
<!-- Decorative small spheres -->
|
||||
<circle cx="120" cy="350" r="8" fill="#3a7ca5" opacity="0.4" filter="url(#glow)"/>
|
||||
<circle cx="680" cy="120" r="12" fill="#b58b4c" opacity="0.3" filter="url(#glow)"/>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 7.7 KiB |
BIN
web/src/assets/images/workbench-hero-right-bg.png
Normal file
|
After Width: | Height: | Size: 625 KiB |
@@ -0,0 +1,271 @@
|
||||
/* Current DigitalEmployeeWorkRecords scoped styles. Kept last to override retired rules above. */
|
||||
.digital-work-records {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.digital-employee-list-panel,
|
||||
.digital-work-records-list-stage {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.digital-work-records-table {
|
||||
min-width: 1180px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.digital-work-records-table .col-time { width: 14%; }
|
||||
.digital-work-records-table .col-module { width: 13%; }
|
||||
.digital-work-records-table .col-source { width: 10%; }
|
||||
.digital-work-records-table .col-status { width: 16%; }
|
||||
.digital-work-records-table .col-summary { width: 31%; }
|
||||
.digital-work-records-table .col-trace { width: 16%; }
|
||||
|
||||
.work-record-row {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.work-record-row:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px rgba(58, 124, 165, 0.28);
|
||||
}
|
||||
|
||||
.work-record-summary-cell {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.work-record-summary-cell strong,
|
||||
.work-record-summary-cell span,
|
||||
.work-record-summary-cell em {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.work-record-summary-cell strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.work-record-summary-cell span {
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.work-record-summary-cell em {
|
||||
margin-top: 6px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.work-record-trace-cell {
|
||||
color: #2563eb !important;
|
||||
}
|
||||
|
||||
:deep(.work-record-detail-page) {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
padding: 16px 18px 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.work-record-detail-page .detail-scroll) {
|
||||
min-height: 0;
|
||||
display: block;
|
||||
padding-right: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:deep(.work-record-detail-page .detail-scroll) > * + * {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
:deep(.work-record-detail-page .detail-grid) {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
width: min(100%, 1160px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
:deep(.work-record-detail-page .detail-main),
|
||||
:deep(.work-record-detail-page .detail-side) {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.work-record-detail-page .detail-actions) {
|
||||
margin-top: 10px;
|
||||
padding: 10px 0 0;
|
||||
}
|
||||
|
||||
:deep(.work-record-detail-page .enterprise-detail-card) {
|
||||
min-height: 0;
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.work-record-detail-page .enterprise-detail-card .card-head) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(.work-record-detail-page .enterprise-detail-card .card-head h3) {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
:deep(.work-record-detail-page .enterprise-detail-card .card-head p) {
|
||||
margin: 4px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:deep(.work-record-detail-page .edit-badge) {
|
||||
min-height: 26px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
|
||||
border-radius: 4px;
|
||||
background: var(--theme-primary-soft);
|
||||
color: var(--theme-primary-active);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
:deep(.work-record-summary-card .json-risk-meta-grid) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.work-record-detail-page .json-risk-meta-item),
|
||||
:deep(.work-record-detail-page .json-risk-description-text) {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.work-record-summary-text {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.work-record-error-text {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 4px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.work-record-tool-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.work-record-tool-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
transition: border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.work-record-tool-item:hover {
|
||||
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.26);
|
||||
background: #f9fbff;
|
||||
}
|
||||
|
||||
.work-record-tool-item strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.work-record-tool-item span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.work-record-inline-empty {
|
||||
min-height: 92px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 14px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.work-record-code-block {
|
||||
max-height: 360px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 4px;
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.digital-work-records :deep(.toolbar-actions .picker-filter),
|
||||
.digital-work-records :deep(.toolbar-actions .picker-trigger) {
|
||||
min-width: 148px;
|
||||
}
|
||||
|
||||
.digital-refresh-now {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.digital-refresh-now .mdi {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.work-record-tool-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,171 @@
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.document-filter,
|
||||
.date-range-filter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.document-filter-menu,
|
||||
.date-range-popover {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.document-filter-menu {
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
min-width: 150px;
|
||||
max-height: 280px;
|
||||
padding: 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.document-filter-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-filter-menu button:hover,
|
||||
.document-filter-menu button.active {
|
||||
background: rgba(58, 124, 165, 0.1);
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.date-range-trigger {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.date-range-label {
|
||||
max-width: 104px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-range-popover {
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
width: 320px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.date-range-popover header,
|
||||
.date-range-popover footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.date-range-popover header strong {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.date-range-popover header button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.date-range-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.date-range-fields label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.date-range-fields span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.date-range-fields input {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 4px;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ghost-btn,
|
||||
.apply-btn {
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
border: 1px solid #d7e0ea;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.apply-btn {
|
||||
border: 0;
|
||||
background: var(--theme-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.apply-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.document-status-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.status-dropdown-filter,
|
||||
.status-filter-trigger,
|
||||
.status-filter-menu {
|
||||
min-width: 154px;
|
||||
}
|
||||
|
||||
.status-filter-trigger > .mdi:first-child {
|
||||
color: var(--theme-primary);
|
||||
}
|
||||
|
||||
.clear-filter-btn {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.create-request-btn {
|
||||
min-height: 40px;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 18px;
|
||||
z-index: 60;
|
||||
z-index: 120;
|
||||
width: min(320px, calc(100% - 36px));
|
||||
max-width: calc(100vw - 32px);
|
||||
display: grid;
|
||||
|
||||
@@ -14,38 +14,22 @@
|
||||
}
|
||||
|
||||
.capability-card {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16);
|
||||
border-left: 3px solid color-mix(in srgb, var(--capability-color) 42%, rgba(255, 255, 255, 0.72));
|
||||
background:
|
||||
var(--workbench-glass-base),
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--capability-soft) 46%, transparent) 0%, transparent 52%, color-mix(in srgb, var(--capability-color) 11%, transparent) 100%),
|
||||
var(--workbench-glass-theme-tint);
|
||||
background-color: rgba(255, 255, 255, 0.64);
|
||||
box-shadow:
|
||||
0 10px 28px rgba(15, 23, 42, 0.055),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.84),
|
||||
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
|
||||
backdrop-filter: var(--workbench-glass-blur);
|
||||
-webkit-backdrop-filter: var(--workbench-glass-blur);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(12px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(150%);
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
|
||||
border-left: 3px solid color-mix(in srgb, var(--capability-color) 60%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.8));
|
||||
box-shadow:
|
||||
0 12px 28px rgba(15, 23, 42, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.capability-card::before,
|
||||
.capability-card::after,
|
||||
.workbench-card::before,
|
||||
.workbench-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.capability-card::before {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.12), transparent 38%),
|
||||
var(--workbench-capability-bg-image) 0 0 / var(--workbench-capability-tile-size) repeat;
|
||||
mix-blend-mode: soft-light;
|
||||
opacity: var(--workbench-glass-noise-opacity);
|
||||
}
|
||||
@@ -77,40 +61,18 @@
|
||||
.workbench-card {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.78), rgba(255, 255, 255, 0.64) 55%, rgba(255, 255, 255, 0.72)),
|
||||
var(--workbench-glass-theme-tint);
|
||||
background-color: rgba(255, 255, 255, 0.66);
|
||||
box-shadow:
|
||||
0 12px 30px rgba(15, 23, 42, 0.052),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.86),
|
||||
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.07);
|
||||
backdrop-filter: var(--workbench-glass-blur);
|
||||
-webkit-backdrop-filter: var(--workbench-glass-blur);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(12px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(150%);
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
|
||||
box-shadow:
|
||||
0 12px 28px rgba(15, 23, 42, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.workbench-card::before,
|
||||
.workbench-card::after {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.workbench-card::before {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent 42%),
|
||||
var(--workbench-panel-bg-image) 0 0 / var(--workbench-panel-tile-size) repeat;
|
||||
mix-blend-mode: soft-light;
|
||||
opacity: calc(var(--workbench-glass-noise-opacity) * 0.8);
|
||||
}
|
||||
|
||||
.workbench-card::after {
|
||||
border: 1px solid rgba(255, 255, 255, 0.36);
|
||||
background: var(--workbench-glass-highlight);
|
||||
opacity: 0.56;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.58),
|
||||
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.055);
|
||||
transition: opacity 180ms var(--ease);
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.workbench-card > * {
|
||||
@@ -138,15 +100,10 @@
|
||||
|
||||
.capability-card:hover,
|
||||
.workbench-card:hover {
|
||||
box-shadow:
|
||||
0 16px 36px rgba(15, 23, 42, 0.075),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
|
||||
}
|
||||
|
||||
.capability-card:hover::after,
|
||||
.workbench-card:hover::after {
|
||||
opacity: 0.88;
|
||||
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.4);
|
||||
box-shadow:
|
||||
0 16px 36px rgba(15, 23, 42, 0.06),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.capability-card:hover {
|
||||
|
||||
@@ -66,10 +66,12 @@
|
||||
.insight-metric-list,
|
||||
.insight-profile-list {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
grid-auto-rows: minmax(0, 1fr);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.insight-metric-row,
|
||||
@@ -94,6 +96,15 @@
|
||||
transition:
|
||||
border-color 180ms var(--ease),
|
||||
background-color 180ms var(--ease);
|
||||
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
|
||||
}
|
||||
|
||||
.insight-metric-row {
|
||||
animation-delay: calc(400ms + var(--item-index, 0) * 80ms);
|
||||
}
|
||||
|
||||
.insight-profile-card {
|
||||
animation-delay: calc(500ms + var(--item-index, 0) * 80ms);
|
||||
}
|
||||
|
||||
.insight-metric-row:hover,
|
||||
@@ -104,22 +115,6 @@
|
||||
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04);
|
||||
}
|
||||
|
||||
/* 局部改造:让费用统计内层的小卡片也变为低透明度透镜,形成双层液态玻璃(Double Glassmorphism)的极品手感 */
|
||||
.expense-stats-panel .insight-metric-row {
|
||||
background: rgba(255, 255, 255, 0.05) !important;
|
||||
border-color: rgba(255, 255, 255, 0.2) !important;
|
||||
border-left-color: rgba(255, 255, 255, 0.6) !important;
|
||||
backdrop-filter: blur(8px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.03),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
.expense-stats-panel .insight-metric-row:hover {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
border-color: rgba(255, 255, 255, 0.4) !important;
|
||||
}
|
||||
|
||||
.insight-metric-icon,
|
||||
.insight-profile-icon {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
--hero-padding-top: 20px;
|
||||
--hero-padding-bottom: 20px;
|
||||
--hero-title-size: 28px;
|
||||
--hero-copy-gap: 5px;
|
||||
--hero-title-bottom-gap: 14px;
|
||||
--hero-copy-gap: 16px;
|
||||
--hero-title-bottom-gap: 10px;
|
||||
--composer-min-height: 108px;
|
||||
--composer-textarea-height: 48px;
|
||||
--composer-padding-block: 10px;
|
||||
@@ -15,7 +15,9 @@
|
||||
}
|
||||
|
||||
.assistant-hero {
|
||||
--assistant-bg-position: 56% center;
|
||||
--assistant-bg-position: right center;
|
||||
--assistant-decor-width: clamp(760px, 66vw, 980px);
|
||||
--assistant-decor-opacity: 0.86;
|
||||
padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px;
|
||||
}
|
||||
|
||||
@@ -58,7 +60,9 @@
|
||||
}
|
||||
|
||||
.assistant-hero {
|
||||
--assistant-bg-position: 58% center;
|
||||
--assistant-bg-position: right center;
|
||||
--assistant-decor-width: clamp(760px, 66vw, 980px);
|
||||
--assistant-decor-opacity: 0.9;
|
||||
padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px;
|
||||
}
|
||||
|
||||
@@ -83,7 +87,7 @@
|
||||
}
|
||||
|
||||
.capability-copy {
|
||||
padding-left: 14px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.workbench-content-grid {
|
||||
@@ -110,13 +114,15 @@
|
||||
}
|
||||
|
||||
.assistant-hero {
|
||||
--assistant-bg-position: 62% center;
|
||||
--assistant-bg-position: right center;
|
||||
--assistant-decor-width: clamp(620px, 74vw, 860px);
|
||||
--assistant-decor-opacity: 0.62;
|
||||
--assistant-readability-mask:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 255, 255, 0.88) 58%, rgba(255, 255, 255, 0.44) 100%);
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.82) 0%, rgba(255, 255, 255, 0.5) 58%, rgba(255, 255, 255, 0.06) 100%);
|
||||
--assistant-theme-tint:
|
||||
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06) 58%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14) 100%);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04) 58%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.09) 100%);
|
||||
backdrop-filter: blur(10px) saturate(1.12);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(1.12);
|
||||
}
|
||||
|
||||
.assistant-copy {
|
||||
@@ -140,6 +146,125 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 961px) and (max-width: 1440px),
|
||||
(min-width: 961px) and (max-height: 820px) {
|
||||
.workbench {
|
||||
--hero-padding-top: 14px;
|
||||
--hero-padding-bottom: 14px;
|
||||
--hero-title-size: 24px;
|
||||
--hero-copy-gap: 14px;
|
||||
--hero-title-bottom-gap: 8px;
|
||||
--composer-min-height: 92px;
|
||||
--composer-textarea-height: 38px;
|
||||
--composer-padding-block: 8px;
|
||||
--quick-prompts-gap-top: 5px;
|
||||
--capability-row-height: 82px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-hero {
|
||||
--assistant-decor-width: clamp(680px, 60vw, 880px);
|
||||
--assistant-decor-opacity: 0.72;
|
||||
padding: var(--hero-padding-top) 16px var(--hero-padding-bottom) 34px;
|
||||
}
|
||||
|
||||
.assistant-copy {
|
||||
width: min(900px, 92%);
|
||||
}
|
||||
|
||||
.assistant-copy h1 {
|
||||
margin-bottom: var(--hero-title-bottom-gap);
|
||||
font-size: var(--hero-title-size);
|
||||
line-height: 1.14;
|
||||
}
|
||||
|
||||
.assistant-composer {
|
||||
min-height: var(--composer-min-height);
|
||||
gap: 4px;
|
||||
padding: var(--composer-padding-block) 14px 8px;
|
||||
}
|
||||
|
||||
.assistant-composer textarea {
|
||||
height: var(--composer-textarea-height);
|
||||
min-height: var(--composer-textarea-height);
|
||||
max-height: var(--composer-textarea-height);
|
||||
font-size: 14px;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.composer-toolbar {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.composer-icon-button,
|
||||
.composer-send-button {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.composer-icon-button {
|
||||
width: 30px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.composer-send-button {
|
||||
width: 46px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.composer-count {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.quick-prompts {
|
||||
gap: 8px;
|
||||
margin-top: var(--quick-prompts-gap-top);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.quick-prompts button {
|
||||
min-height: 24px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.capability-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.capability-card {
|
||||
grid-template-columns: 34px minmax(0, 1fr) 14px;
|
||||
gap: 10px;
|
||||
padding: 12px 12px 12px 16px;
|
||||
}
|
||||
|
||||
.capability-icon {
|
||||
--workbench-list-icon-size: 34px;
|
||||
--workbench-list-icon-art-size: 20px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.capability-copy {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.capability-copy strong {
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.capability-copy small {
|
||||
font-size: 11px;
|
||||
line-height: 1.22;
|
||||
}
|
||||
|
||||
.capability-arrow {
|
||||
width: 14px;
|
||||
min-width: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.workbench {
|
||||
height: auto;
|
||||
@@ -156,15 +281,17 @@
|
||||
|
||||
.assistant-hero {
|
||||
min-height: auto;
|
||||
--assistant-bg-position: 68% center;
|
||||
--assistant-bg-position: right center;
|
||||
--assistant-decor-width: min(620px, 118vw);
|
||||
--assistant-decor-opacity: 0.36;
|
||||
--assistant-readability-mask:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.94) 0%, rgba(255, 255, 255, 0.88) 100%),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 255, 255, 0.72) 100%);
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.86) 0%, rgba(255, 255, 255, 0.76) 100%),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.88) 0%, rgba(255, 255, 255, 0.52) 100%);
|
||||
--assistant-theme-tint:
|
||||
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.2) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08) 100%);
|
||||
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04) 100%);
|
||||
padding: 24px 18px 24px;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(9px) saturate(1.1);
|
||||
-webkit-backdrop-filter: blur(9px) saturate(1.1);
|
||||
}
|
||||
|
||||
.assistant-copy {
|
||||
@@ -311,7 +438,7 @@
|
||||
}
|
||||
|
||||
.capability-copy {
|
||||
padding-left: 6px;
|
||||
padding-left: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,31 +57,48 @@
|
||||
.workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; }
|
||||
|
||||
.assistant-hero {
|
||||
--assistant-bg-position: right center;
|
||||
--assistant-decor-width: clamp(860px, 62vw, 1180px);
|
||||
--assistant-decor-opacity: 0.92;
|
||||
--assistant-readability-mask:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.74) 0%, rgba(255, 255, 255, 0.34) 46%, rgba(255, 255, 255, 0) 100%);
|
||||
--assistant-theme-tint:
|
||||
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.11), rgba(var(--theme-primary-rgb, 58, 124, 165), 0.025) 54%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.075));
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
padding: var(--hero-padding-top) 20px var(--hero-padding-bottom) 52px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%);
|
||||
background-color: transparent;
|
||||
backdrop-filter: blur(40px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(200%);
|
||||
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.04), inset 0 2px 4px rgba(255, 255, 255, 1);
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
|
||||
border-radius: 4px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.54)),
|
||||
var(--assistant-theme-tint);
|
||||
background-color: rgba(247, 252, 255, 0.72);
|
||||
backdrop-filter: blur(14px) saturate(1.18);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(1.18);
|
||||
box-shadow:
|
||||
0 12px 28px rgba(15, 23, 42, 0.045),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.86),
|
||||
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.07);
|
||||
isolation: isolate;
|
||||
animation: workbenchItemIn 560ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
|
||||
.assistant-hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 100px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
min-width: 400px;
|
||||
background: url("../../images/hero-financial-decor.svg") right center / auto 100% no-repeat;
|
||||
width: 82%;
|
||||
min-width: 760px;
|
||||
background: url("../../images/workbench-hero-right-bg.png") var(--assistant-bg-position) / var(--assistant-decor-width) auto no-repeat;
|
||||
opacity: var(--assistant-decor-opacity);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -92,8 +109,9 @@
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%),
|
||||
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%);
|
||||
var(--assistant-readability-mask),
|
||||
linear-gradient(120deg, rgba(255, 255, 255, 0.36), transparent 22%, transparent 72%, rgba(255, 255, 255, 0.18)),
|
||||
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.05), transparent 58%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -114,8 +132,23 @@
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.assistant-copy h1 span {
|
||||
.assistant-copy h1 span:not(.typing-cursor) {
|
||||
color: var(--workbench-primary-active);
|
||||
display: inline-block;
|
||||
animation: workbenchItemIn 400ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
|
||||
}
|
||||
|
||||
.typing-cursor {
|
||||
display: inline-block;
|
||||
color: var(--workbench-primary-active);
|
||||
font-weight: 400;
|
||||
margin-left: 2px;
|
||||
animation: cursorBlink 0.9s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes cursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.assistant-copy p {
|
||||
@@ -127,29 +160,70 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-copy > * {
|
||||
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
|
||||
}
|
||||
|
||||
.assistant-copy > h1 { animation-delay: 80ms; }
|
||||
.assistant-copy > p { animation-delay: 160ms; }
|
||||
.assistant-copy > .assistant-composer { animation-delay: 240ms; }
|
||||
.assistant-copy > .assistant-file-strip { animation-delay: 320ms; }
|
||||
.assistant-copy > .quick-prompts { animation-delay: 320ms; }
|
||||
|
||||
.assistant-file-input { display: none; }
|
||||
|
||||
.assistant-composer {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-width: 920px;
|
||||
min-height: var(--composer-min-height);
|
||||
padding: var(--composer-padding-block) 18px 10px;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(4px);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.74)),
|
||||
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.045), rgba(255, 255, 255, 0.18));
|
||||
box-shadow:
|
||||
0 10px 24px rgba(15, 23, 42, 0.045),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06);
|
||||
backdrop-filter: blur(10px) saturate(1.14);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(1.14);
|
||||
transition:
|
||||
border-color 180ms var(--ease),
|
||||
background 180ms var(--ease),
|
||||
box-shadow 180ms var(--ease);
|
||||
}
|
||||
|
||||
.assistant-composer::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(110deg, rgba(255, 255, 255, 0.32), transparent 32%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 42%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.assistant-composer > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.assistant-composer:focus-within {
|
||||
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.85);
|
||||
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.58);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.78)),
|
||||
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06), rgba(255, 255, 255, 0.22));
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14),
|
||||
0 16px 36px rgba(15, 23, 42, 0.06),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.96);
|
||||
0 0 0 3px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.11),
|
||||
0 14px 30px rgba(15, 23, 42, 0.055),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.94),
|
||||
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
|
||||
}
|
||||
|
||||
.assistant-composer textarea {
|
||||
@@ -331,24 +405,28 @@
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr) 10px;
|
||||
grid-template-columns: 40px minmax(0, 1fr) 18px;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
padding: 17px 12px 17px 26px;
|
||||
padding: 16px 18px 16px 22px;
|
||||
overflow: visible;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
border-left: 3px solid color-mix(in srgb, var(--capability-color) 80%, rgba(255, 255, 255, 0.9));
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%);
|
||||
background-color: transparent;
|
||||
backdrop-filter: blur(40px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(200%);
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.15);
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 16px 32px rgba(0, 0, 0, 0.04),
|
||||
inset 0 2px 4px rgba(255, 255, 255, 1);
|
||||
0 8px 24px rgba(15, 23, 42, 0.03),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 1);
|
||||
color: var(--workbench-ink);
|
||||
text-decoration: none;
|
||||
animation: workbenchItemIn 560ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
|
||||
animation-delay: var(--delay, 100ms);
|
||||
transition:
|
||||
border-color 180ms var(--ease),
|
||||
box-shadow 180ms var(--ease),
|
||||
@@ -356,16 +434,12 @@
|
||||
transform 180ms var(--ease);
|
||||
}
|
||||
|
||||
.capability-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%),
|
||||
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
.capability-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.4);
|
||||
box-shadow:
|
||||
0 16px 32px rgba(15, 23, 42, 0.06),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.capability-card > * {
|
||||
@@ -373,13 +447,9 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.capability-card::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.capability-icon {
|
||||
--workbench-list-icon-size: 40px;
|
||||
--workbench-list-icon-art-size: 23px;
|
||||
--workbench-list-icon-art-size: 24px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--capability-color);
|
||||
@@ -388,8 +458,9 @@
|
||||
.capability-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 4px;
|
||||
padding-left: 18px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.capability-copy strong {
|
||||
@@ -400,6 +471,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.capability-copy small {
|
||||
@@ -409,11 +481,19 @@
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.capability-arrow {
|
||||
justify-self: end;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
min-width: 18px;
|
||||
color: color-mix(in srgb, var(--workbench-muted) 68%, #ffffff);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.capability-card--green {
|
||||
@@ -461,28 +541,16 @@
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 12px 14px;
|
||||
background:
|
||||
linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%);
|
||||
background-color: transparent;
|
||||
backdrop-filter: blur(40px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(200%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.15);
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 16px 32px rgba(0, 0, 0, 0.04),
|
||||
inset 0 2px 4px rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.workbench-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%),
|
||||
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
0 12px 28px rgba(15, 23, 42, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 1);
|
||||
animation: workbenchItemIn 560ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
|
||||
animation-delay: var(--delay, 200ms);
|
||||
}
|
||||
|
||||
.workbench-card > * {
|
||||
@@ -534,6 +602,18 @@
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.insight-metric-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--insight-color) 4%, transparent);
|
||||
transition: transform 180ms var(--ease), background-color 180ms var(--ease);
|
||||
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
|
||||
animation-delay: calc(400ms + var(--item-index, 0) * 80ms);
|
||||
}
|
||||
|
||||
.link-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -560,6 +640,11 @@
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
|
||||
animation-delay: calc(300ms + var(--item-index, 0) * 80ms);
|
||||
}
|
||||
|
||||
.progress-identity,
|
||||
.progress-result {
|
||||
gap: 12px;
|
||||
@@ -716,40 +801,66 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0 4px 10px rgba(0, 0, 0, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.expense-type-icon::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
border-radius: inherit;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.expense-type-icon i {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
|
||||
.expense-type-icon--blue {
|
||||
background: color-mix(in srgb, var(--workbench-primary, #3a7ca5) 12%, #ffffff);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--workbench-primary, #3a7ca5) 12%, #ffffff) 0%, color-mix(in srgb, var(--workbench-primary, #3a7ca5) 3%, #ffffff) 100%);
|
||||
border: 1px solid color-mix(in srgb, var(--workbench-primary, #3a7ca5) 20%, #ffffff);
|
||||
color: var(--workbench-primary, #3a7ca5);
|
||||
}
|
||||
|
||||
.expense-type-icon--amber {
|
||||
background: color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 12%, #ffffff);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 12%, #ffffff) 0%, color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 3%, #ffffff) 100%);
|
||||
border: 1px solid color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 20%, #ffffff);
|
||||
color: var(--workbench-chart-amber, #b58b4c);
|
||||
}
|
||||
|
||||
.expense-type-icon--emerald {
|
||||
background: color-mix(in srgb, #10b981 12%, #ffffff);
|
||||
color: #10b981;
|
||||
background: linear-gradient(135deg, color-mix(in srgb, #0f8f68 12%, #ffffff) 0%, color-mix(in srgb, #0f8f68 3%, #ffffff) 100%);
|
||||
border: 1px solid color-mix(in srgb, #0f8f68 20%, #ffffff);
|
||||
color: #0f8f68;
|
||||
}
|
||||
|
||||
.expense-type-icon--violet {
|
||||
background: color-mix(in srgb, #8b5cf6 12%, #ffffff);
|
||||
color: #8b5cf6;
|
||||
background: linear-gradient(135deg, color-mix(in srgb, #6d5bd0 12%, #ffffff) 0%, color-mix(in srgb, #6d5bd0 3%, #ffffff) 100%);
|
||||
border: 1px solid color-mix(in srgb, #6d5bd0 20%, #ffffff);
|
||||
color: #6d5bd0;
|
||||
}
|
||||
|
||||
.expense-type-icon--cyan {
|
||||
background: color-mix(in srgb, #06b6d4 12%, #ffffff);
|
||||
color: #06b6d4;
|
||||
background: linear-gradient(135deg, color-mix(in srgb, #0788a2 12%, #ffffff) 0%, color-mix(in srgb, #0788a2 3%, #ffffff) 100%);
|
||||
border: 1px solid color-mix(in srgb, #0788a2 20%, #ffffff);
|
||||
color: #0788a2;
|
||||
}
|
||||
|
||||
.expense-type-icon--muted {
|
||||
background: var(--info-soft, #f1f5f9);
|
||||
background: linear-gradient(135deg, var(--info-soft, #f1f5f9) 0%, #ffffff 100%);
|
||||
border: 1px solid var(--workbench-line);
|
||||
color: var(--workbench-muted, #64748b);
|
||||
}
|
||||
|
||||
@@ -877,3 +988,22 @@
|
||||
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
|
||||
color: var(--workbench-primary-active);
|
||||
}
|
||||
|
||||
@keyframes workbenchItemIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.assistant-hero,
|
||||
.capability-card,
|
||||
.workbench-card {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1179,6 +1179,136 @@
|
||||
background: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
@media (min-width: 961px) and (max-width: 1440px),
|
||||
(min-width: 961px) and (max-height: 820px) {
|
||||
.topbar {
|
||||
gap: 16px;
|
||||
padding: 12px 20px 14px;
|
||||
}
|
||||
|
||||
.topbar.chat-mode {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin-bottom: 5px;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
font-size: 22px;
|
||||
line-height: 1.16;
|
||||
}
|
||||
|
||||
.topbar p {
|
||||
display: -webkit-box;
|
||||
max-width: 640px;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
color: #64748b;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.35;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.range-shell {
|
||||
height: 36px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.range-meta {
|
||||
height: 30px;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.range-tabs button {
|
||||
height: 30px;
|
||||
min-width: 48px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.custom-range-btn {
|
||||
height: 36px;
|
||||
gap: 6px;
|
||||
padding: 0 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboard-switch-wrap {
|
||||
width: 176px;
|
||||
flex-basis: 176px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.dashboard-switch-select :deep(.el-select__wrapper) {
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.topbar-toolset {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.topbar-icon-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.company-switcher {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kpi-chips {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kpi-chip {
|
||||
gap: 1px 8px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.chip-value {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.chip-value small {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chip-delta {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.detail-alert-pill {
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.create-top-btn {
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.range-combo {
|
||||
width: 100%;
|
||||
|
||||
@@ -99,6 +99,13 @@
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.reimbursement-draft-pending-detail {
|
||||
display: inline;
|
||||
margin-left: 8px;
|
||||
color: #94a3b8;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.application-draft-preview .application-draft-head {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
max-width: min(100%, 760px);
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #d8e4f0;
|
||||
border-radius: 14px;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
color: #24324a;
|
||||
font-size: var(--wb-fs-bubble, 13px);
|
||||
@@ -182,11 +182,54 @@
|
||||
max-width: min(100%, 1080px);
|
||||
}
|
||||
|
||||
.message-feedback-bubble {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
max-width: min(100%, 420px);
|
||||
.message-action-toolbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: -2px;
|
||||
color: #728197;
|
||||
}
|
||||
|
||||
.message-action-btn {
|
||||
width: 30px;
|
||||
height: 28px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 160ms ease,
|
||||
background 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
|
||||
.message-action-btn i {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.message-action-btn:hover,
|
||||
.message-action-btn:focus-visible {
|
||||
color: #245f90;
|
||||
background: #eef6fb;
|
||||
border-color: #c9ddea;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.message-action-btn.active {
|
||||
color: #1d6f9f;
|
||||
background: #e8f4fb;
|
||||
border-color: #bcd8e8;
|
||||
}
|
||||
|
||||
.message-action-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
color: #b7c2cf;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.message-bubble-review-risk-low,
|
||||
@@ -482,7 +525,7 @@
|
||||
margin: 10px 0 12px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 10px;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
@@ -566,7 +609,7 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--wb-fs-chip, 12px);
|
||||
font-weight: 750;
|
||||
}
|
||||
@@ -606,6 +649,44 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active {
|
||||
transition:
|
||||
opacity 220ms cubic-bezier(0.2, 0, 0, 1),
|
||||
transform 240ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
clip-path 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transform-origin: top left;
|
||||
will-change: opacity, transform, clip-path;
|
||||
}
|
||||
|
||||
.structured-card-reveal-leave-active {
|
||||
transition:
|
||||
opacity 140ms ease,
|
||||
transform 140ms ease;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.985);
|
||||
clip-path: inset(0 0 14px 0);
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
|
||||
.structured-card-reveal-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.structured-card-reveal-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.message-suggested-action-btn {
|
||||
height: 100%;
|
||||
min-height: 54px;
|
||||
@@ -614,16 +695,32 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .message-suggested-action-btn {
|
||||
animation: structured-card-item-reveal 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .message-suggested-action-btn:nth-child(2) {
|
||||
animation-delay: 45ms;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .message-suggested-action-btn:nth-child(3) {
|
||||
animation-delay: 90ms;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .message-suggested-action-btn:nth-child(n + 4) {
|
||||
animation-delay: 120ms;
|
||||
}
|
||||
|
||||
.message-suggested-action-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
background: #eff6ff;
|
||||
color: var(--theme-primary, #3a7ca5);
|
||||
}
|
||||
@@ -651,7 +748,7 @@
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
border-radius: 4px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
@@ -670,12 +767,17 @@
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d7e4f2;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
font-size: var(--wb-fs-bubble, 13px);
|
||||
}
|
||||
|
||||
.application-preview-shell {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.application-preview-row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -684,6 +786,30 @@
|
||||
border-top: 1px solid #e6edf5;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .application-preview-row {
|
||||
animation: structured-card-item-reveal 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .application-preview-row:nth-child(2) {
|
||||
animation-delay: 35ms;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .application-preview-row:nth-child(3) {
|
||||
animation-delay: 70ms;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .application-preview-row:nth-child(4) {
|
||||
animation-delay: 105ms;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .application-preview-row:nth-child(5) {
|
||||
animation-delay: 140ms;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .application-preview-row:nth-child(n + 6) {
|
||||
animation-delay: 165ms;
|
||||
}
|
||||
|
||||
.application-preview-row.editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -786,7 +912,7 @@
|
||||
height: 30px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.48);
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
@@ -809,7 +935,7 @@
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--theme-primary-soft, #eaf4fa);
|
||||
color: var(--theme-primary-active, #255b7d);
|
||||
cursor: pointer;
|
||||
@@ -903,7 +1029,7 @@
|
||||
min-height: 22px;
|
||||
padding: 0 7px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
|
||||
color: var(--theme-primary-active, #255b7d);
|
||||
font-weight: 880;
|
||||
@@ -914,6 +1040,17 @@
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
@keyframes structured-card-item-reveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.expense-query-record-list,
|
||||
.message-citation-list {
|
||||
display: grid;
|
||||
@@ -926,7 +1063,7 @@
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -1054,7 +1191,7 @@
|
||||
|
||||
.message-bubble {
|
||||
max-width: 100%;
|
||||
border-radius: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.steward-task-missing-list li {
|
||||
@@ -1062,3 +1199,15 @@
|
||||
gap: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.structured-card-reveal-enter-active,
|
||||
.structured-card-reveal-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.structured-card-reveal-enter-active .application-preview-row,
|
||||
.structured-card-reveal-enter-active .message-suggested-action-btn {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,3 +208,22 @@
|
||||
) {
|
||||
border-radius: var(--enterprise-detail-radius);
|
||||
}
|
||||
|
||||
.digital-work-records.digital-work-records .work-record-detail-page :is(
|
||||
.detail-inline-state,
|
||||
.detail-loading-state,
|
||||
.detail-hero,
|
||||
.enterprise-detail-card,
|
||||
.json-risk-meta-item,
|
||||
.json-risk-description-text,
|
||||
.work-record-error-text,
|
||||
.work-record-tool-item,
|
||||
.work-record-inline-empty,
|
||||
.work-record-code-block,
|
||||
.edit-badge,
|
||||
.back-action,
|
||||
.minor-action,
|
||||
.major-action
|
||||
) {
|
||||
border-radius: var(--enterprise-detail-radius);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
--el-text-color-primary: var(--ink);
|
||||
--el-text-color-regular: var(--text);
|
||||
--el-text-color-secondary: var(--muted);
|
||||
--el-font-family: Inter, "SF Pro Text", "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
--el-font-family: var(--font-sans);
|
||||
--el-font-size-base: 14px;
|
||||
--el-box-shadow-light: 0 8px 22px rgba(15, 23, 42, 0.08);
|
||||
--el-box-shadow-lighter: 0 4px 14px rgba(15, 23, 42, 0.06);
|
||||
|
||||
@@ -72,7 +72,8 @@
|
||||
--desktop-stage-height: 100dvh;
|
||||
--desktop-viewport-width: 1440;
|
||||
--desktop-viewport-height: 900;
|
||||
font-family: Inter, "SF Pro Display", "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "PingFang SC", "Hiragino Sans GB", "Helvetica Neue", "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
@@ -41,20 +41,6 @@
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.budget-select-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.budget-select-filter .enterprise-select {
|
||||
min-width: 118px;
|
||||
}
|
||||
|
||||
.budget-primary-btn,
|
||||
.budget-ghost-btn {
|
||||
min-height: 38px;
|
||||
@@ -464,17 +450,11 @@
|
||||
padding: 12px 12px 0;
|
||||
}
|
||||
|
||||
.budget-select-filter,
|
||||
.budget-select-filter .enterprise-select,
|
||||
.budget-primary-btn,
|
||||
.budget-ghost-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.budget-select-filter {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.budget-scope-tabs {
|
||||
gap: 18px;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
@@ -15,173 +15,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.document-filter,
|
||||
.date-range-filter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.document-filter-menu,
|
||||
.date-range-popover {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.document-filter-menu {
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
min-width: 150px;
|
||||
max-height: 280px;
|
||||
padding: 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.document-filter-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-filter-menu button:hover,
|
||||
.document-filter-menu button.active {
|
||||
background: rgba(58, 124, 165, 0.1);
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.date-range-trigger {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.date-range-label {
|
||||
max-width: 104px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-range-popover {
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
width: 320px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.date-range-popover header,
|
||||
.date-range-popover footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.date-range-popover header strong {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.date-range-popover header button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.date-range-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.date-range-fields label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.date-range-fields span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.date-range-fields input {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 4px;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ghost-btn,
|
||||
.apply-btn {
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
border: 1px solid #d7e0ea;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.apply-btn {
|
||||
border: 0;
|
||||
background: var(--theme-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.apply-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.document-status-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.status-dropdown-filter {
|
||||
min-width: 154px;
|
||||
}
|
||||
|
||||
.status-filter-trigger {
|
||||
min-width: 154px;
|
||||
}
|
||||
|
||||
.status-filter-trigger > .mdi:first-child {
|
||||
color: var(--theme-primary);
|
||||
}
|
||||
|
||||
.status-filter-menu {
|
||||
min-width: 154px;
|
||||
}
|
||||
|
||||
.col-id { width: 11%; }
|
||||
.col-created { width: 10%; }
|
||||
.col-stay { width: 9%; }
|
||||
|
||||
@@ -40,10 +40,6 @@
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.system-logs-list .document-filter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.system-logs-list .status-dropdown-filter,
|
||||
.system-logs-list .status-filter-trigger,
|
||||
.system-logs-list .status-filter-menu {
|
||||
@@ -74,42 +70,6 @@
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.system-logs-list .document-filter-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
min-width: 150px;
|
||||
max-height: 280px;
|
||||
padding: 6px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.system-logs-list .document-filter-menu button {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
display: block;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.system-logs-list .document-filter-menu button:hover,
|
||||
.system-logs-list .document-filter-menu button.active {
|
||||
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.system-logs-list .system-log-table {
|
||||
min-width: 1260px;
|
||||
}
|
||||
|
||||
@@ -114,44 +114,46 @@
|
||||
border-right: 1px solid #edf2f7;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.folder-tree {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.folder-tree button {
|
||||
min-height: 34px;
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
.folder-tree {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.folder-tree button {
|
||||
min-height: 34px;
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
animation: listRowIn 460ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
animation-delay: var(--delay, 0ms);
|
||||
}
|
||||
|
||||
.folder-tree button.active {
|
||||
background: var(--theme-primary-light-9);
|
||||
color: var(--theme-primary-active);
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
|
||||
.folder-tree b {
|
||||
min-width: 24px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -189,7 +191,7 @@
|
||||
color: #64748b;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
.document-area {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -201,51 +203,51 @@
|
||||
.document-area.read-only {
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.upload-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
min-height: 112px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 8px;
|
||||
border: 1px dashed #93c5fd;
|
||||
border-radius: 10px;
|
||||
background: #f8fbff;
|
||||
color: #334155;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 180ms ease, background 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: #60a5fa;
|
||||
background: #f3f8ff;
|
||||
}
|
||||
|
||||
.upload-zone.disabled {
|
||||
cursor: default;
|
||||
border-color: #cbd5e1;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.upload-zone.busy {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.upload-zone i {
|
||||
color: #2563eb;
|
||||
font-size: 31px;
|
||||
}
|
||||
|
||||
.upload-zone strong {
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
|
||||
.upload-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
min-height: 112px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 8px;
|
||||
border: 1px dashed #93c5fd;
|
||||
border-radius: 10px;
|
||||
background: #f8fbff;
|
||||
color: #334155;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 180ms ease, background 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: #60a5fa;
|
||||
background: #f3f8ff;
|
||||
}
|
||||
|
||||
.upload-zone.disabled {
|
||||
cursor: default;
|
||||
border-color: #cbd5e1;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.upload-zone.busy {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.upload-zone i {
|
||||
color: #2563eb;
|
||||
font-size: 31px;
|
||||
}
|
||||
|
||||
.upload-zone strong {
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.upload-zone span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
@@ -255,24 +257,24 @@
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 780px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
color: #24324a;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
color: #24324a;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f7fafc;
|
||||
color: #64748b;
|
||||
@@ -289,59 +291,61 @@ th {
|
||||
.knowledge-document-table td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.doc-row {
|
||||
cursor: pointer;
|
||||
transition: background 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.doc-row:hover {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
|
||||
.doc-row {
|
||||
cursor: pointer;
|
||||
animation: listRowIn 460ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
animation-delay: var(--delay, 0ms);
|
||||
transition: background 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.doc-row:hover {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.doc-row.selected {
|
||||
background: linear-gradient(90deg, rgba(var(--theme-primary-rgb), 0.08), rgba(59, 130, 246, 0.04));
|
||||
box-shadow: inset 3px 0 0 var(--theme-primary);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-name .pdf,
|
||||
.viewer-filetype.pdf { color: #ef4444; }
|
||||
.file-name .word,
|
||||
.viewer-filetype.word { color: #2563eb; }
|
||||
|
||||
.file-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-name .pdf,
|
||||
.viewer-filetype.pdf { color: #ef4444; }
|
||||
.file-name .word,
|
||||
.viewer-filetype.word { color: #2563eb; }
|
||||
.file-name .excel,
|
||||
.viewer-filetype.excel { color: var(--success); }
|
||||
|
||||
.doc-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 6px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.state-tag {
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.doc-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 6px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.state-tag {
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.state-tag.success {
|
||||
background: var(--success-soft);
|
||||
color: var(--success-hover);
|
||||
@@ -351,7 +355,7 @@ th {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
|
||||
.state-tag.warning {
|
||||
background: #ffedd5;
|
||||
color: #f97316;
|
||||
@@ -373,14 +377,14 @@ th {
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.more-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #2563eb;
|
||||
}
|
||||
@@ -417,43 +421,43 @@ th {
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
.empty-row {
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list-foot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.pager button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.pager button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.pager button:hover:not(.active) {
|
||||
background: #fff;
|
||||
color: var(--theme-primary-active);
|
||||
@@ -465,101 +469,101 @@ th {
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 16px var(--theme-primary-shadow);
|
||||
}
|
||||
|
||||
.list-foot .page-summary {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
|
||||
.list-foot .page-summary {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.page-size-select {
|
||||
width: 112px;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
|
||||
.preview-panel {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
padding: 20px 22px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.preview-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mini-action,
|
||||
.icon-action,
|
||||
.viewer-toolbar-actions button {
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.mini-action {
|
||||
min-height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.icon-action {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.preview-summary-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.preview-secondary-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
padding: 20px 22px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.preview-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mini-action,
|
||||
.icon-action,
|
||||
.viewer-toolbar-actions button {
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.mini-action {
|
||||
min-height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.icon-action {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.preview-summary-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.preview-secondary-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.preview-viewer {
|
||||
min-height: 0;
|
||||
margin-top: 18px;
|
||||
@@ -585,6 +589,7 @@ th {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
.preview-modal-panel {
|
||||
height: 100%;
|
||||
border-radius: 24px;
|
||||
@@ -1401,3 +1406,22 @@ th {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes listRowIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.folder-tree button,
|
||||
.doc-row,
|
||||
.page-sheet {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,8 +365,10 @@
|
||||
}
|
||||
|
||||
.dialog-panel {
|
||||
flex: 1 1 auto;
|
||||
flex: 1 1 0;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.insight-panel-shell {
|
||||
|
||||
@@ -619,10 +619,13 @@
|
||||
.assistant-layout {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
padding: clamp(12px, 1.5vw, 16px);
|
||||
align-items: stretch;
|
||||
gap: clamp(12px, 1.5vw, 16px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-panel,
|
||||
@@ -641,8 +644,11 @@
|
||||
|
||||
.dialog-panel {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
transition:
|
||||
@@ -671,6 +677,7 @@
|
||||
}
|
||||
|
||||
.dialog-toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
@@ -766,12 +773,15 @@
|
||||
}
|
||||
|
||||
.message-list {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.message-row.user .message-avatar {
|
||||
@@ -1918,6 +1928,13 @@
|
||||
padding: 0 18px 18px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
flex: 0 0 auto;
|
||||
flex-shrink: 0;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 -10px 22px rgba(248, 250, 252, 0.92);
|
||||
}
|
||||
|
||||
.hidden-file-input {
|
||||
@@ -1994,3 +2011,37 @@
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.message-row-reveal-enter-active {
|
||||
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 350ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.message-row-reveal-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.98);
|
||||
}
|
||||
|
||||
.message-row-reveal-enter-to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.message-row-reveal-leave-active {
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-row-reveal-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.message-row-reveal-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scale(0.98);
|
||||
}
|
||||
|
||||
.message-row-reveal-move {
|
||||
transition: transform 350ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Workbench Icons
|
||||
|
||||
Icons in this folder are sourced from [Heroicons](https://heroicons.com) (MIT License).
|
||||
Icons in this folder are SVG assets used by the Personal Workbench todo,
|
||||
progress, and assistant capability entries.
|
||||
|
||||
Used on the Personal Workbench todo and progress lists.
|
||||
The assistant capability icons are custom line icons designed for the
|
||||
X-Financial workbench visual system.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M8.5 4.5h7A1.5 1.5 0 0 1 17 6v13a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 5 19V6a1.5 1.5 0 0 1 1.5-1.5h2"/>
|
||||
<path d="M8.5 4.5A2 2 0 0 1 10.5 3h2A2 2 0 0 1 14.5 4.5v1h-6Z"/>
|
||||
<path d="M8.5 12.8 11 15.2l5-5.2"/>
|
||||
<path d="M8 18h7"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path class="icon-fill" d="M7.1 4.7h8.4c.9 0 1.6.7 1.6 1.6v12.3c0 .9-.7 1.6-1.6 1.6H7.1c-.9 0-1.6-.7-1.6-1.6V6.3c0-.9.7-1.6 1.6-1.6Z"/>
|
||||
<path d="M8.5 4.7H7.1c-.9 0-1.6.7-1.6 1.6v12.3c0 .9.7 1.6 1.6 1.6h8.4c.9 0 1.6-.7 1.6-1.6V6.3c0-.9-.7-1.6-1.6-1.6h-1.3"/>
|
||||
<path class="icon-accent" d="M8.5 4.7c.2-.9 1-1.6 2-1.6h1.8c1 0 1.8.7 2 1.6v1.2H8.5Z"/>
|
||||
<path d="m8.7 12.6 2.3 2.2 4.9-5.1"/>
|
||||
<path class="icon-muted" d="M8.2 18h6.6"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 431 B After Width: | Height: | Size: 625 B |
@@ -1,5 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 4.5v7h7A7 7 0 1 1 12 4.5Z"/>
|
||||
<path d="M14.5 3.8A7.2 7.2 0 0 1 20.2 9h-5.7Z"/>
|
||||
<path d="M7.5 16.5h4M7.5 13.5H10"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path class="icon-fill" d="M11.6 4.2a7.5 7.5 0 1 0 7.5 7.5h-7.5Z"/>
|
||||
<path d="M11.6 4.2a7.5 7.5 0 1 0 7.5 7.5h-7.5Z"/>
|
||||
<path class="icon-accent" d="M14.3 3.9a7.3 7.3 0 0 1 5.8 5.7h-5.8Z"/>
|
||||
<path d="M7.5 15.7h4.5"/>
|
||||
<path class="icon-muted" d="M7.5 18h3.1"/>
|
||||
<path d="M16.8 14.6v2.9M19.2 13.2v4.3"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 320 B After Width: | Height: | Size: 498 B |
@@ -1,6 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M7 3.5h7l3 3v13a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5v-15A1.5 1.5 0 0 1 7.5 3.5Z"/>
|
||||
<path d="M14 3.5V7h3.5"/>
|
||||
<path d="M9 11h6M9 14h3"/>
|
||||
<path d="M15.5 13.5v4M13.5 15.5h4"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path class="icon-fill" d="M7.5 3.7h7.1l3.2 3.2v11.7c0 .9-.7 1.6-1.6 1.6H7.5c-.9 0-1.6-.7-1.6-1.6V5.3c0-.9.7-1.6 1.6-1.6Z"/>
|
||||
<path d="M7.5 3.7h7.1l3.2 3.2v11.7c0 .9-.7 1.6-1.6 1.6H7.5c-.9 0-1.6-.7-1.6-1.6V5.3c0-.9.7-1.6 1.6-1.6Z"/>
|
||||
<path class="icon-accent" d="M14.5 3.8v3.3h3.3"/>
|
||||
<path d="M8.8 10.4h6.4M8.8 13h4.2"/>
|
||||
<path class="icon-muted" d="M8.8 15.7h2.6"/>
|
||||
<path d="M16.4 13.9v4.1M14.4 16h4.1"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 386 B After Width: | Height: | Size: 603 B |
@@ -1,6 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4.5 19.5h15"/>
|
||||
<path d="M6 16.5V8.8M10 16.5V5.5M14 16.5v-6M18 16.5V7"/>
|
||||
<path d="M6 12.5c2.2-3.2 4-1.2 6.2-4.1 1.5-2 3-1.3 5.3-3.4"/>
|
||||
<path d="m17.6 5 .4 3.2-3.1-.5"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path class="icon-fill" d="M5.2 5.5h14v13.2h-14Z"/>
|
||||
<path d="M4.8 19h14.8"/>
|
||||
<path class="icon-muted" d="M6.5 16.2V9.8M10.3 16.2V7.2M14.1 16.2v-5.7M17.9 16.2V8.6"/>
|
||||
<path d="M6.4 13c2.2-3.1 4.1-1.4 6-4 1.5-2 3.2-1.1 5.4-3.2"/>
|
||||
<path class="icon-accent" d="m17.9 5.8.2 3-2.8-.5"/>
|
||||
<circle cx="12.4" cy="9" r="1" class="icon-fill"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 373 B After Width: | Height: | Size: 531 B |
@@ -1,6 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5.5 5.5A2.5 2.5 0 0 1 8 3h12v16.5H8A2.5 2.5 0 0 0 5.5 22Z"/>
|
||||
<path d="M5.5 5.5v16"/>
|
||||
<path d="M9 7.5h7M9 10.5h6M9 14.5h4"/>
|
||||
<path d="M17 3v16.5"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path class="icon-fill" d="M5.5 5.7A2.4 2.4 0 0 1 7.9 3.3h11.2v16.2H7.9a2.4 2.4 0 0 0-2.4 2.2Z"/>
|
||||
<path d="M5.5 5.7A2.4 2.4 0 0 1 7.9 3.3h11.2v16.2H7.9a2.4 2.4 0 0 0-2.4 2.2Z"/>
|
||||
<path class="icon-muted" d="M5.5 5.7v16"/>
|
||||
<path d="M9 7.4h5.6M9 10.2h4.8M9 13h3.7"/>
|
||||
<path class="icon-accent" d="M16 3.3v5.2l1.4-1 1.4 1V3.3"/>
|
||||
<path d="M14.8 15.2h2.8"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 352 B After Width: | Height: | Size: 551 B |
@@ -1,6 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M6.5 4.5h9.8A1.7 1.7 0 0 1 18 6.2v13.3l-2.2-1.2-2.2 1.2-2.2-1.2-2.2 1.2-2.2-1.2-2.2 1.2V6.2a1.7 1.7 0 0 1 1.7-1.7Z"/>
|
||||
<path d="M8 9h8M8 12h6"/>
|
||||
<path d="M9 15.5h2.7c1.8 0 3.3-1.4 3.3-3.2v-.8"/>
|
||||
<path d="m13.3 10 1.7 1.7 1.7-1.7"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path class="icon-fill" d="M5.2 6.3h11.5c1 0 1.8.8 1.8 1.8v8.1c0 1-.8 1.8-1.8 1.8H5.2c-1 0-1.8-.8-1.8-1.8V8.1c0-1 .8-1.8 1.8-1.8Z"/>
|
||||
<path d="M5.2 6.3h11.5c1 0 1.8.8 1.8 1.8v8.1c0 1-.8 1.8-1.8 1.8H5.2c-1 0-1.8-.8-1.8-1.8V8.1c0-1 .8-1.8 1.8-1.8Z"/>
|
||||
<path class="icon-accent" d="M6.8 6.3V4.9c0-.7.5-1.2 1.2-1.2h8.9c.7 0 1.2.5 1.2 1.2v2.2"/>
|
||||
<path d="M7 10.2h5.8M7 13.3h4.1"/>
|
||||
<path d="m13.2 14.1 1.7 1.6 3.1-3.6"/>
|
||||
<path class="icon-muted" d="M18.5 9.7h1.7v4.6h-1.7"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 435 B After Width: | Height: | Size: 667 B |
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="picker-filter" :class="{ open: activeFilterPopover === id }">
|
||||
<div class="picker-filter document-filter" :class="{ open: activeFilterPopover === id }">
|
||||
<button
|
||||
class="picker-trigger"
|
||||
class="picker-trigger filter-btn"
|
||||
type="button"
|
||||
:aria-expanded="activeFilterPopover === id"
|
||||
aria-haspopup="dialog"
|
||||
aria-haspopup="listbox"
|
||||
@click="emit('toggle', id)"
|
||||
>
|
||||
<span class="picker-label">{{ label }}</span>
|
||||
@@ -12,28 +12,21 @@
|
||||
</button>
|
||||
<div
|
||||
v-if="activeFilterPopover === id"
|
||||
class="picker-popover"
|
||||
role="dialog"
|
||||
class="picker-popover document-filter-menu"
|
||||
role="listbox"
|
||||
:aria-label="title"
|
||||
>
|
||||
<header>
|
||||
<strong>{{ title }}</strong>
|
||||
<button type="button" :aria-label="closeLabel" @click="emit('close')">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
<div class="picker-option-list">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value || `all-${id}`"
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: selectedValue === option.value }"
|
||||
@click="emit('select', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value || `all-${id}`"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="selectedValue === option.value"
|
||||
:class="{ active: selectedValue === option.value }"
|
||||
@click="emit('select', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -56,5 +49,4 @@ defineProps({
|
||||
const emit = defineEmits(['toggle', 'close', 'select'])
|
||||
</script>
|
||||
|
||||
<style scoped src="../../assets/styles/views/audit-view.css"></style>
|
||||
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>
|
||||
<style scoped src="../../assets/styles/components/document-list-shared.css"></style>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<article v-if="visible" class="detail-card panel run-products-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>本次任务产物</h3>
|
||||
<p>{{ productSubtitle }}</p>
|
||||
</div>
|
||||
<EnterpriseDetailCard
|
||||
v-if="visible"
|
||||
class="run-products-card"
|
||||
title="本次任务产物"
|
||||
:description="productSubtitle"
|
||||
>
|
||||
<template #actions>
|
||||
<span class="edit-badge">{{ productBadge }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="run-product-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
@@ -211,12 +212,13 @@
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</article>
|
||||
</EnterpriseDetailCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import EnterpriseDetailCard from '../shared/EnterpriseDetailCard.vue'
|
||||
import { fetchRunRiskObservations } from '../../services/riskObservations.js'
|
||||
import {
|
||||
extractWorkRecordToolSummary,
|
||||
|
||||
@@ -157,34 +157,26 @@
|
||||
</template>
|
||||
</EnterpriseListPage>
|
||||
|
||||
<!-- 详情视图 (全屏样式,参考 AuditJsonRiskRuleDetail) -->
|
||||
<div v-else key="detail" class="json-risk-editor-shell panel work-records-detail-stage">
|
||||
<div v-if="detailLoading" class="work-record-detail-state panel" style="min-height: 200px; display: grid; place-items: center; border: 0;">
|
||||
<TableLoadingState
|
||||
variant="panel"
|
||||
title="详情加载中"
|
||||
message="正在读取该次工作记录的完整执行信息"
|
||||
icon="mdi mdi-clipboard-text-search-outline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="detailError" class="work-record-detail-state error panel" style="min-height: 200px; display: grid; place-items: center; text-align: center; border: 0; color: #dc2626;">
|
||||
<i class="mdi mdi-alert-circle-outline" style="font-size: 32px; margin-bottom: 8px;"></i>
|
||||
<strong>工作记录详情加载失败</strong>
|
||||
<p>{{ detailError }}</p>
|
||||
<button class="minor-action" type="button" @click="reloadSelectedDetail" style="margin-top: 12px;">重新加载</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="json-risk-editor-body work-record-detail-shell">
|
||||
<section class="json-risk-main-stage work-record-detail-body inline-detail">
|
||||
<!-- 卡片1:基本信息 -->
|
||||
<article class="detail-card panel json-risk-summary-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>基本信息</h3>
|
||||
<p>此次运行的执行周期、触发来源、标识信息与最终状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
<EnterpriseDetailPage
|
||||
v-else
|
||||
key="detail"
|
||||
variant="work-record-detail-page"
|
||||
actions-class="work-record-detail-actions"
|
||||
:loading="detailLoading"
|
||||
:error="detailError"
|
||||
error-title="工作记录详情加载失败"
|
||||
loading-title="详情加载中"
|
||||
loading-message="正在读取该次工作记录的完整执行信息"
|
||||
loading-icon="mdi mdi-clipboard-text-search-outline"
|
||||
back-label="返回工作记录列表"
|
||||
@back="closeWorkRecordDetail"
|
||||
>
|
||||
<template #main>
|
||||
<EnterpriseDetailCard
|
||||
class="work-record-detail-card work-record-summary-card"
|
||||
title="基本信息"
|
||||
description="此次运行的执行周期、触发来源、标识信息与最终状态。"
|
||||
>
|
||||
<div class="json-risk-meta-grid">
|
||||
<div class="json-risk-meta-item">
|
||||
<span class="json-risk-meta-label">Run ID</span>
|
||||
@@ -211,75 +203,63 @@
|
||||
<span class="json-risk-meta-value">{{ resolveWorkRecordStatusNote(selectedRunDetail) || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<!-- 卡片2:执行摘要 -->
|
||||
<article class="detail-card panel json-risk-description-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>执行摘要</h3>
|
||||
<p>本次数字员工工作流的执行内容与结果摘要。</p>
|
||||
</div>
|
||||
<EnterpriseDetailCard
|
||||
class="work-record-detail-card work-record-summary-copy-card"
|
||||
title="执行摘要"
|
||||
description="本次数字员工工作流的执行内容与结果摘要。"
|
||||
>
|
||||
<template #actions>
|
||||
<span class="edit-badge">{{ resolveWorkRecordSummaryMeta(selectedRunDetail) }}</span>
|
||||
</div>
|
||||
<p class="json-risk-description-text" style="padding: 0 12px 12px; margin: 0;">{{ selectedRunDetail.result_summary || '暂无执行摘要。' }}</p>
|
||||
<p v-if="selectedRunDetail.error_message" class="work-record-error-text" style="margin: 0 12px 12px; padding: 10px 12px; border: 1px solid #fecaca; border-radius: 4px; background: #fef2f2; color: #b91c1c;">
|
||||
</template>
|
||||
<p class="json-risk-description-text work-record-summary-text">{{ selectedRunDetail.result_summary || '暂无执行摘要。' }}</p>
|
||||
<p v-if="selectedRunDetail.error_message" class="work-record-error-text">
|
||||
{{ selectedRunDetail.error_message }}
|
||||
</p>
|
||||
</article>
|
||||
</p>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<DigitalEmployeeRunProducts :run="selectedRunDetail" />
|
||||
<DigitalEmployeeRunProducts :run="selectedRunDetail" />
|
||||
</template>
|
||||
|
||||
<!-- 卡片3:工具调用 -->
|
||||
<article class="detail-card panel">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>工具调用</h3>
|
||||
<p>此任务在执行期间调用的外部系统/工具细节与执行状态。</p>
|
||||
</div>
|
||||
<template #side>
|
||||
<EnterpriseDetailCard
|
||||
class="work-record-detail-card"
|
||||
title="工具调用"
|
||||
description="此任务在执行期间调用的外部系统/工具细节与执行状态。"
|
||||
>
|
||||
<template #actions>
|
||||
<span class="edit-badge">{{ (selectedRunDetail.tool_calls || []).length }} 次调用</span>
|
||||
</div>
|
||||
<div v-if="(selectedRunDetail.tool_calls || []).length" class="work-record-tool-list" style="padding: 0 12px 12px; display: grid; gap: 8px;">
|
||||
<article v-for="toolCall in selectedRunDetail.tool_calls" :key="toolCall.id" style="display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border: 1px solid #edf2f7; border-radius: 4px; background: #f8fafc;">
|
||||
<strong style="color: #0f172a; font-size: 13px;">{{ toolCall.tool_name }}</strong>
|
||||
<span style="color: #64748b; font-size: 12px;">{{ toolCall.tool_type || 'tool' }} · {{ toolCall.status || 'unknown' }}</span>
|
||||
</template>
|
||||
<div v-if="(selectedRunDetail.tool_calls || []).length" class="work-record-tool-list">
|
||||
<article v-for="toolCall in selectedRunDetail.tool_calls" :key="toolCall.id" class="work-record-tool-item">
|
||||
<strong>{{ toolCall.tool_name }}</strong>
|
||||
<span>{{ toolCall.tool_type || 'tool' }} · {{ toolCall.status || 'unknown' }}</span>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="work-record-inline-empty" style="padding: 0 12px 12px; color: #94a3b8; font-size: 13px;">当前暂无工具调用明细。</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="work-record-inline-empty">当前暂无工具调用明细。</div>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<!-- 卡片4:执行上下文 -->
|
||||
<article class="detail-card panel">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>执行上下文</h3>
|
||||
<p>后台调度的运行时配置与状态信息(JSON 格式)。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 0 12px 12px;">
|
||||
<pre class="work-record-code-block" style="max-height: 320px; margin: 0; padding: 12px; overflow: auto; border: 1px solid #e2e8f0; border-radius: 4px; background: #0f172a; color: #e2e8f0; font-size: 12px; line-height: 1.55;">{{ formatJson(selectedRunDetail.route_json) }}</pre>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
<EnterpriseDetailCard
|
||||
class="work-record-detail-card"
|
||||
title="执行上下文"
|
||||
description="后台调度的运行时配置与状态信息(JSON 格式)。"
|
||||
>
|
||||
<pre class="work-record-code-block">{{ formatJson(selectedRunDetail.route_json) }}</pre>
|
||||
</EnterpriseDetailCard>
|
||||
</template>
|
||||
|
||||
<footer class="detail-actions">
|
||||
<button class="back-action" type="button" @click="closeWorkRecordDetail">
|
||||
<i class="mdi mdi-arrow-left"></i>
|
||||
<span>返回工作记录列表</span>
|
||||
<template #actions>
|
||||
<button class="minor-action" type="button" :disabled="!selectedRunDetail?.run_id" @click="openTraceCenter">
|
||||
<i class="mdi mdi-timeline-text-outline"></i>
|
||||
<span>查看 Trace</span>
|
||||
</button>
|
||||
<div class="detail-action-group">
|
||||
<button class="minor-action" type="button" :disabled="!selectedRunDetail?.run_id" @click="openTraceCenter">
|
||||
<i class="mdi mdi-timeline-text-outline"></i>
|
||||
<span>查看 Trace</span>
|
||||
</button>
|
||||
<button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</EnterpriseDetailPage>
|
||||
</Transition>
|
||||
</section>
|
||||
</template>
|
||||
@@ -290,8 +270,9 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import AuditPickerFilter from './AuditPickerFilter.vue'
|
||||
import DigitalEmployeeRunProducts from './DigitalEmployeeRunProducts.vue'
|
||||
import EnterpriseDetailCard from '../shared/EnterpriseDetailCard.vue'
|
||||
import EnterpriseDetailPage from '../shared/EnterpriseDetailPage.vue'
|
||||
import EnterpriseListPage from '../shared/EnterpriseListPage.vue'
|
||||
import TableLoadingState from '../shared/TableLoadingState.vue'
|
||||
import { fetchAgentRunDetail, fetchAgentRuns } from '../../services/agentAssets.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import {
|
||||
@@ -650,103 +631,5 @@ onBeforeUnmount(() => {
|
||||
<style scoped src="../../assets/styles/views/audit-view.css"></style>
|
||||
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>
|
||||
|
||||
<style scoped>
|
||||
.digital-work-records {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.digital-employee-list-panel,
|
||||
.digital-work-records-list-stage {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.digital-work-records-table {
|
||||
min-width: 1180px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.digital-work-records-table .col-time { width: 14%; }
|
||||
.digital-work-records-table .col-module { width: 13%; }
|
||||
.digital-work-records-table .col-source { width: 10%; }
|
||||
.digital-work-records-table .col-status { width: 16%; }
|
||||
.digital-work-records-table .col-summary { width: 31%; }
|
||||
.digital-work-records-table .col-trace { width: 16%; }
|
||||
|
||||
.work-record-row {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.work-record-row:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px rgba(58, 124, 165, 0.28);
|
||||
}
|
||||
|
||||
.work-record-summary-cell {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.work-record-summary-cell strong,
|
||||
.work-record-summary-cell span,
|
||||
.work-record-summary-cell em {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.work-record-summary-cell strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.work-record-summary-cell span {
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.work-record-summary-cell em {
|
||||
margin-top: 6px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.work-record-trace-cell {
|
||||
color: #2563eb !important;
|
||||
}
|
||||
|
||||
.work-records-detail-stage,
|
||||
.work-record-detail-shell {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.digital-work-records :deep(.toolbar-actions .picker-filter),
|
||||
.digital-work-records :deep(.toolbar-actions .picker-trigger) {
|
||||
min-width: 148px;
|
||||
}
|
||||
|
||||
.digital-refresh-now {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.digital-refresh-now .mdi {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.work-record-detail-body.inline-detail {
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
</style>
|
||||
<style scoped src="../../assets/styles/components/digital-employee-work-records.css"></style>
|
||||
<style scoped src="../../assets/styles/components/digital-employee-work-records-overrides.css"></style>
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
|
||||
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${workbenchHeroBackground})` }">
|
||||
<div class="assistant-copy">
|
||||
<h1>嗨,{{ displayUserName }},我是您的 <span>小财管家</span></h1>
|
||||
<h1 class="assistant-hero-title">
|
||||
{{ typedTitlePrefix }}<span v-if="titleTypingDone">小财管家</span><span v-if="!titleTypingDone" class="typing-cursor">|</span>
|
||||
</h1>
|
||||
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
@@ -173,11 +175,12 @@
|
||||
|
||||
<div :class="['capability-grid', capabilityGridClass]" aria-label="AI 财务助手能力">
|
||||
<button
|
||||
v-for="item in visibleAssistantCapabilities"
|
||||
v-for="(item, index) in visibleAssistantCapabilities"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="capability-card panel"
|
||||
:class="`capability-card--${item.tone}`"
|
||||
:style="{ '--delay': `${index * 80 + 100}ms` }"
|
||||
@click="openCapabilityAssistant(item)"
|
||||
>
|
||||
<WorkbenchListIcon
|
||||
@@ -196,18 +199,19 @@
|
||||
</div>
|
||||
|
||||
<div class="workbench-content-grid">
|
||||
<article class="panel workbench-card progress-panel">
|
||||
<article class="panel workbench-card progress-panel" style="--delay: 200ms;">
|
||||
<div class="section-head">
|
||||
<h2>费用进度</h2>
|
||||
</div>
|
||||
|
||||
<div class="progress-list">
|
||||
<button
|
||||
v-for="item in visibleProgressItems"
|
||||
v-for="(item, index) in visibleProgressItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="progress-row"
|
||||
:class="{ 'has-long-duration-divider': item.hasLongDurationDivider }"
|
||||
:style="{ '--item-index': index }"
|
||||
@click="openWorkbenchTarget(item)"
|
||||
>
|
||||
<span class="progress-time-wrapper">
|
||||
@@ -256,7 +260,7 @@
|
||||
</article>
|
||||
|
||||
<aside class="side-column">
|
||||
<article class="panel workbench-card side-panel expense-stats-panel">
|
||||
<article class="panel workbench-card side-panel expense-stats-panel" style="--delay: 300ms;">
|
||||
<div class="section-head side-card-head">
|
||||
<h2>费用统计</h2>
|
||||
<button
|
||||
@@ -273,10 +277,11 @@
|
||||
|
||||
<div class="insight-metric-list" aria-label="费用统计">
|
||||
<div
|
||||
v-for="item in visibleExpenseStatItems"
|
||||
v-for="(item, index) in visibleExpenseStatItems"
|
||||
:key="item.key"
|
||||
class="insight-metric-row"
|
||||
:class="`insight-metric-row--${item.tone}`"
|
||||
:style="{ '--item-index': index }"
|
||||
>
|
||||
<span class="insight-metric-icon" aria-hidden="true">
|
||||
<i :class="item.icon"></i>
|
||||
@@ -289,7 +294,7 @@
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel workbench-card side-panel usage-profile-panel">
|
||||
<article class="panel workbench-card side-panel usage-profile-panel" style="--delay: 400ms;">
|
||||
<div class="section-head side-card-head">
|
||||
<h2>用户画像</h2>
|
||||
<button
|
||||
@@ -306,10 +311,11 @@
|
||||
|
||||
<div class="insight-profile-list" aria-label="用户画像">
|
||||
<div
|
||||
v-for="metric in visibleUsageProfileMetrics"
|
||||
v-for="(metric, index) in visibleUsageProfileMetrics"
|
||||
:key="metric.key"
|
||||
class="insight-profile-card"
|
||||
:class="`insight-profile-card--${metric.tone}`"
|
||||
:style="{ '--item-index': index }"
|
||||
>
|
||||
<span class="insight-profile-icon" aria-hidden="true">
|
||||
<i :class="metric.icon"></i>
|
||||
@@ -441,6 +447,35 @@ const displayUserName = computed(() => {
|
||||
const user = currentUser.value || {}
|
||||
return String(user.name || user.username || '同事').trim() || '同事'
|
||||
})
|
||||
|
||||
const heroTitleText = computed(() => `嗨,${displayUserName.value},我是您的 `)
|
||||
const typedTitlePrefix = ref('')
|
||||
const titleTypingDone = ref(false)
|
||||
let typingInterval = null
|
||||
|
||||
const startTypewriter = () => {
|
||||
typedTitlePrefix.value = ''
|
||||
titleTypingDone.value = false
|
||||
clearInterval(typingInterval)
|
||||
let i = 0
|
||||
const text = heroTitleText.value
|
||||
typingInterval = setInterval(() => {
|
||||
if (i < text.length) {
|
||||
typedTitlePrefix.value += text.charAt(i)
|
||||
i++
|
||||
} else {
|
||||
clearInterval(typingInterval)
|
||||
titleTypingDone.value = true
|
||||
}
|
||||
}, 60)
|
||||
}
|
||||
|
||||
watch(displayUserName, (newVal, oldVal) => {
|
||||
if (oldVal !== newVal && titleTypingDone.value) {
|
||||
typedTitlePrefix.value = `嗨,${newVal},我是您的 `
|
||||
}
|
||||
})
|
||||
|
||||
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
|
||||
const isComposerPending = computed(() => Boolean(pendingAction.value))
|
||||
const composerPendingLabel = computed(() => {
|
||||
@@ -861,6 +896,7 @@ async function handleExpenseConversationAction() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startTypewriter()
|
||||
refreshLocalExpenseSnapshot()
|
||||
refreshLatestExpenseConversation()
|
||||
loadCurrentEmployeeProfile()
|
||||
@@ -869,6 +905,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(typingInterval)
|
||||
clearPendingAction()
|
||||
document.removeEventListener('click', handleWorkbenchDatePickerOutside)
|
||||
window.removeEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
<template>
|
||||
<div class="system-agent-ratio-bar">
|
||||
<div class="agent-ratio-legend" aria-hidden="true">
|
||||
<span v-for="agent in resolvedAgents" :key="agent.key">
|
||||
<i :style="{ background: agent.resolvedColor }"></i>{{ agent.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { BarChart as EChartsBarChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CustomChart as EChartsCustomChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
import { useEcharts } from '../../composables/useEcharts.js'
|
||||
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
|
||||
|
||||
use([GridComponent, LegendComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
|
||||
use([GridComponent, TooltipComponent, EChartsCustomChart, CanvasRenderer])
|
||||
|
||||
const props = defineProps({
|
||||
labels: { type: Array, required: true },
|
||||
@@ -36,36 +41,31 @@ const ariaLabel = computed(() =>
|
||||
const parts = resolvedAgents.value.map((agent) => (
|
||||
`${agent.name}${props.series[agent.key]?.[dayIndex] || 0}%`
|
||||
))
|
||||
return `${label}${parts.join(',')}`
|
||||
return `${label}${parts.join(';')}`
|
||||
}).join(';')
|
||||
)
|
||||
|
||||
const stackedAgentRatioData = computed(() => props.labels.map((_, index) => [
|
||||
index,
|
||||
...resolvedAgents.value.map((agent) => Number(props.series[agent.key]?.[index] || 0))
|
||||
]))
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
animationDuration: 900,
|
||||
animationEasing: 'cubicOut',
|
||||
legend: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
itemGap: 14,
|
||||
textStyle: {
|
||||
color: '#475569',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
animationDuration: 1200,
|
||||
animationDurationUpdate: 1200,
|
||||
animationEasing: 'linear',
|
||||
animationEasingUpdate: 'linear',
|
||||
grid: {
|
||||
top: 38,
|
||||
top: 8,
|
||||
right: 16,
|
||||
bottom: 24,
|
||||
left: 34,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
trigger: 'item',
|
||||
confine: true,
|
||||
appendToBody: true,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
@@ -78,7 +78,7 @@ const chartOptions = computed(() => ({
|
||||
fontWeight: 700
|
||||
},
|
||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
|
||||
valueFormatter: (value) => `${value}%`
|
||||
formatter: (params) => formatStackedTooltip(params)
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -104,32 +104,161 @@ const chartOptions = computed(() => ({
|
||||
},
|
||||
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
|
||||
},
|
||||
series: resolvedAgents.value.map((agent, index) => ({
|
||||
name: agent.name,
|
||||
type: 'bar',
|
||||
stack: 'agentRatio',
|
||||
data: props.series[agent.key] || [],
|
||||
barWidth: 34,
|
||||
emphasis: { focus: 'series' },
|
||||
itemStyle: {
|
||||
color: agent.resolvedColor,
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: index === resolvedAgents.value.length - 1 ? 0 : 1,
|
||||
borderRadius: index === resolvedAgents.value.length - 1 ? [4, 4, 0, 0] : 0
|
||||
series: [{
|
||||
name: '智能体调用占比',
|
||||
type: 'custom',
|
||||
data: stackedAgentRatioData.value,
|
||||
renderItem: renderStackedAgentRatioBar,
|
||||
animationDelay: (index) => index * 18,
|
||||
tooltip: {
|
||||
formatter: (params) => formatStackedTooltip(params)
|
||||
}
|
||||
}))
|
||||
}]
|
||||
}))
|
||||
|
||||
useEcharts(chartElement, chartOptions)
|
||||
|
||||
function renderStackedAgentRatioBar(params, api) {
|
||||
const categoryIndex = Number(api.value(0))
|
||||
const zeroPoint = api.coord([categoryIndex, 0])
|
||||
const xCenter = zeroPoint[0]
|
||||
const zeroY = zeroPoint[1]
|
||||
const categoryWidth = api.size([1, 0])?.[0] || 48
|
||||
const barWidth = Math.max(24, Math.min(34, categoryWidth * 0.48))
|
||||
const barX = xCenter - barWidth / 2
|
||||
let accumulated = 0
|
||||
const values = resolvedAgents.value.map((_, index) => Number(api.value(index + 1) || 0))
|
||||
const lastVisibleIndex = values.reduce((last, value, index) => (value > 0 ? index : last), -1)
|
||||
const children = []
|
||||
let topY = zeroY
|
||||
|
||||
values.forEach((value, index) => {
|
||||
if (value <= 0) {
|
||||
return
|
||||
}
|
||||
const lower = accumulated
|
||||
const upper = accumulated + value
|
||||
const lowerY = api.coord([categoryIndex, lower])[1]
|
||||
const upperY = api.coord([categoryIndex, upper])[1]
|
||||
const height = Math.max(1, lowerY - upperY)
|
||||
topY = Math.min(topY, upperY)
|
||||
accumulated = upper
|
||||
children.push({
|
||||
type: 'rect',
|
||||
shape: {
|
||||
x: barX,
|
||||
y: upperY,
|
||||
width: barWidth,
|
||||
height,
|
||||
r: index === lastVisibleIndex ? [4, 4, 0, 0] : 0
|
||||
},
|
||||
style: {
|
||||
fill: resolvedAgents.value[index]?.resolvedColor || themeColors.value.chartPrimary,
|
||||
stroke: index === lastVisibleIndex ? 'transparent' : '#ffffff',
|
||||
lineWidth: index === lastVisibleIndex ? 0 : 1
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!children.length) {
|
||||
return {
|
||||
type: 'group',
|
||||
children: []
|
||||
}
|
||||
}
|
||||
|
||||
const totalHeight = Math.max(1, zeroY - topY)
|
||||
return {
|
||||
type: 'group',
|
||||
originX: xCenter,
|
||||
originY: zeroY,
|
||||
scaleY: 1,
|
||||
enterFrom: {
|
||||
scaleY: 0
|
||||
},
|
||||
transition: ['scaleY'],
|
||||
clipPath: {
|
||||
type: 'rect',
|
||||
shape: {
|
||||
x: barX,
|
||||
y: topY,
|
||||
width: barWidth,
|
||||
height: totalHeight
|
||||
},
|
||||
enterFrom: {
|
||||
shape: {
|
||||
x: barX,
|
||||
y: zeroY,
|
||||
width: barWidth,
|
||||
height: 0
|
||||
}
|
||||
},
|
||||
transition: ['shape']
|
||||
},
|
||||
children
|
||||
}
|
||||
}
|
||||
|
||||
function formatStackedTooltip(params) {
|
||||
const index = Number(params?.data?.[0] ?? params?.dataIndex ?? 0)
|
||||
const label = props.labels[index] || params?.axisValueLabel || ''
|
||||
const rows = resolvedAgents.value
|
||||
.map((agent) => ({
|
||||
name: agent.name,
|
||||
color: agent.resolvedColor,
|
||||
value: Number(props.series[agent.key]?.[index] || 0)
|
||||
}))
|
||||
.filter((item) => item.value > 0)
|
||||
const details = rows.map((item) => (
|
||||
`<span style="display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px;background:${item.color};"></span>${item.name}:${item.value}%`
|
||||
))
|
||||
return [label, ...details].join('<br/>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-agent-ratio-bar {
|
||||
height: 292px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agent-ratio-legend {
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 14px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.agent-ratio-legend span {
|
||||
max-width: 132px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-ratio-legend i {
|
||||
flex: 0 0 auto;
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -364,8 +364,9 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
|
||||
import { useTopBarNotificationStates } from '../../composables/useTopBarNotificationStates.js'
|
||||
import { useTopBarWorkbenchPopovers } from '../../composables/useTopBarWorkbenchPopovers.js'
|
||||
import { createCurrentYearDateRange, formatDateValue } from '../../utils/dateRangeDefaults.js'
|
||||
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
|
||||
import { createCurrentYearDateRange, formatDateValue } from '../../utils/dateRangeDefaults.js'
|
||||
import { resolveDocumentNotificationId } from '../../utils/documentCenterNewState.js'
|
||||
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
|
||||
|
||||
const props = defineProps({
|
||||
currentView: { type: Object, required: true },
|
||||
@@ -520,7 +521,7 @@ function resolveWorkbenchNotificationId(item, index) {
|
||||
const documentNotificationItems = computed(() =>
|
||||
documentInboxNotificationRows.value
|
||||
.map((row) => {
|
||||
const id = normalizeNotificationId(`document:${row.documentKey || row.claimId || row.documentNo}`)
|
||||
const id = normalizeNotificationId(resolveDocumentNotificationId(row))
|
||||
if (!id || isNotificationHidden(id)) {
|
||||
return null
|
||||
}
|
||||
|
||||
57
web/src/components/shared/DocumentDropdownFilter.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="picker-filter document-filter" :class="[rootClass, { open: isOpen }]">
|
||||
<button
|
||||
class="picker-trigger filter-btn"
|
||||
:class="triggerClass"
|
||||
type="button"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="listbox"
|
||||
@click="emit('toggle', id)"
|
||||
>
|
||||
<i v-if="icon" :class="icon"></i>
|
||||
<span class="picker-label">{{ label }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="picker-popover document-filter-menu"
|
||||
:class="menuClass"
|
||||
role="listbox"
|
||||
:aria-label="title || label"
|
||||
>
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value || `all-${id}`"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="selectedValue === option.value"
|
||||
:class="{ active: selectedValue === option.value }"
|
||||
@click="emit('select', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, required: true },
|
||||
title: { type: String, default: '' },
|
||||
activeFilterKey: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
options: { type: Array, default: () => [] },
|
||||
selectedValue: { type: [String, Number, Boolean], default: '' },
|
||||
rootClass: { type: [String, Array, Object], default: '' },
|
||||
triggerClass: { type: [String, Array, Object], default: '' },
|
||||
menuClass: { type: [String, Array, Object], default: '' },
|
||||
icon: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle', 'select'])
|
||||
const isOpen = computed(() => props.activeFilterKey === props.id)
|
||||
</script>
|
||||
|
||||
<style scoped src="../../assets/styles/components/document-list-shared.css"></style>
|
||||
@@ -70,7 +70,7 @@ const props = defineProps({
|
||||
severityLabel: { type: String, default: '中风险' }
|
||||
})
|
||||
|
||||
const FONT = "Helvetica, Arial, sans-serif"
|
||||
const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC', 'Segoe UI', Arial, sans-serif"
|
||||
const TEXT = '#0d0d0d'
|
||||
const MUTED = '#6e6e80'
|
||||
const NEUTRAL_LINE = '#cbd5e1'
|
||||
|
||||
@@ -39,13 +39,15 @@ const iconStyle = computed(() => iconMeta.value.style)
|
||||
|
||||
.workbench-list-icon__halo {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
inset: 6px auto 6px -2px;
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--icon-color, var(--theme-primary)) 78%, #ffffff);
|
||||
opacity: 0.72;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 78%, #ffffff),
|
||||
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 28%, #ffffff)
|
||||
);
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.workbench-list-icon__panel {
|
||||
@@ -57,18 +59,20 @@ const iconStyle = computed(() => iconMeta.value.style)
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--icon-color, var(--theme-primary)) 22%, var(--line, #e2e8f0));
|
||||
border: 1px solid color-mix(in srgb, var(--icon-color, var(--theme-primary)) 24%, var(--line, #e2e8f0));
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.44)),
|
||||
radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0) 48%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.52)),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--icon-accent, var(--theme-primary-soft)) 64%, #fff) 0%,
|
||||
#fff 52%,
|
||||
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 8%, var(--surface-soft, #f8fafc)) 100%
|
||||
color-mix(in srgb, var(--icon-accent, var(--theme-primary-soft)) 72%, #fff) 0%,
|
||||
#fff 46%,
|
||||
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 12%, var(--surface-soft, #f8fafc)) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||
0 1px 2px rgba(15, 23, 42, 0.045);
|
||||
inset 0 -1px 0 color-mix(in srgb, var(--icon-color, var(--theme-primary)) 8%, rgba(255, 255, 255, 0.9)),
|
||||
0 8px 18px rgba(15, 23, 42, 0.055);
|
||||
}
|
||||
|
||||
.workbench-list-icon__shine {
|
||||
@@ -95,10 +99,37 @@ const iconStyle = computed(() => iconMeta.value.style)
|
||||
}
|
||||
|
||||
.workbench-list-icon--outline .workbench-list-icon__art :deep(.workbench-heroicon) {
|
||||
stroke-width: 1.65;
|
||||
stroke-width: 1.55;
|
||||
}
|
||||
|
||||
.workbench-list-icon__art :deep(.icon-fill) {
|
||||
fill: currentColor;
|
||||
stroke: none;
|
||||
opacity: 0.09;
|
||||
}
|
||||
|
||||
.workbench-list-icon__art :deep(.icon-accent) {
|
||||
opacity: 0.36;
|
||||
}
|
||||
|
||||
.workbench-list-icon__art :deep(.icon-muted) {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.workbench-list-icon--solid .workbench-list-icon__art :deep(.workbench-heroicon path) {
|
||||
opacity: 0.96;
|
||||
}
|
||||
|
||||
.workbench-list-icon__art :deep(.workbench-image-icon) {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
max-width: none;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
filter: drop-shadow(0 4px 8px rgba(15, 23, 42, 0.15));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -148,104 +148,111 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="message.role === 'assistant' && message.applicationPreview"
|
||||
class="application-preview-table"
|
||||
role="table"
|
||||
aria-label="申请信息核对表"
|
||||
>
|
||||
<div class="application-preview-row head" role="row">
|
||||
<span role="columnheader">字段</span>
|
||||
<span role="columnheader">内容</span>
|
||||
</div>
|
||||
<Transition name="structured-card-reveal" appear>
|
||||
<div
|
||||
v-for="row in ui.resolveApplicationPreviewRows(message)"
|
||||
:key="`${message.id}-${row.key}`"
|
||||
class="application-preview-row"
|
||||
:class="{
|
||||
missing: row.missing,
|
||||
editable: row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy,
|
||||
highlight: row.highlight
|
||||
}"
|
||||
role="row"
|
||||
:tabindex="row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy ? 0 : -1"
|
||||
:aria-label="row.editable ? `编辑${row.label}` : row.label"
|
||||
@click.stop="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
@keydown.enter.prevent="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
@keydown.space.prevent="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
v-if="message.role === 'assistant' && message.applicationPreview"
|
||||
class="application-preview-shell"
|
||||
aria-label="申请信息核对结果"
|
||||
>
|
||||
<span class="application-preview-label" role="cell">{{ row.label }}</span>
|
||||
<span class="application-preview-value" role="cell">
|
||||
<input
|
||||
v-if="ui.isApplicationPreviewEditing(message, row.key) && ui.resolveApplicationPreviewEditorControl(row.key) === 'text'"
|
||||
v-model="ui.applicationPreviewEditor.draftValue"
|
||||
class="application-preview-input"
|
||||
type="text"
|
||||
autofocus
|
||||
@click.stop
|
||||
@keydown.stop="ui.handleApplicationPreviewEditorKeydown($event, message)"
|
||||
@blur="ui.commitApplicationPreviewEditor(message)"
|
||||
/>
|
||||
<EnterpriseSelect
|
||||
v-else-if="ui.isApplicationPreviewEditing(message, row.key) && ui.resolveApplicationPreviewEditorControl(row.key) === 'select'"
|
||||
v-model="ui.applicationPreviewEditor.draftValue"
|
||||
class="application-preview-select"
|
||||
:options="ui.resolveApplicationPreviewEditorOptions(row.key)"
|
||||
clearable
|
||||
:teleported="false"
|
||||
autofocus
|
||||
@click.stop
|
||||
@change="ui.commitApplicationPreviewEditor(message)"
|
||||
@keydown.stop="ui.handleApplicationPreviewEditorKeydown($event, message)"
|
||||
@blur="ui.commitApplicationPreviewEditor(message)"
|
||||
/>
|
||||
<template v-else>
|
||||
<span
|
||||
class="application-preview-text"
|
||||
:class="{ 'application-preview-date-chip': row.key === 'time' && !row.missing }"
|
||||
>{{ row.value }}</span>
|
||||
<button
|
||||
v-if="row.editable"
|
||||
type="button"
|
||||
class="application-preview-edit-btn"
|
||||
title="修改内容"
|
||||
aria-label="修改内容"
|
||||
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
|
||||
@click.stop="ui.openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
>
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
</button>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="message.role === 'assistant' && message.applicationPreview && ui.resolveApplicationPreviewMissingFields(message)?.length"
|
||||
class="application-preview-footer application-preview-footer-missing"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="application-preview-missing-prefix">当前还需要补充:</span>
|
||||
<span class="application-preview-missing-list">
|
||||
<template
|
||||
v-for="(field, index) in ui.resolveApplicationPreviewMissingFields(message)"
|
||||
:key="`${message.id}-missing-${field}`"
|
||||
<div
|
||||
class="application-preview-table"
|
||||
role="table"
|
||||
aria-label="申请信息核对表"
|
||||
>
|
||||
<span class="application-preview-missing-chip">{{ field }}</span>
|
||||
<span
|
||||
v-if="index < ui.resolveApplicationPreviewMissingFields(message).length - 1"
|
||||
class="application-preview-missing-separator"
|
||||
>、</span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="application-preview-missing-suffix">。补齐后我再帮您提交申请。</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="message.role === 'assistant' && message.applicationPreview && ui.buildApplicationPreviewFooterText(message)"
|
||||
class="application-preview-footer message-answer-content message-answer-markdown"
|
||||
v-html="ui.renderMarkdown(ui.buildApplicationPreviewFooterText(message))"
|
||||
@click="ui.handleAssistantMarkdownClick($event, message)"
|
||||
></div>
|
||||
<div class="application-preview-row head" role="row">
|
||||
<span role="columnheader">字段</span>
|
||||
<span role="columnheader">内容</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in ui.resolveApplicationPreviewRows(message)"
|
||||
:key="`${message.id}-${row.key}`"
|
||||
class="application-preview-row"
|
||||
:class="{
|
||||
missing: row.missing,
|
||||
editable: row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy,
|
||||
highlight: row.highlight
|
||||
}"
|
||||
role="row"
|
||||
:tabindex="row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy ? 0 : -1"
|
||||
:aria-label="row.editable ? `编辑${row.label}` : row.label"
|
||||
@click.stop="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
@keydown.enter.prevent="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
@keydown.space.prevent="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
>
|
||||
<span class="application-preview-label" role="cell">{{ row.label }}</span>
|
||||
<span class="application-preview-value" role="cell">
|
||||
<input
|
||||
v-if="ui.isApplicationPreviewEditing(message, row.key) && ui.resolveApplicationPreviewEditorControl(row.key) === 'text'"
|
||||
v-model="ui.applicationPreviewEditor.draftValue"
|
||||
class="application-preview-input"
|
||||
type="text"
|
||||
autofocus
|
||||
@click.stop
|
||||
@keydown.stop="ui.handleApplicationPreviewEditorKeydown($event, message)"
|
||||
@blur="ui.commitApplicationPreviewEditor(message)"
|
||||
/>
|
||||
<EnterpriseSelect
|
||||
v-else-if="ui.isApplicationPreviewEditing(message, row.key) && ui.resolveApplicationPreviewEditorControl(row.key) === 'select'"
|
||||
v-model="ui.applicationPreviewEditor.draftValue"
|
||||
class="application-preview-select"
|
||||
:options="ui.resolveApplicationPreviewEditorOptions(row.key)"
|
||||
clearable
|
||||
:teleported="false"
|
||||
autofocus
|
||||
@click.stop
|
||||
@change="ui.commitApplicationPreviewEditor(message)"
|
||||
@keydown.stop="ui.handleApplicationPreviewEditorKeydown($event, message)"
|
||||
@blur="ui.commitApplicationPreviewEditor(message)"
|
||||
/>
|
||||
<template v-else>
|
||||
<span
|
||||
class="application-preview-text"
|
||||
:class="{ 'application-preview-date-chip': row.key === 'time' && !row.missing }"
|
||||
>{{ row.value }}</span>
|
||||
<button
|
||||
v-if="row.editable"
|
||||
type="button"
|
||||
class="application-preview-edit-btn"
|
||||
title="修改内容"
|
||||
aria-label="修改内容"
|
||||
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
|
||||
@click.stop="ui.openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
>
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
</button>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="ui.resolveApplicationPreviewMissingFields(message)?.length"
|
||||
class="application-preview-footer application-preview-footer-missing"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="application-preview-missing-prefix">当前还需要补充:</span>
|
||||
<span class="application-preview-missing-list">
|
||||
<template
|
||||
v-for="(field, index) in ui.resolveApplicationPreviewMissingFields(message)"
|
||||
:key="`${message.id}-missing-${field}`"
|
||||
>
|
||||
<span class="application-preview-missing-chip">{{ field }}</span>
|
||||
<span
|
||||
v-if="index < ui.resolveApplicationPreviewMissingFields(message).length - 1"
|
||||
class="application-preview-missing-separator"
|
||||
>、</span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="application-preview-missing-suffix">。补齐后我再帮您提交申请。</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="ui.buildApplicationPreviewFooterText(message)"
|
||||
class="application-preview-footer message-answer-content message-answer-markdown"
|
||||
v-html="ui.renderMarkdown(ui.buildApplicationPreviewFooterText(message))"
|
||||
@click="ui.handleAssistantMarkdownClick($event, message)"
|
||||
></div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
v-if="message.role === 'assistant' && message.welcomeQuickActions?.length"
|
||||
@@ -267,32 +274,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
|
||||
class="message-suggested-actions"
|
||||
>
|
||||
<button
|
||||
v-for="action in message.suggestedActions"
|
||||
:key="`${message.id}-${action.action_type}-${action.label}`"
|
||||
type="button"
|
||||
class="message-suggested-action-btn"
|
||||
:class="{
|
||||
selected: ui.isSuggestedActionSelected(message, action),
|
||||
locked: message.suggestedActionsLocked
|
||||
}"
|
||||
:disabled="message.suggestedActionsLocked || ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
|
||||
@click="ui.handleSuggestedAction(message, action)"
|
||||
<Transition name="structured-card-reveal" appear>
|
||||
<div
|
||||
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
|
||||
class="message-suggested-actions"
|
||||
>
|
||||
<span class="message-suggested-action-icon" aria-hidden="true">
|
||||
<i :class="action.icon || 'mdi mdi-shape-outline'"></i>
|
||||
</span>
|
||||
<span class="message-suggested-action-copy">
|
||||
<span class="message-suggested-action-title">{{ action.label }}</span>
|
||||
<small v-if="action.description">{{ action.description }}</small>
|
||||
</span>
|
||||
<i class="message-suggested-action-arrow mdi mdi-arrow-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-for="action in message.suggestedActions"
|
||||
:key="`${message.id}-${action.action_type}-${action.label}`"
|
||||
type="button"
|
||||
class="message-suggested-action-btn"
|
||||
:class="{
|
||||
selected: ui.isSuggestedActionSelected(message, action),
|
||||
locked: message.suggestedActionsLocked
|
||||
}"
|
||||
:disabled="message.suggestedActionsLocked || ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
|
||||
@click="ui.handleSuggestedAction(message, action)"
|
||||
>
|
||||
<span class="message-suggested-action-icon" aria-hidden="true">
|
||||
<i :class="action.icon || 'mdi mdi-shape-outline'"></i>
|
||||
</span>
|
||||
<span class="message-suggested-action-copy">
|
||||
<span class="message-suggested-action-title">{{ action.label }}</span>
|
||||
<small v-if="action.description">{{ action.description }}</small>
|
||||
</span>
|
||||
<i class="message-suggested-action-arrow mdi mdi-arrow-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
|
||||
<strong>风险标签</strong>
|
||||
@@ -481,20 +490,26 @@
|
||||
</footer>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="reimbursement-draft-card" role="group" aria-label="报销草稿已生成">
|
||||
<div
|
||||
class="reimbursement-draft-card"
|
||||
role="group"
|
||||
:aria-label="ui.canOpenDraftDetail(message) ? '报销草稿已生成' : '报销草稿待保存'"
|
||||
>
|
||||
<span class="reimbursement-draft-icon" aria-hidden="true">
|
||||
<i class="mdi mdi-file-document-edit-outline"></i>
|
||||
</span>
|
||||
<div class="reimbursement-draft-main">
|
||||
<strong>报销草稿已生成</strong>
|
||||
<strong>{{ ui.canOpenDraftDetail(message) ? '报销草稿已生成' : '报销草稿待保存' }}</strong>
|
||||
<p>
|
||||
单号:<span>{{ ui.resolveReimbursementDraftClaimNo(message.draftPayload) }}</span>
|
||||
<button
|
||||
v-if="ui.canOpenDraftDetail(message)"
|
||||
type="button"
|
||||
class="reimbursement-draft-link"
|
||||
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
|
||||
@click="ui.openApplicationDraftDetail(message)"
|
||||
>查看详情</button>
|
||||
<span v-else class="reimbursement-draft-pending-detail">保存后可查看详情</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -580,21 +595,53 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="ui.isOperationFeedbackVisible(message)"
|
||||
class="message-feedback-bubble"
|
||||
v-if="ui.shouldShowAssistantMessageActions(message)"
|
||||
class="message-action-toolbar"
|
||||
role="toolbar"
|
||||
aria-label="系统消息操作"
|
||||
>
|
||||
<OperationFeedbackInlineCard
|
||||
:busy="Boolean(message.operationFeedback?.submitting)"
|
||||
:error-message="message.operationFeedback?.error || ''"
|
||||
:submitted="Boolean(message.operationFeedback?.submitted)"
|
||||
:submitted-rating="Number(message.operationFeedback?.rating || 0)"
|
||||
:reset-key="`${message.id}-${message.operationFeedback?.context?.runId || message.operationFeedback?.context?.run_id || ''}`"
|
||||
@dismiss="ui.dismissOperationFeedbackForMessage(message)"
|
||||
@submit="ui.submitOperationFeedbackForMessage(message, $event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-btn"
|
||||
title="复制"
|
||||
aria-label="复制"
|
||||
@click="ui.copyAssistantMessage(message)"
|
||||
>
|
||||
<i class="mdi mdi-content-copy"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-btn"
|
||||
title="语音播报"
|
||||
aria-label="语音播报"
|
||||
@click="ui.speakAssistantMessage(message)"
|
||||
>
|
||||
<i class="mdi mdi-volume-high"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-btn"
|
||||
:class="{ active: ui.isMessageFeedbackSelected(message, 5) }"
|
||||
:disabled="Boolean(message.operationFeedback?.submitting)"
|
||||
title="点赞"
|
||||
aria-label="点赞"
|
||||
@click="ui.submitOperationFeedbackForMessage(message, { rating: 5, reason: 'thumbs_up' })"
|
||||
>
|
||||
<i :class="ui.isMessageFeedbackSelected(message, 5) ? 'mdi mdi-thumb-up' : 'mdi mdi-thumb-up-outline'"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-btn"
|
||||
:class="{ active: ui.isMessageFeedbackSelected(message, 1) }"
|
||||
:disabled="Boolean(message.operationFeedback?.submitting)"
|
||||
title="点踩"
|
||||
aria-label="点踩"
|
||||
@click="ui.submitOperationFeedbackForMessage(message, { rating: 1, reason: 'thumbs_down' })"
|
||||
>
|
||||
<i :class="ui.isMessageFeedbackSelected(message, 1) ? 'mdi mdi-thumb-down' : 'mdi mdi-thumb-down-outline'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -602,14 +649,12 @@
|
||||
<script>
|
||||
import BudgetAssistantReport from './BudgetAssistantReport.vue'
|
||||
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
|
||||
import OperationFeedbackInlineCard from '../shared/OperationFeedbackInlineCard.vue'
|
||||
|
||||
export default {
|
||||
name: 'TravelReimbursementMessageItem',
|
||||
components: {
|
||||
BudgetAssistantReport,
|
||||
EnterpriseSelect,
|
||||
OperationFeedbackInlineCard
|
||||
EnterpriseSelect
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { fetchApprovalExpenseClaims } from '../services/reimbursements.js'
|
||||
import {
|
||||
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
|
||||
extractExpenseClaimItems,
|
||||
fetchApprovalExpenseClaims
|
||||
} from '../services/reimbursements.js'
|
||||
import { canAccessAppView } from '../utils/accessControl.js'
|
||||
import { resolvePendingClaimIds } from '../utils/approvalInbox.js'
|
||||
import { useSystemState } from './useSystemState.js'
|
||||
@@ -109,8 +113,8 @@ export function useApprovalInbox() {
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await fetchApprovalExpenseClaims()
|
||||
syncPendingClaimIds(resolvePendingClaimIds(payload, user))
|
||||
const payload = await fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
syncPendingClaimIds(resolvePendingClaimIds(extractExpenseClaimItems(payload), user))
|
||||
} catch {
|
||||
pendingClaimIds.value = []
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims, fetchExpenseClaims } from '../services/reimbursements.js'
|
||||
import {
|
||||
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
|
||||
extractExpenseClaimItems,
|
||||
fetchApprovalExpenseClaims,
|
||||
fetchArchivedExpenseClaims,
|
||||
fetchExpenseClaims
|
||||
} from '../services/reimbursements.js'
|
||||
import { fetchNotificationStates } from '../services/notificationStates.js'
|
||||
import {
|
||||
DOCUMENT_VIEWED_KEYS_CHANGE_EVENT,
|
||||
countNewDocuments,
|
||||
isNewDocument,
|
||||
markDocumentViewed,
|
||||
markDocumentsViewed,
|
||||
mergeNotificationStatesIntoViewedDocumentKeys,
|
||||
readViewedDocumentKeys,
|
||||
resolveDocumentNewKey
|
||||
} from '../utils/documentCenterNewState.js'
|
||||
@@ -140,6 +148,17 @@ function refreshViewedDocumentKeys() {
|
||||
viewedDocumentKeys.value = readViewedDocumentKeys()
|
||||
}
|
||||
|
||||
async function refreshRemoteViewedDocumentKeys() {
|
||||
try {
|
||||
viewedDocumentKeys.value = mergeNotificationStatesIntoViewedDocumentKeys(
|
||||
await fetchNotificationStates(),
|
||||
readViewedDocumentKeys()
|
||||
)
|
||||
} catch {
|
||||
refreshViewedDocumentKeys()
|
||||
}
|
||||
}
|
||||
|
||||
function attachViewedKeysListener() {
|
||||
if (typeof window === 'undefined' || viewedKeysListenerAttached) {
|
||||
return
|
||||
@@ -150,8 +169,8 @@ function attachViewedKeysListener() {
|
||||
}
|
||||
|
||||
async function readClaimList(fetcher) {
|
||||
const result = await fetcher()
|
||||
return Array.isArray(result) ? result : []
|
||||
const result = await fetcher(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
return extractExpenseClaimItems(result)
|
||||
}
|
||||
|
||||
export function useDocumentCenterInbox() {
|
||||
@@ -206,8 +225,8 @@ export function useDocumentCenterInbox() {
|
||||
approvalClaims: approvalResult.status === 'fulfilled' ? approvalResult.value : [],
|
||||
archivedClaims: archiveResult.status === 'fulfilled' ? archiveResult.value : []
|
||||
})
|
||||
await refreshRemoteViewedDocumentKeys()
|
||||
lastRefreshAt = Date.now()
|
||||
refreshViewedDocumentKeys()
|
||||
|
||||
return documentRows.value
|
||||
})()
|
||||
|
||||
@@ -29,7 +29,7 @@ export const navItems = [
|
||||
id: 'documents',
|
||||
label: '单据中心',
|
||||
navHint: '统一查看申请、报销、审批与归档',
|
||||
icon: icons.file,
|
||||
icon: icons.documentCenter,
|
||||
title: '单据中心',
|
||||
desc: '统一查看申请、报销、审批与归档。'
|
||||
},
|
||||
@@ -37,7 +37,7 @@ export const navItems = [
|
||||
id: 'receiptFolder',
|
||||
label: '票据夹',
|
||||
navHint: '存放已上传并识别的原始票据',
|
||||
icon: icons.receipt,
|
||||
icon: icons.receiptFolder,
|
||||
title: '票据夹',
|
||||
desc: '集中查看未关联和已关联票据,避免 OCR 后票据丢失。'
|
||||
},
|
||||
|
||||
@@ -54,6 +54,8 @@ const DOCUMENT_BACKED_EXPENSE_TYPES = new Set([
|
||||
const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||
const RELATED_APPLICATION_STEP_LABEL = '关联单据'
|
||||
const APPLICATION_LINK_STATUS_STEP_LABEL = '关联单据状态'
|
||||
const APPLICATION_ARCHIVE_STAGE_LABEL = '申请归档'
|
||||
const ARCHIVED_STEP_LABEL = '已归档'
|
||||
|
||||
const REIMBURSEMENT_PROGRESS_LABELS = [
|
||||
@@ -70,13 +72,17 @@ const APPLICATION_PROGRESS_LABELS = [
|
||||
'创建申请',
|
||||
'直属领导审批',
|
||||
'预算管理者审批',
|
||||
'审批完成'
|
||||
'审批完成',
|
||||
APPLICATION_LINK_STATUS_STEP_LABEL,
|
||||
ARCHIVED_STEP_LABEL
|
||||
]
|
||||
|
||||
const APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET = [
|
||||
'创建申请',
|
||||
'直属领导审批',
|
||||
'审批完成'
|
||||
'审批完成',
|
||||
APPLICATION_LINK_STATUS_STEP_LABEL,
|
||||
ARCHIVED_STEP_LABEL
|
||||
]
|
||||
|
||||
function parseNumber(value) {
|
||||
@@ -425,6 +431,17 @@ function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false)
|
||||
const rawNode = String(claim?.approval_stage || '').trim()
|
||||
|
||||
if (rawNode) {
|
||||
if (
|
||||
isApplicationDocument
|
||||
&& approvalMeta.key === 'completed'
|
||||
&& (
|
||||
rawNode === '审批完成'
|
||||
|| rawNode.includes('审批完成')
|
||||
|| rawNode.includes('申请完成')
|
||||
)
|
||||
) {
|
||||
return APPLICATION_LINK_STATUS_STEP_LABEL
|
||||
}
|
||||
if (rawNode === '审批流转' || rawNode.includes('AI预审') || rawNode.includes('AI验审')) {
|
||||
return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? '待提交' : '直属领导审批'
|
||||
}
|
||||
@@ -444,7 +461,7 @@ function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false)
|
||||
|
||||
if (approvalMeta.key === 'completed') {
|
||||
const normalizedStatus = String(claim?.status || '').trim().toLowerCase()
|
||||
return isApplicationDocument ? '审批完成' : normalizedStatus === 'paid' ? '已付款' : '归档入账'
|
||||
return isApplicationDocument ? APPLICATION_LINK_STATUS_STEP_LABEL : normalizedStatus === 'paid' ? '已付款' : '归档入账'
|
||||
}
|
||||
|
||||
return '直属领导审批'
|
||||
@@ -578,9 +595,15 @@ function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
|
||||
const normalizedNode = String(workflowNode || '').trim()
|
||||
|
||||
if (approvalMeta.key === 'completed') {
|
||||
return 3
|
||||
return normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) ? 4 : 3
|
||||
}
|
||||
|
||||
if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) {
|
||||
return 4
|
||||
}
|
||||
if (normalizedNode.includes(APPLICATION_LINK_STATUS_STEP_LABEL)) {
|
||||
return 3
|
||||
}
|
||||
if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) {
|
||||
return 3
|
||||
}
|
||||
@@ -602,6 +625,44 @@ function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
|
||||
return 1
|
||||
}
|
||||
|
||||
function isApplicationArchivedWorkflow(claim, workflowNode) {
|
||||
const normalizedNode = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode)
|
||||
if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) {
|
||||
return true
|
||||
}
|
||||
return getRiskFlags(claim).some((flag) => (
|
||||
flag
|
||||
&& typeof flag === 'object'
|
||||
&& normalizeText(flag.source) === 'application_archive_sync'
|
||||
))
|
||||
}
|
||||
|
||||
function resolveApplicationLinkedReimbursementNo(claim) {
|
||||
for (const flag of [...getRiskFlags(claim)].reverse()) {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
continue
|
||||
}
|
||||
const generatedNo = normalizeText(
|
||||
flag.generated_draft_claim_no
|
||||
|| flag.generatedDraftClaimNo
|
||||
|| flag.reimbursement_claim_no
|
||||
|| flag.reimbursementClaimNo
|
||||
)
|
||||
if (generatedNo) {
|
||||
return generatedNo
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildApplicationLinkStatusStepMeta(claim) {
|
||||
const reimbursementNo = resolveApplicationLinkedReimbursementNo(claim)
|
||||
const updatedAt = formatDateTime(claim?.updated_at)
|
||||
return reimbursementNo
|
||||
? buildProgressStepMeta(`关联中 ${reimbursementNo}`, updatedAt)
|
||||
: buildProgressStepMeta('未关联', updatedAt)
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
@@ -1069,6 +1130,10 @@ function buildCompletedStepMeta(claim, label) {
|
||||
return buildProgressStepMeta('待核对关联单据', createdAt)
|
||||
}
|
||||
|
||||
if (stepLabel === APPLICATION_LINK_STATUS_STEP_LABEL) {
|
||||
return buildApplicationLinkStatusStepMeta(claim)
|
||||
}
|
||||
|
||||
if (stepLabel === '创建单据' || stepLabel === '创建申请') {
|
||||
const createdAt = formatDateTime(claim?.created_at)
|
||||
return buildProgressStepMeta(stepLabel === '创建申请' ? `${employeeName}发起申请` : `${employeeName}创建`, createdAt)
|
||||
@@ -1201,24 +1266,32 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
|
||||
&& !hasMergedApplicationBudgetApproval
|
||||
&& applicationRequiresBudgetReviewStep(claim, workflowNode)
|
||||
)
|
||||
const isApplicationDocument = documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
const applicationArchived = isApplicationDocument && isApplicationArchivedWorkflow(claim, workflowNode)
|
||||
const progressLabels =
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
isApplicationDocument
|
||||
? hasApplicationReturnStep
|
||||
? ['创建申请', '直属领导审批', '退回', '待提交']
|
||||
: hasMergedApplicationBudgetApproval
|
||||
? ['创建申请', '直属领导审批', '审批完成']
|
||||
? ['创建申请', '直属领导审批', '审批完成', APPLICATION_LINK_STATUS_STEP_LABEL, ARCHIVED_STEP_LABEL]
|
||||
: shouldShowApplicationBudgetStep
|
||||
? APPLICATION_PROGRESS_LABELS
|
||||
: APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET
|
||||
: REIMBURSEMENT_PROGRESS_LABELS
|
||||
const applicationLinkIndex = progressLabels.indexOf(APPLICATION_LINK_STATUS_STEP_LABEL)
|
||||
const applicationArchiveIndex = progressLabels.indexOf(ARCHIVED_STEP_LABEL)
|
||||
const currentIndex =
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
isApplicationDocument
|
||||
? hasApplicationReturnStep
|
||||
? 3
|
||||
: Math.min(
|
||||
resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode),
|
||||
Math.max(0, progressLabels.length - 1)
|
||||
)
|
||||
: applicationArchived && applicationArchiveIndex >= 0
|
||||
? applicationArchiveIndex
|
||||
: approvalMeta.key === 'completed' && applicationLinkIndex >= 0
|
||||
? applicationLinkIndex
|
||||
: Math.min(
|
||||
resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode),
|
||||
Math.max(0, progressLabels.length - 1)
|
||||
)
|
||||
: resolveProgressCurrentIndex(approvalMeta, workflowNode)
|
||||
const currentTime =
|
||||
approvalMeta.key === 'completed'
|
||||
@@ -1233,7 +1306,7 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
|
||||
|
||||
return progressLabels.map((label, index) => {
|
||||
const displayLabel = resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta)
|
||||
if (approvalMeta.key === 'completed') {
|
||||
if (approvalMeta.key === 'completed' && (!isApplicationDocument || applicationArchived)) {
|
||||
const stepMeta = buildCompletedStepMeta(claim, label)
|
||||
return {
|
||||
index: index + 1,
|
||||
@@ -1264,6 +1337,20 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
|
||||
}
|
||||
|
||||
if (index === currentIndex) {
|
||||
if (isApplicationDocument && label === APPLICATION_LINK_STATUS_STEP_LABEL) {
|
||||
const stepMeta = buildApplicationLinkStatusStepMeta(claim)
|
||||
return {
|
||||
index: index + 1,
|
||||
label: displayLabel,
|
||||
rawLabel: label,
|
||||
time: stepMeta.time,
|
||||
detail: stepMeta.detail,
|
||||
title: stepMeta.title,
|
||||
done: false,
|
||||
active: true,
|
||||
current: true
|
||||
}
|
||||
}
|
||||
const stayDuration = formatDurationFrom(resolveCurrentStepStartedAt(claim, label))
|
||||
return {
|
||||
index: index + 1,
|
||||
@@ -1385,6 +1472,11 @@ export function mapExpenseClaimToRequest(claim) {
|
||||
const isApplicationDocument = documentTypeMeta.documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
const approvalMeta = resolveApprovalMeta(claim?.status)
|
||||
const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument)
|
||||
const applicationArchived = isApplicationDocument && isApplicationArchivedWorkflow(claim, workflowNode)
|
||||
const applicationLinkedReimbursementNo = isApplicationDocument ? resolveApplicationLinkedReimbursementNo(claim) : ''
|
||||
const applicationLinkStatusText = applicationLinkedReimbursementNo
|
||||
? `关联中 ${applicationLinkedReimbursementNo}`
|
||||
: '未关联'
|
||||
const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count))
|
||||
const riskMeta = buildRiskMeta(claim?.risk_flags_json)
|
||||
const riskSummary = riskMeta.summary
|
||||
@@ -1453,10 +1545,18 @@ export function mapExpenseClaimToRequest(claim) {
|
||||
secondaryStatusValue: isApplicationDocument
|
||||
? approvalMeta.key === 'supplement'
|
||||
? '领导已退回,待重新提交'
|
||||
: '已进入审批流程'
|
||||
: applicationArchived
|
||||
? '已归档'
|
||||
: approvalMeta.key === 'completed'
|
||||
? applicationLinkStatusText
|
||||
: '已进入审批流程'
|
||||
: (invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据'),
|
||||
secondaryStatusTone: isApplicationDocument
|
||||
? approvalMeta.key === 'supplement' ? 'warning' : 'success'
|
||||
? approvalMeta.key === 'supplement'
|
||||
? 'warning'
|
||||
: approvalMeta.key === 'completed' && !applicationArchived && !applicationLinkedReimbursementNo
|
||||
? 'warning'
|
||||
: 'success'
|
||||
: (invoiceCount > 0 ? 'success' : 'warning'),
|
||||
riskSummary,
|
||||
attachmentSummary: isApplicationDocument ? '申请单' : (invoiceCount > 0 ? `${invoiceCount} 张票据` : '无'),
|
||||
|
||||
@@ -9,11 +9,13 @@ export const icons = {
|
||||
budget: iconPath('<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>'),
|
||||
archive: iconPath('<path d="M3 7h18v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7l2-3h14l2 3"/><path d="M10 12h4"/>'),
|
||||
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
|
||||
documentCenter: iconPath('<path d="M8 4H6a2 2 0 0 0-2 2v16h16V6a2 2 0 0 0-2-2h-2"/><path d="M9 2h6v5H9z"/><path d="M8 12h.01"/><path d="M11 12h6"/><path d="M8 16h.01"/><path d="M11 16h6"/>'),
|
||||
receipt: iconPath('<path d="M5 3v18l2-1 2 1 2-1 2 1 2-1 2 1 2-1V3z"/><path d="M8 8h8"/><path d="M8 12h8"/><path d="M8 16h5"/>'),
|
||||
receiptFolder: iconPath('<path d="M3 7h6l2 2h10v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7V5a2 2 0 0 1 2-2h4l2 2h4"/><path d="M8 13h8"/><path d="M8 16h6"/><path d="M8 19h4"/>'),
|
||||
book: iconPath('<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>'),
|
||||
library: iconPath('<path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/><path d="M2 20h20"/>'),
|
||||
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),
|
||||
digitalEmployee: iconPath('<text x="12" y="12.2" text-anchor="middle" dominant-baseline="middle" font-size="16" font-weight="800" fill="currentColor" stroke="none" font-family="system-ui, -apple-system, Segoe UI, Arial, sans-serif" letter-spacing="-0.45">AI</text>'),
|
||||
digitalEmployee: iconPath('<text x="12" y="12.2" text-anchor="middle" dominant-baseline="middle" font-size="16" font-weight="800" fill="currentColor" stroke="none" font-family="-apple-system, BlinkMacSystemFont, SF Pro Text, PingFang SC, Segoe UI, Arial, sans-serif" letter-spacing="-0.45">AI</text>'),
|
||||
users: iconPath('<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M20 8v6"/><path d="M23 11h-6"/>'),
|
||||
audit: iconPath('<path d="M12 8v4l3 3"/><path d="M3.05 11a9 9 0 1 1 .5 4"/><path d="M3 4v7h7"/>'),
|
||||
logs: iconPath('<path d="M4 5h16"/><path d="M4 12h16"/><path d="M4 19h10"/><path d="M18 17v4"/><path d="M16 19h4"/>'),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
export const NOTIFICATION_STATE_BATCH_SIZE = 100
|
||||
|
||||
function normalizeStateItem(item) {
|
||||
const notificationId = String(item?.notification_id || item?.notificationId || '').trim()
|
||||
if (!notificationId) {
|
||||
@@ -16,6 +18,17 @@ function normalizeStateItem(item) {
|
||||
}
|
||||
}
|
||||
|
||||
export function chunkNotificationStatePatches(states = [], batchSize = NOTIFICATION_STATE_BATCH_SIZE) {
|
||||
const size = Math.max(1, Number(batchSize) || NOTIFICATION_STATE_BATCH_SIZE)
|
||||
const chunks = []
|
||||
|
||||
for (let index = 0; index < states.length; index += size) {
|
||||
chunks.push(states.slice(index, index + size))
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
export async function fetchNotificationStates() {
|
||||
const payload = await apiRequest('/notification-states')
|
||||
return Array.isArray(payload?.states) ? payload.states : []
|
||||
@@ -30,9 +43,14 @@ export async function patchNotificationStates(states = []) {
|
||||
return []
|
||||
}
|
||||
|
||||
const payload = await apiRequest('/notification-states', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ states: normalizedStates })
|
||||
})
|
||||
return Array.isArray(payload?.states) ? payload.states : []
|
||||
let latestStates = []
|
||||
for (const batch of chunkNotificationStatePatches(normalizedStates)) {
|
||||
const payload = await apiRequest('/notification-states', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ states: batch })
|
||||
})
|
||||
latestStates = Array.isArray(payload?.states) ? payload.states : latestStates
|
||||
}
|
||||
|
||||
return latestStates
|
||||
}
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
const inflightOcrRequests = new Map()
|
||||
|
||||
function buildOcrRequestKey(files = []) {
|
||||
return files
|
||||
.map((file) => [
|
||||
String(file?.name || ''),
|
||||
String(file?.size || 0),
|
||||
String(file?.lastModified || 0),
|
||||
String(file?.receiptId || '')
|
||||
].join(':'))
|
||||
.join('|')
|
||||
}
|
||||
|
||||
export function recognizeOcrFiles(files, options = {}) {
|
||||
const requestKey = buildOcrRequestKey(files)
|
||||
if (requestKey && inflightOcrRequests.has(requestKey)) {
|
||||
return inflightOcrRequests.get(requestKey)
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
for (const file of files) {
|
||||
formData.append('files', file)
|
||||
formData.append('receipt_ids', String(file?.receiptId || ''))
|
||||
}
|
||||
|
||||
return apiRequest('/ocr/recognize', {
|
||||
const request = apiRequest('/ocr/recognize', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
contentType: null,
|
||||
...options
|
||||
})
|
||||
if (!requestKey) {
|
||||
return request
|
||||
}
|
||||
|
||||
inflightOcrRequests.set(requestKey, request)
|
||||
request.then(
|
||||
() => inflightOcrRequests.delete(requestKey),
|
||||
() => inflightOcrRequests.delete(requestKey)
|
||||
)
|
||||
return request
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
export const REIMBURSEMENT_LIST_PREVIEW_PARAMS = Object.freeze({ page: 1, pageSize: 100 })
|
||||
|
||||
function buildListQuery(params = {}) {
|
||||
const search = new URLSearchParams()
|
||||
const page = params.page
|
||||
@@ -17,6 +19,14 @@ function buildListQuery(params = {}) {
|
||||
return query ? `?${query}` : ''
|
||||
}
|
||||
|
||||
export function extractExpenseClaimItems(payload) {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
}
|
||||
|
||||
return Array.isArray(payload?.items) ? payload.items : []
|
||||
}
|
||||
|
||||
export function fetchExpenseClaims(params = {}) {
|
||||
return apiRequest(`/reimbursements/claims${buildListQuery(params)}`)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,22 @@ export function fetchStewardPlan(payload, options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchStewardSlotDecision(payload, options = {}) {
|
||||
return apiRequest('/steward/slot-decisions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchStewardRuntimeDecision(payload, options = {}) {
|
||||
return apiRequest('/steward/runtime-decisions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchStewardPlanStream(payload, handlers = {}, options = {}) {
|
||||
const {
|
||||
timeoutMs = 0,
|
||||
|
||||
@@ -12,6 +12,16 @@ export function resolveDocumentNewKey(row) {
|
||||
return id ? `${source}:${id}` : ''
|
||||
}
|
||||
|
||||
export function resolveDocumentNotificationKey(row) {
|
||||
const documentKey = String(row?.documentKey || '').trim()
|
||||
return documentKey || resolveDocumentNewKey(row)
|
||||
}
|
||||
|
||||
export function resolveDocumentNotificationId(row) {
|
||||
const key = resolveDocumentNotificationKey(row)
|
||||
return key ? `document:${key}` : ''
|
||||
}
|
||||
|
||||
export function readViewedDocumentKeys(storage = getStorage()) {
|
||||
if (!storage) {
|
||||
return new Set()
|
||||
@@ -66,6 +76,80 @@ export function countNewDocuments(rows, viewedKeys) {
|
||||
return rows.filter((row) => isNewDocument(row, viewedKeys)).length
|
||||
}
|
||||
|
||||
function resolveRemoteStateId(item) {
|
||||
return String(item?.notification_id || item?.notificationId || '').trim()
|
||||
}
|
||||
|
||||
function isRemoteStateViewed(item) {
|
||||
return Boolean(item?.read_at || item?.readAt || item?.hidden_at || item?.hiddenAt)
|
||||
}
|
||||
|
||||
export function mergeNotificationStatesIntoViewedDocumentKeys(states, viewedKeys, storage = getStorage()) {
|
||||
const nextKeys = new Set(viewedKeys)
|
||||
let changed = false
|
||||
|
||||
;(Array.isArray(states) ? states : []).forEach((item) => {
|
||||
if (!isRemoteStateViewed(item)) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = resolveRemoteStateId(item)
|
||||
if (!id.startsWith('document:')) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = id.slice('document:'.length).trim()
|
||||
if (key && !nextKeys.has(key)) {
|
||||
nextKeys.add(key)
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
|
||||
if (changed) {
|
||||
writeViewedDocumentKeys(nextKeys, storage)
|
||||
}
|
||||
|
||||
return nextKeys
|
||||
}
|
||||
|
||||
export function buildDocumentViewedStatePatch(row) {
|
||||
if (!isNewDocument(row, new Set())) {
|
||||
return null
|
||||
}
|
||||
|
||||
const notificationId = resolveDocumentNotificationId(row)
|
||||
if (!notificationId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
notification_id: notificationId,
|
||||
read: true,
|
||||
hidden: false,
|
||||
context_json: {
|
||||
kind: 'document',
|
||||
source: String(row?.source || '').trim(),
|
||||
target_type: 'documents-center'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDocumentsViewedStatePatches(rows, viewedKeys) {
|
||||
const seenIds = new Set()
|
||||
|
||||
return (Array.isArray(rows) ? rows : [])
|
||||
.filter((row) => isNewDocument(row, viewedKeys))
|
||||
.map(buildDocumentViewedStatePatch)
|
||||
.filter((patch) => {
|
||||
if (!patch?.notification_id || seenIds.has(patch.notification_id)) {
|
||||
return false
|
||||
}
|
||||
|
||||
seenIds.add(patch.notification_id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function markDocumentViewed(row, viewedKeys, storage = getStorage()) {
|
||||
const key = resolveDocumentNewKey(row)
|
||||
if (!key) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isApplicationRequestLike } from './documentClassification.js'
|
||||
|
||||
const ARCHIVED_CLAIM_STATUSES = new Set(['approved', 'completed', 'paid'])
|
||||
const APPLICATION_ARCHIVE_STAGE = '申请归档'
|
||||
|
||||
function isArchivedRequestPayload(request) {
|
||||
if (!request) {
|
||||
@@ -9,6 +10,11 @@ function isArchivedRequestPayload(request) {
|
||||
|
||||
const normalizedStatus = String(request.status || '').trim().toLowerCase()
|
||||
const stage = String(request.approval_stage || request.approvalStage || '').trim()
|
||||
const isApplicationRequest = isApplicationRequestLike(request)
|
||||
|
||||
if (isApplicationRequest) {
|
||||
return ARCHIVED_CLAIM_STATUSES.has(normalizedStatus) && stage === APPLICATION_ARCHIVE_STAGE
|
||||
}
|
||||
|
||||
if (stage === '归档入账' || stage === '已付款' || stage === 'completed') {
|
||||
return true
|
||||
@@ -18,14 +24,6 @@ function isArchivedRequestPayload(request) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
isApplicationRequestLike(request)
|
||||
&& ARCHIVED_CLAIM_STATUSES.has(normalizedStatus)
|
||||
&& ['审批完成', '申请归档'].includes(stage)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ARCHIVED_CLAIM_STATUSES.has(normalizedStatus)
|
||||
&& (stage === '' || stage === '归档入账' || stage === '已付款' || stage === 'completed')
|
||||
}
|
||||
|
||||
@@ -125,6 +125,36 @@ export function resolveApplicationDaysFromDateRange(rangeText) {
|
||||
return resolveDaysFromDateRange(rangeText)
|
||||
}
|
||||
|
||||
export function resolveApplicationDateRange(rangeText, daysText = '') {
|
||||
const matchedDates = String(rangeText || '').match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||||
const startDate = normalizeDateText(matchedDates[0] || '')
|
||||
const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
|
||||
const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
|
||||
? explicitEndDate
|
||||
: buildEndDateFromDays(startDate, daysText)
|
||||
const endDate = inferredEndDate || explicitEndDate || startDate
|
||||
const start = parseIsoDate(startDate)
|
||||
const end = parseIsoDate(endDate)
|
||||
if (!start || !end) {
|
||||
return null
|
||||
}
|
||||
const orderedStart = start.getTime() <= end.getTime() ? start : end
|
||||
const orderedEnd = start.getTime() <= end.getTime() ? end : start
|
||||
return {
|
||||
startDate: formatIsoDate(orderedStart),
|
||||
endDate: formatIsoDate(orderedEnd),
|
||||
startTime: orderedStart.getTime(),
|
||||
endTime: orderedEnd.getTime()
|
||||
}
|
||||
}
|
||||
|
||||
export function applicationDateRangesOverlap(leftRange, rightRange) {
|
||||
if (!leftRange || !rightRange) {
|
||||
return false
|
||||
}
|
||||
return leftRange.startTime <= rightRange.endTime && rightRange.startTime <= leftRange.endTime
|
||||
}
|
||||
|
||||
function resolvePreviewToday(options = {}) {
|
||||
const explicitToday = String(options.today || options.currentDate || '').trim()
|
||||
if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)
|
||||
@@ -392,7 +422,7 @@ function normalizeApplicationTypeLabel(value, fallback = '') {
|
||||
return `${label}申请`
|
||||
}
|
||||
|
||||
function normalizeTransportModeOption(value, fallback = '') {
|
||||
export function normalizeTransportModeOption(value, fallback = '') {
|
||||
const text = String(value || '').trim()
|
||||
if (/飞机|机票|航班/.test(text)) return '飞机'
|
||||
if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { isApplicationRequestLike } from './documentClassification.js'
|
||||
|
||||
const APPLICATION_ARCHIVE_STAGE = '申请归档'
|
||||
|
||||
export function isArchivedExpenseClaim(claim) {
|
||||
const stage = String(claim?.approval_stage || claim?.approvalStage || '').trim()
|
||||
const status = String(claim?.status || '').trim().toLowerCase()
|
||||
|
||||
if (isApplicationRequestLike(claim)) {
|
||||
return stage === APPLICATION_ARCHIVE_STAGE
|
||||
&& ['approved', 'completed', 'paid'].includes(status)
|
||||
}
|
||||
|
||||
if (stage === '归档入账' || stage === '已付款' || stage === 'completed' || stage.includes('归档')) {
|
||||
return true
|
||||
}
|
||||
@@ -12,7 +19,7 @@ export function isArchivedExpenseClaim(claim) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isApplicationRequestLike(claim) && ['审批完成', '申请归档'].includes(stage)) {
|
||||
if (stage === APPLICATION_ARCHIVE_STAGE) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,22 @@ const ALLOWED_COLON_HEADING_TITLES = new Set([
|
||||
'补充信息'
|
||||
])
|
||||
|
||||
const BUSINESS_FIELD_LABELS = new Set([
|
||||
'时间',
|
||||
'地点',
|
||||
'事由',
|
||||
'金额',
|
||||
'费用类型',
|
||||
'报销类型',
|
||||
'商户',
|
||||
'商户/开票方',
|
||||
'客户',
|
||||
'客户/项目对象',
|
||||
'附件',
|
||||
'附件/凭证',
|
||||
'出行方式'
|
||||
])
|
||||
|
||||
function splitColonHeadingLine(line) {
|
||||
const rawLine = String(line || '')
|
||||
const trimmed = rawLine.trim()
|
||||
@@ -142,7 +158,31 @@ function splitColonHeadingLine(line) {
|
||||
return [rawLine]
|
||||
}
|
||||
|
||||
return body ? [`### ${title}`, '', body] : [`### ${title}`]
|
||||
return body ? [`### ${titleText}`, '', body] : [`### ${titleText}`]
|
||||
}
|
||||
|
||||
function normalizeBusinessFieldLine(line) {
|
||||
const rawLine = String(line || '')
|
||||
const trimmed = rawLine.trim()
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.startsWith('|') ||
|
||||
/^[-*+]\s/.test(trimmed) ||
|
||||
/^#{1,6}\s/.test(trimmed)
|
||||
) {
|
||||
return rawLine
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^([^::\n]{1,16})[::]\s*(.+)$/u)
|
||||
if (!match) {
|
||||
return rawLine
|
||||
}
|
||||
const label = match[1].trim()
|
||||
const value = match[2].trim()
|
||||
if (!BUSINESS_FIELD_LABELS.has(label) || !value) {
|
||||
return rawLine
|
||||
}
|
||||
return `- **${label}**:${value}`
|
||||
}
|
||||
|
||||
function normalizeColonHeadings(text) {
|
||||
@@ -168,7 +208,7 @@ function normalizeColonHeadings(text) {
|
||||
normalizedLines.push('')
|
||||
}
|
||||
}
|
||||
normalizedLines.push(...nextLines)
|
||||
normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine)))
|
||||
})
|
||||
|
||||
return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n')
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import briefcaseIcon from '../assets/workbench-icons/outline-briefcase.svg?raw'
|
||||
import approvalIcon from '../assets/workbench-icons/outline-approval.svg?raw'
|
||||
import budgetIcon from '../assets/workbench-icons/outline-budget.svg?raw'
|
||||
import documentTextIcon from '../assets/workbench-icons/outline-document-text.svg?raw'
|
||||
import expenseApplicationIcon from '../assets/workbench-icons/outline-expense-application.svg?raw'
|
||||
import financeAnalysisIcon from '../assets/workbench-icons/outline-finance-analysis.svg?raw'
|
||||
import paperAirplaneIcon from '../assets/workbench-icons/outline-paper-airplane.svg?raw'
|
||||
import policyIcon from '../assets/workbench-icons/outline-policy.svg?raw'
|
||||
import reimbursementIcon from '../assets/workbench-icons/outline-reimbursement.svg?raw'
|
||||
import shoppingBagIcon from '../assets/workbench-icons/outline-shopping-bag.svg?raw'
|
||||
import truckIcon from '../assets/workbench-icons/outline-truck.svg?raw'
|
||||
import usersIcon from '../assets/workbench-icons/outline-users.svg?raw'
|
||||
|
||||
import capExpenseImg from '../assets/images/cap-expense.png'
|
||||
import capReimbImg from '../assets/images/cap-reimb.png'
|
||||
import capBudgetImg from '../assets/images/cap-budget.png'
|
||||
import capApprovalImg from '../assets/images/cap-approval.png'
|
||||
import capAnalysisImg from '../assets/images/cap-analysis.png'
|
||||
import capPolicyImg from '../assets/images/cap-policy.png'
|
||||
|
||||
function prepareHeroiconMarkup(svgRaw) {
|
||||
return String(svgRaw || '')
|
||||
.replace(/<svg\b([^>]*)>/i, '<svg class="workbench-heroicon"$1>')
|
||||
@@ -18,13 +19,17 @@ function prepareHeroiconMarkup(svgRaw) {
|
||||
.replace(/\saria-hidden="[^"]*"/g, '')
|
||||
}
|
||||
|
||||
function prepareImageMarkup(src) {
|
||||
return `<img src="${src}" class="workbench-image-icon" alt="" />`
|
||||
}
|
||||
|
||||
export const workbenchIconMap = {
|
||||
'expense-application': { markup: prepareHeroiconMarkup(expenseApplicationIcon), style: 'outline' },
|
||||
'quick-reimbursement': { markup: prepareHeroiconMarkup(reimbursementIcon), style: 'outline' },
|
||||
'budget-planning': { markup: prepareHeroiconMarkup(budgetIcon), style: 'outline' },
|
||||
'quick-approval': { markup: prepareHeroiconMarkup(approvalIcon), style: 'outline' },
|
||||
'finance-analysis': { markup: prepareHeroiconMarkup(financeAnalysisIcon), style: 'outline' },
|
||||
'company-policy': { markup: prepareHeroiconMarkup(policyIcon), style: 'outline' },
|
||||
'expense-application': { markup: prepareImageMarkup(capExpenseImg), style: 'image' },
|
||||
'quick-reimbursement': { markup: prepareImageMarkup(capReimbImg), style: 'image' },
|
||||
'budget-planning': { markup: prepareImageMarkup(capBudgetImg), style: 'image' },
|
||||
'quick-approval': { markup: prepareImageMarkup(capApprovalImg), style: 'image' },
|
||||
'finance-analysis': { markup: prepareImageMarkup(capAnalysisImg), style: 'image' },
|
||||
'company-policy': { markup: prepareImageMarkup(capPolicyImg), style: 'image' },
|
||||
hospitality: { markup: prepareHeroiconMarkup(usersIcon), style: 'outline' },
|
||||
travelDraft: { markup: prepareHeroiconMarkup(briefcaseIcon), style: 'outline' },
|
||||
receipts: { markup: prepareHeroiconMarkup(documentTextIcon), style: 'outline' },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isApplicationRequestLike } from './documentClassification.js'
|
||||
|
||||
function parseNumber(value) {
|
||||
const nextValue = Number(value)
|
||||
return Number.isFinite(nextValue) ? nextValue : 0
|
||||
@@ -139,6 +141,23 @@ function resolveStatusTone(approvalKey) {
|
||||
return 'muted'
|
||||
}
|
||||
|
||||
function resolveDocumentTypeLabel(request, requestId, title) {
|
||||
const explicitLabel = normalizeText(request?.documentTypeLabel || request?.document_type_label)
|
||||
const normalizedRequestId = normalizeText(requestId).toUpperCase()
|
||||
|
||||
if (
|
||||
isApplicationRequestLike(request)
|
||||
|| explicitLabel.includes('申请')
|
||||
|| title.includes('申请')
|
||||
|| normalizedRequestId.startsWith('SQ')
|
||||
|| normalizedRequestId.startsWith('CL')
|
||||
) {
|
||||
return '申请单'
|
||||
}
|
||||
|
||||
return explicitLabel || '报销单'
|
||||
}
|
||||
|
||||
function resolveTodoAction(request) {
|
||||
const approvalKey = normalizeText(request?.approvalKey)
|
||||
const status = normalizeText(request?.status || request?.approvalStatus)
|
||||
@@ -245,8 +264,7 @@ function buildProgressItems(ownedRequests) {
|
||||
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
|
||||
|
||||
const status = normalizeText(request?.approvalStatus || currentStep?.label) || '处理中'
|
||||
const isApplication = title.includes('申请') || (requestId || '').toUpperCase().startsWith('SQ') || (requestId || '').toUpperCase().startsWith('CL')
|
||||
const documentTypeLabel = isApplication ? '申请单' : '报销单'
|
||||
const documentTypeLabel = resolveDocumentTypeLabel(request, requestId, title)
|
||||
|
||||
return {
|
||||
id: requestId,
|
||||
|
||||
@@ -207,7 +207,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import SidebarRail from '../components/layout/SidebarRail.vue'
|
||||
import TopBar from '../components/layout/TopBar.vue'
|
||||
@@ -241,7 +241,7 @@ const digitalEmployeeDetailOpen = ref(false)
|
||||
const receiptFolderDetailOpen = ref(false)
|
||||
const budgetDetailOpen = ref(false)
|
||||
const loginEntryAnimating = ref(false)
|
||||
const sidebarCollapsed = ref(false)
|
||||
const sidebarCollapsed = ref(true)
|
||||
const mobileSidebarOpen = ref(false)
|
||||
const overviewDashboard = ref('finance')
|
||||
let loginEntryTimer = null
|
||||
@@ -393,4 +393,10 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
stopLoginEntryAnimation()
|
||||
})
|
||||
|
||||
watch(activeView, (newView) => {
|
||||
if (newView === 'workbench') {
|
||||
sidebarCollapsed.value = true
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
@@ -20,21 +20,36 @@
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input v-model="budgetKeyword" type="search" placeholder="搜索预算编号、部门、编制人" />
|
||||
</label>
|
||||
|
||||
<label class="budget-select-filter">
|
||||
<span>年度</span>
|
||||
<EnterpriseSelect v-model="filters.year" :options="yearOptions" />
|
||||
</label>
|
||||
|
||||
<label class="budget-select-filter">
|
||||
<span>季度</span>
|
||||
<EnterpriseSelect v-model="filters.quarter" :options="quarterOptions" />
|
||||
</label>
|
||||
|
||||
<label class="budget-select-filter">
|
||||
<span>状态</span>
|
||||
<EnterpriseSelect v-model="filters.status" :options="statusOptions" />
|
||||
</label>
|
||||
<DocumentDropdownFilter
|
||||
id="year"
|
||||
:active-filter-key="activeBudgetFilterKey"
|
||||
:label="budgetYearFilterLabel"
|
||||
title="年度"
|
||||
:options="yearOptions"
|
||||
:selected-value="filters.year"
|
||||
@toggle="toggleBudgetFilter"
|
||||
@select="selectBudgetFilter('year', $event)"
|
||||
/>
|
||||
<DocumentDropdownFilter
|
||||
id="quarter"
|
||||
:active-filter-key="activeBudgetFilterKey"
|
||||
:label="budgetQuarterFilterLabel"
|
||||
title="季度"
|
||||
:options="quarterOptions"
|
||||
:selected-value="filters.quarter"
|
||||
@toggle="toggleBudgetFilter"
|
||||
@select="selectBudgetFilter('quarter', $event)"
|
||||
/>
|
||||
<DocumentDropdownFilter
|
||||
id="status"
|
||||
:active-filter-key="activeBudgetFilterKey"
|
||||
:label="budgetStatusFilterLabel"
|
||||
title="状态"
|
||||
:options="statusOptions"
|
||||
:selected-value="filters.status"
|
||||
@toggle="toggleBudgetFilter"
|
||||
@select="selectBudgetFilter('status', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="document-actions">
|
||||
|
||||
@@ -248,8 +248,25 @@ import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
|
||||
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
||||
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
|
||||
import { countNewDocuments, isNewDocument, markDocumentViewed, markDocumentsViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
|
||||
import {
|
||||
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
|
||||
extractExpenseClaimItems,
|
||||
fetchApprovalExpenseClaims,
|
||||
fetchArchivedExpenseClaims
|
||||
} from '../services/reimbursements.js'
|
||||
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
|
||||
import {
|
||||
buildDocumentViewedStatePatch,
|
||||
buildDocumentsViewedStatePatches,
|
||||
countNewDocuments,
|
||||
isNewDocument,
|
||||
markDocumentViewed,
|
||||
markDocumentsViewed,
|
||||
mergeNotificationStatesIntoViewedDocumentKeys,
|
||||
readDocumentScope,
|
||||
readViewedDocumentKeys,
|
||||
writeDocumentScope
|
||||
} from '../utils/documentCenterNewState.js'
|
||||
import { sortDocumentRowsByLatestTime } from '../utils/documentCenterSort.js'
|
||||
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
|
||||
import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, isArchivedDocumentRow, prepareApplicationScopeRows } from '../utils/documentCenterRows.js'
|
||||
@@ -860,9 +877,36 @@ function changePageSize(size) {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function applyRemoteViewedDocumentStates(states) {
|
||||
viewedDocumentKeys.value = mergeNotificationStatesIntoViewedDocumentKeys(states, viewedDocumentKeys.value)
|
||||
}
|
||||
|
||||
async function loadRemoteViewedDocumentKeys() {
|
||||
try {
|
||||
applyRemoteViewedDocumentStates(await fetchNotificationStates())
|
||||
} catch {
|
||||
// 接口不可用时保留本机已读缓存,避免影响单据中心主流程。
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDocumentViewedPatches(patches) {
|
||||
const normalizedPatches = (Array.isArray(patches) ? patches : [patches]).filter(Boolean)
|
||||
if (!normalizedPatches.length) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
applyRemoteViewedDocumentStates(await patchNotificationStates(normalizedPatches))
|
||||
} catch {
|
||||
// 本机状态已先落地;远端失败时等待下次操作或刷新重试。
|
||||
}
|
||||
}
|
||||
|
||||
function openDocument(row) {
|
||||
writeDocumentScope(activeScopeTab.value, scopeTabs)
|
||||
const viewedPatch = buildDocumentViewedStatePatch(row)
|
||||
viewedDocumentKeys.value = markDocumentViewed(row, viewedDocumentKeys.value)
|
||||
void syncDocumentViewedPatches([viewedPatch])
|
||||
emit('open-document', row.rawRequest || row)
|
||||
}
|
||||
|
||||
@@ -871,7 +915,9 @@ function markAllDocumentsRead() {
|
||||
return
|
||||
}
|
||||
|
||||
const viewedPatches = buildDocumentsViewedStatePatches(allReadableDocumentRows.value, viewedDocumentKeys.value)
|
||||
viewedDocumentKeys.value = markDocumentsViewed(allReadableDocumentRows.value, viewedDocumentKeys.value)
|
||||
void syncDocumentViewedPatches(viewedPatches)
|
||||
}
|
||||
|
||||
async function loadSupportingRows() {
|
||||
@@ -879,30 +925,26 @@ async function loadSupportingRows() {
|
||||
supportingError.value = ''
|
||||
|
||||
const [approvalResult, archiveResult] = await Promise.allSettled([
|
||||
fetchApprovalExpenseClaims(),
|
||||
fetchArchivedExpenseClaims()
|
||||
fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS),
|
||||
fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
])
|
||||
|
||||
if (approvalResult.status === 'fulfilled') {
|
||||
approvalRows.value = excludeArchivedDocumentRows(
|
||||
Array.isArray(approvalResult.value)
|
||||
? approvalResult.value
|
||||
extractExpenseClaimItems(approvalResult.value)
|
||||
.map((item) => mapExpenseClaimToRequest(item))
|
||||
.map((item) => buildDocumentRow(item, { source: 'approval' }))
|
||||
.filter(Boolean)
|
||||
: []
|
||||
)
|
||||
} else {
|
||||
approvalRows.value = []
|
||||
}
|
||||
|
||||
if (archiveResult.status === 'fulfilled') {
|
||||
archiveRows.value = Array.isArray(archiveResult.value)
|
||||
? archiveResult.value
|
||||
.map((item) => mapExpenseClaimToRequest(item))
|
||||
.map((item) => buildDocumentRow(item, { source: 'archive', archived: true }))
|
||||
.filter(Boolean)
|
||||
: []
|
||||
archiveRows.value = extractExpenseClaimItems(archiveResult.value)
|
||||
.map((item) => mapExpenseClaimToRequest(item))
|
||||
.map((item) => buildDocumentRow(item, { source: 'archive', archived: true }))
|
||||
.filter(Boolean)
|
||||
} else {
|
||||
archiveRows.value = []
|
||||
supportingError.value = archiveResult.reason instanceof Error
|
||||
@@ -915,6 +957,7 @@ async function loadSupportingRows() {
|
||||
|
||||
function reloadAll() {
|
||||
emit('reload')
|
||||
void loadRemoteViewedDocumentKeys()
|
||||
void loadSupportingRows()
|
||||
}
|
||||
|
||||
@@ -963,6 +1006,7 @@ watch(documentSummary, (summary) => {
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
void loadRemoteViewedDocumentKeys()
|
||||
void loadSupportingRows()
|
||||
})
|
||||
|
||||
@@ -970,6 +1014,7 @@ watch(
|
||||
() => props.refreshToken,
|
||||
(token, previousToken) => {
|
||||
if (token && token !== previousToken) {
|
||||
void loadRemoteViewedDocumentKeys()
|
||||
void loadSupportingRows()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,143 +380,38 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="picker-filter" :class="{ open: activeFilterPopover === 'department' }">
|
||||
<button
|
||||
class="picker-trigger"
|
||||
type="button"
|
||||
:aria-expanded="activeFilterPopover === 'department'"
|
||||
aria-haspopup="dialog"
|
||||
@click="toggleFilterPopover('department')"
|
||||
>
|
||||
<span class="picker-label">{{ selectedDepartment || '组织部门' }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="activeFilterPopover === 'department'"
|
||||
class="picker-popover"
|
||||
role="dialog"
|
||||
aria-label="选择组织部门"
|
||||
>
|
||||
<header>
|
||||
<strong>选择组织部门</strong>
|
||||
<button type="button" aria-label="关闭组织部门选择" @click="closeFilterPopover">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
<div class="picker-option-list">
|
||||
<button
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: !selectedDepartment }"
|
||||
@click="selectFilter('department', '')"
|
||||
>
|
||||
全部部门
|
||||
</button>
|
||||
<button
|
||||
v-for="department in departmentOptions"
|
||||
:key="department"
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: selectedDepartment === department }"
|
||||
@click="selectFilter('department', department)"
|
||||
>
|
||||
{{ department }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DocumentDropdownFilter
|
||||
id="department"
|
||||
:active-filter-key="activeFilterPopover"
|
||||
:label="departmentFilterLabel"
|
||||
title="选择组织部门"
|
||||
:options="departmentFilterOptions"
|
||||
:selected-value="selectedDepartment"
|
||||
@toggle="toggleFilterPopover"
|
||||
@select="selectFilter('department', $event)"
|
||||
/>
|
||||
|
||||
<div class="picker-filter" :class="{ open: activeFilterPopover === 'grade' }">
|
||||
<button
|
||||
class="picker-trigger"
|
||||
type="button"
|
||||
:aria-expanded="activeFilterPopover === 'grade'"
|
||||
aria-haspopup="dialog"
|
||||
@click="toggleFilterPopover('grade')"
|
||||
>
|
||||
<span class="picker-label">{{ selectedGrade || '职级' }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="activeFilterPopover === 'grade'"
|
||||
class="picker-popover"
|
||||
role="dialog"
|
||||
aria-label="选择职级"
|
||||
>
|
||||
<header>
|
||||
<strong>选择职级</strong>
|
||||
<button type="button" aria-label="关闭职级选择" @click="closeFilterPopover">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
<div class="picker-option-list">
|
||||
<button
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: !selectedGrade }"
|
||||
@click="selectFilter('grade', '')"
|
||||
>
|
||||
全部职级
|
||||
</button>
|
||||
<button
|
||||
v-for="grade in gradeOptions"
|
||||
:key="grade"
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: selectedGrade === grade }"
|
||||
@click="selectFilter('grade', grade)"
|
||||
>
|
||||
{{ grade }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DocumentDropdownFilter
|
||||
id="grade"
|
||||
:active-filter-key="activeFilterPopover"
|
||||
:label="gradeFilterLabel"
|
||||
title="选择职级"
|
||||
:options="gradeFilterOptions"
|
||||
:selected-value="selectedGrade"
|
||||
@toggle="toggleFilterPopover"
|
||||
@select="selectFilter('grade', $event)"
|
||||
/>
|
||||
|
||||
<div class="picker-filter" :class="{ open: activeFilterPopover === 'role' }">
|
||||
<button
|
||||
class="picker-trigger"
|
||||
type="button"
|
||||
:aria-expanded="activeFilterPopover === 'role'"
|
||||
aria-haspopup="dialog"
|
||||
@click="toggleFilterPopover('role')"
|
||||
>
|
||||
<span class="picker-label">{{ selectedRole || '系统角色' }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="activeFilterPopover === 'role'"
|
||||
class="picker-popover"
|
||||
role="dialog"
|
||||
aria-label="选择系统角色"
|
||||
>
|
||||
<header>
|
||||
<strong>选择系统角色</strong>
|
||||
<button type="button" aria-label="关闭系统角色选择" @click="closeFilterPopover">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
<div class="picker-option-list">
|
||||
<button
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: !selectedRole }"
|
||||
@click="selectFilter('role', '')"
|
||||
>
|
||||
全部角色
|
||||
</button>
|
||||
<button
|
||||
v-for="role in roleFilterOptions"
|
||||
:key="role"
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: selectedRole === role }"
|
||||
@click="selectFilter('role', role)"
|
||||
>
|
||||
{{ role }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DocumentDropdownFilter
|
||||
id="role"
|
||||
:active-filter-key="activeFilterPopover"
|
||||
:label="roleFilterLabel"
|
||||
title="选择系统角色"
|
||||
:options="roleDropdownOptions"
|
||||
:selected-value="selectedRole"
|
||||
@toggle="toggleFilterPopover"
|
||||
@select="selectFilter('role', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-actions">
|
||||
|
||||
@@ -211,4 +211,5 @@ import viewModel from './scripts/LogsView.js'
|
||||
|
||||
export default viewModel
|
||||
</script>
|
||||
<style scoped src="../assets/styles/components/document-list-shared.css"></style>
|
||||
<style scoped src="../assets/styles/views/logs-view.css"></style>
|
||||
|
||||
@@ -23,48 +23,49 @@
|
||||
<span>{{ knowledgeSyncButtonLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="library-body">
|
||||
<aside class="folder-rail">
|
||||
<nav class="folder-tree" aria-label="知识库文件夹">
|
||||
<button
|
||||
v-for="folder in filteredFolders"
|
||||
:key="folder.name"
|
||||
type="button"
|
||||
:class="{ active: activeFolder === folder.name }"
|
||||
@click="activeFolder = folder.name"
|
||||
>
|
||||
</header>
|
||||
|
||||
<div class="library-body">
|
||||
<aside class="folder-rail">
|
||||
<nav class="folder-tree" aria-label="知识库文件夹">
|
||||
<button
|
||||
v-for="(folder, index) in filteredFolders"
|
||||
:key="folder.name"
|
||||
type="button"
|
||||
:class="{ active: activeFolder === folder.name }"
|
||||
:style="{ '--delay': `${index * 40}ms` }"
|
||||
@click="activeFolder = folder.name"
|
||||
>
|
||||
<i :class="resolveKnowledgeFolderIcon(folder, activeFolder)"></i>
|
||||
<span>{{ folder.name }}</span>
|
||||
<b>{{ folder.count }}</b>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<span>{{ folder.name }}</span>
|
||||
<b>{{ folder.count }}</b>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
</aside>
|
||||
|
||||
<section class="document-area" :class="{ 'read-only': !isAdmin }">
|
||||
<div
|
||||
v-if="isAdmin"
|
||||
class="upload-zone"
|
||||
:class="{ busy: uploading }"
|
||||
@click="triggerUpload"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<input
|
||||
ref="uploadInput"
|
||||
class="upload-input"
|
||||
type="file"
|
||||
multiple
|
||||
@change="handleFileInput"
|
||||
/>
|
||||
<i class="mdi mdi-cloud-upload"></i>
|
||||
<strong>{{ uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传' }}</strong>
|
||||
<span>{{ uploadHint }}</span>
|
||||
</div>
|
||||
|
||||
<div class="doc-table-wrap">
|
||||
|
||||
<section class="document-area" :class="{ 'read-only': !isAdmin }">
|
||||
<div
|
||||
v-if="isAdmin"
|
||||
class="upload-zone"
|
||||
:class="{ busy: uploading }"
|
||||
@click="triggerUpload"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<input
|
||||
ref="uploadInput"
|
||||
class="upload-input"
|
||||
type="file"
|
||||
multiple
|
||||
@change="handleFileInput"
|
||||
/>
|
||||
<i class="mdi mdi-cloud-upload"></i>
|
||||
<strong>{{ uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传' }}</strong>
|
||||
<span>{{ uploadHint }}</span>
|
||||
</div>
|
||||
|
||||
<div class="doc-table-wrap">
|
||||
<TableLoadingState
|
||||
v-if="loading && !visibleDocuments.length"
|
||||
title="知识库文件同步中"
|
||||
@@ -74,26 +75,27 @@
|
||||
/>
|
||||
|
||||
<table class="knowledge-document-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件名称</th>
|
||||
<th>标签</th>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件名称</th>
|
||||
<th>标签</th>
|
||||
<th>上传时间 <i class="mdi mdi-arrow-down"></i></th>
|
||||
<th>版本</th>
|
||||
<th>状态</th>
|
||||
<th>归纳时间</th>
|
||||
<th>上传人</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="doc in visibleDocuments"
|
||||
:key="doc.id"
|
||||
class="doc-row"
|
||||
:class="{ selected: selectedDocument?.id === doc.id }"
|
||||
@click="selectDocument(doc.id)"
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(doc, index) in visibleDocuments"
|
||||
:key="doc.id"
|
||||
class="doc-row"
|
||||
:class="{ selected: selectedDocument?.id === doc.id }"
|
||||
:style="{ '--delay': `${index * 50}ms` }"
|
||||
@click="selectDocument(doc.id)"
|
||||
>
|
||||
<td data-label="文件名称">
|
||||
<span class="file-name">
|
||||
<i :class="doc.icon"></i>
|
||||
|
||||
@@ -20,9 +20,21 @@
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input v-model="keyword" type="search" placeholder="搜索文件名、票据类型、金额、关联单号..." />
|
||||
</div>
|
||||
<button class="filter-btn" type="button" @click="reloadReceipts">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>刷新</span>
|
||||
<DocumentDropdownFilter
|
||||
v-for="control in receiptFilterControls"
|
||||
:id="control.key"
|
||||
:key="control.key"
|
||||
:active-filter-key="openReceiptFilterKey"
|
||||
:label="resolveReceiptFilterLabel(control)"
|
||||
:title="control.label"
|
||||
:options="control.options"
|
||||
:selected-value="receiptFilters[control.key]"
|
||||
@toggle="toggleReceiptFilter"
|
||||
@select="selectReceiptFilter(control.key, $event)"
|
||||
/>
|
||||
<button v-if="hasActiveReceiptFilters" class="filter-btn clear-filter-btn" type="button" @click="clearReceiptFilters">
|
||||
<i class="mdi mdi-filter-remove-outline"></i>
|
||||
<span>清空筛选</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -349,6 +361,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElCheckbox, ElCheckboxGroup } from 'element-plus/es/components/checkbox/index.mjs'
|
||||
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
|
||||
|
||||
import DocumentDropdownFilter from '../components/shared/DocumentDropdownFilter.vue'
|
||||
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
|
||||
import EnterpriseDetailCard from '../components/shared/EnterpriseDetailCard.vue'
|
||||
import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
|
||||
@@ -365,6 +378,7 @@ import {
|
||||
} from '../services/receiptFolder.js'
|
||||
import { createReceiptDetailDashboardModel } from './scripts/receiptFolderDetailDashboard.js'
|
||||
import { createReceiptDetailFieldModel } from './scripts/receiptFolderDetailFields.js'
|
||||
import { createReceiptFolderListFilterModel } from './scripts/receiptFolderListFilters.js'
|
||||
|
||||
const NEW_CLAIM_VALUE = '__new_claim__'
|
||||
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
|
||||
@@ -417,19 +431,17 @@ const activeRows = computed(() => {
|
||||
return receipts.value
|
||||
})
|
||||
const showStatusColumn = computed(() => activeStatus.value !== 'linked')
|
||||
const filteredRows = computed(() => {
|
||||
const normalized = keyword.value.trim().toLowerCase()
|
||||
if (!normalized) return activeRows.value
|
||||
return activeRows.value.filter((item) => [
|
||||
item.file_name,
|
||||
item.document_type_label,
|
||||
item.scene_label,
|
||||
item.summary,
|
||||
item.amount,
|
||||
item.document_date,
|
||||
item.linked_claim_no
|
||||
].filter(Boolean).join('').toLowerCase().includes(normalized))
|
||||
})
|
||||
const {
|
||||
filteredRows,
|
||||
hasActiveReceiptFilters,
|
||||
openReceiptFilterKey,
|
||||
receiptFilterControls,
|
||||
receiptFilters,
|
||||
clearReceiptFilters,
|
||||
resolveReceiptFilterLabel,
|
||||
selectReceiptFilter,
|
||||
toggleReceiptFilter
|
||||
} = createReceiptFolderListFilterModel({ receipts, activeRows, keyword })
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
|
||||
const pageSummary = computed(() => `共 ${filteredRows.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`)
|
||||
const visibleRows = computed(() => {
|
||||
@@ -531,7 +543,15 @@ const associatePrimaryLabel = computed(() => {
|
||||
return associateStep.value === 1 ? '下一步' : '进入关联对话'
|
||||
})
|
||||
|
||||
watch([activeStatus, keyword, pageSize], () => {
|
||||
watch([
|
||||
activeStatus,
|
||||
keyword,
|
||||
pageSize,
|
||||
() => receiptFilters.documentType,
|
||||
() => receiptFilters.scene,
|
||||
() => receiptFilters.month,
|
||||
() => receiptFilters.quality
|
||||
], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
|
||||
@@ -90,14 +90,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="messageListRef" class="message-list" aria-live="polite">
|
||||
<transition-group tag="div" name="message-row-reveal" ref="messageListRef" class="message-list" aria-live="polite">
|
||||
<div
|
||||
v-if="showStewardInitialRecognition"
|
||||
class="steward-initial-recognition"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
key="initial-recognition"
|
||||
>
|
||||
<div class="steward-initial-recognition-icon">
|
||||
<i class="mdi mdi-brain"></i>
|
||||
</div>
|
||||
<div class="steward-initial-recognition-copy">
|
||||
<strong>小财管家正在识别意图</strong>
|
||||
<p>我正在读取你的输入,准备拆解申请、报销和附件任务。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TravelReimbursementMessageItem
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:ui="messageItemUi"
|
||||
/>
|
||||
</div>
|
||||
</transition-group>
|
||||
|
||||
<form class="composer" @submit.prevent="submitComposer">
|
||||
<input
|
||||
|
||||
@@ -2,10 +2,10 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElButton } from 'element-plus/es/components/button/index.mjs'
|
||||
|
||||
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
||||
import DocumentDropdownFilter from '../../components/shared/DocumentDropdownFilter.vue'
|
||||
import EnterpriseDetailCard from '../../components/shared/EnterpriseDetailCard.vue'
|
||||
import EnterpriseDetailPage from '../../components/shared/EnterpriseDetailPage.vue'
|
||||
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
|
||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import { fetchEmployeeMeta } from '../../services/employees.js'
|
||||
@@ -47,6 +47,10 @@ function mapOptions(values, suffix = '') {
|
||||
}))
|
||||
}
|
||||
|
||||
function resolveOptionLabel(options, value, fallback) {
|
||||
return (Array.isArray(options) ? options : []).find((option) => option.value === value)?.label || fallback
|
||||
}
|
||||
|
||||
function resolveBudgetUpdatedAt(row) {
|
||||
return row?.updatedAt || row?.submittedAt || row?.archivedAt || '-'
|
||||
}
|
||||
@@ -99,8 +103,8 @@ export default {
|
||||
emits: ['openAssistant', 'detail-open-change', 'detail-topbar-change'],
|
||||
components: {
|
||||
BudgetTrendChart,
|
||||
DocumentDropdownFilter,
|
||||
EnterprisePagination,
|
||||
EnterpriseSelect,
|
||||
EnterpriseDetailCard,
|
||||
EnterpriseDetailPage,
|
||||
TableEmptyState,
|
||||
@@ -116,6 +120,7 @@ export default {
|
||||
const budgetLoading = ref(true)
|
||||
const budgetError = ref('')
|
||||
const selectedBudgetId = ref('')
|
||||
const activeBudgetFilterKey = ref('')
|
||||
const filters = ref({
|
||||
year: '2026',
|
||||
quarter: 'Q1',
|
||||
@@ -158,6 +163,9 @@ export default {
|
||||
() => budgetScopeTabs.value.find((item) => item.value === activeBudgetScope.value)?.label || '预算'
|
||||
)
|
||||
const statusOptions = computed(() => mapOptions(getBudgetStatusOptions(activeBudgetScope.value)))
|
||||
const budgetYearFilterLabel = computed(() => resolveOptionLabel(yearOptions, filters.value.year, '年度'))
|
||||
const budgetQuarterFilterLabel = computed(() => resolveOptionLabel(quarterOptions, filters.value.quarter, '季度'))
|
||||
const budgetStatusFilterLabel = computed(() => resolveOptionLabel(statusOptions.value, filters.value.status, '状态'))
|
||||
|
||||
const filteredBudgetRows = computed(() =>
|
||||
activeScopeRows.value
|
||||
@@ -322,6 +330,15 @@ export default {
|
||||
budgetPage.value = 1
|
||||
}
|
||||
|
||||
function toggleBudgetFilter(key) {
|
||||
activeBudgetFilterKey.value = activeBudgetFilterKey.value === key ? '' : key
|
||||
}
|
||||
|
||||
function selectBudgetFilter(key, value) {
|
||||
filters.value[key] = value
|
||||
activeBudgetFilterKey.value = ''
|
||||
}
|
||||
|
||||
function resolveScopedDepartments(options) {
|
||||
if (!isDepartmentBudgetMonitor.value) return options
|
||||
|
||||
@@ -419,6 +436,7 @@ export default {
|
||||
BUDGET_SCOPE_ALL,
|
||||
BUDGET_SCOPE_ARCHIVE,
|
||||
BUDGET_SCOPE_REVIEW,
|
||||
activeBudgetFilterKey,
|
||||
activeBudgetScope,
|
||||
budgetError,
|
||||
budgetKeyword,
|
||||
@@ -427,6 +445,9 @@ export default {
|
||||
budgetPageSize,
|
||||
budgetPageSizeOptions,
|
||||
budgetScopeTabs,
|
||||
budgetQuarterFilterLabel,
|
||||
budgetStatusFilterLabel,
|
||||
budgetYearFilterLabel,
|
||||
backToList,
|
||||
canAuditBudgetDrafts,
|
||||
canEditBudget,
|
||||
@@ -447,8 +468,10 @@ export default {
|
||||
showEmpty,
|
||||
showTable,
|
||||
statusOptions,
|
||||
selectBudgetFilter,
|
||||
totalBudgetPages,
|
||||
totalBudgetRows,
|
||||
toggleBudgetFilter,
|
||||
visibleBudgetRows,
|
||||
yearOptions
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import DocumentDropdownFilter from '../../components/shared/DocumentDropdownFilter.vue'
|
||||
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
|
||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
@@ -449,10 +450,18 @@ function buildEmployeeSummary(employees) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapSimpleFilterOptions(values, allLabel) {
|
||||
return [
|
||||
{ label: allLabel, value: '' },
|
||||
...values.map((value) => ({ label: value, value }))
|
||||
]
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'EmployeeManagementView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
DocumentDropdownFilter,
|
||||
EnterprisePagination,
|
||||
EnterpriseSelect,
|
||||
TableLoadingState,
|
||||
@@ -559,6 +568,12 @@ export default {
|
||||
)
|
||||
)
|
||||
)
|
||||
const departmentFilterOptions = computed(() => mapSimpleFilterOptions(departmentOptions.value, '全部部门'))
|
||||
const gradeFilterOptions = computed(() => mapSimpleFilterOptions(gradeOptions.value, '全部职级'))
|
||||
const roleDropdownOptions = computed(() => mapSimpleFilterOptions(roleFilterOptions.value, '全部角色'))
|
||||
const departmentFilterLabel = computed(() => selectedDepartment.value || '组织部门')
|
||||
const gradeFilterLabel = computed(() => selectedGrade.value || '职级')
|
||||
const roleFilterLabel = computed(() => selectedRole.value || '系统角色')
|
||||
|
||||
const managerOptions = computed(() => {
|
||||
const currentId = selectedEmployee.value?.id
|
||||
@@ -1440,6 +1455,12 @@ export default {
|
||||
selectedDepartment,
|
||||
selectedGrade,
|
||||
selectedRole,
|
||||
departmentFilterLabel,
|
||||
departmentFilterOptions,
|
||||
gradeFilterLabel,
|
||||
gradeFilterOptions,
|
||||
roleDropdownOptions,
|
||||
roleFilterLabel,
|
||||
activeFilterPopover,
|
||||
currentPage,
|
||||
pageSize,
|
||||
|
||||
@@ -203,7 +203,7 @@ export function shouldUseBudgetCompileReport(rawText, options = {}) {
|
||||
const isBudgetContextEditIntent = isBudgetContext && hasBudgetKeyword && hasCompileKeyword
|
||||
|
||||
return Boolean(
|
||||
budgetContext ||
|
||||
(isBudgetContext && budgetContext) ||
|
||||
(
|
||||
text &&
|
||||
(isWholeBudgetCompileIntent || isBudgetContextPeriodIntent || isBudgetContextEditIntent)
|
||||
|
||||
179
web/src/views/scripts/receiptFolderListFilters.js
Normal file
@@ -0,0 +1,179 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
export const RECEIPT_FILTER_ALL = 'all'
|
||||
|
||||
const QUALITY_OPTIONS = [
|
||||
{ value: RECEIPT_FILTER_ALL, label: '全部置信度' },
|
||||
{ value: 'high', label: '高置信度' },
|
||||
{ value: 'medium', label: '中等置信度' },
|
||||
{ value: 'low', label: '低置信度' },
|
||||
{ value: 'missing', label: '待确认' }
|
||||
]
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function getFilterValue(filters, key) {
|
||||
return normalizeText(filters?.[key]) || RECEIPT_FILTER_ALL
|
||||
}
|
||||
|
||||
function buildUniqueOptions(rows, valueKey, labelKey, allLabel) {
|
||||
const seen = new Map()
|
||||
for (const row of Array.isArray(rows) ? rows : []) {
|
||||
const value = normalizeText(row?.[valueKey])
|
||||
if (!value || seen.has(value)) continue
|
||||
seen.set(value, normalizeText(row?.[labelKey]) || value)
|
||||
}
|
||||
|
||||
return [
|
||||
{ value: RECEIPT_FILTER_ALL, label: allLabel },
|
||||
...Array.from(seen.entries())
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((left, right) => left.label.localeCompare(right.label, 'zh-Hans-CN'))
|
||||
]
|
||||
}
|
||||
|
||||
function resolveReceiptMonth(row) {
|
||||
const raw = normalizeText(row?.document_date) || normalizeText(row?.uploaded_at)
|
||||
const match = raw.match(/^(\d{4})[-/年]?(\d{1,2})/)
|
||||
if (!match) return ''
|
||||
return `${match[1]}-${String(match[2]).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function buildMonthOptions(rows) {
|
||||
const months = new Set((Array.isArray(rows) ? rows : []).map(resolveReceiptMonth).filter(Boolean))
|
||||
return [
|
||||
{ value: RECEIPT_FILTER_ALL, label: '全部月份' },
|
||||
...Array.from(months)
|
||||
.sort((left, right) => right.localeCompare(left))
|
||||
.map((value) => ({ value, label: `${value.replace('-', '年')}月` }))
|
||||
]
|
||||
}
|
||||
|
||||
function resolveScore(row) {
|
||||
const score = Number(row?.avg_score || 0)
|
||||
return Number.isFinite(score) ? score : 0
|
||||
}
|
||||
|
||||
function matchesQuality(row, quality) {
|
||||
if (quality === RECEIPT_FILTER_ALL) return true
|
||||
const score = resolveScore(row)
|
||||
if (quality === 'missing') return score <= 0
|
||||
if (quality === 'high') return score >= 0.9
|
||||
if (quality === 'medium') return score >= 0.75 && score < 0.9
|
||||
if (quality === 'low') return score > 0 && score < 0.75
|
||||
return true
|
||||
}
|
||||
|
||||
export function buildReceiptFilterControls(rows, filters) {
|
||||
return [
|
||||
{
|
||||
key: 'documentType',
|
||||
label: '票据类型',
|
||||
options: buildUniqueOptions(rows, 'document_type', 'document_type_label', '全部类型')
|
||||
},
|
||||
{
|
||||
key: 'scene',
|
||||
label: '费用场景',
|
||||
options: buildUniqueOptions(rows, 'scene_code', 'scene_label', '全部场景')
|
||||
},
|
||||
{
|
||||
key: 'month',
|
||||
label: '票据月份',
|
||||
options: buildMonthOptions(rows)
|
||||
},
|
||||
{
|
||||
key: 'quality',
|
||||
label: '置信度',
|
||||
options: QUALITY_OPTIONS
|
||||
}
|
||||
].map((control) => ({
|
||||
...control,
|
||||
value: getFilterValue(filters, control.key)
|
||||
}))
|
||||
}
|
||||
|
||||
export function applyReceiptListFilters(rows, filters) {
|
||||
const documentType = getFilterValue(filters, 'documentType')
|
||||
const scene = getFilterValue(filters, 'scene')
|
||||
const month = getFilterValue(filters, 'month')
|
||||
const quality = getFilterValue(filters, 'quality')
|
||||
|
||||
return (Array.isArray(rows) ? rows : []).filter((row) => (
|
||||
(documentType === RECEIPT_FILTER_ALL || normalizeText(row?.document_type) === documentType)
|
||||
&& (scene === RECEIPT_FILTER_ALL || normalizeText(row?.scene_code) === scene)
|
||||
&& (month === RECEIPT_FILTER_ALL || resolveReceiptMonth(row) === month)
|
||||
&& matchesQuality(row, quality)
|
||||
))
|
||||
}
|
||||
|
||||
export function buildReceiptFilterTokens(controls, filters) {
|
||||
return (Array.isArray(controls) ? controls : [])
|
||||
.map((control) => {
|
||||
const value = getFilterValue(filters, control.key)
|
||||
if (value === RECEIPT_FILTER_ALL) return ''
|
||||
const option = control.options.find((item) => item.value === value)
|
||||
return `${control.label}:${option?.label || value}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function createReceiptFolderListFilterModel({ receipts, activeRows, keyword }) {
|
||||
const openReceiptFilterKey = ref('')
|
||||
const receiptFilters = reactive({
|
||||
documentType: RECEIPT_FILTER_ALL,
|
||||
scene: RECEIPT_FILTER_ALL,
|
||||
month: RECEIPT_FILTER_ALL,
|
||||
quality: RECEIPT_FILTER_ALL
|
||||
})
|
||||
const receiptFilterControls = computed(() => buildReceiptFilterControls(receipts.value, receiptFilters))
|
||||
const hasActiveReceiptFilters = computed(() => Object.values(receiptFilters).some((value) => value !== RECEIPT_FILTER_ALL))
|
||||
const filteredRows = computed(() => {
|
||||
const normalized = keyword.value.trim().toLowerCase()
|
||||
const filtered = applyReceiptListFilters(activeRows.value, receiptFilters)
|
||||
if (!normalized) return filtered
|
||||
return filtered.filter((item) => [
|
||||
item.file_name,
|
||||
item.document_type_label,
|
||||
item.scene_label,
|
||||
item.summary,
|
||||
item.amount,
|
||||
item.document_date,
|
||||
item.linked_claim_no
|
||||
].filter(Boolean).join('').toLowerCase().includes(normalized))
|
||||
})
|
||||
|
||||
function toggleReceiptFilter(key) {
|
||||
openReceiptFilterKey.value = openReceiptFilterKey.value === key ? '' : key
|
||||
}
|
||||
|
||||
function selectReceiptFilter(key, value) {
|
||||
receiptFilters[key] = value
|
||||
openReceiptFilterKey.value = ''
|
||||
}
|
||||
|
||||
function resolveReceiptFilterLabel(control) {
|
||||
return control.options.find((option) => option.value === receiptFilters[control.key])?.label || control.label
|
||||
}
|
||||
|
||||
function clearReceiptFilters() {
|
||||
receiptFilters.documentType = RECEIPT_FILTER_ALL
|
||||
receiptFilters.scene = RECEIPT_FILTER_ALL
|
||||
receiptFilters.month = RECEIPT_FILTER_ALL
|
||||
receiptFilters.quality = RECEIPT_FILTER_ALL
|
||||
openReceiptFilterKey.value = ''
|
||||
}
|
||||
|
||||
return {
|
||||
filteredRows,
|
||||
hasActiveReceiptFilters,
|
||||
openReceiptFilterKey,
|
||||
receiptFilterControls,
|
||||
receiptFilters,
|
||||
clearReceiptFilters,
|
||||
resolveReceiptFilterLabel,
|
||||
selectReceiptFilter,
|
||||
toggleReceiptFilter
|
||||
}
|
||||
}
|
||||
131
web/src/views/scripts/stewardFieldCompletionModel.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js'
|
||||
|
||||
const APPLICATION_PREVIEW_FIELD_ONTOLOGY_KEY_MAP = {
|
||||
applicationType: 'expense_type',
|
||||
time: 'time_range',
|
||||
location: 'location',
|
||||
reason: 'reason',
|
||||
amount: 'amount',
|
||||
transportMode: 'transport_mode',
|
||||
department: 'department_name',
|
||||
applicant: 'employee_name',
|
||||
grade: 'employee_grade'
|
||||
}
|
||||
|
||||
const APPLICATION_PREVIEW_FIELD_LABEL_MAP = {
|
||||
applicationType: '申请类型',
|
||||
time: '时间',
|
||||
location: '地点',
|
||||
reason: '事由',
|
||||
amount: '金额',
|
||||
transportMode: '出行方式',
|
||||
department: '所属部门',
|
||||
applicant: '申请人',
|
||||
grade: '职级'
|
||||
}
|
||||
|
||||
function compactValue(value = '') {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function resolveStewardCurrentTask(continuation = null) {
|
||||
const task = continuation?.currentTask || continuation?.current_task || null
|
||||
return task && typeof task === 'object' ? task : null
|
||||
}
|
||||
|
||||
function resolveTaskOntologyFields(task = null) {
|
||||
const fields = task?.ontology_fields || task?.ontologyFields || {}
|
||||
return fields && typeof fields === 'object' ? fields : {}
|
||||
}
|
||||
|
||||
function resolveFieldValue(...candidates) {
|
||||
for (const candidate of candidates) {
|
||||
const value = compactValue(candidate)
|
||||
if (value && !['待补充', '待测算', '未知'].includes(value)) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildUpdatedTask(task = null, fieldKey = '', value = '') {
|
||||
if (!task || typeof task !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const canonicalKey = APPLICATION_PREVIEW_FIELD_ONTOLOGY_KEY_MAP[fieldKey] || ''
|
||||
if (!canonicalKey) {
|
||||
return { ...task }
|
||||
}
|
||||
|
||||
const ontologyFields = {
|
||||
...resolveTaskOntologyFields(task),
|
||||
[canonicalKey]: value
|
||||
}
|
||||
const sourceMissingFields = Array.isArray(task.missing_fields)
|
||||
? task.missing_fields
|
||||
: Array.isArray(task.missingFields)
|
||||
? task.missingFields
|
||||
: []
|
||||
|
||||
return {
|
||||
...task,
|
||||
ontology_fields: ontologyFields,
|
||||
missing_fields: sourceMissingFields.filter((field) => compactValue(field) !== canonicalKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStewardFieldCompletionContinuation(continuation = null, fieldKey = '', value = '') {
|
||||
const source = continuation && typeof continuation === 'object' ? continuation : {}
|
||||
const currentTask = resolveStewardCurrentTask(source)
|
||||
const updatedTask = buildUpdatedTask(currentTask, fieldKey, value)
|
||||
if (!updatedTask) {
|
||||
return source
|
||||
}
|
||||
|
||||
return {
|
||||
...source,
|
||||
currentTask: updatedTask
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStewardFieldCompletionRawText({
|
||||
preview = {},
|
||||
fieldKey = '',
|
||||
fieldLabel = '',
|
||||
value = '',
|
||||
continuation = null
|
||||
} = {}) {
|
||||
const normalizedPreview = normalizeApplicationPreview(preview)
|
||||
const fields = normalizedPreview.fields || {}
|
||||
const currentTask = resolveStewardCurrentTask(continuation)
|
||||
const ontologyFields = resolveTaskOntologyFields(currentTask)
|
||||
const selectedLabel = compactValue(fieldLabel) || APPLICATION_PREVIEW_FIELD_LABEL_MAP[fieldKey] || '补充项'
|
||||
const selectedValue = compactValue(value)
|
||||
const transportMode = fieldKey === 'transportMode'
|
||||
? selectedValue
|
||||
: resolveFieldValue(fields.transportMode, ontologyFields.transport_mode)
|
||||
|
||||
const knownLines = [
|
||||
['申请类型', resolveFieldValue(fields.applicationType, ontologyFields.expense_type, '差旅费用申请')],
|
||||
['时间', resolveFieldValue(fields.time, ontologyFields.time_range)],
|
||||
['地点', resolveFieldValue(fields.location, ontologyFields.location)],
|
||||
['事由', resolveFieldValue(fields.reason, ontologyFields.reason, currentTask?.summary)],
|
||||
['天数', resolveFieldValue(fields.days)],
|
||||
['出行方式', transportMode]
|
||||
]
|
||||
.filter(([, fieldValue]) => fieldValue)
|
||||
.map(([label, fieldValue]) => `${label}:${fieldValue}`)
|
||||
|
||||
return [
|
||||
'小财管家继续执行申请单字段补齐。',
|
||||
`用户已补充:${selectedLabel}:${selectedValue}。`,
|
||||
currentTask?.summary ? `任务摘要:${currentTask.summary}` : '',
|
||||
'',
|
||||
'已识别信息:',
|
||||
...knownLines,
|
||||
'',
|
||||
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。',
|
||||
'如果仍有缺失信息,继续向用户追问;不要替用户默认填写,也不要跳过小财管家的思考过程。'
|
||||
].filter((line) => line !== '').join('\n')
|
||||
}
|
||||
@@ -79,6 +79,25 @@ const FIELD_ALIASES = {
|
||||
application_transport_mode: 'transport_mode'
|
||||
}
|
||||
|
||||
const APPLICATION_NON_BLOCKING_MISSING_FIELDS = new Set([
|
||||
'amount',
|
||||
'attachments',
|
||||
'employee_no',
|
||||
'employee_name',
|
||||
'department_name'
|
||||
])
|
||||
|
||||
const FIELD_VALUE_DISPLAY_CONFIG = {
|
||||
expense_type: {
|
||||
travel: '差旅',
|
||||
business_entertainment: '业务招待',
|
||||
transportation: '交通费',
|
||||
traffic: '交通费',
|
||||
accommodation: '住宿费',
|
||||
meal: '餐饮费'
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStewardPlanRequest({ rawText = '', files = [], currentUser = {} } = {}) {
|
||||
const safeFiles = Array.isArray(files) ? files : []
|
||||
return {
|
||||
@@ -123,9 +142,10 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
|
||||
tasks: Array.isArray(rawPlan.tasks)
|
||||
? rawPlan.tasks.map((item) => {
|
||||
const taskType = String(item.task_type || item.taskType || '')
|
||||
const missingFields = Array.isArray(item.missing_fields || item.missingFields)
|
||||
const rawMissingFields = Array.isArray(item.missing_fields || item.missingFields)
|
||||
? item.missing_fields || item.missingFields
|
||||
: []
|
||||
const missingFields = filterStewardBlockingMissingFields(rawMissingFields, taskType)
|
||||
return {
|
||||
taskId: String(item.task_id || item.taskId || ''),
|
||||
taskType,
|
||||
@@ -188,7 +208,7 @@ export function buildStewardPlanMessageText(plan) {
|
||||
}
|
||||
|
||||
export function buildStewardFieldItems(fields = [], taskType = '') {
|
||||
const safeFields = Array.isArray(fields) ? fields : []
|
||||
const safeFields = filterStewardBlockingMissingFields(fields, taskType)
|
||||
const seen = new Set()
|
||||
return safeFields
|
||||
.map((field) => normalizeFieldKey(field))
|
||||
@@ -202,18 +222,44 @@ export function buildStewardFieldItems(fields = [], taskType = '') {
|
||||
.map((field) => resolveFieldDisplay(field, taskType))
|
||||
}
|
||||
|
||||
export function formatStewardMissingFieldList(fields = [], taskType = '') {
|
||||
export function formatStewardMissingFieldList(fields = [], taskType = '', options = {}) {
|
||||
const includeHints = options.includeHints !== false
|
||||
return buildStewardFieldItems(fields, taskType)
|
||||
.map((item) => item.hint ? `${item.label}(${item.hint})` : item.label)
|
||||
.map((item) => includeHints && item.hint ? `${item.label}(${item.hint})` : item.label)
|
||||
.join('、')
|
||||
}
|
||||
|
||||
export function filterStewardBlockingMissingFields(fields = [], taskType = '') {
|
||||
const safeFields = Array.isArray(fields) ? fields : []
|
||||
const seen = new Set()
|
||||
if (taskType !== 'expense_application') {
|
||||
return safeFields
|
||||
.map((field) => normalizeFieldKey(field))
|
||||
.filter((field) => {
|
||||
if (!field || seen.has(field)) {
|
||||
return false
|
||||
}
|
||||
seen.add(field)
|
||||
return true
|
||||
})
|
||||
}
|
||||
return safeFields
|
||||
.map((field) => normalizeFieldKey(field))
|
||||
.filter((field) => {
|
||||
if (!field || seen.has(field) || APPLICATION_NON_BLOCKING_MISSING_FIELDS.has(field)) {
|
||||
return false
|
||||
}
|
||||
seen.add(field)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function formatStewardOntologyFields(fields = {}, taskType = '') {
|
||||
return Object.entries(fields || {})
|
||||
.filter(([, value]) => String(value || '').trim())
|
||||
.map(([key, value]) => {
|
||||
const field = resolveFieldDisplay(key, taskType)
|
||||
return `${field.label}:${value}`
|
||||
return `${field.label}:${formatStewardFieldDisplayValue(field.key, value)}`
|
||||
})
|
||||
.join(';')
|
||||
}
|
||||
@@ -246,6 +292,7 @@ export function buildStewardSuggestedActions(plan) {
|
||||
steward_confirmation_id: String(action.confirmation_id || action.confirmationId || ''),
|
||||
steward_plan_id: normalized.planId,
|
||||
steward_next_task_id: task?.taskId || '',
|
||||
steward_current_task: buildStewardTaskPayload(task),
|
||||
steward_remaining_task_count: normalized.tasks.filter((item) => item.taskId !== task?.taskId).length,
|
||||
steward_remaining_tasks: buildRemainingTaskPayload(normalized, task?.taskId)
|
||||
}
|
||||
@@ -447,7 +494,11 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
|
||||
}
|
||||
|
||||
const fields = formatStewardOntologyFields(task.ontologyFields || {}, task.taskType)
|
||||
const missingFields = formatStewardMissingFieldList(task.missingFields || [], task.taskType)
|
||||
const missingFields = formatStewardMissingFieldList(
|
||||
task.missingFields || [],
|
||||
task.taskType,
|
||||
{ includeHints: false }
|
||||
)
|
||||
const lines = [
|
||||
actionType === 'confirm_create_application'
|
||||
? `小财管家已完成意图识别,请先创建申请单:${task.title || task.taskTypeLabel}。`
|
||||
@@ -458,8 +509,12 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
|
||||
group?.excludedAttachmentNames?.length ? `暂不归集附件:${group.excludedAttachmentNames.join('、')}` : '',
|
||||
missingFields ? `还需要补充:${missingFields}` : '',
|
||||
actionType === 'confirm_create_application'
|
||||
? '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
|
||||
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
|
||||
? missingFields
|
||||
? '请先追问上述缺失信息,不要直接生成申请单核对表,也不要替用户默认填写。'
|
||||
: '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
|
||||
: missingFields
|
||||
? '请先追问上述缺失信息,不要直接生成报销核对结果,也不要替用户默认填写。'
|
||||
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
|
||||
]
|
||||
const remainingTaskText = normalized ? buildRemainingTaskText(normalized, task.taskId) : ''
|
||||
if (remainingTaskText) {
|
||||
@@ -495,6 +550,12 @@ function resolveFieldDisplay(field, taskType = '') {
|
||||
}
|
||||
}
|
||||
|
||||
function formatStewardFieldDisplayValue(field, value) {
|
||||
const key = normalizeFieldKey(field)
|
||||
const normalizedValue = String(value || '').trim()
|
||||
return FIELD_VALUE_DISPLAY_CONFIG[key]?.[normalizedValue] || normalizedValue
|
||||
}
|
||||
|
||||
function buildRemainingTaskText(normalized, currentTaskId) {
|
||||
const remainingTasks = normalized.tasks.filter((task) => task.taskId !== currentTaskId)
|
||||
if (!remainingTasks.length) {
|
||||
@@ -512,13 +573,20 @@ function buildRemainingTaskText(normalized, currentTaskId) {
|
||||
function buildRemainingTaskPayload(normalized, currentTaskId) {
|
||||
return normalized.tasks
|
||||
.filter((task) => task.taskId !== currentTaskId)
|
||||
.map((task) => ({
|
||||
task_id: task.taskId,
|
||||
task_type: task.taskType,
|
||||
title: task.title,
|
||||
summary: task.summary,
|
||||
assigned_agent: task.assignedAgent,
|
||||
ontology_fields: task.ontologyFields || {},
|
||||
missing_fields: task.missingFields || []
|
||||
}))
|
||||
.map((task) => buildStewardTaskPayload(task))
|
||||
}
|
||||
|
||||
function buildStewardTaskPayload(task) {
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
task_id: task.taskId || task.task_id || '',
|
||||
task_type: task.taskType || task.task_type || '',
|
||||
title: task.title || '',
|
||||
summary: task.summary || '',
|
||||
assigned_agent: task.assignedAgent || task.assigned_agent || '',
|
||||
ontology_fields: task.ontologyFields || task.ontology_fields || {},
|
||||
missing_fields: task.missingFields || task.missing_fields || []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
} from './stewardPlanModel.js'
|
||||
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
|
||||
|
||||
const STEWARD_TYPEWRITER_INTERVAL_MS = 18
|
||||
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 14
|
||||
const STEWARD_TYPEWRITER_INTERVAL_MS = 10
|
||||
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 8
|
||||
const STEWARD_TYPEWRITER_CHUNK_SIZE = 4
|
||||
const STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5
|
||||
|
||||
export function useStewardPlanFlow({
|
||||
activeSessionType,
|
||||
@@ -174,7 +176,7 @@ export function useStewardPlanFlow({
|
||||
if (runId !== stewardTypewriterRunId) {
|
||||
return
|
||||
}
|
||||
index += 1
|
||||
index = Math.min(total, index + STEWARD_TYPEWRITER_CHUNK_SIZE)
|
||||
const message = messages.value.find((item) => item.id === messageId)
|
||||
if (!message) {
|
||||
return
|
||||
@@ -269,7 +271,7 @@ export function useStewardPlanFlow({
|
||||
if (runId !== stewardTypewriterRunId) {
|
||||
return
|
||||
}
|
||||
index += 1
|
||||
index = Math.min(chars.length, index + STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE)
|
||||
updateStewardThinkingEvent(messageId, eventId, chars.slice(0, index).join(''), 'running', runId)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
buildLocalApplicationPreview,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
buildModelRefinedApplicationPreview,
|
||||
applicationDateRangesOverlap,
|
||||
normalizeApplicationPreview,
|
||||
normalizeTransportModeOption,
|
||||
resolveApplicationDateRange,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
|
||||
@@ -21,16 +24,275 @@ import { fetchOntologyParse } from '../../services/ontology.js'
|
||||
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
|
||||
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
|
||||
import { fetchReceiptFolderItems } from '../../services/receiptFolder.js'
|
||||
import { fetchStewardSlotDecision } from '../../services/steward.js'
|
||||
import {
|
||||
handleBudgetCompileReportSubmit,
|
||||
shouldUseBudgetCompileReport
|
||||
} from './budgetAssistantReportModel.js'
|
||||
|
||||
const STEWARD_ASSISTANT_NAME = '小财管家'
|
||||
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 18
|
||||
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 14
|
||||
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10
|
||||
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8
|
||||
const STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4
|
||||
const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5
|
||||
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
|
||||
|
||||
const APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP = {
|
||||
applicationType: 'expense_type',
|
||||
time: 'time_range',
|
||||
location: 'location',
|
||||
reason: 'reason',
|
||||
amount: 'amount',
|
||||
transportMode: 'transport_mode',
|
||||
department: 'department_name',
|
||||
applicant: 'employee_name',
|
||||
grade: 'employee_grade'
|
||||
}
|
||||
|
||||
const APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP = {
|
||||
费用类型: 'expense_type',
|
||||
申请类型: 'expense_type',
|
||||
发生时间: 'time_range',
|
||||
出发时间: 'time_range',
|
||||
申请时间: 'time_range',
|
||||
地点: 'location',
|
||||
事由: 'reason',
|
||||
金额: 'amount',
|
||||
系统预估费用: 'amount',
|
||||
出行方式: 'transport_mode',
|
||||
附件: 'attachments',
|
||||
'附件/凭证': 'attachments',
|
||||
商户: 'merchant_name',
|
||||
'商户/开票方': 'merchant_name',
|
||||
客户: 'customer_name',
|
||||
客户或项目对象: 'customer_name'
|
||||
}
|
||||
|
||||
const ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP = {
|
||||
expense_type: 'applicationType',
|
||||
time_range: 'time',
|
||||
location: 'location',
|
||||
reason: 'reason',
|
||||
amount: 'amount',
|
||||
transport_mode: 'transportMode',
|
||||
department_name: 'department',
|
||||
employee_name: 'applicant',
|
||||
employee_grade: 'grade'
|
||||
}
|
||||
|
||||
const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
|
||||
expense_type: '费用类型',
|
||||
time_range: '时间',
|
||||
location: '地点',
|
||||
reason: '事由',
|
||||
amount: '金额',
|
||||
transport_mode: '出行方式',
|
||||
attachments: '附件/凭证',
|
||||
customer_name: '客户或项目对象',
|
||||
merchant_name: '商户/开票方',
|
||||
department_name: '所属部门',
|
||||
employee_name: '申请人',
|
||||
employee_grade: '职级'
|
||||
}
|
||||
|
||||
const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([
|
||||
'amount',
|
||||
'attachments',
|
||||
'employee_no',
|
||||
'department_name',
|
||||
'employee_name'
|
||||
])
|
||||
|
||||
const APPLICATION_DUPLICATE_IGNORED_STATUSES = new Set([
|
||||
'cancelled',
|
||||
'canceled',
|
||||
'void',
|
||||
'voided',
|
||||
'deleted',
|
||||
'已取消',
|
||||
'已作废',
|
||||
'作废',
|
||||
'已删除'
|
||||
])
|
||||
|
||||
function normalizeClaimListPayload(payload) {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
}
|
||||
return Array.isArray(payload?.items) ? payload.items : []
|
||||
}
|
||||
|
||||
function normalizeClaimRiskFlags(claim) {
|
||||
const flags = claim?.risk_flags_json || claim?.riskFlagsJson || claim?.riskFlags || []
|
||||
if (Array.isArray(flags)) {
|
||||
return flags
|
||||
}
|
||||
return flags && typeof flags === 'object' ? [flags] : []
|
||||
}
|
||||
|
||||
function extractApplicationDetailFromClaim(claim) {
|
||||
return normalizeClaimRiskFlags(claim).reduce((found, item) => {
|
||||
if (found || !item || typeof item !== 'object') {
|
||||
return found
|
||||
}
|
||||
const detail = item.application_detail || item.applicationDetail
|
||||
return detail && typeof detail === 'object' ? detail : null
|
||||
}, null)
|
||||
}
|
||||
|
||||
function isApplicationClaimRecord(claim) {
|
||||
const expenseType = String(claim?.expense_type || claim?.expenseType || '').trim().toLowerCase()
|
||||
const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase()
|
||||
return (
|
||||
expenseType === 'application' ||
|
||||
expenseType === 'expense_application' ||
|
||||
expenseType.endsWith('_application') ||
|
||||
claimNo.startsWith('AP-') ||
|
||||
claimNo.startsWith('APP-') ||
|
||||
Boolean(extractApplicationDetailFromClaim(claim))
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeApplicationExpenseType(value) {
|
||||
const text = String(value || '').trim().toLowerCase()
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
if (text === 'travel_application' || /差旅|出差/.test(text)) {
|
||||
return 'travel_application'
|
||||
}
|
||||
if (text === 'purchase_application' || /采购/.test(text)) {
|
||||
return 'purchase_application'
|
||||
}
|
||||
if (text === 'meeting_application' || /会务|会议/.test(text)) {
|
||||
return 'meeting_application'
|
||||
}
|
||||
if (text === 'expense_application' || text === 'application' || text.endsWith('_application')) {
|
||||
return text === 'application' ? 'expense_application' : text
|
||||
}
|
||||
return 'expense_application'
|
||||
}
|
||||
|
||||
function resolveClaimApplicationExpenseType(claim) {
|
||||
const detail = extractApplicationDetailFromClaim(claim) || {}
|
||||
return normalizeApplicationExpenseType(
|
||||
claim?.expense_type ||
|
||||
claim?.expenseType ||
|
||||
detail.application_type ||
|
||||
detail.applicationType ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
function isIgnoredApplicationDuplicateStatus(status) {
|
||||
return APPLICATION_DUPLICATE_IGNORED_STATUSES.has(String(status || '').trim().toLowerCase())
|
||||
}
|
||||
|
||||
function resolveClaimApplicationDateRange(claim) {
|
||||
const detail = extractApplicationDetailFromClaim(claim) || {}
|
||||
return (
|
||||
resolveApplicationDateRange(
|
||||
detail.time ||
|
||||
detail.time_range ||
|
||||
detail.timeRange ||
|
||||
detail.application_time ||
|
||||
detail.applicationTime ||
|
||||
detail.application_business_time ||
|
||||
detail.applicationBusinessTime ||
|
||||
detail.application_date ||
|
||||
detail.applicationDate,
|
||||
detail.days || detail.application_days || detail.applicationDays
|
||||
) ||
|
||||
resolveApplicationDateRange(claim?.occurred_at || claim?.occurredAt || '')
|
||||
)
|
||||
}
|
||||
|
||||
function formatApplicationDateRangeLabel(range) {
|
||||
if (!range?.startDate) {
|
||||
return '待确认'
|
||||
}
|
||||
return range.startDate === range.endDate ? range.startDate : `${range.startDate} 至 ${range.endDate}`
|
||||
}
|
||||
|
||||
function findOverlappingApplicationClaim(applicationPreview, claimsPayload) {
|
||||
const preview = normalizeApplicationPreview(applicationPreview)
|
||||
const fields = preview.fields || {}
|
||||
const currentRange = resolveApplicationDateRange(fields.time, fields.days)
|
||||
if (!currentRange) {
|
||||
return null
|
||||
}
|
||||
const currentExpenseType = normalizeApplicationExpenseType(fields.applicationType)
|
||||
const claims = normalizeClaimListPayload(claimsPayload)
|
||||
for (const claim of claims) {
|
||||
if (!isApplicationClaimRecord(claim) || isIgnoredApplicationDuplicateStatus(claim?.status)) {
|
||||
continue
|
||||
}
|
||||
const existingExpenseType = resolveClaimApplicationExpenseType(claim)
|
||||
if (currentExpenseType && existingExpenseType && currentExpenseType !== existingExpenseType) {
|
||||
continue
|
||||
}
|
||||
const existingRange = resolveClaimApplicationDateRange(claim)
|
||||
if (!existingRange || !applicationDateRangesOverlap(currentRange, existingRange)) {
|
||||
continue
|
||||
}
|
||||
return {
|
||||
claim,
|
||||
currentRange,
|
||||
existingRange,
|
||||
claimId: String(claim?.id || claim?.claim_id || claim?.claimId || '').trim(),
|
||||
claimNo: String(claim?.claim_no || claim?.claimNo || claim?.id || '').trim(),
|
||||
status: String(claim?.approval_stage || claim?.approvalStage || claim?.status || '').trim(),
|
||||
reason: String(claim?.reason || '').trim(),
|
||||
location: String(claim?.location || '').trim()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildApplicationDateConflictMessage(conflict) {
|
||||
const claimNo = conflict?.claimNo || '已有申请'
|
||||
return [
|
||||
'我先检查了你的申请时间,发现同一天或重叠日期已经存在差旅申请,不能重复创建。',
|
||||
'',
|
||||
'已有申请:',
|
||||
`- **单号**:${claimNo}`,
|
||||
`- **申请时间**:${formatApplicationDateRangeLabel(conflict?.existingRange)}`,
|
||||
conflict?.location ? `- **地点**:${conflict.location}` : '',
|
||||
conflict?.reason ? `- **事由**:${conflict.reason}` : '',
|
||||
`- **当前节点**:${conflict?.status || '处理中'}`,
|
||||
'',
|
||||
`本次识别时间:${formatApplicationDateRangeLabel(conflict?.currentRange)}`,
|
||||
'',
|
||||
'请先查看已有申请,或修改本次出差时间后再继续。'
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function buildApplicationDateConflictActions(conflict) {
|
||||
const actions = []
|
||||
if (conflict?.claimId) {
|
||||
actions.push({
|
||||
action_type: 'open_application_detail',
|
||||
label: '查看已有申请',
|
||||
description: conflict.claimNo ? `进入 ${conflict.claimNo} 单据详情。` : '进入已有申请单据详情。',
|
||||
icon: 'mdi mdi-file-search-outline',
|
||||
payload: {
|
||||
claim_id: conflict.claimId,
|
||||
claim_no: conflict.claimNo
|
||||
}
|
||||
})
|
||||
}
|
||||
actions.push({
|
||||
action_type: 'prefill_composer',
|
||||
label: '修改出差时间',
|
||||
description: '在输入框中补充新的出差日期后继续。',
|
||||
icon: 'mdi mdi-calendar-edit-outline',
|
||||
payload: {
|
||||
prompt_prefill: '修改出差时间为:'
|
||||
}
|
||||
})
|
||||
return actions
|
||||
}
|
||||
|
||||
export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const {
|
||||
MAX_ATTACHMENTS,
|
||||
@@ -145,8 +407,21 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return Array.isArray(normalized.missingFields) ? normalized.missingFields : []
|
||||
}
|
||||
|
||||
function isBlockingApplicationOntologyField(key = '') {
|
||||
const normalizedKey = String(key || '').trim()
|
||||
return Boolean(normalizedKey && !APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS.has(normalizedKey))
|
||||
}
|
||||
|
||||
function resolveBlockingApplicationMissingFieldsForSteward(preview = {}) {
|
||||
return resolveApplicationPreviewMissingFieldsForSteward(preview).filter((label) => {
|
||||
const ontologyKey = APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || ''
|
||||
return !ontologyKey || isBlockingApplicationOntologyField(ontologyKey)
|
||||
})
|
||||
}
|
||||
|
||||
function buildStewardApplicationPreviewSuggestedActions(preview = {}) {
|
||||
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(preview)
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
|
||||
if (!missingFields.includes('出行方式')) {
|
||||
return []
|
||||
}
|
||||
@@ -158,77 +433,298 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return APPLICATION_TRANSPORT_MODE_OPTIONS.map((mode) => ({
|
||||
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
|
||||
label: mode,
|
||||
description: `选择${mode}作为本次出行方式,并同步费用测算。`,
|
||||
description: `选择${mode}后,由小财管家继续查询票价并测算费用。`,
|
||||
icon: iconMap[mode] || 'mdi mdi-map-marker-path',
|
||||
payload: {
|
||||
field_key: 'transportMode',
|
||||
field_label: '出行方式',
|
||||
value: mode
|
||||
value: mode,
|
||||
applicationPreview: normalized,
|
||||
steward_delegated_field_completion: true
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function resolveStewardContinuationCurrentTask(continuation = null) {
|
||||
const task = continuation?.currentTask || continuation?.current_task || null
|
||||
return task && typeof task === 'object' ? task : null
|
||||
}
|
||||
|
||||
function normalizeCanonicalFieldList(fields = []) {
|
||||
const normalized = []
|
||||
if (!Array.isArray(fields)) {
|
||||
return normalized
|
||||
}
|
||||
fields.forEach((field) => {
|
||||
const key = String(field || '').trim()
|
||||
if (key && !normalized.includes(key)) {
|
||||
normalized.push(key)
|
||||
}
|
||||
})
|
||||
return normalized
|
||||
}
|
||||
|
||||
function buildStewardSlotDecisionOntologyFields(preview = {}, continuation = null) {
|
||||
const normalizedPreview = normalizeApplicationPreview(preview)
|
||||
const previewFields = normalizedPreview.fields || {}
|
||||
const task = resolveStewardContinuationCurrentTask(continuation)
|
||||
const taskFields = task?.ontology_fields || task?.ontologyFields || {}
|
||||
const fields = {}
|
||||
Object.entries(taskFields || {}).forEach(([key, value]) => {
|
||||
const normalizedKey = String(key || '').trim()
|
||||
const normalizedValue = String(value || '').trim()
|
||||
if (normalizedKey && normalizedValue) {
|
||||
fields[normalizedKey] = normalizedValue
|
||||
}
|
||||
})
|
||||
Object.entries(APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP).forEach(([previewKey, ontologyKey]) => {
|
||||
const value = String(previewFields[previewKey] || '').trim()
|
||||
if (value && value !== '待补充' && !fields[ontologyKey]) {
|
||||
fields[ontologyKey] = value
|
||||
}
|
||||
})
|
||||
return fields
|
||||
}
|
||||
|
||||
function buildStewardSlotDecisionMissingFields(preview = {}, continuation = null, ontologyFields = {}) {
|
||||
const task = resolveStewardContinuationCurrentTask(continuation)
|
||||
const taskMissingFields = normalizeCanonicalFieldList(task?.missing_fields || task?.missingFields || [])
|
||||
.filter((key) => isBlockingApplicationOntologyField(key) && !String(ontologyFields[key] || '').trim())
|
||||
if (taskMissingFields.length) {
|
||||
return taskMissingFields
|
||||
}
|
||||
return resolveApplicationPreviewMissingFieldsForSteward(preview)
|
||||
.map((label) => APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || '')
|
||||
.filter((key, index, list) =>
|
||||
key &&
|
||||
isBlockingApplicationOntologyField(key) &&
|
||||
!String(ontologyFields[key] || '').trim() &&
|
||||
list.indexOf(key) === index
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchStewardApplicationSlotDecision(preview = {}, rawText = '', continuation = null) {
|
||||
const ontologyFields = buildStewardSlotDecisionOntologyFields(preview, continuation)
|
||||
const missingFields = buildStewardSlotDecisionMissingFields(preview, continuation, ontologyFields)
|
||||
try {
|
||||
return await fetchStewardSlotDecision({
|
||||
task_type: 'expense_application',
|
||||
user_message: String(rawText || '').trim(),
|
||||
ontology_fields: ontologyFields,
|
||||
missing_fields: missingFields,
|
||||
task_context: {
|
||||
steward_continuation: continuation || null,
|
||||
application_preview: normalizeApplicationPreview(preview)
|
||||
}
|
||||
}, {
|
||||
timeoutMs: 45000,
|
||||
timeoutMessage: '小财管家字段决策超时,已按当前本体缺口继续追问。'
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Steward slot decision failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatStewardDecisionUserText(text = '') {
|
||||
let formatted = String(text || '').trim()
|
||||
Object.entries(ONTOLOGY_FIELD_DISPLAY_LABEL_MAP).forEach(([fieldKey, label]) => {
|
||||
const escapedKey = fieldKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
formatted = formatted
|
||||
.replace(new RegExp(`(\\s*${escapedKey}\\s*)`, 'g'), '')
|
||||
.replace(new RegExp(`\\(\\s*${escapedKey}\\s*\\)`, 'g'), '')
|
||||
.replace(new RegExp(`\\b${escapedKey}\\b`, 'g'), label)
|
||||
})
|
||||
return formatted.replace(/\s{2,}/g, ' ').trim()
|
||||
}
|
||||
|
||||
function buildStewardSlotDecisionMessage(decision = null, preview = {}, fallbackText = '') {
|
||||
if (!decision || String(decision.next_action || '').trim() !== 'ask_user') {
|
||||
return fallbackText
|
||||
}
|
||||
const question = formatStewardDecisionUserText(decision.question || '')
|
||||
const rationale = formatStewardDecisionUserText(decision.rationale || '')
|
||||
const parts = [
|
||||
'我已经识别出这一步要先处理申请单,但当前还不能直接生成可提交的申请核对表。',
|
||||
'',
|
||||
rationale ? `**原因是:${rationale}**` : '',
|
||||
'',
|
||||
question || buildStewardApplicationPreviewMessage(preview, fallbackText)
|
||||
].filter((item) => item !== '')
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function buildStewardSlotDecisionSuggestedActions(decision = null, preview = {}) {
|
||||
if (!decision || String(decision.next_action || '').trim() !== 'ask_user') {
|
||||
return []
|
||||
}
|
||||
const normalizedPreview = normalizeApplicationPreview(preview)
|
||||
const iconMap = {
|
||||
火车: 'mdi mdi-train',
|
||||
飞机: 'mdi mdi-airplane',
|
||||
轮船: 'mdi mdi-ferry'
|
||||
}
|
||||
const actions = Array.isArray(decision.options) ? decision.options : []
|
||||
return actions.map((option) => {
|
||||
const canonicalField = String(option?.field_key || option?.fieldKey || '').trim()
|
||||
if (canonicalField && !isBlockingApplicationOntologyField(canonicalField)) {
|
||||
return null
|
||||
}
|
||||
const fieldKey = ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP[canonicalField] || canonicalField
|
||||
const value = String(option?.value || option?.label || '').trim()
|
||||
const label = String(option?.label || value).trim()
|
||||
const normalizedValue = fieldKey === 'transportMode'
|
||||
? normalizeTransportModeOption(value || label, '')
|
||||
: value
|
||||
if (!fieldKey || !value || !label) {
|
||||
return null
|
||||
}
|
||||
if (fieldKey === 'transportMode' && !normalizedValue) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
|
||||
label,
|
||||
description: String(option?.description || '').trim() || `选择${label}后,由小财管家继续测算并生成核对结果。`,
|
||||
icon: iconMap[label] || iconMap[value] || 'mdi mdi-form-select',
|
||||
payload: {
|
||||
field_key: fieldKey,
|
||||
field_label: ONTOLOGY_FIELD_DISPLAY_LABEL_MAP[canonicalField] || label,
|
||||
value: normalizedValue,
|
||||
applicationPreview: normalizedPreview,
|
||||
steward_delegated_field_completion: true
|
||||
}
|
||||
}
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const fields = normalized.fields || {}
|
||||
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
|
||||
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized)
|
||||
if (!missingFields.length) {
|
||||
return fallbackText
|
||||
}
|
||||
|
||||
if (missingFields.includes('出行方式')) {
|
||||
return [
|
||||
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
|
||||
'我已经识别出这一步要先处理出差申请,但现在还不能生成可提交的申请核对表。',
|
||||
'',
|
||||
'**原因是:还缺少“出行方式”。**',
|
||||
'',
|
||||
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
|
||||
'',
|
||||
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择更新下方核对表和费用测算,再继续判断是否可以提交申请。'
|
||||
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
|
||||
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
|
||||
'',
|
||||
`**还需要你补充:${missingFields.join('、')}。**`,
|
||||
'',
|
||||
`请先补充 **${missingFields[0]}**。你也可以直接点击下方核对表里的对应行编辑;补齐后我再继续推进下一步。`
|
||||
`请先补充 **${missingFields[0]}**。补齐后我再生成申请核对表并继续推进下一步。`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function shouldPauseStewardApplicationPreview(preview = {}) {
|
||||
return resolveBlockingApplicationMissingFieldsForSteward(preview).length > 0
|
||||
}
|
||||
|
||||
function extractStewardCarryLine(text = '', label = '') {
|
||||
const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[::]([^\\n]+)`, 'u'))
|
||||
return match ? match[1].trim() : ''
|
||||
}
|
||||
|
||||
function extractStewardDelegatedTaskTitle(text = '', sessionType = '') {
|
||||
const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[::]([^。\n]+)/u)
|
||||
if (taskMatch?.[1]) {
|
||||
return taskMatch[1].trim()
|
||||
}
|
||||
return String(sessionType || '').trim() === 'application' ? '本次出差申请' : '本次费用报销'
|
||||
}
|
||||
|
||||
function sanitizeStewardDelegatedTaskSummary(summary = '', sessionType = '') {
|
||||
const text = String(summary || '').trim()
|
||||
if (String(sessionType || '').trim() !== 'application') {
|
||||
return text
|
||||
}
|
||||
return text
|
||||
.replace(/交通方式和(?:预算|预计)?金额待补充/g, '交通方式待补充')
|
||||
.replace(/出行方式和(?:预算|预计)?金额待补充/g, '出行方式待补充')
|
||||
.replace(/交通方式及(?:预估|预计|预算)?费用/g, '交通方式')
|
||||
.replace(/出行方式及(?:预估|预计|预算)?费用/g, '出行方式')
|
||||
.replace(/(?:费用预算|预算费用|出差费用预算)(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)?[,,、;;\s]*/g, '')
|
||||
.replace(/[,,、;;\s]*(?:预估|预计|预算)费用(?:待补充|待确认|待填写|需补充|需要补充|需确认|需要确认|未补充|缺失)?/g, '')
|
||||
.replace(/[,,、;;\s]*(?:预算|预计)?金额(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)/g, '')
|
||||
.replace(/([,,、;;。])\1+/g, '$1')
|
||||
.replace(/[,,、;;\s]+。/g, '。')
|
||||
.replace(/[,,、;;\s]+$/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function summarizeApplicationPreviewForSteward(preview = {}) {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const fields = normalized.fields || {}
|
||||
return [
|
||||
fields.time ? `时间:${fields.time}` : '',
|
||||
fields.location ? `地点:${fields.location}` : '',
|
||||
fields.reason ? `事由:${fields.reason}` : '',
|
||||
fields.applicationType ? `类型:${fields.applicationType}` : ''
|
||||
].filter(Boolean).join(';')
|
||||
}
|
||||
|
||||
function buildStewardDelegatedThinkingEvents(sessionType = '', continuation = null, context = {}) {
|
||||
const actionLabel = resolveStewardDelegatedActionLabel(sessionType)
|
||||
const eventPrefix = `steward-delegated-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const rawText = String(context.rawText || '').trim()
|
||||
const taskTitle = extractStewardDelegatedTaskTitle(rawText, sessionType)
|
||||
const taskSummary = sanitizeStewardDelegatedTaskSummary(
|
||||
extractStewardCarryLine(rawText, '任务摘要'),
|
||||
sessionType
|
||||
)
|
||||
const identifiedInfo = summarizeApplicationPreviewForSteward(context.applicationPreview)
|
||||
|| extractStewardCarryLine(rawText, '已识别信息')
|
||||
const carryMissingInfo = extractStewardCarryLine(rawText, '还需要补充')
|
||||
const applicationMissingFields = context.applicationPreview
|
||||
? resolveBlockingApplicationMissingFieldsForSteward(context.applicationPreview)
|
||||
: []
|
||||
const missingInfo = applicationMissingFields.length
|
||||
? applicationMissingFields.join('、')
|
||||
: carryMissingInfo
|
||||
const events = [
|
||||
{
|
||||
eventId: `${eventPrefix}-confirm`,
|
||||
title: '接收确认',
|
||||
content: '已收到你的确认,小财管家继续推进当前任务。'
|
||||
eventId: `${eventPrefix}-intent`,
|
||||
title: '理解当前任务',
|
||||
content: taskSummary
|
||||
? `你确认先处理“${taskTitle}”。我把这一步理解为:${taskSummary}。`
|
||||
: `你确认先处理“${taskTitle}”,我会先生成${actionLabel}结果。`
|
||||
},
|
||||
{
|
||||
eventId: `${eventPrefix}-coordinate`,
|
||||
title: '协调能力',
|
||||
content: `正在协调${actionLabel}能力,先生成可核对的结果;这个过程仍由小财管家统一呈现。`
|
||||
eventId: `${eventPrefix}-known`,
|
||||
title: '核对已知信息',
|
||||
content: identifiedInfo
|
||||
? `当前已识别到:${identifiedInfo}。`
|
||||
: `当前先围绕“${taskTitle}”生成可核对内容,具体缺口会在核对结果里继续判断。`
|
||||
}
|
||||
]
|
||||
const applicationMissingFields = context.applicationPreview
|
||||
? resolveApplicationPreviewMissingFieldsForSteward(context.applicationPreview)
|
||||
: []
|
||||
if (applicationMissingFields.length) {
|
||||
if (missingInfo) {
|
||||
const transportMissing = /出行方式/.test(missingInfo)
|
||||
events.push({
|
||||
eventId: `${eventPrefix}-gap`,
|
||||
title: '识别缺口',
|
||||
content: `核对结果还缺少${applicationMissingFields.join('、')},我会先向你追问,不直接推进提交。`
|
||||
title: '判断待补充信息',
|
||||
content: transportMissing
|
||||
? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。'
|
||||
: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
|
||||
})
|
||||
} else {
|
||||
events.push({
|
||||
eventId: `${eventPrefix}-ready`,
|
||||
title: '判断下一步动作',
|
||||
content: `这一步的关键业务信息已形成核对结果。我会先让你检查${actionLabel},确认后再继续入库、生成草稿或处理后续任务。`
|
||||
})
|
||||
}
|
||||
events.push(
|
||||
{
|
||||
eventId: `${eventPrefix}-output`,
|
||||
title: '准备输出',
|
||||
content: '结果准备好后,我会先逐字输出正文,再展示核对表、卡片或确认按钮。'
|
||||
}
|
||||
)
|
||||
return events
|
||||
}
|
||||
|
||||
@@ -257,6 +753,9 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
async function typeStewardDelegatedMessage(messageId, finalText, finalExtras = {}, context = {}) {
|
||||
const continuation = finalExtras.stewardContinuation || context.stewardContinuation || null
|
||||
const pendingSuggestedActions = Array.isArray(finalExtras.suggestedActions)
|
||||
? finalExtras.suggestedActions
|
||||
: []
|
||||
const message = messages.value.find((item) => item.id === messageId)
|
||||
if (!message) {
|
||||
return
|
||||
@@ -287,11 +786,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const chars = Array.from(String(eventData.content || ''))
|
||||
for (let index = 0; index < chars.length; index += 1) {
|
||||
for (let index = 0; index < chars.length;) {
|
||||
await waitStewardDelegatedTick(STEWARD_DELEGATED_THINKING_INTERVAL_MS)
|
||||
event.content = chars.slice(0, index + 1).join('')
|
||||
index = Math.min(chars.length, index + STEWARD_DELEGATED_THINKING_CHUNK_SIZE)
|
||||
event.content = chars.slice(0, index).join('')
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
|
||||
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
|
||||
if (index % STEWARD_DELEGATED_THINKING_CHUNK_SIZE === 0 || index === chars.length) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
}
|
||||
@@ -304,14 +804,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const text = String(finalText || '')
|
||||
message.text = ''
|
||||
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
message.suggestedActions = pendingSuggestedActions
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
|
||||
const chars = Array.from(text)
|
||||
for (let index = 0; index < chars.length; index += 1) {
|
||||
for (let index = 0; index < chars.length;) {
|
||||
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
|
||||
message.text = chars.slice(0, index + 1).join('')
|
||||
index = Math.min(chars.length, index + STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE)
|
||||
message.text = chars.slice(0, index).join('')
|
||||
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
|
||||
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
|
||||
if (index % STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE === 0 || index === chars.length) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
}
|
||||
@@ -670,7 +1172,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return currentUser.value || user
|
||||
}
|
||||
|
||||
async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null, sessionTypeOverride = '') {
|
||||
async function buildApplicationPreviewWithModelReview(
|
||||
rawText,
|
||||
businessTimeContext = null,
|
||||
sessionTypeOverride = '',
|
||||
options = {}
|
||||
) {
|
||||
const user = await resolveApplicationPreviewUser()
|
||||
const localPreview = applyApplicationBusinessTimeContext(
|
||||
buildLocalApplicationPreview(rawText, user),
|
||||
@@ -697,6 +1204,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
if (options.skipModelReview) {
|
||||
return {
|
||||
applicationPreview: await enrichWithPolicyEstimate({
|
||||
...localPreview,
|
||||
modelReviewStatus: 'skipped'
|
||||
}),
|
||||
meta: ['申请核对预览', '结构化快路径']
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const ontology = await fetchOntologyParse(
|
||||
{
|
||||
@@ -828,7 +1345,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
|
||||
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
|
||||
|
||||
if (shouldUseBudgetCompileReport(rawText, {
|
||||
if (!stewardDelegated && shouldUseBudgetCompileReport(rawText, {
|
||||
sessionType: effectiveSessionType,
|
||||
entrySource: props.entrySource,
|
||||
budgetContext: props.initialBudgetContext
|
||||
@@ -944,9 +1461,62 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(
|
||||
rawText,
|
||||
selectedBusinessTimeContext,
|
||||
effectiveSessionType
|
||||
effectiveSessionType,
|
||||
{
|
||||
skipModelReview: Boolean(stewardDelegated && options.skipApplicationModelReview)
|
||||
}
|
||||
)
|
||||
const reviewStatus = String(meta?.[1] || '').trim()
|
||||
let applicationDateConflict = null
|
||||
try {
|
||||
const existingClaims = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
||||
applicationDateConflict = findOverlappingApplicationClaim(applicationPreview, existingClaims)
|
||||
} catch (error) {
|
||||
console.warn('Failed to check overlapping application dates:', error)
|
||||
}
|
||||
|
||||
if (applicationDateConflict) {
|
||||
const conflictText = buildApplicationDateConflictMessage(applicationDateConflict)
|
||||
const conflictActions = buildApplicationDateConflictActions(applicationDateConflict)
|
||||
if (!stewardDelegated) {
|
||||
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
|
||||
completeFlowStep(
|
||||
'application-review-preview',
|
||||
'检测到同日期已有申请,已停止重复创建',
|
||||
Date.now() - reviewStartedAt
|
||||
)
|
||||
replaceMessage(pendingMessage.id, createMessage(
|
||||
'assistant',
|
||||
conflictText,
|
||||
[],
|
||||
{
|
||||
meta: ['申请日期冲突'],
|
||||
suggestedActions: conflictActions,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
))
|
||||
} else {
|
||||
await typeStewardDelegatedMessage(
|
||||
pendingMessage.id,
|
||||
conflictText,
|
||||
{
|
||||
meta: [STEWARD_ASSISTANT_NAME, '申请日期冲突'],
|
||||
suggestedActions: conflictActions,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
},
|
||||
{
|
||||
sessionType: effectiveSessionType,
|
||||
rawText,
|
||||
applicationPreview,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
)
|
||||
}
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!stewardDelegated) {
|
||||
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
|
||||
completeFlowStep(
|
||||
@@ -960,16 +1530,43 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
)
|
||||
}
|
||||
if (stewardDelegated) {
|
||||
const fallbackStewardApplicationText = buildStewardApplicationPreviewMessage(
|
||||
applicationPreview,
|
||||
buildLocalApplicationPreviewMessage(applicationPreview)
|
||||
)
|
||||
const localPauseForMissingFields = shouldPauseStewardApplicationPreview(applicationPreview)
|
||||
const shouldFetchSlotDecision = localPauseForMissingFields && !options.skipStewardSlotDecision
|
||||
const slotDecision = shouldFetchSlotDecision
|
||||
? await fetchStewardApplicationSlotDecision(
|
||||
applicationPreview,
|
||||
rawText,
|
||||
options.stewardContinuation || null
|
||||
)
|
||||
: null
|
||||
const slotDecisionActions = buildStewardSlotDecisionSuggestedActions(slotDecision, applicationPreview)
|
||||
const pauseForMissingFields = slotDecision
|
||||
? String(slotDecision.next_action || '').trim() === 'ask_user'
|
||||
: localPauseForMissingFields
|
||||
const stewardApplicationText = buildStewardSlotDecisionMessage(
|
||||
slotDecision,
|
||||
applicationPreview,
|
||||
fallbackStewardApplicationText
|
||||
)
|
||||
await typeStewardDelegatedMessage(
|
||||
pendingMessage.id,
|
||||
buildLocalApplicationPreviewMessage(applicationPreview),
|
||||
stewardApplicationText,
|
||||
{
|
||||
meta,
|
||||
applicationPreview,
|
||||
applicationPreview: pauseForMissingFields ? null : applicationPreview,
|
||||
suggestedActions: slotDecisionActions.length
|
||||
? slotDecisionActions
|
||||
: buildStewardApplicationPreviewSuggestedActions(applicationPreview),
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
},
|
||||
{
|
||||
sessionType: effectiveSessionType,
|
||||
rawText,
|
||||
applicationPreview,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
)
|
||||
@@ -1478,6 +2075,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
},
|
||||
{
|
||||
sessionType: effectiveSessionType,
|
||||
rawText,
|
||||
fileNames: effectiveFileNames,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
)
|
||||
|
||||
@@ -51,11 +51,6 @@ const chatViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/ChatView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const operationFeedbackInlineTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/shared/OperationFeedbackInlineCard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('application and reimbursement entries open the same financial assistant modal', () => {
|
||||
assert.match(appShellRouteView, /<TravelReimbursementCreateView[\s\S]*:entry-source="smartEntryContext\.source"/)
|
||||
assert.match(appShellRouteView, /@create-request="openTravelCreate"/)
|
||||
@@ -134,8 +129,10 @@ test('application edit prefill opens assistant without auto submit', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('financial assistant toolbar renders four isolated assistant sessions', () => {
|
||||
test('financial assistant toolbar renders isolated assistant sessions without steward entry', () => {
|
||||
assert.match(assistantScript, /filterAssistantSessionModes\(ASSISTANT_SESSION_MODE_OPTIONS, currentUser\.value\)/)
|
||||
assert.match(assistantScript, /\.filter\(\(mode\) => mode\.key !== SESSION_TYPE_STEWARD\)/)
|
||||
assert.match(assistantScript, /mode\.key === SESSION_TYPE_BUDGET/)
|
||||
assert.match(assistantScript, /visibleModes\.map/)
|
||||
assert.match(assistantScript, /targetSessionType:\s*mode\.key/)
|
||||
assert.match(assistantScript, /active:\s*mode\.key === activeSessionType\.value/)
|
||||
@@ -199,38 +196,39 @@ test('assistant message meta hides internal routing and permission chips', () =>
|
||||
assert.doesNotMatch(chatViewTemplate, /agent-meta-row|agent-meta-chip/)
|
||||
})
|
||||
|
||||
test('assistant operation feedback is inline and persists run context', () => {
|
||||
test('assistant message action toolbar collects lightweight feedback', () => {
|
||||
assert.doesNotMatch(appShellRouteView, /<OperationFeedbackDialog/)
|
||||
assert.doesNotMatch(appShellRouteView, /@operation-completed="handleOperationCompleted"/)
|
||||
assert.doesNotMatch(appShellComposable, /useOperationFeedback/)
|
||||
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
|
||||
assert.match(messageItemTemplate, /class="message-feedback-bubble"/)
|
||||
assert.match(messageItemTemplate, /:submitted="Boolean\(message\.operationFeedback\?\.submitted\)"/)
|
||||
assert.match(messageItemTemplate, /:submitted-rating="Number\(message\.operationFeedback\?\.rating \|\| 0\)"/)
|
||||
assert.doesNotMatch(messageItemTemplate, /<OperationFeedbackInlineCard/)
|
||||
assert.match(messageItemTemplate, /class="message-action-toolbar"/)
|
||||
assert.match(messageItemTemplate, /ui\.shouldShowAssistantMessageActions\(message\)/)
|
||||
assert.match(messageItemTemplate, /ui\.copyAssistantMessage\(message\)/)
|
||||
assert.match(messageItemTemplate, /ui\.speakAssistantMessage\(message\)/)
|
||||
assert.match(messageItemTemplate, /rating:\s*5,\s*reason:\s*'thumbs_up'/)
|
||||
assert.match(messageItemTemplate, /rating:\s*1,\s*reason:\s*'thumbs_down'/)
|
||||
assert.match(messageItemTemplate, /mdi mdi-content-copy/)
|
||||
assert.match(messageItemTemplate, /mdi mdi-volume-high/)
|
||||
assert.match(messageItemTemplate, /mdi mdi-thumb-up-outline/)
|
||||
assert.match(messageItemTemplate, /mdi mdi-thumb-down-outline/)
|
||||
assert.match(assistantScript, /emits:\s*\['close', 'draft-saved', 'request-updated'\]/)
|
||||
assert.match(appShellRouteView, /@request-updated="handleRequestUpdated"/)
|
||||
assert.match(assistantScript, /function buildMessageOperationFeedbackContext/)
|
||||
assert.match(assistantScript, /source:\s*'assistant_message_action'/)
|
||||
assert.match(assistantScript, /operation_type:\s*message\?\.stewardPlan \? 'steward_message' : 'assistant_message'/)
|
||||
assert.match(assistantScript, /function shouldShowAssistantMessageActions/)
|
||||
assert.match(assistantScript, /function copyAssistantMessage/)
|
||||
assert.match(assistantScript, /function speakAssistantMessage/)
|
||||
assert.match(assistantScript, /function isMessageFeedbackSelected/)
|
||||
assert.match(assistantScript, /function submitOperationFeedbackForMessage/)
|
||||
assert.match(assistantScript, /createOperationFeedback/)
|
||||
assert.match(assistantScript, /normalizeOperationFeedbackContext/)
|
||||
assert.match(assistantScript, /&& !feedback\.dismissed/)
|
||||
assert.doesNotMatch(assistantScript, /&& !feedback\.submitted/)
|
||||
assert.match(assistantScript, /submitted:\s*true/)
|
||||
assert.match(assistantScript, /dismissed:\s*false/)
|
||||
assert.doesNotMatch(assistantScript, /emit\('operation-completed'/)
|
||||
assert.match(assistantSubmitComposerScript, /emitOperationCompleted\?\.\(payload/)
|
||||
assert.match(assistantSubmitComposerScript, /operationFeedback:\s*buildOperationFeedbackState/)
|
||||
assert.match(assistantSubmitComposerScript, /rating:\s*0/)
|
||||
assert.match(operationFeedbackInlineTemplate, /v-for="option in ratingOptions"/)
|
||||
assert.match(operationFeedbackInlineTemplate, /is-submitted/)
|
||||
assert.match(operationFeedbackInlineTemplate, /submittedRating/)
|
||||
assert.match(operationFeedbackInlineTemplate, /感谢您的反馈。谢谢/)
|
||||
assert.match(operationFeedbackInlineTemplate, /busy \|\| submitted/)
|
||||
assert.match(operationFeedbackInlineTemplate, /role="radiogroup"/)
|
||||
assert.match(operationFeedbackInlineTemplate, /handleRatingKeydown/)
|
||||
assert.match(operationFeedbackInlineTemplate, /operation-feedback-stars/)
|
||||
assert.match(operationFeedbackInlineTemplate, /score > 3/)
|
||||
assert.match(operationFeedbackInlineTemplate, /v-if="showReasonInput"/)
|
||||
assert.match(operationFeedbackInlineTemplate, /稍后/)
|
||||
|
||||
const context = normalizeOperationFeedbackContext(
|
||||
{
|
||||
|
||||
@@ -25,6 +25,17 @@ test('document center archived rows are detected from archive flag or request st
|
||||
expense_type: 'travel_application'
|
||||
}
|
||||
}),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
isArchivedDocumentRow({
|
||||
rawRequest: {
|
||||
status: 'approved',
|
||||
approval_stage: '申请归档',
|
||||
claim_no: 'AP-20260525120000-ABCDEFGH',
|
||||
expense_type: 'travel_application'
|
||||
}
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
|
||||
@@ -2,13 +2,17 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildDocumentViewedStatePatch,
|
||||
buildDocumentsViewedStatePatches,
|
||||
countNewDocuments,
|
||||
isNewDocument,
|
||||
markDocumentViewed,
|
||||
markDocumentsViewed,
|
||||
mergeNotificationStatesIntoViewedDocumentKeys,
|
||||
readDocumentScope,
|
||||
readViewedDocumentKeys,
|
||||
resolveDocumentNewKey,
|
||||
resolveDocumentNotificationId,
|
||||
writeDocumentScope
|
||||
} from '../src/utils/documentCenterNewState.js'
|
||||
import { buildDocumentInboxRows } from '../src/composables/useDocumentCenterInbox.js'
|
||||
@@ -28,6 +32,38 @@ function createMemoryStorage(initial = {}) {
|
||||
test('document center new state resolves source scoped document keys', () => {
|
||||
assert.equal(resolveDocumentNewKey({ source: 'archive', claimId: 'claim-1' }), 'archive:claim-1')
|
||||
assert.equal(resolveDocumentNewKey({ source: 'approval', documentNo: 'EXP-1' }), 'approval:EXP-1')
|
||||
assert.equal(
|
||||
resolveDocumentNotificationId({ source: 'owned', claimId: 'claim-1', documentKey: 'owned:claim-1' }),
|
||||
'document:owned:claim-1'
|
||||
)
|
||||
})
|
||||
|
||||
test('document center merges backend notification states into viewed keys', () => {
|
||||
const storage = createMemoryStorage()
|
||||
const viewedKeys = mergeNotificationStatesIntoViewedDocumentKeys([
|
||||
{ notification_id: 'document:owned:claim-1', read_at: '2026-06-05T09:00:00+08:00' },
|
||||
{ notificationId: 'document:approval:claim-2', hiddenAt: '2026-06-05T09:01:00+08:00' },
|
||||
{ notification_id: 'workbench:todo:claim-3', read_at: '2026-06-05T09:02:00+08:00' }
|
||||
], readViewedDocumentKeys(storage), storage)
|
||||
|
||||
assert.equal(isNewDocument({ source: 'owned', claimId: 'claim-1' }, viewedKeys), false)
|
||||
assert.equal(isNewDocument({ source: 'approval', claimId: 'claim-2' }, viewedKeys), false)
|
||||
assert.deepEqual([...readViewedDocumentKeys(storage)], ['owned:claim-1', 'approval:claim-2'])
|
||||
})
|
||||
|
||||
test('document center builds backend viewed-state patches for unread rows', () => {
|
||||
const rows = [
|
||||
{ source: 'owned', claimId: 'claim-1', documentKey: 'owned:claim-1' },
|
||||
{ source: 'approval', claimId: 'claim-2', documentKey: 'approval:claim-2' },
|
||||
{ source: 'archive', claimId: 'claim-3', documentKey: 'archive:claim-3' }
|
||||
]
|
||||
const patches = buildDocumentsViewedStatePatches(rows, new Set(['owned:claim-1']))
|
||||
|
||||
assert.deepEqual(patches.map((patch) => patch.notification_id), ['document:approval:claim-2'])
|
||||
assert.equal(patches[0].read, true)
|
||||
assert.equal(patches[0].hidden, false)
|
||||
assert.equal(patches[0].context_json.kind, 'document')
|
||||
assert.equal(buildDocumentViewedStatePatch(rows[2]), null)
|
||||
})
|
||||
|
||||
test('document center new state counts unseen documents and persists viewed rows', () => {
|
||||
|
||||
@@ -208,6 +208,9 @@ test('documents center category tabs render bubble counts for new documents', ()
|
||||
|
||||
test('documents center can mark all unread documents as read from toolbar', () => {
|
||||
assert.match(documentsCenterView, /markDocumentsViewed/)
|
||||
assert.match(documentsCenterView, /patchNotificationStates/)
|
||||
assert.match(documentsCenterView, /buildDocumentsViewedStatePatches/)
|
||||
assert.match(documentsCenterView, /mergeNotificationStatesIntoViewedDocumentKeys/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/const allReadableDocumentRows = computed\(\(\) => \[[\s\S]*nonArchivedRows\.value[\s\S]*filterApplicationScopeNewRows\(applicationScopeRows\.value\)[\s\S]*approvalRows\.value/
|
||||
@@ -222,6 +225,10 @@ test('documents center can mark all unread documents as read from toolbar', () =
|
||||
documentsCenterView,
|
||||
/function markAllDocumentsRead\(\) \{[\s\S]*viewedDocumentKeys\.value = markDocumentsViewed\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)/
|
||||
)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/function markAllDocumentsRead\(\) \{[\s\S]*const viewedPatches = buildDocumentsViewedStatePatches\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)[\s\S]*syncDocumentViewedPatches\(viewedPatches\)/
|
||||
)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/function resolveLiveDocumentRow\(row\) \{[\s\S]*isNewDocument: isNewDocument\(row, viewedDocumentKeys\.value\)[\s\S]*const visibleRows = computed\(\(\) => \{[\s\S]*\.map\(resolveLiveDocumentRow\)/
|
||||
@@ -232,9 +239,10 @@ test('documents center can mark all unread documents as read from toolbar', () =
|
||||
test('documents center rows show NEW marker until the row is opened', () => {
|
||||
assert.match(documentsCenterView, /<span v-if="row\.isNewDocument" class="new-document-badge">NEW<\/span>/)
|
||||
assert.match(documentsCenterView, /isNewDocument: archived\s*\?\s*false\s*:\s*isNewDocument\(/)
|
||||
assert.match(documentsCenterView, /buildDocumentViewedStatePatch\(row\)/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/function openDocument\(row\) \{[\s\S]*writeDocumentScope\(activeScopeTab\.value, scopeTabs\)[\s\S]*viewedDocumentKeys\.value = markDocumentViewed\(row, viewedDocumentKeys\.value\)[\s\S]*emit\('open-document', row\.rawRequest \|\| row\)/
|
||||
/function openDocument\(row\) \{[\s\S]*writeDocumentScope\(activeScopeTab\.value, scopeTabs\)[\s\S]*viewedDocumentKeys\.value = markDocumentViewed\(row, viewedDocumentKeys\.value\)[\s\S]*syncDocumentViewedPatches\(\[viewedPatch\]\)[\s\S]*emit\('open-document', row\.rawRequest \|\| row\)/
|
||||
)
|
||||
assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*background:\s*#fff5f5;/)
|
||||
assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*border:\s*1px solid #fecaca;/)
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
buildLocalApplicationPreview,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
buildModelRefinedApplicationPreview,
|
||||
applicationDateRangesOverlap,
|
||||
normalizeApplicationPreview,
|
||||
normalizeTransportModeOption,
|
||||
resolveApplicationDateRange,
|
||||
resolveApplicationTimeLabel,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../src/utils/expenseApplicationPreview.js'
|
||||
@@ -36,6 +39,17 @@ import {
|
||||
createMessage as createConversationMessage,
|
||||
hasMeaningfulSessionMessages
|
||||
} from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||||
import {
|
||||
buildStewardSuggestedActions,
|
||||
filterStewardBlockingMissingFields
|
||||
} from '../src/views/scripts/stewardPlanModel.js'
|
||||
import {
|
||||
buildStewardFieldCompletionContinuation,
|
||||
buildStewardFieldCompletionRawText
|
||||
} from '../src/views/scripts/stewardFieldCompletionModel.js'
|
||||
import {
|
||||
shouldUseBudgetCompileReport
|
||||
} from '../src/views/scripts/budgetAssistantReportModel.js'
|
||||
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
|
||||
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js'
|
||||
|
||||
@@ -43,10 +57,22 @@ const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardServiceScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/steward.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardPlanFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useStewardPlanFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardFieldCompletionScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/stewardFieldCompletionModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -506,7 +532,7 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(submitComposerScript, /startFlowStep\('intent'/)
|
||||
assert.match(submitComposerScript, /startFlowStep\('application-review-preview'/)
|
||||
assert.match(submitComposerScript, /completeFlowStep\('intent'/)
|
||||
assert.doesNotMatch(submitComposerScript, /insightPanelCollapsed\.value = true/)
|
||||
assert.match(submitComposerScript, /function resetStewardDelegatedInsightState\(\) \{[\s\S]*insightPanelCollapsed\.value = true/)
|
||||
assert.doesNotMatch(submitComposerScript, /void refineApplicationPreviewWithModel/)
|
||||
assert.match(submitComposerScript, /return null[\s\S]*const hasUnsavedReviewDraft/)
|
||||
assert.ok(
|
||||
@@ -542,9 +568,16 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemTemplate, /申请单据已生成/)
|
||||
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
|
||||
assert.match(messageItemTemplate, /报销草稿已生成/)
|
||||
assert.match(messageItemTemplate, /报销草稿待保存/)
|
||||
assert.match(messageItemTemplate, /ui\.resolveReimbursementDraftClaimNo\(message\.draftPayload\)/)
|
||||
assert.match(messageItemTemplate, /v-if="ui\.canOpenDraftDetail\(message\)"/)
|
||||
assert.match(messageItemTemplate, /class="reimbursement-draft-link"/)
|
||||
assert.match(messageItemTemplate, /查看详情/)
|
||||
assert.match(messageItemTemplate, /class="reimbursement-draft-pending-detail"/)
|
||||
assert.match(messageItemTemplate, /保存后可查看详情/)
|
||||
assert.match(createViewScript, /function canOpenDraftDetail\(message\)/)
|
||||
assert.match(createViewScript, /canOpenDraftDetail,/)
|
||||
assert.match(createViewScript, /保存后生成/)
|
||||
assert.doesNotMatch(messageItemTemplate, /ui\.buildReimbursementDraftSummaryItems\(message\.draftPayload\)/)
|
||||
assert.doesNotMatch(messageItemTemplate, /可以继续上传票据,我会归集到这张草稿。/)
|
||||
assert.ok(
|
||||
@@ -558,9 +591,15 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemTemplate, /完整审批链、附件和明细可在单据详情中[\s\S]*application-draft-detail-link[\s\S]*>查看<\/button>/)
|
||||
assert.doesNotMatch(messageItemTemplate, /application-draft-detail-btn/)
|
||||
assert.match(messageItemTemplate, /ui\.openApplicationDraftDetail\(message\)/)
|
||||
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
|
||||
assert.match(messageItemTemplate, /ui\.isOperationFeedbackVisible\(message\)/)
|
||||
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \$event\)/)
|
||||
assert.doesNotMatch(messageItemTemplate, /<OperationFeedbackInlineCard/)
|
||||
assert.match(messageItemTemplate, /class="message-action-toolbar"/)
|
||||
assert.match(messageItemTemplate, /ui\.shouldShowAssistantMessageActions\(message\)/)
|
||||
assert.match(messageItemTemplate, /ui\.copyAssistantMessage\(message\)/)
|
||||
assert.match(messageItemTemplate, /ui\.speakAssistantMessage\(message\)/)
|
||||
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \{ rating: 5, reason: 'thumbs_up' \}\)/)
|
||||
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \{ rating: 1, reason: 'thumbs_down' \}\)/)
|
||||
assert.match(messageItemTemplate, /mdi mdi-content-copy/)
|
||||
assert.match(messageItemTemplate, /mdi mdi-volume-high/)
|
||||
assert.match(submitComposerScript, /employee_grade:\s*user\.grade \|\| user\.employeeGrade \|\| user\.employee_grade/)
|
||||
assert.match(submitComposerScript, /employeeGrade:\s*user\.grade \|\| user\.employeeGrade \|\| user\.employee_grade/)
|
||||
assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/)
|
||||
@@ -580,7 +619,12 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(createViewScript, /onComposerDateSelection: applyLinkedApplicationPreviewDateSelection/)
|
||||
assert.match(createViewScript, /function openApplicationPreviewEditorFromUi/)
|
||||
assert.match(createViewScript, /syncComposerDateFromApplicationEditor/)
|
||||
assert.match(createViewScript, /function shouldShowAssistantMessageActions/)
|
||||
assert.match(createViewScript, /function buildMessageOperationFeedbackContext/)
|
||||
assert.match(createViewScript, /function isMessageFeedbackSelected/)
|
||||
assert.match(createViewScript, /function submitOperationFeedbackForMessage/)
|
||||
assert.match(createViewScript, /const stewardSubmitContinuation = message\?\.stewardContinuation \|\| null/)
|
||||
assert.match(createViewScript, /stewardContinuation:\s*stewardSubmitContinuation/)
|
||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('single'\)/)
|
||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-start'\)/)
|
||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-end'\)/)
|
||||
@@ -621,9 +665,9 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(flowScript, /return null/)
|
||||
assert.match(flowScript, /申请单提交成功/)
|
||||
assert.match(submitComposerScript, /const isApplicationSubmitOperation = feedbackOperationType === 'submit_application'/)
|
||||
assert.match(submitComposerScript, /if \(isApplicationSubmitOperation\) \{[\s\S]*startFlowStep\('application-submit-success'/)
|
||||
assert.match(submitComposerScript, /else if \(rawText && !reviewAction\) \{[\s\S]*startFlowStep\('intent'/)
|
||||
assert.match(submitComposerScript, /if \(!isApplicationSubmitOperation\) \{[\s\S]*startExpenseClaimDraftFlowStep/)
|
||||
assert.match(submitComposerScript, /if \(!stewardDelegated && isApplicationSubmitOperation\) \{[\s\S]*startFlowStep\('application-submit-success'/)
|
||||
assert.match(submitComposerScript, /else if \(!stewardDelegated && rawText && !reviewAction\) \{[\s\S]*startFlowStep\('intent'/)
|
||||
assert.match(submitComposerScript, /if \(!isApplicationSubmitOperation && !stewardDelegated\) \{[\s\S]*startExpenseClaimDraftFlowStep/)
|
||||
assert.match(flowScript, /function resolveDurationFromFields/)
|
||||
assert.match(flowScript, /function resolveStartedTimestamp/)
|
||||
assert.match(flowScript, /function resolveFinishedTimestamp/)
|
||||
@@ -631,6 +675,261 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(flowScript, /refreshCompleted/)
|
||||
})
|
||||
|
||||
test('steward application missing transport asks before rendering preview table', () => {
|
||||
assert.match(submitComposerScript, /function shouldPauseStewardApplicationPreview/)
|
||||
assert.match(submitComposerScript, /function sanitizeStewardDelegatedTaskSummary/)
|
||||
assert.match(submitComposerScript, /交通方式和\(\?:预算\|预计\)\?金额待补充/)
|
||||
assert.match(submitComposerScript, /出差费用预算/)
|
||||
assert.match(submitComposerScript, /预估\|预计\|预算\)\?费用/)
|
||||
assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/)
|
||||
assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
|
||||
assert.match(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/)
|
||||
assert.doesNotMatch(submitComposerScript, /缺少“出行方式”[\s\S]{0,500}更新下方核对表/)
|
||||
|
||||
assert.match(createViewScript, /payload\.applicationPreview/)
|
||||
assert.match(createViewScript, /function continueStewardApplicationFieldCompletion/)
|
||||
assert.match(createViewScript, /submitComposerInternal\(\{[\s\S]*stewardContinuation: continuation/)
|
||||
assert.match(createViewScript, /skipUserMessage:\s*true/)
|
||||
assert.match(createViewScript, /targetMessage\.applicationPreview = normalizeApplicationPreview\(sourcePreview\)/)
|
||||
assert.match(createViewScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
|
||||
assert.match(createViewScript, /commitApplicationPreviewEditor\(targetMessage\)/)
|
||||
assert.match(stewardFieldCompletionScript, /transportMode:\s*'transport_mode'/)
|
||||
assert.match(stewardFieldCompletionScript, /模拟查询交通票据/)
|
||||
})
|
||||
|
||||
test('steward field completion reruns application preview instead of directly rendering table', () => {
|
||||
const continuation = {
|
||||
planId: 'steward-plan-transport-gap',
|
||||
currentTaskId: 'task-application-beijing',
|
||||
currentTask: {
|
||||
task_id: 'task-application-beijing',
|
||||
task_type: 'expense_application',
|
||||
summary: '明天前往北京出差3天,支撑国网仿生产部署',
|
||||
ontology_fields: {
|
||||
time_range: '2026-06-05 至 2026-06-07',
|
||||
location: '北京',
|
||||
reason: '支撑国网仿生产部署'
|
||||
},
|
||||
missing_fields: ['transport_mode']
|
||||
},
|
||||
remainingTasks: []
|
||||
}
|
||||
const preview = normalizeApplicationPreview({
|
||||
fields: {
|
||||
applicationType: '差旅费用申请',
|
||||
time: '2026-06-05 至 2026-06-07',
|
||||
location: '北京',
|
||||
reason: '支撑国网仿生产部署',
|
||||
days: '3天',
|
||||
transportMode: ''
|
||||
}
|
||||
})
|
||||
|
||||
const nextContinuation = buildStewardFieldCompletionContinuation(continuation, 'transportMode', '火车')
|
||||
assert.equal(nextContinuation.currentTask.ontology_fields.transport_mode, '火车')
|
||||
assert.deepEqual(nextContinuation.currentTask.missing_fields, [])
|
||||
|
||||
const carryText = buildStewardFieldCompletionRawText({
|
||||
preview,
|
||||
fieldKey: 'transportMode',
|
||||
fieldLabel: '出行方式',
|
||||
value: '火车',
|
||||
continuation: nextContinuation
|
||||
})
|
||||
assert.match(carryText, /用户已补充:出行方式:火车/)
|
||||
assert.match(carryText, /地点:北京/)
|
||||
assert.match(carryText, /天数:3天/)
|
||||
assert.match(carryText, /请先根据已补齐字段模拟查询交通票据/)
|
||||
|
||||
const rebuiltPreview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
|
||||
assert.equal(rebuiltPreview.fields.location, '北京')
|
||||
assert.equal(rebuiltPreview.fields.transportMode, '火车')
|
||||
assert.equal(rebuiltPreview.fields.days, '3天')
|
||||
})
|
||||
|
||||
test('budget compile report does not steal steward delegated application rerun', () => {
|
||||
const staleBudgetContext = {
|
||||
budgetNo: 'BUD-2026-TECH',
|
||||
mode: 'edit',
|
||||
categoryRows: []
|
||||
}
|
||||
const stewardApplicationText = [
|
||||
'小财管家继续执行申请单字段补齐。',
|
||||
'用户已补充:出行方式:火车。',
|
||||
'地点:北京',
|
||||
'天数:3天',
|
||||
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。'
|
||||
].join('\n')
|
||||
|
||||
assert.equal(shouldUseBudgetCompileReport(stewardApplicationText, {
|
||||
sessionType: 'application',
|
||||
entrySource: 'workbench',
|
||||
budgetContext: staleBudgetContext
|
||||
}), false)
|
||||
assert.equal(shouldUseBudgetCompileReport('帮我生成 2026 年 Q3 预算编制建议', {
|
||||
sessionType: 'budget',
|
||||
entrySource: 'budget',
|
||||
budgetContext: staleBudgetContext
|
||||
}), true)
|
||||
assert.match(submitComposerScript, /if \(!stewardDelegated && shouldUseBudgetCompileReport/)
|
||||
})
|
||||
|
||||
test('text confirmation submits pending application preview before replanning steward task', () => {
|
||||
assert.match(stewardServiceScript, /fetchStewardRuntimeDecision/)
|
||||
assert.match(stewardServiceScript, /\/steward\/runtime-decisions/)
|
||||
assert.match(createViewScript, /function buildStewardRuntimeState/)
|
||||
assert.match(createViewScript, /function buildStewardRuntimeFastPathDecision/)
|
||||
assert.match(createViewScript, /function shouldUseStewardRuntimeLlmDecision/)
|
||||
assert.match(createViewScript, /function findPendingSlotSuggestedActionContextByInput/)
|
||||
assert.match(createViewScript, /function shouldPlanNewStewardTasksLocally/)
|
||||
assert.match(createViewScript, /function resolveStewardRuntimeTransportAlias/)
|
||||
assert.match(createViewScript, /const actionTransportAlias = resolveStewardRuntimeTransportAlias/)
|
||||
assert.match(createViewScript, /actionTransportAlias === transportAlias/)
|
||||
assert.match(createViewScript, /next_action:\s*'continue_next_task'/)
|
||||
assert.match(createViewScript, /next_action:\s*'submit_current_application'/)
|
||||
assert.match(createViewScript, /next_action:\s*'fill_current_slot'/)
|
||||
assert.match(createViewScript, /next_action:\s*'plan_new_tasks'/)
|
||||
assert.match(createViewScript, /suppressUserEcho:\s*userMessageAlreadyAdded/)
|
||||
assert.match(createViewScript, /if \(!action\?\.suppressUserEcho\) \{[\s\S]*messages\.value\.push\(createMessage\('user', userText\)\)/)
|
||||
assert.match(createViewScript, /skipApplicationModelReview:\s*true/)
|
||||
assert.match(createViewScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
|
||||
assert.match(createViewScript, /skipStewardSlotDecision:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
|
||||
assert.match(submitComposerScript, /skipModelReview:\s*Boolean\(stewardDelegated && options\.skipApplicationModelReview\)/)
|
||||
assert.match(submitComposerScript, /if \(options\.skipModelReview\) \{[\s\S]*结构化快路径/)
|
||||
assert.match(submitComposerScript, /const localPauseForMissingFields = shouldPauseStewardApplicationPreview\(applicationPreview\)/)
|
||||
assert.match(submitComposerScript, /const shouldFetchSlotDecision = localPauseForMissingFields && !options\.skipStewardSlotDecision/)
|
||||
assert.match(submitComposerScript, /const slotDecision = shouldFetchSlotDecision[\s\S]*fetchStewardApplicationSlotDecision/)
|
||||
assert.match(submitComposerScript, /const pendingSuggestedActions = Array\.isArray\(finalExtras\.suggestedActions\)/)
|
||||
assert.match(submitComposerScript, /message\.suggestedActions = pendingSuggestedActions[\s\S]*message\.stewardPlan = buildStewardDelegatedPlan\(continuation, \[\.\.\.typedEvents\], 'typing'\)/)
|
||||
assert.match(createViewScript, /async function handleStewardRuntimeDecision/)
|
||||
assert.match(createViewScript, /const runtimeState = buildStewardRuntimeState\(\)/)
|
||||
assert.match(createViewScript, /if \(!hasActiveStewardRuntimeDecisionContext\(runtimeState\)\) \{[\s\S]*return false/)
|
||||
assert.match(createViewScript, /function pushStewardRuntimeUserMessage\(userText = ''\)/)
|
||||
assert.match(createViewScript, /const userMessageAlreadyAdded = options\.skipUserMessage[\s\S]*pushStewardRuntimeUserMessage\(rawText\)/)
|
||||
assert.match(createViewScript, /const fastDecision = buildStewardRuntimeFastPathDecision\(rawText, runtimeState\)[\s\S]*submitStewardPlan\(\{[\s\S]*skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
|
||||
assert.match(createViewScript, /executeStewardRuntimeDecision\(fastDecision, rawText, \{ userMessageAlreadyAdded \}\)[\s\S]*if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)/)
|
||||
assert.match(createViewScript, /if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)[\s\S]*fetchStewardRuntimeDecision/)
|
||||
assert.match(createViewScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
|
||||
assert.match(createViewScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
|
||||
assert.match(createViewScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
|
||||
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) \{[\s\S]*return null/)
|
||||
assert.match(createViewScript, /function isApplicationSubmitConfirmationText/)
|
||||
assert.match(createViewScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
|
||||
assert.match(createViewScript, /function findPendingApplicationSubmitMessage/)
|
||||
assert.match(createViewScript, /normalizedPreview\.readyToSubmit/)
|
||||
assert.match(createViewScript, /async function handleApplicationSubmitConfirmationText/)
|
||||
assert.match(createViewScript, /await confirmApplicationSubmit\(\{ userText: rawText \}\)/)
|
||||
assert.match(createViewScript, /if \(await handleApplicationSubmitConfirmationText\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*if \(isStewardSession\.value && !options\.skipStewardPlan/)
|
||||
assert.match(createViewScript, /message\.applicationSubmitConfirmed = true/)
|
||||
assert.match(createViewScript, /message\.applicationSubmitConfirmed[\s\S]*continue/)
|
||||
})
|
||||
|
||||
test('steward streaming uses chunked typewriter to reduce perceived latency', () => {
|
||||
assert.match(stewardPlanFlowScript, /STEWARD_TYPEWRITER_CHUNK_SIZE = 4/)
|
||||
assert.match(stewardPlanFlowScript, /STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5/)
|
||||
assert.match(stewardPlanFlowScript, /index = Math\.min\(total, index \+ STEWARD_TYPEWRITER_CHUNK_SIZE\)/)
|
||||
assert.match(stewardPlanFlowScript, /index = Math\.min\(chars\.length, index \+ STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE\)/)
|
||||
assert.match(submitComposerScript, /STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4/)
|
||||
assert.match(submitComposerScript, /STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5/)
|
||||
assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE\)/)
|
||||
assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_THINKING_CHUNK_SIZE\)/)
|
||||
assert.match(createViewScript, /STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE = 4/)
|
||||
assert.match(createViewScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/)
|
||||
assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE\)/)
|
||||
assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE\)/)
|
||||
})
|
||||
|
||||
test('steward initial workbench entry shows recognition state before messages arrive', () => {
|
||||
assert.match(createViewScript, /const hasStewardInitialAutoSubmitPayload = computed/)
|
||||
assert.match(createViewScript, /const showStewardInitialRecognition = computed/)
|
||||
assert.match(createViewScript, /!messages\.value\.length/)
|
||||
assert.match(createViewScript, /workbenchVisible\.value \|\| submitting\.value/)
|
||||
assert.match(createViewScript, /showStewardInitialRecognition/)
|
||||
assert.match(createViewTemplate, /v-if="showStewardInitialRecognition"/)
|
||||
assert.match(createViewTemplate, /class="steward-initial-recognition"/)
|
||||
assert.match(createViewTemplate, /小财管家正在识别意图/)
|
||||
})
|
||||
|
||||
test('steward application carry text does not leak transport examples into extraction', () => {
|
||||
const actions = buildStewardSuggestedActions({
|
||||
plan_id: 'steward-plan-transport-gap',
|
||||
plan_status: 'ready',
|
||||
tasks: [
|
||||
{
|
||||
task_id: 'task-application-beijing',
|
||||
task_type: 'expense_application',
|
||||
title: '北京出差申请',
|
||||
summary: '明天前往北京出差3天,支撑国网仿生产部署',
|
||||
assigned_agent: 'application_assistant',
|
||||
ontology_fields: {
|
||||
expense_type: 'travel',
|
||||
time_range: '2026-06-05 至 2026-06-07',
|
||||
location: '北京',
|
||||
reason: '支撑国网仿生产部署'
|
||||
},
|
||||
missing_fields: ['transport_mode', 'amount', 'attachments', 'employee_no']
|
||||
}
|
||||
],
|
||||
confirmation_groups: [
|
||||
{
|
||||
action_type: 'confirm_create_application',
|
||||
target_task_id: 'task-application-beijing'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const carryText = actions[0]?.payload?.carry_text || ''
|
||||
const currentTask = actions[0]?.payload?.steward_current_task || null
|
||||
assert.match(carryText, /费用类型:差旅/)
|
||||
assert.doesNotMatch(carryText, /费用类型:travel/)
|
||||
assert.match(carryText, /还需要补充:出行方式/)
|
||||
assert.match(carryText, /请先追问上述缺失信息/)
|
||||
assert.doesNotMatch(carryText, /高铁|火车|飞机|轮船|自驾|出租车/)
|
||||
assert.doesNotMatch(carryText, /预计金额|附件\/凭证|员工编号|金额/)
|
||||
assert.equal(currentTask?.task_type, 'expense_application')
|
||||
assert.deepEqual(currentTask?.missing_fields, ['transport_mode'])
|
||||
assert.deepEqual(
|
||||
filterStewardBlockingMissingFields(
|
||||
['transport_type', 'amount', 'attachments', 'employee_no', 'department_name'],
|
||||
'expense_application'
|
||||
),
|
||||
['transport_mode']
|
||||
)
|
||||
assert.deepEqual(
|
||||
filterStewardBlockingMissingFields(['amount', 'attachments'], 'reimbursement'),
|
||||
['amount', 'attachments']
|
||||
)
|
||||
|
||||
const preview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
|
||||
assert.equal(preview.fields.transportMode, '')
|
||||
assert.equal(preview.missingFields.includes('出行方式'), true)
|
||||
|
||||
assert.match(stewardServiceScript, /fetchStewardSlotDecision/)
|
||||
assert.match(stewardServiceScript, /\/steward\/slot-decisions/)
|
||||
assert.match(submitComposerScript, /fetchStewardApplicationSlotDecision/)
|
||||
assert.match(submitComposerScript, /task_type:\s*'expense_application'/)
|
||||
assert.match(submitComposerScript, /steward_continuation:\s*continuation/)
|
||||
assert.match(createViewScript, /currentTask:\s*actionPayload\.steward_current_task/)
|
||||
})
|
||||
|
||||
test('steward application slot fallback ignores non-blocking application fields', () => {
|
||||
assert.match(submitComposerScript, /APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS/)
|
||||
assert.match(submitComposerScript, /'attachments'/)
|
||||
assert.match(submitComposerScript, /'employee_no'/)
|
||||
assert.match(submitComposerScript, /'amount'/)
|
||||
assert.match(submitComposerScript, /function formatStewardDecisionUserText/)
|
||||
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.question/)
|
||||
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.rationale/)
|
||||
assert.match(submitComposerScript, /normalizeTransportModeOption\(value \|\| label, ''\)/)
|
||||
assert.match(createViewScript, /normalizeTransportModeOption\(value, ''\)/)
|
||||
assert.equal(normalizeTransportModeOption('高铁', ''), '火车')
|
||||
assert.equal(normalizeTransportModeOption('自驾', ''), '')
|
||||
assert.match(submitComposerScript, /function resolveBlockingApplicationMissingFieldsForSteward/)
|
||||
assert.match(submitComposerScript, /isBlockingApplicationOntologyField\(key\)/)
|
||||
assert.match(submitComposerScript, /canonicalField && !isBlockingApplicationOntologyField\(canonicalField\)/)
|
||||
assert.doesNotMatch(submitComposerScript, /附件\/凭证和员工编号为合规必需字段/)
|
||||
})
|
||||
|
||||
test('flow panel durations use backend timing instead of local preview delay', () => {
|
||||
const flow = createFlowHarness()
|
||||
flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') })
|
||||
@@ -750,6 +1049,42 @@ test('assistant markdown tables render with component-scoped table styling', ()
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;/)
|
||||
})
|
||||
|
||||
test('assistant reimbursement recognition copy renders structured markdown sections', () => {
|
||||
const rendered = renderMarkdown([
|
||||
'识别到您希望报销一笔“业务招待费”费用:',
|
||||
'',
|
||||
'基础信息识别结果:',
|
||||
'时间:2026-06-04',
|
||||
'事由:小财管家继续执行剩余任务,请填写报销单:客户接待费用报销。',
|
||||
'',
|
||||
'报销测算参考:',
|
||||
'先以用户填写金额或票据识别金额为基础,再结合费用类型、发生地点、业务事由和规则中心限额进行复核。'
|
||||
].join('\n'))
|
||||
|
||||
assert.match(rendered, /<h3>基础信息识别结果<\/h3>/)
|
||||
assert.match(rendered, /<li><strong>时间<\/strong>:2026-06-04<\/li>/)
|
||||
assert.match(rendered, /<li><strong>事由<\/strong>:小财管家继续执行剩余任务/)
|
||||
assert.match(rendered, /<h3>报销测算参考<\/h3>/)
|
||||
assert.doesNotMatch(rendered, /基础信息识别结果:<\/h3>/)
|
||||
})
|
||||
|
||||
test('application date overlap blocks steward preview before duplicate application table', () => {
|
||||
const existingRange = resolveApplicationDateRange('2026-06-05 至 2026-06-07')
|
||||
const currentRange = resolveApplicationDateRange('2026-06-06 至 2026-06-08')
|
||||
const disjointRange = resolveApplicationDateRange('2026-06-08 至 2026-06-10')
|
||||
|
||||
assert.equal(applicationDateRangesOverlap(currentRange, existingRange), true)
|
||||
assert.equal(applicationDateRangesOverlap(disjointRange, existingRange), false)
|
||||
assert.match(submitComposerScript, /function findOverlappingApplicationClaim\(applicationPreview, claimsPayload\)/)
|
||||
assert.match(submitComposerScript, /function normalizeApplicationExpenseType\(value\)/)
|
||||
assert.match(submitComposerScript, /currentExpenseType !== existingExpenseType/)
|
||||
assert.match(submitComposerScript, /fetchExpenseClaims\(\{ page: 1, pageSize: 100 \}\)/)
|
||||
assert.match(submitComposerScript, /buildApplicationDateConflictMessage\(applicationDateConflict\)/)
|
||||
assert.match(submitComposerScript, /meta: \[STEWARD_ASSISTANT_NAME, '申请日期冲突'\]/)
|
||||
assert.match(submitComposerScript, /applicationPreview: pauseForMissingFields \? null : applicationPreview/)
|
||||
assert.match(createViewScript, /actionType === 'open_application_detail'/)
|
||||
})
|
||||
|
||||
test('application preview merges rule center travel estimate into highlighted rows', () => {
|
||||
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去上海出差3天,服务项目部署,火车,预计费用1800元', {
|
||||
name: '李文静',
|
||||
|
||||
@@ -17,6 +17,15 @@ test('isArchivedExpenseClaim recognizes finance archive stage', () => {
|
||||
claim_no: 'AP-20260525120000-ABCDEFGH',
|
||||
expense_type: 'travel_application'
|
||||
}),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
isArchivedExpenseClaim({
|
||||
status: 'approved',
|
||||
approval_stage: '申请归档',
|
||||
claim_no: 'AP-20260525120000-ABCDEFGH',
|
||||
expense_type: 'travel_application'
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
|
||||
53
web/tests/list-dropdown-filter-style.test.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const root = process.cwd()
|
||||
|
||||
function readProjectFile(path) {
|
||||
return readFileSync(join(root, path), 'utf8')
|
||||
}
|
||||
|
||||
function testSharedDropdownStyleOwnsDocumentCenterPattern() {
|
||||
const sharedStyles = readProjectFile('web/src/assets/styles/components/document-list-shared.css')
|
||||
const documentStyles = readProjectFile('web/src/assets/styles/views/documents-center-view.css')
|
||||
const logsStyles = readProjectFile('web/src/assets/styles/views/logs-view.css')
|
||||
|
||||
assert.match(sharedStyles, /\.document-filter-menu\b/)
|
||||
assert.match(sharedStyles, /\.date-range-popover\b/)
|
||||
assert.match(sharedStyles, /\.status-filter-trigger\b/)
|
||||
assert.doesNotMatch(documentStyles, /\.document-filter-menu\b/)
|
||||
assert.doesNotMatch(logsStyles, /\.document-filter-menu\b[\s\S]*box-shadow/)
|
||||
}
|
||||
|
||||
function testListViewsUseSharedDropdownFilter() {
|
||||
const receiptView = readProjectFile('web/src/views/ReceiptFolderView.vue')
|
||||
const budgetView = readProjectFile('web/src/views/BudgetCenterView.vue')
|
||||
const budgetScript = readProjectFile('web/src/views/scripts/BudgetCenterView.js')
|
||||
const employeeView = readProjectFile('web/src/views/EmployeeManagementView.vue')
|
||||
const employeeScript = readProjectFile('web/src/views/scripts/EmployeeManagementView.js')
|
||||
const logsView = readProjectFile('web/src/views/LogsView.vue')
|
||||
const auditPicker = readProjectFile('web/src/components/audit/AuditPickerFilter.vue')
|
||||
const dropdown = readProjectFile('web/src/components/shared/DocumentDropdownFilter.vue')
|
||||
|
||||
assert.match(dropdown, /class="picker-filter document-filter"/)
|
||||
assert.match(dropdown, /class="picker-trigger filter-btn"/)
|
||||
assert.match(dropdown, /class="picker-popover document-filter-menu"/)
|
||||
assert.match(receiptView, /DocumentDropdownFilter/)
|
||||
assert.match(budgetView, /DocumentDropdownFilter/)
|
||||
assert.match(budgetScript, /DocumentDropdownFilter/)
|
||||
assert.doesNotMatch(budgetScript, /EnterpriseSelect/)
|
||||
assert.match(employeeView, /DocumentDropdownFilter/)
|
||||
assert.match(employeeScript, /DocumentDropdownFilter/)
|
||||
assert.match(logsView, /document-list-shared\.css/)
|
||||
assert.match(auditPicker, /document-filter-menu/)
|
||||
assert.doesNotMatch(auditPicker, /<header>/)
|
||||
}
|
||||
|
||||
function run() {
|
||||
testSharedDropdownStyleOwnsDocumentCenterPattern()
|
||||
testListViewsUseSharedDropdownFilter()
|
||||
console.log('list dropdown filter style tests passed')
|
||||
}
|
||||
|
||||
run()
|
||||
20
web/tests/notification-states-service.test.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
NOTIFICATION_STATE_BATCH_SIZE,
|
||||
chunkNotificationStatePatches
|
||||
} from '../src/services/notificationStates.js'
|
||||
|
||||
test('notification state patches are split before posting to backend', () => {
|
||||
const patches = Array.from({ length: 205 }, (_, index) => ({
|
||||
notification_id: `document:owned:DOC-${index + 1}`,
|
||||
read: true
|
||||
}))
|
||||
const chunks = chunkNotificationStatePatches(patches)
|
||||
|
||||
assert.equal(NOTIFICATION_STATE_BATCH_SIZE, 100)
|
||||
assert.deepEqual(chunks.map((chunk) => chunk.length), [100, 100, 5])
|
||||
assert.equal(chunks[0][0].notification_id, 'document:owned:DOC-1')
|
||||
assert.equal(chunks[2][4].notification_id, 'document:owned:DOC-205')
|
||||
})
|
||||
27
web/tests/personal-workbench-compact-laptop.test.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const responsiveStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-responsive.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('personal workbench compacts hero input and capability cards on laptop screens', () => {
|
||||
assert.match(
|
||||
responsiveStyles,
|
||||
/@media \(min-width: 961px\) and \(max-width: 1440px\),\s*\n\s*\(min-width: 961px\) and \(max-height: 820px\)/
|
||||
)
|
||||
assert.match(responsiveStyles, /--hero-padding-top:\s*14px;/)
|
||||
assert.match(responsiveStyles, /--hero-padding-bottom:\s*14px;/)
|
||||
assert.match(responsiveStyles, /--hero-title-size:\s*24px;/)
|
||||
assert.match(responsiveStyles, /--composer-min-height:\s*92px;/)
|
||||
assert.match(responsiveStyles, /--composer-textarea-height:\s*38px;/)
|
||||
assert.match(responsiveStyles, /--capability-row-height:\s*82px;/)
|
||||
assert.match(responsiveStyles, /\.assistant-copy h1\s*\{[\s\S]*font-size:\s*var\(--hero-title-size\);/)
|
||||
assert.match(responsiveStyles, /\.assistant-composer\s*\{[\s\S]*padding:\s*var\(--composer-padding-block\) 14px 8px;/)
|
||||
assert.match(responsiveStyles, /\.quick-prompts button\s*\{[\s\S]*min-height:\s*24px;/)
|
||||
assert.match(responsiveStyles, /\.capability-card\s*\{[\s\S]*grid-template-columns:\s*34px minmax\(0,\s*1fr\) 14px;[\s\S]*padding:\s*12px 12px 12px 16px;/)
|
||||
assert.match(responsiveStyles, /@media \(max-width: 760px\)[\s\S]*\.workbench\s*\{[\s\S]*grid-template-rows:\s*none;/)
|
||||
})
|
||||
81
web/tests/receipt-folder-list-filters.test.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
RECEIPT_FILTER_ALL,
|
||||
applyReceiptListFilters,
|
||||
buildReceiptFilterControls,
|
||||
buildReceiptFilterTokens
|
||||
} from '../src/views/scripts/receiptFolderListFilters.js'
|
||||
|
||||
const rows = [
|
||||
{
|
||||
id: 'r-1',
|
||||
document_type: 'train_ticket',
|
||||
document_type_label: '火车票',
|
||||
scene_code: 'travel',
|
||||
scene_label: '差旅',
|
||||
document_date: '2026-05-02',
|
||||
avg_score: 0.96
|
||||
},
|
||||
{
|
||||
id: 'r-2',
|
||||
document_type: 'vat_invoice',
|
||||
document_type_label: '增值税发票',
|
||||
scene_code: 'office',
|
||||
scene_label: '办公',
|
||||
uploaded_at: '2026-04-18T10:00:00Z',
|
||||
avg_score: 0.82
|
||||
},
|
||||
{
|
||||
id: 'r-3',
|
||||
document_type: 'vat_invoice',
|
||||
document_type_label: '增值税发票',
|
||||
scene_code: 'travel',
|
||||
scene_label: '差旅',
|
||||
document_date: '',
|
||||
uploaded_at: '2026-05-20T08:00:00Z',
|
||||
avg_score: 0
|
||||
}
|
||||
]
|
||||
|
||||
function testBuildsDynamicOptions() {
|
||||
const controls = buildReceiptFilterControls(rows, {})
|
||||
const typeControl = controls.find((item) => item.key === 'documentType')
|
||||
const monthControl = controls.find((item) => item.key === 'month')
|
||||
|
||||
assert.equal(typeControl.options[0].value, RECEIPT_FILTER_ALL)
|
||||
assert.deepEqual(typeControl.options.map((item) => item.value).sort(), ['all', 'train_ticket', 'vat_invoice'])
|
||||
assert.deepEqual(monthControl.options.map((item) => item.value), ['all', '2026-05', '2026-04'])
|
||||
}
|
||||
|
||||
function testAppliesCombinedFilters() {
|
||||
const result = applyReceiptListFilters(rows, {
|
||||
documentType: 'vat_invoice',
|
||||
scene: 'travel',
|
||||
month: '2026-05',
|
||||
quality: 'missing'
|
||||
})
|
||||
|
||||
assert.deepEqual(result.map((item) => item.id), ['r-3'])
|
||||
}
|
||||
|
||||
function testBuildsReadableTokens() {
|
||||
const filters = {
|
||||
documentType: 'train_ticket',
|
||||
scene: 'travel',
|
||||
month: RECEIPT_FILTER_ALL,
|
||||
quality: 'high'
|
||||
}
|
||||
const tokens = buildReceiptFilterTokens(buildReceiptFilterControls(rows, filters), filters)
|
||||
|
||||
assert.deepEqual(tokens, ['票据类型:火车票', '费用场景:差旅', '置信度:高置信度'])
|
||||
}
|
||||
|
||||
function run() {
|
||||
testBuildsDynamicOptions()
|
||||
testAppliesCombinedFilters()
|
||||
testBuildsReadableTokens()
|
||||
console.log('receipt folder list filter tests passed')
|
||||
}
|
||||
|
||||
run()
|
||||
@@ -48,6 +48,12 @@ function testReceiptFolderViewSurface() {
|
||||
assert.match(view, /deleteCurrentReceipt/)
|
||||
assert.match(view, /ElCheckboxGroup/)
|
||||
assert.match(view, /fetchReceiptFolderItems\('all'\)/)
|
||||
assert.match(view, /DocumentDropdownFilter/)
|
||||
assert.match(view, /receiptFilterControls/)
|
||||
assert.match(view, /clear-filter-btn/)
|
||||
assert.match(view, /receiptFilters\[control\.key\]/)
|
||||
assert.match(view, /clearReceiptFilters/)
|
||||
assert.doesNotMatch(view, /class="filter-btn" type="button" @click="reloadReceipts"/)
|
||||
assert.match(view, /buildReceiptFile\(item\)/)
|
||||
assert.match(view, /source: selectedDraft \? 'detail' : 'receipt-folder'/)
|
||||
assert.match(view, /emit\('open-assistant'/)
|
||||
@@ -92,9 +98,13 @@ function testSharedDocumentListStyleReuse() {
|
||||
assert.match(sharedStyles, /\.table-wrap\b/)
|
||||
assert.match(sharedStyles, /\.doc-kind-tag\b/)
|
||||
assert.match(sharedStyles, /\.list-foot\b/)
|
||||
assert.match(sharedStyles, /\.clear-filter-btn\b/)
|
||||
assert.match(sharedStyles, /\.document-filter-menu\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.table-wrap\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.doc-kind-tag\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.list-foot\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.receipt-select-filter\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.receipt-clear-filters\b/)
|
||||
}
|
||||
|
||||
function testReceiptFolderDetailLayoutAdjustments() {
|
||||
|
||||
@@ -11,6 +11,8 @@ const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7'
|
||||
const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279'
|
||||
const BUDGET_MANAGER_APPROVAL = '\u9884\u7b97\u7ba1\u7406\u8005\u5ba1\u6279'
|
||||
const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210'
|
||||
const APPLICATION_LINK_STATUS = '\u5173\u8054\u5355\u636e\u72b6\u6001'
|
||||
const APPLICATION_ARCHIVE = '\u7533\u8bf7\u5f52\u6863'
|
||||
const RETURNED = '\u9000\u56de'
|
||||
const WAIT_SUBMIT = '\u5f85\u63d0\u4ea4'
|
||||
const LINKED_APPLICATION = '\u5173\u8054\u5355\u636e'
|
||||
@@ -156,7 +158,7 @@ test('application claims are mapped as application documents', () => {
|
||||
assert.equal(request.expenseTableSummary, '预计金额已随申请提交')
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED]
|
||||
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||
)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
|
||||
@@ -250,7 +252,7 @@ test('application claims wait for department P8 budget monitor after leader appr
|
||||
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPROVAL_COMPLETED]
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||
)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_ZHAO_APPROVAL)?.current, true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
|
||||
@@ -293,7 +295,7 @@ test('application budget wait label uses claim-level budget approver snapshot',
|
||||
assert.equal(request.budgetApproverName, 'P8 Executive')
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_P8_EXECUTIVE_APPROVAL, APPROVAL_COMPLETED]
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_P8_EXECUTIVE_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||
)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_P8_EXECUTIVE_APPROVAL)?.current, true)
|
||||
})
|
||||
@@ -386,14 +388,16 @@ test('approved application claims complete after budget approval', () => {
|
||||
})
|
||||
|
||||
assert.equal(request.documentTypeCode, 'application')
|
||||
assert.equal(request.workflowNode, '审批完成')
|
||||
assert.equal(request.workflowNode, APPLICATION_LINK_STATUS)
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
['创建申请', '直属领导审批', '预算管理者审批', '审批完成']
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, BUDGET_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||
)
|
||||
assert.equal(request.progressSteps.every((step) => step.done), true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.time, '李经理通过')
|
||||
assert.equal(request.progressSteps.find((step) => step.label === '预算管理者审批')?.time, '赵预算通过')
|
||||
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.time, '未关联')
|
||||
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, '李经理通过')
|
||||
assert.equal(request.progressSteps.find((step) => step.label === BUDGET_MANAGER_APPROVAL)?.time, '赵预算通过')
|
||||
})
|
||||
|
||||
test('application claims hide budget step when leader approval also covers budget approval', () => {
|
||||
@@ -430,9 +434,10 @@ test('application claims hide budget step when leader approval also covers budge
|
||||
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED]
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||
)
|
||||
assert.equal(request.progressSteps.every((step) => step.done), true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, '李预算经理通过')
|
||||
})
|
||||
@@ -481,13 +486,92 @@ test('approved application claims hide budget step when dynamic route skipped bu
|
||||
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED]
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||
)
|
||||
assert.equal(request.progressSteps.every((step) => step.done), true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
|
||||
})
|
||||
|
||||
test('approved application claims show linked reimbursement status before archive', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-linked-draft',
|
||||
claim_no: 'AP-202606050001-LINKED',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
manager_name: 'Leader Li',
|
||||
expense_type: 'travel_application',
|
||||
reason: 'Project onsite support',
|
||||
location: 'Shanghai',
|
||||
amount: 500,
|
||||
invoice_count: 0,
|
||||
occurred_at: '2026-06-05T00:00:00.000Z',
|
||||
submitted_at: '2026-06-05T02:00:00.000Z',
|
||||
created_at: '2026-06-05T01:30:00.000Z',
|
||||
updated_at: '2026-06-05T03:00:00.000Z',
|
||||
status: 'approved',
|
||||
approval_stage: APPLICATION_LINK_STATUS,
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'manual_approval',
|
||||
event_type: 'expense_application_approval',
|
||||
operator: 'Leader Li',
|
||||
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
|
||||
next_approval_stage: APPLICATION_LINK_STATUS,
|
||||
generated_draft_claim_no: 'RE-202606050001-LINKED',
|
||||
created_at: '2026-06-05T03:00:00.000Z'
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
const linkStep = request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)
|
||||
assert.equal(request.workflowNode, APPLICATION_LINK_STATUS)
|
||||
assert.equal(linkStep?.current, true)
|
||||
assert.equal(linkStep?.time, '关联中 RE-202606050001-LINKED')
|
||||
assert.equal(request.secondaryStatusValue, '关联中 RE-202606050001-LINKED')
|
||||
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
|
||||
})
|
||||
|
||||
test('application claims are archived only after linked reimbursement is paid', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-archived',
|
||||
claim_no: 'AP-202606050001-ARCHIVED',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
manager_name: 'Leader Li',
|
||||
expense_type: 'travel_application',
|
||||
reason: 'Project onsite support',
|
||||
location: 'Shanghai',
|
||||
amount: 500,
|
||||
invoice_count: 0,
|
||||
occurred_at: '2026-06-05T00:00:00.000Z',
|
||||
submitted_at: '2026-06-05T02:00:00.000Z',
|
||||
created_at: '2026-06-05T01:30:00.000Z',
|
||||
updated_at: '2026-06-07T03:00:00.000Z',
|
||||
status: 'approved',
|
||||
approval_stage: APPLICATION_ARCHIVE,
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'application_archive_sync',
|
||||
event_type: 'expense_application_archived_by_reimbursement',
|
||||
reimbursement_claim_no: 'RE-202606050001-ARCHIVED',
|
||||
created_at: '2026-06-07T03:00:00.000Z'
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
assert.equal(request.workflowNode, APPLICATION_ARCHIVE)
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||
)
|
||||
assert.equal(request.progressSteps.every((step) => step.done), true)
|
||||
assert.equal(request.secondaryStatusValue, '已归档')
|
||||
})
|
||||
|
||||
test('progress steps show approval operator time and current stay duration', () => {
|
||||
const originalNow = Date.now
|
||||
Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime()
|
||||
|
||||
@@ -63,6 +63,7 @@ test('sidebar no longer renders document center unread indicators', () => {
|
||||
test('topbar bell owns document center unread notifications', () => {
|
||||
assert.match(topbar, /useDocumentCenterInbox/)
|
||||
assert.match(topbar, /useTopBarNotificationStates/)
|
||||
assert.match(topbar, /resolveDocumentNotificationId/)
|
||||
assert.match(topbar, /notificationRows: documentInboxNotificationRows/)
|
||||
assert.match(topbar, /const documentNotificationItems = computed/)
|
||||
assert.match(topbar, /title: `\$\{row\.documentTypeLabel \|\| '单据'\} \$\{row\.documentNo \|\| row\.claimId \|\| '待生成'\}`/)
|
||||
@@ -109,6 +110,8 @@ test('topbar notification state is persisted through backend API with local fall
|
||||
|
||||
test('document inbox reuses document center viewed-key state', () => {
|
||||
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
|
||||
assert.match(documentInbox, /fetchNotificationStates/)
|
||||
assert.match(documentInbox, /mergeNotificationStatesIntoViewedDocumentKeys/)
|
||||
assert.match(documentInbox, /readViewedDocumentKeys/)
|
||||
assert.match(documentInbox, /countNewDocuments\(documentRows\.value, viewedDocumentKeys\.value\)/)
|
||||
assert.match(documentInbox, /const notificationRows = computed/)
|
||||
@@ -120,5 +123,7 @@ test('document inbox reuses document center viewed-key state', () => {
|
||||
assert.match(documentInbox, /fetchArchivedExpenseClaims/)
|
||||
assert.match(documentInbox, /window\.addEventListener\(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT, refreshViewedDocumentKeys\)/)
|
||||
assert.match(documentNewState, /export const DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
|
||||
assert.match(documentNewState, /resolveDocumentNotificationId/)
|
||||
assert.match(documentNewState, /buildDocumentsViewedStatePatches/)
|
||||
assert.match(documentNewState, /window\.dispatchEvent\(new CustomEvent\(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT\)\)/)
|
||||
})
|
||||
|
||||
23
web/tests/topbar-compact-laptop.test.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const topbarStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/top-bar.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('topbar uses a compact laptop layout without overriding mobile layout', () => {
|
||||
assert.match(
|
||||
topbarStyles,
|
||||
/@media \(min-width: 961px\) and \(max-width: 1440px\),\s*\n\s*\(min-width: 961px\) and \(max-height: 820px\)/
|
||||
)
|
||||
assert.match(topbarStyles, /\.topbar\s*\{[\s\S]*padding:\s*12px 20px 14px;/)
|
||||
assert.match(topbarStyles, /\.topbar h1\s*\{[\s\S]*font-size:\s*22px;/)
|
||||
assert.match(topbarStyles, /\.topbar p\s*\{[\s\S]*-webkit-line-clamp:\s*1;/)
|
||||
assert.match(topbarStyles, /\.range-shell\s*\{[\s\S]*height:\s*36px;/)
|
||||
assert.match(topbarStyles, /\.dashboard-switch-select :deep\(\.el-select__wrapper\)\s*\{[\s\S]*min-height:\s*38px;/)
|
||||
assert.match(topbarStyles, /\.topbar-icon-btn\s*\{[\s\S]*width:\s*30px;[\s\S]*height:\s*30px;/)
|
||||
assert.match(topbarStyles, /@media \(max-width: 960px\)[\s\S]*\.topbar\s*\{[\s\S]*flex-direction:\s*column;/)
|
||||
})
|
||||
@@ -118,6 +118,21 @@ test('document review drawer fills sidebar height and preview dialog is centered
|
||||
assert.match(createViewPart4Styles, /\.review-preview-modal\s*\{[\s\S]*margin:\s*auto;[\s\S]*flex:\s*none;/)
|
||||
})
|
||||
|
||||
test('assistant conversation keeps composer visible when generated cards grow tall', () => {
|
||||
assert.match(createViewBaseStyles, /\.assistant-layout\s*\{[\s\S]*height:\s*100%;[\s\S]*max-height:\s*100%;[\s\S]*overflow:\s*hidden;/)
|
||||
assert.match(
|
||||
createViewBaseStyles,
|
||||
/\.dialog-panel\s*\{[\s\S]*display:\s*flex;[\s\S]*flex-direction:\s*column;[\s\S]*height:\s*100%;[\s\S]*max-height:\s*100%;[\s\S]*min-height:\s*0;[\s\S]*overflow:\s*hidden;/
|
||||
)
|
||||
assert.match(createViewBaseStyles, /\.dialog-toolbar\s*\{[\s\S]*flex:\s*0 0 auto;/)
|
||||
assert.match(
|
||||
createViewBaseStyles,
|
||||
/\.message-list\s*\{[\s\S]*flex:\s*1 1 0;[\s\S]*min-height:\s*0;[\s\S]*max-height:\s*100%;[\s\S]*overflow-y:\s*auto;[\s\S]*overscroll-behavior:\s*contain;/
|
||||
)
|
||||
assert.match(createViewBaseStyles, /\.composer\s*\{[\s\S]*position:\s*sticky;[\s\S]*bottom:\s*0;[\s\S]*flex:\s*0 0 auto;[\s\S]*flex-shrink:\s*0;/)
|
||||
assert.match(createViewPart4Styles, /@media \(max-width:\s*1440px\)[\s\S]*\.dialog-panel\s*\{[\s\S]*flex:\s*1 1 0;[\s\S]*height:\s*auto;[\s\S]*max-height:\s*100%;/)
|
||||
})
|
||||
|
||||
test('document review OCR result card header keeps copy and navigation separated', () => {
|
||||
assert.match(insightPanelTemplate, /class="review-side-head-copy"[\s\S]*票据识别结果卡片[\s\S]*逐张查看 OCR 结果/)
|
||||
assert.match(insightPanelStyles, /\.review-document-switch-head\s*\{[\s\S]*display:\s*grid;[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\) auto;/)
|
||||
@@ -619,7 +634,10 @@ test('guided save draft emits refresh and exposes reimbursement draft detail car
|
||||
/emitSavedDraftRefresh\(payload\?\.result\?\.draft_payload \|\| null\)/
|
||||
)
|
||||
assert.match(createViewScript, /function shouldShowDraftSavedCard\(message\)/)
|
||||
assert.match(createViewScript, /function canOpenDraftDetail\(message\)/)
|
||||
assert.match(createViewScript, /function resolveReimbursementDraftClaimNo\(draftPayload\)/)
|
||||
assert.doesNotMatch(createViewScript, /function buildReimbursementDraftSummaryItems\(draftPayload\)/)
|
||||
assert.match(messageItemTemplate, /v-if="ui\.canOpenDraftDetail\(message\)"/)
|
||||
assert.match(messageItemTemplate, /class="reimbursement-draft-link"/)
|
||||
assert.match(messageItemTemplate, /reimbursement-draft-pending-detail/)
|
||||
})
|
||||
|
||||
@@ -87,3 +87,52 @@ test('workbench summary builds real user notifications and progress from request
|
||||
assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '待办'))
|
||||
assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '进度'))
|
||||
})
|
||||
|
||||
test('workbench progress keeps application document type for AP claims', () => {
|
||||
const summary = buildWorkbenchSummary(
|
||||
[
|
||||
{
|
||||
id: 'AP-202606050001-ABCDEFGH',
|
||||
claimId: 'application-1',
|
||||
claimNo: 'AP-202606050001-ABCDEFGH',
|
||||
person: currentUser.name,
|
||||
title: '差旅费用',
|
||||
approvalKey: 'in_progress',
|
||||
approvalStatus: '直属领导审批',
|
||||
amount: 1880,
|
||||
createdAt: '2026-06-05T09:00:00+08:00',
|
||||
updatedAt: '2026-06-05T09:10:00+08:00',
|
||||
progressSteps: [
|
||||
buildStep('创建申请', 0, 1),
|
||||
buildStep('直属领导审批', 1, 1),
|
||||
buildStep('归档', 2, 1)
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'REQ-APPLICATION-001',
|
||||
claimId: 'application-2',
|
||||
claimNo: 'REQ-APPLICATION-001',
|
||||
documentTypeCode: 'application',
|
||||
documentTypeLabel: '报销单',
|
||||
person: currentUser.name,
|
||||
title: '办公用品采购',
|
||||
approvalKey: 'in_progress',
|
||||
approvalStatus: '直属领导审批',
|
||||
amount: 2600,
|
||||
createdAt: '2026-06-05T09:05:00+08:00',
|
||||
updatedAt: '2026-06-05T09:15:00+08:00',
|
||||
progressSteps: [
|
||||
buildStep('创建申请', 0, 1),
|
||||
buildStep('直属领导审批', 1, 1),
|
||||
buildStep('归档', 2, 1)
|
||||
]
|
||||
}
|
||||
],
|
||||
currentUser
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
summary.progressItems.map((item) => item.documentTypeLabel),
|
||||
['申请单', '申请单']
|
||||
)
|
||||
})
|
||||
|
||||