feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器 - 引入员工画像扫描调度与定时提醒任务 - 完善财务看板快照、排行口径与部门人员占比计算 - 优化数字员工工作看板仪表盘与技能目录 - 增强前端总览页图表、工作台摘要与顶部导航栏交互 - 新增差旅申请规划推动提醒与报销创建会话状态管理 - 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
@@ -123,7 +123,6 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.todo-row,
|
||||
.progress-row {
|
||||
position: relative;
|
||||
border-top: 0;
|
||||
@@ -131,12 +130,10 @@
|
||||
box-shadow: inset 0 1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
|
||||
}
|
||||
|
||||
.todo-row:first-child,
|
||||
.progress-row:first-child {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.todo-row:hover,
|
||||
.progress-row:hover {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0.18)),
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
}
|
||||
|
||||
.assistant-copy {
|
||||
width: min(1040px, 92%);
|
||||
width: min(940px, 92%);
|
||||
}
|
||||
|
||||
.assistant-copy h1 {
|
||||
@@ -71,11 +71,11 @@
|
||||
}
|
||||
|
||||
.capability-grid--privileged {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.capability-grid--standard {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.capability-card {
|
||||
@@ -87,7 +87,7 @@
|
||||
}
|
||||
|
||||
.workbench-content-grid {
|
||||
grid-template-columns: minmax(300px, 0.92fr) minmax(480px, 1.34fr) minmax(270px, 0.76fr);
|
||||
grid-template-columns: minmax(480px, 1.34fr) minmax(270px, 0.76fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -202,25 +202,15 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.todo-row,
|
||||
.progress-row {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.todo-row {
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.todo-meta,
|
||||
.progress-result {
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.todo-meta {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.progress-steps {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -332,30 +322,6 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 我的待办列表项更精致 */
|
||||
.todo-row {
|
||||
padding: 5px 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.todo-copy strong {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.todo-copy small {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.todo-status {
|
||||
font-size: 11px;
|
||||
min-height: 18px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.todo-meta small {
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
/* 重点优化:费用进度行的网格区域(Grid Area)双行重构 */
|
||||
.progress-row {
|
||||
display: grid;
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
.assistant-copy {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
width: min(1120px, 94%);
|
||||
width: min(980px, 94%);
|
||||
display: grid;
|
||||
gap: var(--hero-copy-gap);
|
||||
}
|
||||
@@ -130,6 +130,7 @@
|
||||
z-index: 5;
|
||||
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);
|
||||
@@ -416,7 +417,7 @@
|
||||
|
||||
.workbench-content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 0.95fr) minmax(560px, 1.4fr) minmax(320px, 0.82fr);
|
||||
grid-template-columns: minmax(560px, 1.45fr) minmax(320px, 0.82fr);
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
@@ -434,7 +435,6 @@
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035);
|
||||
}
|
||||
|
||||
.todo-panel,
|
||||
.progress-panel,
|
||||
.side-panel {
|
||||
display: grid;
|
||||
@@ -493,7 +493,6 @@
|
||||
color: var(--workbench-muted);
|
||||
}
|
||||
|
||||
.todo-list,
|
||||
.progress-list {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
@@ -501,45 +500,11 @@
|
||||
grid-auto-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.todo-row {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 2px 0;
|
||||
border-top: 1px solid var(--workbench-line-soft);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.todo-row:first-child,
|
||||
.progress-row:first-child {
|
||||
padding-top: 2px;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.todo-row :deep(.workbench-list-icon) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.todo-row :deep(.workbench-list-icon__panel) {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.todo-row :deep(.workbench-list-icon__art),
|
||||
.todo-row :deep(.workbench-heroicon) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.todo-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.todo-copy strong,
|
||||
.progress-identity strong,
|
||||
.progress-result strong {
|
||||
overflow: hidden;
|
||||
@@ -551,8 +516,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.todo-copy small,
|
||||
.todo-meta small,
|
||||
.progress-identity small {
|
||||
overflow: hidden;
|
||||
color: var(--workbench-muted);
|
||||
@@ -562,14 +525,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.todo-meta {
|
||||
min-width: 96px;
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.todo-status,
|
||||
.progress-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -582,33 +537,16 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.todo-status--warning,
|
||||
.progress-status--warning {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.todo-status--success,
|
||||
.progress-status--success {
|
||||
background: var(--workbench-primary-soft);
|
||||
color: var(--workbench-primary-active);
|
||||
}
|
||||
|
||||
.todo-status--danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.todo-status--info {
|
||||
background: var(--info-soft);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.todo-status--orange {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.progress-status--muted {
|
||||
background: var(--info-soft);
|
||||
color: var(--workbench-muted);
|
||||
@@ -708,7 +646,6 @@
|
||||
}
|
||||
|
||||
.capability-card:hover,
|
||||
.todo-row:hover,
|
||||
.progress-row:hover,
|
||||
.quick-prompts button:hover,
|
||||
.composer-icon-button:hover {
|
||||
|
||||
@@ -403,6 +403,11 @@
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.notification-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
@@ -423,6 +428,179 @@
|
||||
box-shadow: 0 5px 10px rgba(239, 68, 68, .22);
|
||||
}
|
||||
|
||||
.notification-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: -8px;
|
||||
z-index: 60;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e5edf5;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow:
|
||||
0 18px 42px rgba(15, 23, 42, 0.14),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.notification-popover::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: 18px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-top: 1px solid #e5edf5;
|
||||
border-left: 1px solid #e5edf5;
|
||||
background: #fff;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.notification-head,
|
||||
.notification-tabs {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notification-head {
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notification-head strong {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.notification-head button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 4px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.notification-head button:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.notification-tabs {
|
||||
gap: 6px;
|
||||
padding: 3px;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.notification-tabs button {
|
||||
flex: 1 1 0;
|
||||
height: 28px;
|
||||
border-radius: 3px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.notification-tabs button.active {
|
||||
background: #fff;
|
||||
color: var(--theme-primary-active);
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.notification-row {
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(0, 1fr) 16px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 4px;
|
||||
border-top: 1px solid #edf2f7;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.notification-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.notification-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.notification-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--theme-primary);
|
||||
}
|
||||
|
||||
.notification-dot.danger { background: #ef4444; }
|
||||
.notification-dot.warning { background: #f59e0b; }
|
||||
.notification-dot.success { background: var(--success); }
|
||||
.notification-dot.info { background: #3b82f6; }
|
||||
|
||||
.notification-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.notification-copy strong,
|
||||
.notification-copy small,
|
||||
.notification-copy em {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-copy strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.notification-copy small {
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notification-copy em {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.notification-row > .mdi {
|
||||
color: #94a3b8;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.notification-empty {
|
||||
min-height: 112px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notification-empty .mdi {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.company-switcher {
|
||||
max-width: min(220px, 28vw);
|
||||
height: 38px;
|
||||
@@ -593,6 +771,61 @@
|
||||
.title-group {
|
||||
padding-right: 56px;
|
||||
}
|
||||
|
||||
.topbar.detail-mode {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.topbar.detail-mode .title-group {
|
||||
min-width: 0;
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.topbar.detail-mode .eyebrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar.detail-mode h1 {
|
||||
font-size: 22px;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.topbar.detail-mode p {
|
||||
max-width: 100%;
|
||||
margin-top: 3px;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.topbar.detail-mode .top-actions,
|
||||
.topbar.detail-mode .detail-topbar-actions {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.topbar.detail-mode .detail-alert-strip {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.topbar.detail-mode .detail-alert-pill {
|
||||
min-height: 26px;
|
||||
max-width: 100%;
|
||||
padding: 0 9px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -676,3 +909,22 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.topbar.detail-mode {
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.topbar.detail-mode h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.topbar.detail-mode p {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.topbar.detail-mode .detail-alert-pill {
|
||||
min-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,19 +142,16 @@
|
||||
|
||||
.trend-count-panel,
|
||||
.donut-panel,
|
||||
.rank-panel,
|
||||
.employee-rank-panel,
|
||||
.top-claim-panel,
|
||||
.budget-metrics-panel,
|
||||
.bottleneck-panel,
|
||||
.budget-panel,
|
||||
.model-panel,
|
||||
.feedback-panel {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.bottleneck-panel,
|
||||
.rank-panel,
|
||||
.employee-rank-panel,
|
||||
.top-claim-panel,
|
||||
.budget-metrics-panel,
|
||||
.bottleneck-panel,
|
||||
.budget-panel {
|
||||
grid-column: span 6;
|
||||
}
|
||||
@@ -188,6 +185,21 @@
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.card-range-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), .18);
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--theme-primary-rgb, 58, 124, 165), .07);
|
||||
color: var(--theme-primary-active);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel-note {
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
@@ -581,6 +593,42 @@
|
||||
.top-claim-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.top-claim-split {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, .92fr) minmax(0, 1.08fr);
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.department-employee-mix {
|
||||
min-width: 0;
|
||||
padding-right: 18px;
|
||||
border-right: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.department-employee-mix :deep(.donut-chart) {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.department-employee-mix :deep(.donut-body) {
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.department-employee-mix :deep(.donut-legend) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.department-employee-mix :deep(.legend-name) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.top-claim-row {
|
||||
@@ -873,6 +921,17 @@
|
||||
grid-template-columns: 24px 64px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.top-claim-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.department-employee-mix {
|
||||
padding-right: 0;
|
||||
padding-bottom: 14px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.budget-metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
@media (max-width: 760px) {
|
||||
.approval-page,
|
||||
.approval-detail,
|
||||
.detail-scroll,
|
||||
.detail-hero,
|
||||
.progress-card,
|
||||
.detail-grid,
|
||||
.detail-left,
|
||||
.detail-card {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.detail-scroll {
|
||||
padding-right: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.detail-scroll > * {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.hero-banner-main,
|
||||
.hero-fact-grid,
|
||||
.applicant-card,
|
||||
.detail-card-head {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.progress-card,
|
||||
.progress-block {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.progress-line {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.detail-expense-table {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.approval-action-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.back-action,
|
||||
.approval-action-group > button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -704,6 +704,7 @@
|
||||
.ai-preview-secondary:disabled,
|
||||
.ai-preview-primary:disabled,
|
||||
.approve-action:disabled,
|
||||
.secondary-action:disabled,
|
||||
.return-action:disabled,
|
||||
.ai-send-btn:disabled {
|
||||
opacity: .45;
|
||||
|
||||
@@ -1645,6 +1645,13 @@
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.secondary-action {
|
||||
min-width: 98px;
|
||||
border: 1px solid #bfdbfe;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.return-action {
|
||||
min-width: 98px;
|
||||
border: 1px solid #fed7aa;
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:pages="pageNumbers"
|
||||
:show-page-size="true"
|
||||
:summary="paginationSummary"
|
||||
:total="visibleSkills.length"
|
||||
@@ -326,14 +325,6 @@ const pagedSkills = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return props.visibleSkills.slice(start, start + pageSize.value)
|
||||
})
|
||||
const pageNumbers = computed(() => {
|
||||
const total = totalPages.value
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, index) => index + 1)
|
||||
}
|
||||
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
|
||||
return Array.from({ length: 7 }, (_, index) => start + index)
|
||||
})
|
||||
const paginationSummary = computed(() =>
|
||||
`共 ${props.visibleSkills.length} 条,每页 ${pageSize.value} 条,当前第 ${currentPage.value} / ${totalPages.value} 页`
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:pages="pageNumbers"
|
||||
:show-page-size="true"
|
||||
:summary="paginationSummary"
|
||||
:total="visibleEmployees.length"
|
||||
@@ -225,14 +224,6 @@ const pagedEmployees = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return props.visibleEmployees.slice(start, start + pageSize.value)
|
||||
})
|
||||
const pageNumbers = computed(() => {
|
||||
const total = totalPages.value
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, index) => index + 1)
|
||||
}
|
||||
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
|
||||
return Array.from({ length: 7 }, (_, index) => start + index)
|
||||
})
|
||||
const paginationSummary = computed(() =>
|
||||
`共 ${props.visibleEmployees.length} 条,每页 ${pageSize.value} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
|
||||
)
|
||||
|
||||
@@ -110,6 +110,26 @@
|
||||
<p v-else class="run-product-inline-empty">本次运行没有生成新的风险观察。</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="productKind === 'finance_snapshot'" class="run-product-section">
|
||||
<div class="run-product-section-head">
|
||||
<h4>财务经营快照</h4>
|
||||
<span>{{ summary.period || summary.month || '本期' }}</span>
|
||||
</div>
|
||||
<p class="run-product-copy">
|
||||
本次产物已刷新财务看板缓存,沉淀报销金额、预算使用、费用结构和高额单据等经营指标。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="productKind === 'reminder_scan'" class="run-product-section">
|
||||
<div class="run-product-section-head">
|
||||
<h4>提醒与待办沉淀</h4>
|
||||
<span>{{ summary.reminder_count || summary.reminders || 0 }} 条</span>
|
||||
</div>
|
||||
<p class="run-product-copy">
|
||||
本次产物已生成审批提醒、预算编制提醒、报销逾期提醒和差旅申请闭环提醒。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="productKind === 'risk_clue'" class="run-product-section">
|
||||
<div class="run-product-section-head">
|
||||
<h4>待复核线索</h4>
|
||||
@@ -230,6 +250,12 @@ const productSubtitle = computed(() => {
|
||||
if (productKind.value === 'risk_graph') {
|
||||
return '展示本次巡检生成的风险观察、证据数量和图谱关系计数。'
|
||||
}
|
||||
if (productKind.value === 'finance_snapshot') {
|
||||
return '展示本次财务经营快照沉淀的预算、费用和报销统计。'
|
||||
}
|
||||
if (productKind.value === 'reminder_scan') {
|
||||
return '展示本次定时提醒扫描生成的待办和触达结果。'
|
||||
}
|
||||
if (productKind.value === 'employee_profile') {
|
||||
return '展示本次画像巡检写入的员工画像快照摘要。'
|
||||
}
|
||||
@@ -245,6 +271,12 @@ const productBadge = computed(() => {
|
||||
if (productKind.value === 'risk_graph') {
|
||||
return '风险观察'
|
||||
}
|
||||
if (productKind.value === 'finance_snapshot') {
|
||||
return '财务快照'
|
||||
}
|
||||
if (productKind.value === 'reminder_scan') {
|
||||
return '提醒事项'
|
||||
}
|
||||
if (productKind.value === 'employee_profile') {
|
||||
return '画像快照'
|
||||
}
|
||||
@@ -281,6 +313,25 @@ const metrics = computed(() => {
|
||||
buildMetric('图谱关系', payload.graph_edge_count)
|
||||
]
|
||||
}
|
||||
if (productKind.value === 'finance_snapshot') {
|
||||
return [
|
||||
buildMetric('报销单数', payload.claim_count ?? payload.claims ?? payload.total_claims),
|
||||
buildMetric(
|
||||
'报销金额',
|
||||
formatMoney(payload.claim_amount ?? payload.reimbursement_amount ?? payload.total_amount)
|
||||
),
|
||||
buildMetric('预算使用率', formatPercent(payload.budget_usage_rate ?? payload.budget_rate)),
|
||||
buildMetric('高额单据', payload.high_value_claim_count ?? payload.high_amount_claims)
|
||||
]
|
||||
}
|
||||
if (productKind.value === 'reminder_scan') {
|
||||
return [
|
||||
buildMetric('提醒人数', payload.recipient_count),
|
||||
buildMetric('提醒事项', payload.reminder_count),
|
||||
buildMetric('待审批', payload.approval_pending_count),
|
||||
buildMetric('逾期报销', payload.reimbursement_overdue_count)
|
||||
]
|
||||
}
|
||||
if (productKind.value === 'employee_profile') {
|
||||
return [
|
||||
buildMetric('目标员工', payload.target_employee_count),
|
||||
@@ -376,6 +427,23 @@ function formatWindowDays(value) {
|
||||
return days.length ? days.map((item) => `${item}天`).join(' / ') : '-'
|
||||
}
|
||||
|
||||
function formatMoney(value) {
|
||||
const amount = Number(value)
|
||||
if (!Number.isFinite(amount)) {
|
||||
return '-'
|
||||
}
|
||||
return `¥${amount.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const numericValue = Number(value)
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return '-'
|
||||
}
|
||||
const percent = numericValue > 1 ? numericValue : numericValue * 100
|
||||
return `${Math.round(percent)}%`
|
||||
}
|
||||
|
||||
function observationGraphCount(item) {
|
||||
return (item.graphNodeKeys || []).length + (item.graphEdgeKeys || []).length
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
:show-pagination="!loading && !errorMessage && visibleRuns.length > 0"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:pages="pageNumbers"
|
||||
:show-page-size="false"
|
||||
:summary="paginationSummary"
|
||||
:total="filteredRuns.length"
|
||||
@@ -303,6 +302,7 @@ import {
|
||||
import {
|
||||
formatWorkRecordDateTime,
|
||||
formatWorkRecordSummary,
|
||||
compactDigitalEmployeeWorkRecords,
|
||||
resolveWorkRecordModuleLabel,
|
||||
resolveWorkRecordSourceLabel,
|
||||
resolveWorkRecordStatusLabel,
|
||||
@@ -456,14 +456,6 @@ const visibleRuns = computed(() => {
|
||||
return filteredRuns.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
const pageNumbers = computed(() => {
|
||||
const total = totalPages.value
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, index) => index + 1)
|
||||
}
|
||||
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
|
||||
return Array.from({ length: 7 }, (_, index) => start + index)
|
||||
})
|
||||
const paginationSummary = computed(() =>
|
||||
`共 ${filteredRuns.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
|
||||
)
|
||||
@@ -523,7 +515,7 @@ async function loadWorkRecords(showToast = false) {
|
||||
|
||||
try {
|
||||
const payload = await fetchAgentRuns({ agent: 'hermes', limit: 100 })
|
||||
runs.value = Array.isArray(payload) ? payload : []
|
||||
runs.value = Array.isArray(payload) ? compactDigitalEmployeeWorkRecords(payload) : []
|
||||
emit('summary-change', {
|
||||
total: workRecordSummary.value.total,
|
||||
succeeded: workRecordSummary.value.succeeded,
|
||||
|
||||
@@ -191,41 +191,6 @@
|
||||
</div>
|
||||
|
||||
<div class="workbench-content-grid">
|
||||
<article class="panel workbench-card todo-panel">
|
||||
<div class="section-head">
|
||||
<div class="title-with-badge">
|
||||
<h2>我的待办</h2>
|
||||
<span class="soft-badge">{{ todoAlertCount }}</span>
|
||||
</div>
|
||||
<button type="button" class="link-action">全部待办 <i class="mdi mdi-chevron-right"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="todo-list">
|
||||
<button
|
||||
v-for="item in visibleTodoItems"
|
||||
:key="item.title"
|
||||
type="button"
|
||||
class="todo-row"
|
||||
@click="openPromptAssistant(`帮我处理:${item.title},${item.description}`)"
|
||||
>
|
||||
<WorkbenchListIcon
|
||||
:icon-key="item.iconKey"
|
||||
:color="item.color"
|
||||
:accent="item.accent"
|
||||
/>
|
||||
<span class="todo-copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>{{ item.description }}</small>
|
||||
</span>
|
||||
<span class="todo-meta">
|
||||
<span class="todo-status" :class="`todo-status--${item.statusTone}`">{{ item.status }}</span>
|
||||
<small>{{ item.due }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</article>
|
||||
|
||||
<article class="panel workbench-card progress-panel">
|
||||
<div class="section-head">
|
||||
<h2>费用进度</h2>
|
||||
@@ -238,7 +203,7 @@
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="progress-row"
|
||||
@click="openPromptAssistant(`查询 ${item.id} 的费用进度`)"
|
||||
@click="openWorkbenchTarget(item)"
|
||||
>
|
||||
<span class="progress-identity">
|
||||
<strong>{{ item.id }}</strong>
|
||||
@@ -247,17 +212,17 @@
|
||||
|
||||
<span class="progress-steps" aria-hidden="true">
|
||||
<span
|
||||
v-for="(step, index) in progressSteps"
|
||||
:key="step"
|
||||
v-for="step in item.steps"
|
||||
:key="step.label"
|
||||
class="progress-step"
|
||||
:class="{
|
||||
'is-done': index < item.activeStep,
|
||||
'is-current': index === item.activeStep,
|
||||
'is-future': index > item.activeStep
|
||||
'is-done': step.done,
|
||||
'is-current': step.current,
|
||||
'is-future': !step.done && !step.current
|
||||
}"
|
||||
>
|
||||
<i :class="index <= item.activeStep ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
|
||||
<small>{{ step }}</small>
|
||||
<i :class="step.done || step.current ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
|
||||
<small>{{ step.label }}</small>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -357,7 +322,6 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import PanelHead from '../shared/PanelHead.vue'
|
||||
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
|
||||
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
|
||||
import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
@@ -365,11 +329,8 @@ import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposer
|
||||
import {
|
||||
buildExpenseStatItems,
|
||||
filterAssistantCapabilitiesForUser,
|
||||
progressItems,
|
||||
progressSteps,
|
||||
quickPromptItems,
|
||||
resolveWorkbenchCapabilityGridClass,
|
||||
todoItems,
|
||||
} from '../../data/personalWorkbench.js'
|
||||
import { fetchAgentRuns } from '../../services/agentAssets.js'
|
||||
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
|
||||
@@ -394,7 +355,7 @@ const props = defineProps({
|
||||
workbenchSummary: { type: Object, default: () => ({}) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['open-assistant'])
|
||||
const emit = defineEmits(['open-assistant', 'open-document'])
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
const assistantDraft = ref('')
|
||||
@@ -494,9 +455,12 @@ const currentUserProfileKey = computed(() => {
|
||||
user.employee_no
|
||||
].map((item) => String(item || '').trim()).filter(Boolean).join('|')
|
||||
})
|
||||
const visibleTodoItems = computed(() => todoItems.slice(0, 5))
|
||||
const visibleProgressItems = computed(() => progressItems.slice(0, 5))
|
||||
const todoAlertCount = computed(() => visibleTodoItems.value.length)
|
||||
const visibleProgressItems = computed(() => {
|
||||
const rows = Array.isArray(props.workbenchSummary.progressItems)
|
||||
? props.workbenchSummary.progressItems
|
||||
: []
|
||||
return rows.slice(0, 5)
|
||||
})
|
||||
|
||||
function buildSelectedFileKey(file) {
|
||||
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
|
||||
@@ -625,6 +589,20 @@ function openPromptAssistant(prompt) {
|
||||
emitAssistant(payload)
|
||||
}
|
||||
|
||||
function openWorkbenchTarget(item) {
|
||||
const target = item?.target || {}
|
||||
if (target.type === 'document' && (target.id || target.claimNo)) {
|
||||
emit('open-document', {
|
||||
claimId: target.id,
|
||||
id: target.id || target.claimNo,
|
||||
claimNo: target.claimNo
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''} 的费用进度`)
|
||||
}
|
||||
|
||||
function openCapabilityAssistant(item) {
|
||||
if (pendingAction.value) {
|
||||
return
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
</svg>
|
||||
<template v-else>{{ idx + 1 }}</template>
|
||||
</span>
|
||||
<span class="rank-name">{{ item.name || item.shortName }}</span>
|
||||
<span class="rank-copy">
|
||||
<span class="rank-name">{{ item.name || item.shortName }}</span>
|
||||
<small v-if="item.meta" class="rank-meta">{{ item.meta }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="chartElement" class="chart-area" role="img" :aria-label="ariaLabel"></div>
|
||||
@@ -90,7 +93,11 @@ const chartOptions = computed(() => ({
|
||||
fontWeight: 700
|
||||
},
|
||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
|
||||
formatter: (params) => `${params.marker}${params.name}: ${formatValue(params.value)}`
|
||||
formatter: (params) => {
|
||||
const item = resolvedItems.value.find((row) => (row.name || row.shortName) === params.name)
|
||||
const meta = item?.meta ? `<br/>${item.meta}` : ''
|
||||
return `${params.marker}${params.name}: ${formatValue(params.value)}${meta}`
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
@@ -180,7 +187,8 @@ const formatValue = (value) => {
|
||||
}
|
||||
|
||||
.rank-labels {
|
||||
flex: 0 0 auto;
|
||||
flex: 0 0 min(34%, 150px);
|
||||
min-width: 112px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
@@ -214,10 +222,24 @@ const formatValue = (value) => {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rank-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rank-name {
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rank-meta {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<section class="risk-observation-dashboard">
|
||||
<section class="risk-observation-dashboard" :class="{ 'is-loading': loading }">
|
||||
<div v-if="loading" class="risk-dashboard-loading-overlay" role="status" aria-live="polite">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>{{ loadingLabel }}</span>
|
||||
</div>
|
||||
<article class="panel dashboard-card risk-trend-panel">
|
||||
<div class="card-head">
|
||||
<h3>风险观察趋势 <i class="mdi mdi-information-outline"></i></h3>
|
||||
<div class="risk-window-controls">
|
||||
<span v-if="lastUpdatedLabel" class="risk-refresh-label">{{ lastUpdatedLabel }}</span>
|
||||
<span class="risk-window-label">近 {{ dashboard.windowDays }} 天</span>
|
||||
<EnterpriseSelect
|
||||
class="risk-window-select"
|
||||
@@ -174,11 +179,29 @@ const props = defineProps({
|
||||
signalRanking: { type: Array, default: () => [] },
|
||||
dailyRows: { type: Array, default: () => [] },
|
||||
windowOptions: { type: Array, default: () => [] },
|
||||
activeWindowDays: { type: Number, default: 30 }
|
||||
activeWindowDays: { type: Number, default: 30 },
|
||||
lastUpdatedAt: { type: String, default: '' }
|
||||
})
|
||||
const emit = defineEmits(['update:windowDays'])
|
||||
|
||||
const router = useRouter()
|
||||
const loadingLabel = computed(() => (
|
||||
props.lastUpdatedAt ? '正在同步最新风险数据' : '正在加载风险看板数据'
|
||||
))
|
||||
const lastUpdatedLabel = computed(() => {
|
||||
if (!props.lastUpdatedAt) {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(props.lastUpdatedAt)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
return `上次同步 ${date.toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}`
|
||||
})
|
||||
const errorMessage = computed(() => props.error?.message || '风险看板数据加载失败')
|
||||
const recentHighObservations = computed(() => props.dashboard.recentHighObservations || [])
|
||||
const dimensionGroups = computed(() => [
|
||||
@@ -315,12 +338,39 @@ function openClaim(item) {
|
||||
|
||||
<style scoped>
|
||||
.risk-observation-dashboard {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.risk-dashboard-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: rgba(248, 250, 252, .82);
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.risk-dashboard-loading-overlay i {
|
||||
color: var(--theme-primary);
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.risk-observation-dashboard.is-loading .dashboard-card {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
min-width: 0;
|
||||
padding: 18px;
|
||||
@@ -359,6 +409,13 @@ function openClaim(item) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.risk-refresh-label {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.risk-window-controls {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<header class="topbar" :class="{ 'chat-mode': isChat }">
|
||||
<header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }">
|
||||
<div class="title-group">
|
||||
<div class="eyebrow">{{ eyebrowLabel }}</div>
|
||||
<h1>{{ currentView.title }}</h1>
|
||||
@@ -121,12 +121,73 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isWorkbench">
|
||||
<template v-else-if="isWorkbench">
|
||||
<div class="topbar-toolset" aria-label="工作台快捷工具">
|
||||
<button class="topbar-icon-btn notification-btn" type="button" aria-label="通知">
|
||||
<i class="mdi mdi-bell-outline"></i>
|
||||
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
|
||||
</button>
|
||||
<div class="notification-wrap">
|
||||
<button
|
||||
class="topbar-icon-btn notification-btn"
|
||||
type="button"
|
||||
aria-label="通知"
|
||||
:aria-expanded="notificationOpen"
|
||||
aria-haspopup="dialog"
|
||||
@click="notificationOpen = !notificationOpen"
|
||||
>
|
||||
<i class="mdi mdi-bell-outline"></i>
|
||||
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="notificationOpen" class="notification-popover" role="dialog" aria-label="通知中心">
|
||||
<header class="notification-head">
|
||||
<strong>通知</strong>
|
||||
<button type="button" aria-label="关闭通知" @click="notificationOpen = false">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="notification-tabs" role="tablist" aria-label="通知状态">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="notificationTab === 'unread'"
|
||||
:class="{ active: notificationTab === 'unread' }"
|
||||
@click="notificationTab = 'unread'"
|
||||
>
|
||||
未读 {{ unreadNotifications.length }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="notificationTab === 'read'"
|
||||
:class="{ active: notificationTab === 'read' }"
|
||||
@click="notificationTab = 'read'"
|
||||
>
|
||||
已读 {{ readNotifications.length }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeNotifications.length" class="notification-list">
|
||||
<button
|
||||
v-for="item in activeNotifications"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="notification-row"
|
||||
@click="openNotification(item)"
|
||||
>
|
||||
<span class="notification-dot" :class="item.tone"></span>
|
||||
<span class="notification-copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>{{ item.description }}</small>
|
||||
<em>{{ item.time }}</em>
|
||||
</span>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="notification-empty">
|
||||
<i class="mdi mdi-bell-check-outline"></i>
|
||||
<span>{{ notificationTab === 'unread' ? '暂无未读通知' : '暂无已读通知' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="topbar-icon-btn" type="button" aria-label="帮助">
|
||||
<i class="mdi mdi-help-circle-outline"></i>
|
||||
@@ -243,6 +304,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
workbenchSummary: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
companyName: {
|
||||
type: String,
|
||||
default: ''
|
||||
@@ -276,7 +341,8 @@ const emit = defineEmits([
|
||||
'update:overviewDashboard',
|
||||
'batchApprove',
|
||||
'openChat',
|
||||
'newApplication'
|
||||
'newApplication',
|
||||
'openDocument'
|
||||
])
|
||||
const isChat = computed(() => props.activeView === 'chat')
|
||||
const isOverview = computed(() => props.activeView === 'overview')
|
||||
@@ -294,10 +360,34 @@ const eyebrowLabel = computed(() => (
|
||||
))
|
||||
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
|
||||
const topbarNotificationCount = computed(() => {
|
||||
const summary = props.documentSummary ?? {}
|
||||
const count = Number(summary.toProcess ?? summary.toSubmit ?? 8)
|
||||
const summary = props.workbenchSummary ?? {}
|
||||
const count = Number(summary.unreadNotificationCount ?? 0)
|
||||
return Number.isFinite(count) && count > 0 ? Math.min(count, 99) : 0
|
||||
})
|
||||
})
|
||||
const notificationOpen = ref(false)
|
||||
const notificationTab = ref('unread')
|
||||
const notificationItems = computed(() => (
|
||||
Array.isArray(props.workbenchSummary?.notifications)
|
||||
? props.workbenchSummary.notifications
|
||||
: []
|
||||
))
|
||||
const unreadNotifications = computed(() => notificationItems.value.filter((item) => item.unread))
|
||||
const readNotifications = computed(() => notificationItems.value.filter((item) => !item.unread))
|
||||
const activeNotifications = computed(() => (
|
||||
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
|
||||
))
|
||||
|
||||
function openNotification(item) {
|
||||
notificationOpen.value = false
|
||||
const target = item?.target || {}
|
||||
if (target.type === 'document' && (target.id || target.claimNo)) {
|
||||
emit('openDocument', {
|
||||
claimId: target.id,
|
||||
id: target.id || target.claimNo,
|
||||
claimNo: target.claimNo
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const requestKpis = computed(() => {
|
||||
const summary = props.requestSummary ?? {}
|
||||
|
||||
@@ -99,7 +99,6 @@
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:pages="pages"
|
||||
:show-page-size="showPageSize"
|
||||
:summary="summary"
|
||||
:total="total"
|
||||
@@ -139,10 +138,6 @@ const props = defineProps({
|
||||
default: () => []
|
||||
},
|
||||
panel: { type: Boolean, default: true },
|
||||
pages: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
retryLabel: { type: String, default: '重新加载' },
|
||||
searchable: { type: Boolean, default: false },
|
||||
searchPlaceholder: { type: String, default: '搜索' },
|
||||
|
||||
@@ -78,10 +78,6 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
pages: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
showPageSize: { type: Boolean, default: true },
|
||||
summary: { type: String, default: '' },
|
||||
total: { type: Number, default: 0 },
|
||||
@@ -106,7 +102,7 @@ const summaryText = computed(() => {
|
||||
return props.summary
|
||||
}
|
||||
|
||||
return `共 ${props.total} 条,当前第 ${props.currentPage} 页`
|
||||
return `共 ${props.total} 条,当前第 ${props.currentPage} / ${props.totalPages} 页`
|
||||
})
|
||||
|
||||
function setPage(page) {
|
||||
@@ -142,3 +138,140 @@ watch(
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-foot.enterprise-pagination {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-summary {
|
||||
min-width: 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.enterprise-pagination .pager {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.enterprise-pagination .pager button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.enterprise-pagination .pager button:hover:not(.active) {
|
||||
background: #fff;
|
||||
color: var(--theme-primary-active);
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.enterprise-pagination .pager button.active {
|
||||
background: var(--theme-primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 16px var(--theme-primary-shadow);
|
||||
}
|
||||
|
||||
.enterprise-pagination .pager button:disabled {
|
||||
color: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-ellipsis {
|
||||
width: 28px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-tools {
|
||||
justify-self: end;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-size-select {
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-jump input {
|
||||
width: 54px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-jump input:focus {
|
||||
border-color: var(--theme-primary);
|
||||
box-shadow: 0 0 0 3px var(--theme-focus-ring);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.list-foot.enterprise-pagination {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: stretch;
|
||||
}
|
||||
|
||||
.enterprise-pagination .pager {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.enterprise-pagination .pager button,
|
||||
.enterprise-pagination .page-ellipsis {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-tools {
|
||||
justify-self: stretch;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,12 +34,15 @@ export function useAppShell() {
|
||||
conversation: null,
|
||||
scope: null,
|
||||
sessionType: '',
|
||||
budgetContext: null
|
||||
budgetContext: null,
|
||||
initialPromptAutoSubmit: true,
|
||||
initialApplicationPreview: null
|
||||
})
|
||||
const smartEntrySessionId = ref(0)
|
||||
const smartEntryRevealToken = ref(0)
|
||||
const smartEntryInvalidatedDraftClaimId = ref('')
|
||||
const selectedRequestSnapshot = ref(null)
|
||||
const selectedRequestSnapshot = ref(null)
|
||||
const documentCenterRefreshToken = ref(0)
|
||||
|
||||
const { activeView, currentView, setView } = useNavigation()
|
||||
const {
|
||||
@@ -98,12 +101,19 @@ export function useAppShell() {
|
||||
: []
|
||||
))
|
||||
|
||||
const requestsNeeded = computed(() => ['documents', 'workbench'].includes(activeView.value))
|
||||
async function reloadDocumentCenterRequests() {
|
||||
documentCenterRefreshToken.value += 1
|
||||
return reloadRequests()
|
||||
}
|
||||
|
||||
watch(
|
||||
requestsNeeded,
|
||||
(isNeeded) => {
|
||||
if (isNeeded) {
|
||||
() => [activeView.value, route.name],
|
||||
([view]) => {
|
||||
if (view === 'documents') {
|
||||
void reloadDocumentCenterRequests()
|
||||
return
|
||||
}
|
||||
if (view === 'workbench') {
|
||||
void ensureRequestsLoaded()
|
||||
}
|
||||
},
|
||||
@@ -166,10 +176,17 @@ export function useAppShell() {
|
||||
toast(message)
|
||||
}
|
||||
|
||||
function handleNavigate(view) {
|
||||
smartEntryOpen.value = false
|
||||
setView(view)
|
||||
}
|
||||
function handleNavigate(view) {
|
||||
smartEntryOpen.value = false
|
||||
const shouldRefreshCurrentDocumentCenter =
|
||||
view === 'documents'
|
||||
&& activeView.value === 'documents'
|
||||
&& route.name === 'app-documents'
|
||||
setView(view)
|
||||
if (shouldRefreshCurrentDocumentCenter) {
|
||||
void reloadDocumentCenterRequests()
|
||||
}
|
||||
}
|
||||
|
||||
function openFinancialAssistantCreate(source) {
|
||||
if (smartEntryOpen.value) {
|
||||
@@ -185,7 +202,9 @@ export function useAppShell() {
|
||||
conversation: null,
|
||||
scope: null,
|
||||
sessionType: '',
|
||||
budgetContext: null
|
||||
budgetContext: null,
|
||||
initialPromptAutoSubmit: true,
|
||||
initialApplicationPreview: null
|
||||
}
|
||||
smartEntrySessionId.value += 1
|
||||
}
|
||||
@@ -320,6 +339,7 @@ export function useAppShell() {
|
||||
|| String(payload?.prompt || '').trim()
|
||||
|| (Array.isArray(payload?.files) && payload.files.length)
|
||||
|| payload?.conversation
|
||||
|| payload?.applicationPreview
|
||||
)
|
||||
if (smartEntryOpen.value && !shouldReplaceOpenEntry) {
|
||||
smartEntryRevealToken.value += 1
|
||||
@@ -342,6 +362,10 @@ export function useAppShell() {
|
||||
sessionType,
|
||||
budgetContext: payload.budgetContext && typeof payload.budgetContext === 'object'
|
||||
? payload.budgetContext
|
||||
: null,
|
||||
initialPromptAutoSubmit: payload.initialPromptAutoSubmit !== false,
|
||||
initialApplicationPreview: payload.applicationPreview && typeof payload.applicationPreview === 'object'
|
||||
? payload.applicationPreview
|
||||
: null
|
||||
}
|
||||
smartEntrySessionId.value += 1
|
||||
@@ -410,6 +434,7 @@ export function useAppShell() {
|
||||
currentView,
|
||||
customRange,
|
||||
detailMode,
|
||||
documentCenterRefreshToken,
|
||||
filteredRequests,
|
||||
filters,
|
||||
handleApprove,
|
||||
@@ -429,6 +454,7 @@ export function useAppShell() {
|
||||
requestsError,
|
||||
requestsLoading,
|
||||
reloadRequests,
|
||||
reloadDocumentCenterRequests,
|
||||
requests,
|
||||
search,
|
||||
selectedRequest,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
fetchDigitalEmployeeDashboard,
|
||||
@@ -92,6 +92,9 @@ export function useOverviewView(options = {}) {
|
||||
const riskDashboardPayload = ref(null)
|
||||
const riskDashboardLoading = ref(false)
|
||||
const riskDashboardError = ref(null)
|
||||
const riskDashboardLastUpdatedAt = ref('')
|
||||
let riskDashboardRefreshTimer = 0
|
||||
let riskDashboardRequestSeq = 0
|
||||
const digitalEmployeeDashboardPayload = ref(null)
|
||||
const digitalEmployeeDashboardLoading = ref(false)
|
||||
const digitalEmployeeDashboardError = ref(null)
|
||||
@@ -178,22 +181,53 @@ export function useOverviewView(options = {}) {
|
||||
}
|
||||
|
||||
const loadRiskDashboard = async () => {
|
||||
const requestSeq = ++riskDashboardRequestSeq
|
||||
riskDashboardLoading.value = true
|
||||
riskDashboardError.value = null
|
||||
|
||||
try {
|
||||
riskDashboardPayload.value = await fetchRiskObservationDashboard({
|
||||
const payload = await fetchRiskObservationDashboard({
|
||||
windowDays: activeRiskWindowDays.value,
|
||||
limit: 500
|
||||
})
|
||||
if (requestSeq !== riskDashboardRequestSeq) {
|
||||
return
|
||||
}
|
||||
riskDashboardPayload.value = payload
|
||||
riskDashboardLastUpdatedAt.value = new Date().toISOString()
|
||||
} catch (error) {
|
||||
if (requestSeq !== riskDashboardRequestSeq) {
|
||||
return
|
||||
}
|
||||
riskDashboardPayload.value = null
|
||||
riskDashboardError.value = error
|
||||
} finally {
|
||||
riskDashboardLoading.value = false
|
||||
if (requestSeq === riskDashboardRequestSeq) {
|
||||
riskDashboardLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startRiskDashboardRealtimeRefresh = () => {
|
||||
if (riskDashboardRefreshTimer) {
|
||||
window.clearInterval(riskDashboardRefreshTimer)
|
||||
}
|
||||
riskDashboardRefreshTimer = window.setInterval(() => {
|
||||
if (document.visibilityState === 'hidden' || riskDashboardLoading.value) {
|
||||
return
|
||||
}
|
||||
void loadRiskDashboard()
|
||||
}, 30_000)
|
||||
}
|
||||
|
||||
const stopRiskDashboardRealtimeRefresh = () => {
|
||||
if (!riskDashboardRefreshTimer) {
|
||||
return
|
||||
}
|
||||
window.clearInterval(riskDashboardRefreshTimer)
|
||||
riskDashboardRefreshTimer = 0
|
||||
}
|
||||
|
||||
const loadDigitalEmployeeDashboard = async () => {
|
||||
digitalEmployeeDashboardLoading.value = true
|
||||
digitalEmployeeDashboardError.value = null
|
||||
@@ -222,6 +256,11 @@ export function useOverviewView(options = {}) {
|
||||
void loadSystemDashboard()
|
||||
void loadRiskDashboard()
|
||||
void loadDigitalEmployeeDashboard()
|
||||
startRiskDashboardRealtimeRefresh()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopRiskDashboardRealtimeRefresh()
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -323,6 +362,9 @@ export function useOverviewView(options = {}) {
|
||||
const financeDepartmentRanking = computed(() => (
|
||||
financeDashboardPayload.value?.departmentRanking || []
|
||||
))
|
||||
const financeDepartmentEmployeeMix = computed(() => (
|
||||
financeDashboardPayload.value?.departmentEmployeeMix || emptyFinanceDonut
|
||||
))
|
||||
const financeEmployeeRanking = computed(() => (
|
||||
financeDashboardPayload.value?.employeeRanking || []
|
||||
))
|
||||
@@ -501,7 +543,11 @@ export function useOverviewView(options = {}) {
|
||||
const activeTrend = computed(() => financeTrend.value)
|
||||
const spendTotal = computed(() => financeSpendByCategory.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
|
||||
const riskTotal = computed(() => financeExceptionMix.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
|
||||
const departmentEmployeeTotal = computed(() => (
|
||||
financeDepartmentEmployeeMix.value.reduce((sum, item) => sum + Number(item.value || item.amount || 0), 0)
|
||||
))
|
||||
const spendCenterValue = computed(() => formatCurrency(Math.round(spendTotal.value)))
|
||||
const departmentEmployeeCenterValue = computed(() => formatCurrency(Math.round(departmentEmployeeTotal.value)))
|
||||
|
||||
const spendLegend = computed(() => financeSpendByCategory.value.map((item) => ({
|
||||
...item,
|
||||
@@ -513,6 +559,14 @@ export function useOverviewView(options = {}) {
|
||||
display: `${item.value} 单`
|
||||
})))
|
||||
|
||||
const departmentEmployeeLegend = computed(() => financeDepartmentEmployeeMix.value.map((item) => ({
|
||||
...item,
|
||||
value: Number(item.value || item.amount || 0),
|
||||
display: departmentEmployeeTotal.value
|
||||
? `${Math.round((Number(item.value || item.amount || 0) / departmentEmployeeTotal.value) * 100)}%`
|
||||
: '0%'
|
||||
})))
|
||||
|
||||
const systemToolTotal = computed(() =>
|
||||
systemToolCallMix.reduce((sum, item) => sum + item.value, 0)
|
||||
)
|
||||
@@ -542,6 +596,7 @@ export function useOverviewView(options = {}) {
|
||||
rank: index + 1,
|
||||
shortName: item.name,
|
||||
amountLabel: formatCurrency(item.amount),
|
||||
meta: `${Number(item.employeeCount || 0)} 人 / ${Number(item.count || 0)} 单`,
|
||||
width: `${Math.max((item.amount / max) * 100, 18)}%`,
|
||||
color: item.color
|
||||
}))
|
||||
@@ -561,6 +616,7 @@ export function useOverviewView(options = {}) {
|
||||
rank: index + 1,
|
||||
shortName: item.name,
|
||||
amountLabel: formatCurrency(item.amount),
|
||||
meta: `${item.department || '未归属部门'} / ${Number(item.count || 0)} 单`,
|
||||
width: `${Math.max((item.amount / max) * 100, 18)}%`,
|
||||
color: item.color
|
||||
}))
|
||||
@@ -738,6 +794,8 @@ export function useOverviewView(options = {}) {
|
||||
bottlenecks,
|
||||
budgetMetrics,
|
||||
budgetSummary,
|
||||
departmentEmployeeCenterValue,
|
||||
departmentEmployeeLegend,
|
||||
departmentRangeOptions,
|
||||
digitalEmployeeCategoryRows,
|
||||
digitalEmployeeDashboard,
|
||||
@@ -760,6 +818,7 @@ export function useOverviewView(options = {}) {
|
||||
rankedEmployees,
|
||||
riskDashboard,
|
||||
riskDashboardError,
|
||||
riskDashboardLastUpdatedAt,
|
||||
riskDashboardLoading,
|
||||
riskDailyTrendRows,
|
||||
riskLegend,
|
||||
|
||||
@@ -163,7 +163,7 @@ export const exceptionMix = [
|
||||
{ name: '已入账', value: 9, color: 'var(--chart-blue)' }
|
||||
]
|
||||
|
||||
export const departmentRangeOptions = ['本周', '本月', '本季度']
|
||||
export const departmentRangeOptions = ['本月', '本季度', '本年', '全部']
|
||||
|
||||
export const bottlenecks = [
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ const FINANCE_DASHBOARD_FALLBACK = {
|
||||
spendByCategory: null,
|
||||
exceptionMix: null,
|
||||
departmentRanking: null,
|
||||
departmentEmployeeMix: null,
|
||||
employeeRanking: null,
|
||||
topClaims: null,
|
||||
bottlenecks: null,
|
||||
@@ -69,6 +70,7 @@ function normalizeFinanceDashboardPayload(payload = {}) {
|
||||
spendByCategory: payload.spend_by_category || payload.spendByCategory || null,
|
||||
exceptionMix: payload.exception_mix || payload.exceptionMix || null,
|
||||
departmentRanking: payload.department_ranking || payload.departmentRanking || null,
|
||||
departmentEmployeeMix: payload.department_employee_mix || payload.departmentEmployeeMix || null,
|
||||
employeeRanking: payload.employee_ranking || payload.employeeRanking || null,
|
||||
topClaims: payload.top_claims || payload.topClaims || null,
|
||||
bottlenecks: payload.bottlenecks || null,
|
||||
@@ -129,7 +131,7 @@ export async function fetchFinanceDashboard(options = {}) {
|
||||
if (options.endDate) search.set('end_date', String(options.endDate))
|
||||
|
||||
const payload = await apiRequest(`/analytics/finance-dashboard?${search.toString()}`, {
|
||||
timeoutMs: Number(options.timeoutMs || 3500),
|
||||
timeoutMs: Number(options.timeoutMs || 10000),
|
||||
timeoutMessage: '财务看板真实数据加载超时,已保留本地展示数据。'
|
||||
})
|
||||
|
||||
|
||||
@@ -121,6 +121,10 @@ function resolveDaysFromDateRange(rangeText) {
|
||||
return diffDays >= 0 ? `${diffDays + 1}天` : ''
|
||||
}
|
||||
|
||||
export function resolveApplicationDaysFromDateRange(rangeText) {
|
||||
return resolveDaysFromDateRange(rangeText)
|
||||
}
|
||||
|
||||
function resolvePreviewToday(options = {}) {
|
||||
const explicitToday = String(options.today || options.currentDate || '').trim()
|
||||
if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)
|
||||
|
||||
119
web/src/utils/travelApplicationPlanning.js
Normal file
119
web/src/utils/travelApplicationPlanning.js
Normal file
@@ -0,0 +1,119 @@
|
||||
export const TRAVEL_PLANNING_ACTION_GENERATE = 'generate_travel_application_plan'
|
||||
export const TRAVEL_PLANNING_ACTION_SKIP = 'skip_travel_application_plan'
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function isTravelApplication(applicationType = '') {
|
||||
return /差旅|出差/.test(normalizeText(applicationType))
|
||||
}
|
||||
|
||||
function extractDateParts(timeText = '') {
|
||||
const dates = normalizeText(timeText).match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||||
return {
|
||||
startDate: dates[0] || '',
|
||||
endDate: dates[dates.length - 1] || dates[0] || ''
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTravelPlanningContext(preview = {}, draftPayload = {}) {
|
||||
const fields = preview?.fields && typeof preview.fields === 'object' ? preview.fields : {}
|
||||
const applicationType = normalizeText(fields.applicationType)
|
||||
if (!isTravelApplication(applicationType)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const location = normalizeText(fields.location)
|
||||
const time = normalizeText(fields.time)
|
||||
if (!location || !time) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dates = extractDateParts(time)
|
||||
return {
|
||||
applicationType,
|
||||
location,
|
||||
time,
|
||||
startDate: dates.startDate,
|
||||
endDate: dates.endDate,
|
||||
days: normalizeText(fields.days),
|
||||
transportMode: normalizeText(fields.transportMode),
|
||||
reason: normalizeText(fields.reason),
|
||||
claimNo: normalizeText(draftPayload?.claim_no || draftPayload?.claimNo)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTravelPlanningNudgeMessage(preview = {}, draftPayload = {}) {
|
||||
const context = resolveTravelPlanningContext(preview, draftPayload)
|
||||
if (!context) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const timeCopy = context.startDate && context.endDate && context.startDate !== context.endDate
|
||||
? `${context.startDate} 至 ${context.endDate}`
|
||||
: context.time
|
||||
const transportCopy = context.transportMode ? `、${context.transportMode}时间窗口` : '、交通方式比选'
|
||||
return [
|
||||
`本次${context.location}差旅申请已经提交。`,
|
||||
`如果你愿意,我可以继续按 ${timeCopy} 帮你整理一版行程规划,包括出发/返程${transportCopy}、酒店区域建议和还需要确认的事项。`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildTravelPlanningSuggestedActions(preview = {}, draftPayload = {}) {
|
||||
const context = resolveTravelPlanningContext(preview, draftPayload)
|
||||
if (!context) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: '生成行程规划',
|
||||
action_type: TRAVEL_PLANNING_ACTION_GENERATE,
|
||||
description: '按本次申请的地点和时间给出交通、酒店和待确认事项。',
|
||||
icon: 'mdi mdi-map-clock-outline',
|
||||
emphasis: 'primary',
|
||||
payload: {
|
||||
context
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '暂不需要',
|
||||
action_type: TRAVEL_PLANNING_ACTION_SKIP,
|
||||
description: '保留申请结果,不继续生成规划。',
|
||||
icon: 'mdi mdi-check-outline',
|
||||
payload: {
|
||||
context
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function buildTravelPlanningRecommendation(preview = {}, draftPayload = {}) {
|
||||
const context = resolveTravelPlanningContext(preview, draftPayload)
|
||||
if (!context) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const outboundDate = context.startDate || '出发当天'
|
||||
const returnDate = context.endDate || '返回当天'
|
||||
const transport = context.transportMode || '火车/飞机'
|
||||
const reasonLine = context.reason ? `业务安排:${context.reason}` : '业务安排:以申请事由为准,出发前再确认具体到场时间。'
|
||||
const hotelArea = `${context.location}核心办公区、客户现场周边或交通枢纽 30 分钟通勤范围内`
|
||||
const claimLine = context.claimNo ? `关联申请单:${context.claimNo}` : ''
|
||||
|
||||
return [
|
||||
'可以,先给你一版轻量行程规划,后续你可以继续补充偏好。',
|
||||
'',
|
||||
claimLine,
|
||||
`行程时间:${context.time}${context.days ? `(${context.days})` : ''}`,
|
||||
reasonLine,
|
||||
'',
|
||||
`交通建议:${outboundDate} 优先看上午到中午抵达 ${context.location} 的${transport}班次,预留到达后 1.5 小时交通和现场准备时间;${returnDate} 优先看下午或晚间返程,避免压缩最后一天工作安排。`,
|
||||
`酒店建议:优先选择${hotelArea},同时关注可开发票、可取消、早餐和离现场距离。`,
|
||||
'需要确认:出发城市、客户现场地址、是否需要同行人、是否有指定住宿协议酒店、是否需要提前准备会议室或网络环境。',
|
||||
'',
|
||||
'你也可以继续告诉我出发城市、偏好的交通方式或预算,我再把规划细化成更具体的时间段。'
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ function parseNumber(value) {
|
||||
return Number.isFinite(nextValue) ? nextValue : 0
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function toDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
@@ -60,6 +64,174 @@ function formatCurrency(value) {
|
||||
}).format(parseNumber(value))
|
||||
}
|
||||
|
||||
function resolveRequestIdentity(request) {
|
||||
return normalizeText(request?.claimNo || request?.claim_no || request?.id || request?.claimId)
|
||||
}
|
||||
|
||||
function resolveRequestTarget(request) {
|
||||
return {
|
||||
type: 'document',
|
||||
id: normalizeText(request?.claimId || request?.id),
|
||||
claimNo: resolveRequestIdentity(request)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusTone(approvalKey) {
|
||||
if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger'
|
||||
if (approvalKey === 'draft') return 'success'
|
||||
if (approvalKey === 'pending_payment') return 'warning'
|
||||
if (approvalKey === 'in_progress') return 'info'
|
||||
return 'muted'
|
||||
}
|
||||
|
||||
function resolveTodoAction(request) {
|
||||
const approvalKey = normalizeText(request?.approvalKey)
|
||||
const status = normalizeText(request?.status || request?.approvalStatus)
|
||||
|
||||
if (approvalKey === 'supplement' || approvalKey === 'rejected') {
|
||||
return {
|
||||
title: '补充或修改单据',
|
||||
status: approvalKey === 'rejected' ? '退回修改' : '待补充',
|
||||
statusTone: 'danger',
|
||||
iconKey: 'receipts',
|
||||
color: 'var(--danger)',
|
||||
accent: 'var(--danger-soft)'
|
||||
}
|
||||
}
|
||||
|
||||
if (approvalKey === 'draft' || /draft|草稿|待提交/i.test(status)) {
|
||||
return {
|
||||
title: '提交草稿单据',
|
||||
status: '待提交',
|
||||
statusTone: 'success',
|
||||
iconKey: 'travelDraft',
|
||||
color: 'var(--theme-primary)',
|
||||
accent: 'var(--theme-primary-soft)'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildTodoItems(ownedRequests) {
|
||||
return ownedRequests
|
||||
.map((request) => {
|
||||
const action = resolveTodoAction(request)
|
||||
if (!action) {
|
||||
return null
|
||||
}
|
||||
|
||||
const requestId = resolveRequestIdentity(request)
|
||||
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || requestId
|
||||
|
||||
return {
|
||||
...action,
|
||||
id: requestId,
|
||||
requestId,
|
||||
title: action.title,
|
||||
description: `${requestId || '单据'} · ${title || '费用单据'}`,
|
||||
due: normalizeText(request?.updatedAt || request?.applyTime || request?.submittedAt) || '待处理',
|
||||
target: resolveRequestTarget(request),
|
||||
prompt: `帮我处理 ${requestId || title}:${action.status}`
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => normalizeText(right.due).localeCompare(normalizeText(left.due)))
|
||||
}
|
||||
|
||||
function resolveProgressStatusTone(approvalKey) {
|
||||
if (approvalKey === 'completed') return 'muted'
|
||||
if (approvalKey === 'pending_payment') return 'warning'
|
||||
if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
function resolveCurrentProgressIndex(steps) {
|
||||
const currentIndex = steps.findIndex((step) => step?.current)
|
||||
if (currentIndex >= 0) {
|
||||
return currentIndex
|
||||
}
|
||||
|
||||
const activeIndex = steps.findLastIndex((step) => step?.active || step?.done)
|
||||
return Math.max(0, activeIndex)
|
||||
}
|
||||
|
||||
export function buildAdjacentProgressSteps(steps = [], windowSize = 4) {
|
||||
const rows = Array.isArray(steps) ? steps : []
|
||||
if (!rows.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const currentIndex = resolveCurrentProgressIndex(rows)
|
||||
const safeWindowSize = Math.max(1, Number(windowSize) || 4)
|
||||
let start = Math.max(0, currentIndex - 1)
|
||||
let end = Math.min(rows.length, start + safeWindowSize)
|
||||
|
||||
if (end - start < safeWindowSize) {
|
||||
start = Math.max(0, end - safeWindowSize)
|
||||
}
|
||||
|
||||
return rows.slice(start, end).map((step) => ({
|
||||
label: normalizeText(step.label || step.rawLabel),
|
||||
done: Boolean(step.done),
|
||||
current: Boolean(step.current),
|
||||
title: normalizeText(step.title || step.time || step.detail)
|
||||
}))
|
||||
}
|
||||
|
||||
function buildProgressItems(ownedRequests) {
|
||||
return ownedRequests
|
||||
.filter((request) => Array.isArray(request?.progressSteps) && request.progressSteps.length)
|
||||
.map((request) => {
|
||||
const requestId = resolveRequestIdentity(request)
|
||||
const steps = buildAdjacentProgressSteps(request.progressSteps, 4)
|
||||
const currentStep = steps.find((step) => step.current)
|
||||
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
|
||||
|
||||
return {
|
||||
id: requestId,
|
||||
requestId,
|
||||
title,
|
||||
amount: formatCurrency(request?.amount),
|
||||
status: normalizeText(request?.approvalStatus || currentStep?.label) || '处理中',
|
||||
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey)),
|
||||
updatedAt: normalizeText(request?.updatedAt || request?.submittedAt || request?.createdAt),
|
||||
steps,
|
||||
target: resolveRequestTarget(request),
|
||||
prompt: `查询 ${requestId || title} 的费用进度`
|
||||
}
|
||||
})
|
||||
.sort((left, right) => normalizeText(right.updatedAt).localeCompare(normalizeText(left.updatedAt)))
|
||||
}
|
||||
|
||||
function buildNotifications(todoItems, progressItems) {
|
||||
const todoNotifications = todoItems.map((item) => ({
|
||||
id: `todo:${item.requestId || item.description}`,
|
||||
title: item.status,
|
||||
description: item.description,
|
||||
time: item.due,
|
||||
unread: true,
|
||||
tone: item.statusTone,
|
||||
target: item.target,
|
||||
prompt: item.prompt
|
||||
}))
|
||||
|
||||
const progressNotifications = progressItems
|
||||
.filter((item) => ['danger', 'warning'].includes(item.statusTone))
|
||||
.map((item) => ({
|
||||
id: `progress:${item.requestId || item.title}`,
|
||||
title: item.status,
|
||||
description: `${item.requestId || '单据'} · ${item.title}`,
|
||||
time: item.updatedAt || '最近更新',
|
||||
unread: false,
|
||||
tone: item.statusTone,
|
||||
target: item.target,
|
||||
prompt: item.prompt
|
||||
}))
|
||||
|
||||
return [...todoNotifications, ...progressNotifications]
|
||||
}
|
||||
|
||||
export function buildWorkbenchSummary(requests, currentUser) {
|
||||
const ownedRequests = Array.isArray(requests)
|
||||
? requests.filter((item) => belongsToCurrentUser(item, currentUser))
|
||||
@@ -79,6 +251,9 @@ export function buildWorkbenchSummary(requests, currentUser) {
|
||||
const completedCount = ownedRequests.filter((item) => item.approvalKey === 'completed').length
|
||||
const returnCount = ownedRequests.filter((item) => item.approvalKey === 'rejected').length
|
||||
const highRiskCount = monthlyClaims.filter((item) => hasHighRiskFlag(item)).length
|
||||
const todoItems = buildTodoItems(ownedRequests)
|
||||
const progressItems = buildProgressItems(ownedRequests)
|
||||
const notifications = buildNotifications(todoItems, progressItems)
|
||||
|
||||
return {
|
||||
monthlyCount,
|
||||
@@ -91,6 +266,10 @@ export function buildWorkbenchSummary(requests, currentUser) {
|
||||
pendingPaymentCount,
|
||||
completedCount,
|
||||
returnCount,
|
||||
highRiskCount
|
||||
highRiskCount,
|
||||
todoItems,
|
||||
progressItems,
|
||||
notifications,
|
||||
unreadNotificationCount: notifications.filter((item) => item.unread).length
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
:knowledge-summary="knowledgeSummary"
|
||||
:request-summary="requestSummary"
|
||||
:document-summary="documentSummary"
|
||||
:workbench-summary="workbenchSummary"
|
||||
:digital-employee-summary="digitalEmployeeSummary"
|
||||
:company-name="ENTERPRISE_DISPLAY_NAME"
|
||||
:detail-mode="resolvedDetailMode"
|
||||
@@ -84,6 +85,7 @@
|
||||
@update:overview-dashboard="overviewDashboard = $event"
|
||||
@batch-approve="toast('已批量通过 23 条审批任务。')"
|
||||
@new-application="openExpenseApplicationCreate"
|
||||
@open-document="openWorkbenchDocument"
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
@@ -124,6 +126,7 @@
|
||||
:assistant-modal-open="smartEntryOpen"
|
||||
:workbench-summary="workbenchSummary"
|
||||
@open-assistant="openSmartEntry"
|
||||
@open-document="openWorkbenchDocument"
|
||||
/>
|
||||
|
||||
<TravelRequestDetailView
|
||||
@@ -142,6 +145,7 @@
|
||||
:has-data="requests.length > 0"
|
||||
:loading="requestsLoading"
|
||||
:error="requestsError"
|
||||
:refresh-token="documentCenterRefreshToken"
|
||||
@open-document="openRequestDetail"
|
||||
@create-request="openTravelCreate"
|
||||
@create-application="openExpenseApplicationCreate"
|
||||
@@ -188,6 +192,8 @@
|
||||
:initial-conversation="smartEntryContext.conversation"
|
||||
:initial-session-type="smartEntryContext.sessionType"
|
||||
:initial-budget-context="smartEntryContext.budgetContext"
|
||||
:initial-prompt-auto-submit="smartEntryContext.initialPromptAutoSubmit"
|
||||
:initial-application-preview="smartEntryContext.initialApplicationPreview"
|
||||
:entry-source="smartEntryContext.source"
|
||||
:request-context="smartEntryContext.request"
|
||||
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
|
||||
@@ -274,6 +280,7 @@ const {
|
||||
customRange,
|
||||
detailAlerts,
|
||||
detailMode,
|
||||
documentCenterRefreshToken,
|
||||
filteredRequests,
|
||||
filters,
|
||||
handleApprove,
|
||||
@@ -292,6 +299,7 @@ const {
|
||||
workbenchSummary,
|
||||
requestsError,
|
||||
requestsLoading,
|
||||
reloadDocumentCenterRequests,
|
||||
reloadRequests,
|
||||
requests,
|
||||
search,
|
||||
@@ -351,6 +359,20 @@ const resolvedDetailKpis = computed(() => (
|
||||
customDetailTopBarActive.value ? detailTopBarPayload.value?.kpis || [] : []
|
||||
))
|
||||
|
||||
function openWorkbenchDocument(payload = {}) {
|
||||
const requestId = String(payload.claimId || payload.id || payload.claimNo || '').trim()
|
||||
if (!requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
const request = requests.value.find((item) => (
|
||||
String(item.claimId || '').trim() === requestId
|
||||
|| String(item.id || '').trim() === requestId
|
||||
|| String(item.claimNo || '').trim() === requestId
|
||||
))
|
||||
openRequestDetail(request || payload)
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
logout('manual')
|
||||
}
|
||||
|
||||
@@ -196,35 +196,16 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer v-if="showTable" class="list-foot">
|
||||
<span class="page-summary">{{ pageSummary }}</span>
|
||||
<div class="pager" aria-label="分页">
|
||||
<button class="page-nav" type="button" :disabled="budgetPage === 1" aria-label="上一页" @click="goToBudgetPage(budgetPage - 1)">
|
||||
<i class="mdi mdi-chevron-left"></i>
|
||||
</button>
|
||||
<button
|
||||
v-for="page in budgetPageNumbers"
|
||||
:key="page"
|
||||
class="page-number"
|
||||
:class="{ active: budgetPage === page }"
|
||||
type="button"
|
||||
:aria-current="budgetPage === page ? 'page' : undefined"
|
||||
@click="goToBudgetPage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<button class="page-nav" type="button" :disabled="budgetPage === totalBudgetPages" aria-label="下一页" @click="goToBudgetPage(budgetPage + 1)">
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<EnterpriseSelect
|
||||
v-model="budgetPageSize"
|
||||
class="page-size-select"
|
||||
:options="budgetPageSizeOptions"
|
||||
size="small"
|
||||
@change="changeBudgetPageSize"
|
||||
/>
|
||||
</footer>
|
||||
<EnterprisePagination
|
||||
v-if="showTable"
|
||||
:current-page="budgetPage"
|
||||
:page-size="budgetPageSize"
|
||||
:page-size-options="budgetPageSizeOptions"
|
||||
:summary="pageSummary"
|
||||
:total-pages="totalBudgetPages"
|
||||
@page-size-change="changeBudgetPageSize"
|
||||
@update:current-page="goToBudgetPage"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<EnterpriseDetailPage
|
||||
|
||||
@@ -154,7 +154,8 @@ import { isPlatformAdminUser } from '../utils/accessControl.js'
|
||||
import {
|
||||
DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS,
|
||||
formatDigitalEmployeeCron,
|
||||
isDigitalEmployeeAsset
|
||||
isDigitalEmployeeAsset,
|
||||
shouldDisplayDigitalEmployeeAsset
|
||||
} from './scripts/auditViewDigitalEmployeeModel.js'
|
||||
import {
|
||||
buildDigitalEmployeeScheduleConfig,
|
||||
@@ -336,7 +337,9 @@ async function loadEmployees() {
|
||||
try {
|
||||
const payload = await fetchAgentAssets({ assetType: 'task' })
|
||||
const items = Array.isArray(payload)
|
||||
? payload.filter(isDigitalEmployeeAsset).map(buildEmployeeListItem)
|
||||
? payload
|
||||
.filter((asset) => isDigitalEmployeeAsset(asset) && shouldDisplayDigitalEmployeeAsset(asset))
|
||||
.map(buildEmployeeListItem)
|
||||
: []
|
||||
|
||||
employees.value = sortEmployees(items)
|
||||
|
||||
@@ -215,36 +215,23 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer v-if="showTable" class="list-foot">
|
||||
<span class="page-summary">共 {{ filteredRows.length }} 条,目前第 {{ currentPage }} 页</span>
|
||||
<div class="pager" aria-label="分页">
|
||||
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
|
||||
<i class="mdi mdi-chevron-left"></i>
|
||||
</button>
|
||||
<button
|
||||
v-for="page in totalPages"
|
||||
:key="page"
|
||||
class="page-number"
|
||||
:class="{ active: currentPage === page }"
|
||||
type="button"
|
||||
:aria-current="currentPage === page ? 'page' : undefined"
|
||||
@click="currentPage = page"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<EnterpriseSelect v-model="pageSize" class="page-size-select" :options="pageSizeOptions" size="small" @change="changePageSize" />
|
||||
</footer>
|
||||
<EnterprisePagination
|
||||
v-if="showTable"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:summary="pageSummary"
|
||||
:total-pages="totalPages"
|
||||
@page-size-change="changePageSize"
|
||||
@update:current-page="currentPage = $event"
|
||||
/>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
|
||||
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
|
||||
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
|
||||
@@ -318,7 +305,8 @@ const props = defineProps({
|
||||
filteredRequests: { type: Array, required: true },
|
||||
hasData: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
error: { type: String, default: '' }
|
||||
error: { type: String, default: '' },
|
||||
refreshToken: { type: Number, default: 0 }
|
||||
})
|
||||
const emit = defineEmits([
|
||||
'open-document',
|
||||
@@ -463,6 +451,7 @@ const filteredRows = computed(() => {
|
||||
})
|
||||
|
||||
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(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return filteredRows.value.slice(start, start + pageSize.value)
|
||||
@@ -832,6 +821,15 @@ watch(documentSummary, (summary) => {
|
||||
onMounted(() => {
|
||||
void loadSupportingRows()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.refreshToken,
|
||||
(token, previousToken) => {
|
||||
if (token && token !== previousToken) {
|
||||
void loadSupportingRows()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/components/document-list-shared.css"></style>
|
||||
|
||||
@@ -657,47 +657,16 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer v-if="!loading && !errorMessage && totalCount" class="list-foot">
|
||||
<span class="page-summary">共 {{ totalCount }} 条,目前第 {{ currentPage }} 页</span>
|
||||
<div class="pager" aria-label="分页">
|
||||
<button
|
||||
class="page-nav"
|
||||
type="button"
|
||||
:disabled="currentPage === 1"
|
||||
aria-label="上一页"
|
||||
@click="currentPage--"
|
||||
>
|
||||
<i class="mdi mdi-chevron-left"></i>
|
||||
</button>
|
||||
<button
|
||||
v-for="page in totalPages"
|
||||
:key="page"
|
||||
class="page-number"
|
||||
:class="{ active: currentPage === page }"
|
||||
type="button"
|
||||
:aria-current="currentPage === page ? 'page' : undefined"
|
||||
@click="currentPage = page"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<button
|
||||
class="page-nav"
|
||||
type="button"
|
||||
:disabled="currentPage === totalPages"
|
||||
aria-label="下一页"
|
||||
@click="currentPage++"
|
||||
>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<EnterpriseSelect
|
||||
v-model="pageSize"
|
||||
class="page-size-select"
|
||||
:options="pageSizeOptions"
|
||||
size="small"
|
||||
@change="changePageSize"
|
||||
/>
|
||||
</footer>
|
||||
<EnterprisePagination
|
||||
v-if="!loading && !errorMessage && totalCount"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:summary="pageSummary"
|
||||
:total-pages="totalPages"
|
||||
@page-size-change="changePageSize"
|
||||
@update:current-page="currentPage = $event"
|
||||
/>
|
||||
</article>
|
||||
</Transition>
|
||||
|
||||
|
||||
@@ -14,9 +14,8 @@
|
||||
:empty="!systemLogLoading && !visibleSystemLogEntries.length"
|
||||
:total="totalCount"
|
||||
:total-pages="totalPages"
|
||||
:pages="visiblePageItems"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:summary="`共 ${totalCount} 条系统日志,当前第 ${currentPage} 页`"
|
||||
:summary="`共 ${totalCount} 条系统日志,当前第 ${currentPage} / ${totalPages} 页`"
|
||||
:show-pagination="!systemLogLoading && filteredSystemLogEntries.length > 0"
|
||||
loading-title="系统日志同步中"
|
||||
loading-message="正在加载系统运行日志记录"
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<div class="content-grid bottom-grid">
|
||||
<article class="panel dashboard-card rank-panel">
|
||||
<div class="card-head">
|
||||
<h3>部门报销排行(费用金额)<i class="mdi mdi-information-outline"></i></h3>
|
||||
<h3>部门报销排行 <i class="mdi mdi-information-outline"></i></h3>
|
||||
<EnterpriseSelect
|
||||
v-model="activeDepartmentRange"
|
||||
class="card-select"
|
||||
@@ -84,7 +84,14 @@
|
||||
|
||||
<article class="panel dashboard-card employee-rank-panel">
|
||||
<div class="card-head">
|
||||
<h3>个人报销排行(本月)<i class="mdi mdi-information-outline"></i></h3>
|
||||
<h3>个人报销排行 <i class="mdi mdi-information-outline"></i></h3>
|
||||
<EnterpriseSelect
|
||||
v-model="activeDepartmentRange"
|
||||
class="card-select"
|
||||
:options="departmentRangeOptions"
|
||||
aria-label="个人排行时间范围"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BarChart :items="rankedEmployees" />
|
||||
@@ -92,22 +99,33 @@
|
||||
|
||||
<article class="panel dashboard-card top-claim-panel">
|
||||
<div class="card-head">
|
||||
<h3>本月高额单据 <i class="mdi mdi-information-outline"></i></h3>
|
||||
<h3>高额单据 <i class="mdi mdi-information-outline"></i></h3>
|
||||
<span class="card-range-chip">{{ activeDepartmentRange }}</span>
|
||||
</div>
|
||||
|
||||
<div class="top-claim-list">
|
||||
<div
|
||||
v-for="item in topClaims"
|
||||
:key="item.claimNo"
|
||||
class="top-claim-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ item.claimNo }}</strong>
|
||||
<span>{{ item.employeeName }} · {{ item.departmentName || '未归属部门' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ item.amountLabel }}</strong>
|
||||
<span>{{ item.expenseTypeLabel }} · {{ item.statusLabel }}</span>
|
||||
<div class="top-claim-split">
|
||||
<div class="department-employee-mix">
|
||||
<DonutChart
|
||||
:items="departmentEmployeeLegend"
|
||||
:center-value="departmentEmployeeCenterValue"
|
||||
center-label="人员占比"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="top-claim-list">
|
||||
<div
|
||||
v-for="item in topClaims"
|
||||
:key="item.claimNo"
|
||||
class="top-claim-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ item.claimNo }}</strong>
|
||||
<span>{{ item.employeeName }} · {{ item.departmentName || '未归属部门' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ item.amountLabel }}</strong>
|
||||
<span>{{ item.expenseTypeLabel }} · {{ item.statusLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,6 +176,7 @@
|
||||
:dashboard="riskDashboard"
|
||||
:loading="riskDashboardLoading"
|
||||
:error="riskDashboardError"
|
||||
:last-updated-at="riskDashboardLastUpdatedAt"
|
||||
:level-legend="riskLevelLegend"
|
||||
:source-legend="riskSourceLegend"
|
||||
:signal-ranking="riskSignalRanking"
|
||||
@@ -358,6 +377,8 @@ const {
|
||||
activeTrendRange,
|
||||
budgetMetrics,
|
||||
budgetSummary,
|
||||
departmentEmployeeCenterValue,
|
||||
departmentEmployeeLegend,
|
||||
departmentRangeOptions,
|
||||
digitalEmployeeCategoryRows,
|
||||
digitalEmployeeDashboard,
|
||||
@@ -371,6 +392,7 @@ const {
|
||||
rankedEmployees,
|
||||
riskDashboard,
|
||||
riskDashboardError,
|
||||
riskDashboardLastUpdatedAt,
|
||||
riskDashboardLoading,
|
||||
riskDailyTrendRows,
|
||||
riskKpiMetrics,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
:assistant-modal-open="assistantModalOpen"
|
||||
:workbench-summary="workbenchSummary"
|
||||
@open-assistant="emit('open-assistant', $event)"
|
||||
@open-document="emit('open-document', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -15,5 +16,5 @@ defineProps({
|
||||
workbenchSummary: { type: Object, default: () => ({}) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['open-assistant'])
|
||||
const emit = defineEmits(['open-assistant', 'open-document'])
|
||||
</script>
|
||||
|
||||
@@ -146,35 +146,15 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer class="list-foot">
|
||||
<span class="page-summary">共 {{ totalCount }} 条,目前第 {{ currentPage }} 页</span>
|
||||
<div class="pager" aria-label="分页">
|
||||
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
|
||||
<i class="mdi mdi-chevron-left"></i>
|
||||
</button>
|
||||
<button
|
||||
v-for="page in totalPages"
|
||||
:key="page"
|
||||
class="page-number"
|
||||
:class="{ active: currentPage === page }"
|
||||
type="button"
|
||||
:aria-current="currentPage === page ? 'page' : undefined"
|
||||
@click="currentPage = page"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<EnterpriseSelect
|
||||
v-model="pageSize"
|
||||
class="page-size-select"
|
||||
:options="pageSizeOptions"
|
||||
size="small"
|
||||
@change="changePageSize"
|
||||
/>
|
||||
</footer>
|
||||
<EnterprisePagination
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:summary="pageSummary"
|
||||
:total-pages="totalPages"
|
||||
@page-size-change="changePageSize"
|
||||
@update:current-page="currentPage = $event"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -112,28 +112,16 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer v-if="showTable" class="list-foot">
|
||||
<span class="page-summary">共 {{ filteredRows.length }} 条,目前第 {{ currentPage }} 页</span>
|
||||
<div class="pager" aria-label="分页">
|
||||
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
|
||||
<i class="mdi mdi-chevron-left"></i>
|
||||
</button>
|
||||
<button
|
||||
v-for="page in totalPages"
|
||||
:key="page"
|
||||
class="page-number"
|
||||
:class="{ active: currentPage === page }"
|
||||
type="button"
|
||||
@click="currentPage = page"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<EnterpriseSelect v-model="pageSize" class="page-size-select" :options="pageSizeOptions" size="small" @change="currentPage = 1" />
|
||||
</footer>
|
||||
<EnterprisePagination
|
||||
v-if="showTable"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:summary="pageSummary"
|
||||
:total-pages="totalPages"
|
||||
@page-size-change="changePageSize"
|
||||
@update:current-page="currentPage = $event"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<EnterpriseDetailPage
|
||||
@@ -395,7 +383,7 @@ import { ElCheckbox, ElCheckboxGroup } from 'element-plus/es/components/checkbox
|
||||
import { ElCollapse, ElCollapseItem } from 'element-plus/es/components/collapse/index.mjs'
|
||||
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
|
||||
|
||||
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
|
||||
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
|
||||
import EnterpriseDetailCard from '../components/shared/EnterpriseDetailCard.vue'
|
||||
import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
|
||||
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||
@@ -477,6 +465,7 @@ const filteredRows = computed(() => {
|
||||
].filter(Boolean).join('').toLowerCase().includes(normalized))
|
||||
})
|
||||
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(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return filteredRows.value.slice(start, start + pageSize.value)
|
||||
@@ -599,6 +588,11 @@ function switchStatus(status) {
|
||||
activeStatus.value = status
|
||||
}
|
||||
|
||||
function changePageSize(size) {
|
||||
pageSize.value = Number(size) || pageSize.value
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
async function reloadReceipts() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
@@ -476,6 +476,16 @@
|
||||
<i class="mdi mdi-trash-can-outline"></i>
|
||||
{{ deleteBusy ? '删除中' : deleteActionLabel }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canModifyReturnedApplication"
|
||||
class="secondary-action"
|
||||
type="button"
|
||||
:disabled="actionBusy"
|
||||
@click="handleModifyApplication"
|
||||
>
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
修改申请
|
||||
</button>
|
||||
<button class="approve-action" type="button" :disabled="!canSubmit" @click="handleSubmit">
|
||||
<i :class="submitActionIcon"></i>
|
||||
{{ submitActionLabel }}
|
||||
@@ -773,3 +783,4 @@
|
||||
<script src="./scripts/TravelRequestDetailView.js"></script>
|
||||
<style scoped src="../assets/styles/views/travel-request-detail-view.css"></style>
|
||||
<style scoped src="../assets/styles/views/travel-request-detail-view-part2.css"></style>
|
||||
<style scoped src="../assets/styles/views/travel-request-detail-responsive.css"></style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ElButton } from 'element-plus/es/components/button/index.mjs'
|
||||
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.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'
|
||||
@@ -98,6 +99,7 @@ export default {
|
||||
emits: ['openAssistant', 'detail-open-change', 'detail-topbar-change'],
|
||||
components: {
|
||||
BudgetTrendChart,
|
||||
EnterprisePagination,
|
||||
EnterpriseSelect,
|
||||
EnterpriseDetailCard,
|
||||
EnterpriseDetailPage,
|
||||
@@ -169,9 +171,6 @@ export default {
|
||||
const currentBudgetPage = computed(() =>
|
||||
Math.min(Math.max(1, budgetPage.value), totalBudgetPages.value)
|
||||
)
|
||||
const budgetPageNumbers = computed(() =>
|
||||
Array.from({ length: totalBudgetPages.value }, (_, index) => index + 1)
|
||||
)
|
||||
const visibleBudgetRows = computed(() => {
|
||||
const pageSize = Number(budgetPageSize.value || 8)
|
||||
const start = (currentBudgetPage.value - 1) * pageSize
|
||||
@@ -227,7 +226,7 @@ export default {
|
||||
artLabel: '预算列表为空',
|
||||
tips: ['可以调整年度、季度、状态或关键词后重试。']
|
||||
}))
|
||||
const pageSummary = computed(() => `共 ${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value} 页`)
|
||||
const pageSummary = computed(() => `共 ${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value} / ${totalBudgetPages.value} 页`)
|
||||
|
||||
function buildBudgetAssistantContext(row, mode = 'edit') {
|
||||
if (!row) return null
|
||||
@@ -425,7 +424,6 @@ export default {
|
||||
budgetKeyword,
|
||||
budgetLoading,
|
||||
budgetPage: currentBudgetPage,
|
||||
budgetPageNumbers,
|
||||
budgetPageSize,
|
||||
budgetPageSizeOptions,
|
||||
budgetScopeTabs,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
|
||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
@@ -452,6 +453,7 @@ export default {
|
||||
name: 'EmployeeManagementView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
EnterprisePagination,
|
||||
EnterpriseSelect,
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
@@ -672,6 +674,7 @@ export default {
|
||||
|
||||
const totalCount = computed(() => filteredEmployees.value.length)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
|
||||
const pageSummary = computed(() => `共 ${totalCount.value} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`)
|
||||
|
||||
const visibleEmployees = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
@@ -1469,6 +1472,7 @@ export default {
|
||||
hasEmployeeFilters,
|
||||
totalCount,
|
||||
totalPages,
|
||||
pageSummary,
|
||||
resetFilters,
|
||||
handleEmployeeEmptyAction,
|
||||
openEmployeeDetail,
|
||||
|
||||
@@ -152,12 +152,6 @@ export default {
|
||||
filteredSystemLogEntries.value.filter((entry) => entry.level === 'INFO').length
|
||||
)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
|
||||
const visiblePageItems = computed(() => {
|
||||
if (totalPages.value <= 6) {
|
||||
return Array.from({ length: totalPages.value }, (_, index) => index + 1)
|
||||
}
|
||||
return [1, 2, 3, 4, 5, 'ellipsis', totalPages.value]
|
||||
})
|
||||
const visibleSystemLogEntries = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return filteredSystemLogEntries.value.slice(start, start + pageSize.value)
|
||||
@@ -300,7 +294,6 @@ export default {
|
||||
systemSearchKeyword,
|
||||
totalCount,
|
||||
totalPages,
|
||||
visiblePageItems,
|
||||
visibleSystemLogEntries
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
||||
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
@@ -87,7 +87,7 @@ export default {
|
||||
name: 'PoliciesView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
EnterpriseSelect,
|
||||
EnterprisePagination,
|
||||
TableLoadingState
|
||||
},
|
||||
emits: ['summary-change'],
|
||||
@@ -182,9 +182,10 @@ export default {
|
||||
&& activeFolderIngestStats.value.syncing === 0
|
||||
)
|
||||
|
||||
const totalCount = computed(() => filteredDocuments.value.length)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
|
||||
const visibleDocuments = computed(() => {
|
||||
const totalCount = computed(() => filteredDocuments.value.length)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
|
||||
const pageSummary = computed(() => `共 ${totalCount.value} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`)
|
||||
const visibleDocuments = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return filteredDocuments.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
@@ -636,6 +637,7 @@ export default {
|
||||
loading,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
pageSummary,
|
||||
pageSizes,
|
||||
onlyOfficeError,
|
||||
onlyOfficeHostId,
|
||||
|
||||
@@ -49,6 +49,13 @@ import {
|
||||
buildLocalApplicationPreviewMessage,
|
||||
normalizeApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
TRAVEL_PLANNING_ACTION_GENERATE,
|
||||
TRAVEL_PLANNING_ACTION_SKIP,
|
||||
buildTravelPlanningNudgeMessage,
|
||||
buildTravelPlanningRecommendation,
|
||||
buildTravelPlanningSuggestedActions
|
||||
} from '../../utils/travelApplicationPlanning.js'
|
||||
import {
|
||||
calculateTravelReimbursement,
|
||||
createExpenseClaimItem,
|
||||
@@ -524,6 +531,14 @@ export default {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
initialPromptAutoSubmit: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
initialApplicationPreview: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
initialFiles: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
@@ -629,7 +644,9 @@ export default {
|
||||
handleApplicationPreviewEditorKeydown
|
||||
} = useApplicationPreviewEditor({
|
||||
persistSessionState,
|
||||
toast
|
||||
toast,
|
||||
calculateTravelReimbursement,
|
||||
currentUser
|
||||
})
|
||||
|
||||
function applyLinkedApplicationPreviewDateSelection(selection) {
|
||||
@@ -1372,6 +1389,14 @@ export default {
|
||||
currentInsight.value =
|
||||
currentInsight.value
|
||||
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
|
||||
if (props.initialApplicationPreview && typeof props.initialApplicationPreview === 'object') {
|
||||
const applicationPreview = normalizeApplicationPreview(props.initialApplicationPreview)
|
||||
messages.value.push(createMessage('assistant', buildLocalApplicationPreviewMessage(applicationPreview), [], {
|
||||
meta: ['修改申请'],
|
||||
applicationPreview
|
||||
}))
|
||||
persistSessionState()
|
||||
}
|
||||
if (props.initialPrompt?.trim() || props.initialFiles.length) {
|
||||
const initialMerge = mergeFilesWithLimit([], Array.from(props.initialFiles), MAX_ATTACHMENTS)
|
||||
composerDraft.value = props.initialPrompt.trim()
|
||||
@@ -1380,7 +1405,12 @@ export default {
|
||||
if (initialMerge.overflowCount > 0) {
|
||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
|
||||
}
|
||||
submitComposer()
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
})
|
||||
if (props.initialPromptAutoSubmit !== false) {
|
||||
submitComposer()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1576,6 +1606,32 @@ export default {
|
||||
if (await handleGuidedSuggestedAction(message, action)) return
|
||||
if (await handleSceneSelectionApplicationGate(message, action)) return
|
||||
|
||||
if (actionType === TRAVEL_PLANNING_ACTION_GENERATE) {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
const sourcePreview = action?.payload?.applicationPreview || action?.payload?.preview || null
|
||||
const sourceDraftPayload = action?.payload?.draftPayload || action?.payload?.draft_payload || null
|
||||
const recommendation = buildTravelPlanningRecommendation(sourcePreview, sourceDraftPayload)
|
||||
if (recommendation) {
|
||||
messages.value.push(createMessage('user', '生成行程规划'))
|
||||
messages.value.push(createMessage('assistant', recommendation, [], {
|
||||
meta: ['行程规划建议']
|
||||
}))
|
||||
nextTick(scrollToBottom)
|
||||
persistSessionState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === TRAVEL_PLANNING_ACTION_SKIP) {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
messages.value.push(createMessage('assistant', '好的,本次先保留申请结果。后续需要规划交通或酒店时,可以继续在这里告诉我。', [], {
|
||||
meta: ['暂不规划']
|
||||
}))
|
||||
nextTick(scrollToBottom)
|
||||
persistSessionState()
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
|
||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
const targetSessionType = String(actionPayload.session_type || '').trim()
|
||||
@@ -2033,6 +2089,17 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveApplicationEditClaimId() {
|
||||
if (activeSessionType.value !== SESSION_TYPE_APPLICATION) {
|
||||
return ''
|
||||
}
|
||||
const request = linkedRequest.value || {}
|
||||
if (!request.applicationEditMode) {
|
||||
return ''
|
||||
}
|
||||
return String(request.claimId || request.claim_id || '').trim()
|
||||
}
|
||||
|
||||
async function confirmApplicationSubmit() {
|
||||
const message = applicationSubmitConfirmDialog.value.message
|
||||
if (!message || submitting.value || reviewActionBusy.value) {
|
||||
@@ -2044,6 +2111,7 @@ export default {
|
||||
const applicationSubmitText = applicationPreview
|
||||
? buildApplicationPreviewSubmitText(applicationPreview)
|
||||
: '确认提交'
|
||||
const applicationEditClaimId = resolveApplicationEditClaimId()
|
||||
applicationSubmitConfirmDialog.value = {
|
||||
open: false,
|
||||
message: null
|
||||
@@ -2059,7 +2127,16 @@ export default {
|
||||
feedbackOperationType: 'submit_application',
|
||||
extraContext: {
|
||||
application_preview: applicationPreview,
|
||||
user_input_text: applicationSubmitText
|
||||
user_input_text: applicationSubmitText,
|
||||
...(applicationEditClaimId
|
||||
? {
|
||||
application_edit_claim_id: applicationEditClaimId,
|
||||
application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(),
|
||||
application_edit_mode: true,
|
||||
draft_claim_id: applicationEditClaimId,
|
||||
selected_claim_id: applicationEditClaimId
|
||||
}
|
||||
: {})
|
||||
}
|
||||
})
|
||||
const draftPayload = payload?.result?.draft_payload || {}
|
||||
@@ -2074,6 +2151,23 @@ export default {
|
||||
documentType: 'application'
|
||||
})
|
||||
}
|
||||
const planningText = buildTravelPlanningNudgeMessage(applicationPreview, draftPayload)
|
||||
const planningActions = buildTravelPlanningSuggestedActions(applicationPreview, draftPayload).map((action) => ({
|
||||
...action,
|
||||
payload: {
|
||||
...(action.payload || {}),
|
||||
applicationPreview,
|
||||
draftPayload
|
||||
}
|
||||
}))
|
||||
if (planningText && planningActions.length) {
|
||||
messages.value.push(createMessage('assistant', planningText, [], {
|
||||
meta: ['行程规划推荐'],
|
||||
suggestedActions: planningActions
|
||||
}))
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
}
|
||||
|
||||
@@ -460,11 +460,17 @@ export default {
|
||||
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
||||
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
|
||||
const canOpenAiEntry = computed(() => isEditableRequest.value)
|
||||
const canModifyReturnedApplication = computed(() => (
|
||||
isApplicationDocument.value
|
||||
&& isEditableRequest.value
|
||||
&& isCurrentApplicant.value
|
||||
&& String(request.value.status || '').trim().toLowerCase() === 'returned'
|
||||
))
|
||||
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
||||
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
|
||||
const canDeleteRequest = computed(() => {
|
||||
if (isApplicationDocument.value) {
|
||||
return isPlatformAdminUser(currentUser.value)
|
||||
return isPlatformAdminUser(currentUser.value) || (isEditableRequest.value && isCurrentApplicant.value)
|
||||
}
|
||||
if (isArchivedRequest.value) {
|
||||
return canDeleteArchivedExpenseClaims(currentUser.value)
|
||||
@@ -1007,7 +1013,7 @@ export default {
|
||||
if (analysis) {
|
||||
return {
|
||||
label: analysis.label || '已上传',
|
||||
tone: analysis.severity === 'pass' ? 'pass' : analysis.severity || 'low',
|
||||
tone: normalizeRiskTone(analysis.severity || 'low'),
|
||||
headline: analysis.headline || 'AI提示',
|
||||
summary: analysis.summary || '',
|
||||
points: Array.isArray(analysis.points) ? analysis.points : [],
|
||||
@@ -1858,7 +1864,9 @@ export default {
|
||||
toast(
|
||||
isArchivedRequest.value
|
||||
? '已归档单据不能删除,只有高级管理员可以执行删除。'
|
||||
: '当前单据已进入流程,只有高级财务人员可以删除。'
|
||||
: isApplicationDocument.value
|
||||
? '当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。'
|
||||
: '当前单据已进入流程,只有高级财务人员可以删除。'
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -2019,6 +2027,76 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
function buildApplicationEditPreview() {
|
||||
const factEntries = applicationDetailFactItems.value
|
||||
.map((item) => [String(item?.label || '').trim(), String(item?.value || '').trim()])
|
||||
.filter(([label, value]) => label && value)
|
||||
const facts = new Map(factEntries)
|
||||
const pickFact = (...labels) => {
|
||||
for (const label of labels) {
|
||||
const value = facts.get(label)
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
const tripStart = pickFact('出发时间')
|
||||
const tripReturn = pickFact('返回时间')
|
||||
const time = tripStart && tripReturn && tripStart !== tripReturn
|
||||
? `${tripStart} 至 ${tripReturn}`
|
||||
: pickFact('行程时间', '申请时间', '招待时间', '发生时间') || tripStart
|
||||
|
||||
return {
|
||||
sourceText: '修改申请',
|
||||
modelReviewStatus: 'template',
|
||||
fields: {
|
||||
applicationType: pickFact('申请类型') || request.value.typeLabel || '费用申请',
|
||||
applicant: request.value.profileName || request.value.person || request.value.applicant || '',
|
||||
grade: pickFact('职级') || request.value.profileGrade || '',
|
||||
department: request.value.profileDepartment || request.value.departmentName || request.value.department || '',
|
||||
position: request.value.profilePosition || request.value.employeePosition || request.value.position || '',
|
||||
managerName: request.value.profileManager || request.value.managerName || request.value.manager || '',
|
||||
time,
|
||||
location: pickFact('地点') || request.value.location || request.value.city || '',
|
||||
reason: pickFact('事由') || request.value.reason || '',
|
||||
days: pickFact('天数'),
|
||||
transportMode: pickFact('出行方式'),
|
||||
lodgingDailyCap: pickFact('住宿上限/天'),
|
||||
subsidyDailyCap: pickFact('补贴标准/天'),
|
||||
transportPolicy: pickFact('交通费用口径'),
|
||||
policyEstimate: pickFact('规则测算参考'),
|
||||
amount: pickFact('系统预估费用', '用户预估费用', '预计金额') || request.value.amountDisplay || request.value.amount || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleModifyApplication() {
|
||||
if (!canModifyReturnedApplication.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const claimId = String(request.value?.claimId || '').trim()
|
||||
emit('openAssistant', {
|
||||
source: 'application',
|
||||
sessionType: 'application',
|
||||
prompt: '',
|
||||
applicationPreview: buildApplicationEditPreview(),
|
||||
request: {
|
||||
...request.value,
|
||||
applicationEditMode: true
|
||||
},
|
||||
restoreLatestConversation: false,
|
||||
initialPromptAutoSubmit: false,
|
||||
scope: claimId
|
||||
? {
|
||||
type: 'claim',
|
||||
claimId
|
||||
}
|
||||
: null
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
closeAttachmentPreview()
|
||||
})
|
||||
@@ -2032,7 +2110,7 @@ export default {
|
||||
applicationDetailFactItems, relatedApplicationFactItems,
|
||||
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
|
||||
canNavigateAttachmentPreview,
|
||||
canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
|
||||
canModifyReturnedApplication, canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
|
||||
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
|
||||
closeRiskOverrideDialog,
|
||||
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
|
||||
@@ -2046,6 +2124,7 @@ export default {
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
||||
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
|
||||
handleModifyApplication,
|
||||
handlePayRequest,
|
||||
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
|
||||
isMajorExpenseRisk,
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
const DIGITAL_EMPLOYEE_AGENT = 'hermes'
|
||||
export const DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS = ['积累', '升级', '整理', '评估']
|
||||
|
||||
export const DIGITAL_EMPLOYEE_VISIBLE_TASK_TYPES = new Set([
|
||||
'finance_dashboard_snapshot',
|
||||
'digital_employee_reminder_scan',
|
||||
'employee_behavior_profile_scan',
|
||||
'department_expense_baseline_accumulate',
|
||||
'budget_overrun_precontrol_evaluate',
|
||||
'multi_evidence_consistency_evaluate',
|
||||
'travel_spatiotemporal_consistency_evaluate',
|
||||
'global_risk_scan',
|
||||
'finance_policy_knowledge_organize'
|
||||
])
|
||||
|
||||
const TASK_TYPE_LABELS = {
|
||||
finance_dashboard_snapshot: '财务经营快照沉淀',
|
||||
digital_employee_reminder_scan: '定时提醒与待办扫描',
|
||||
daily_risk_scan: '每日风险巡检',
|
||||
global_risk_scan: '财务风险图谱巡检',
|
||||
employee_behavior_profile_scan: '员工行为画像巡检',
|
||||
department_expense_baseline_accumulate: '部门费用基线沉淀',
|
||||
budget_overrun_precontrol_evaluate: '预算占用与超标预警',
|
||||
multi_evidence_consistency_evaluate: '单据多凭证一致性评估',
|
||||
travel_spatiotemporal_consistency_evaluate: '差旅时空一致性评估',
|
||||
weekly_ar_summary: '周度应收账龄汇总',
|
||||
weekly_expense_report: '周度费用洞察',
|
||||
rule_review_digest: '规则待审摘要',
|
||||
@@ -15,9 +33,15 @@ const TASK_TYPE_LABELS = {
|
||||
}
|
||||
|
||||
const TASK_TYPE_SKILL_CATEGORIES = {
|
||||
finance_dashboard_snapshot: '整理',
|
||||
digital_employee_reminder_scan: '升级',
|
||||
daily_risk_scan: '评估',
|
||||
global_risk_scan: '评估',
|
||||
employee_behavior_profile_scan: '评估',
|
||||
employee_behavior_profile_scan: '积累',
|
||||
department_expense_baseline_accumulate: '积累',
|
||||
budget_overrun_precontrol_evaluate: '评估',
|
||||
multi_evidence_consistency_evaluate: '评估',
|
||||
travel_spatiotemporal_consistency_evaluate: '评估',
|
||||
weekly_ar_summary: '整理',
|
||||
weekly_expense_report: '整理',
|
||||
rule_review_digest: '升级',
|
||||
@@ -145,6 +169,12 @@ export function isDigitalEmployeeAsset(source = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldDisplayDigitalEmployeeAsset(source = {}) {
|
||||
const content = parseDigitalEmployeeContent(source.current_version_content)
|
||||
const taskType = resolveDigitalEmployeeTaskType(source, content)
|
||||
return DIGITAL_EMPLOYEE_VISIBLE_TASK_TYPES.has(taskType)
|
||||
}
|
||||
|
||||
export function formatDigitalEmployeeCron(value) {
|
||||
const raw = normalizeDigitalEmployeeText(value)
|
||||
if (!raw) {
|
||||
|
||||
@@ -18,9 +18,31 @@ const KNOWLEDGE_JOB_TYPES = new Set([
|
||||
'finance_policy_knowledge_organize'
|
||||
])
|
||||
|
||||
export const VISIBLE_DIGITAL_EMPLOYEE_WORK_TASK_TYPES = new Set([
|
||||
'finance_dashboard_snapshot',
|
||||
'digital_employee_reminder_scan',
|
||||
'employee_behavior_profile_scan',
|
||||
'department_expense_baseline_accumulate',
|
||||
'budget_overrun_precontrol_evaluate',
|
||||
'multi_evidence_consistency_evaluate',
|
||||
'travel_spatiotemporal_consistency_evaluate',
|
||||
'global_risk_scan',
|
||||
'finance_policy_knowledge_organize'
|
||||
])
|
||||
|
||||
const DAILY_COMPACT_TASK_TYPES = new Set([
|
||||
'finance_dashboard_snapshot'
|
||||
])
|
||||
|
||||
const TASK_TYPE_LABELS = {
|
||||
finance_dashboard_snapshot: '财务经营快照沉淀',
|
||||
digital_employee_reminder_scan: '定时提醒与待办扫描',
|
||||
global_risk_scan: '财务风险图谱巡检',
|
||||
employee_behavior_profile_scan: '员工行为画像巡检',
|
||||
department_expense_baseline_accumulate: '部门费用基线沉淀',
|
||||
budget_overrun_precontrol_evaluate: '预算占用与超标预警',
|
||||
multi_evidence_consistency_evaluate: '单据多凭证一致性评估',
|
||||
travel_spatiotemporal_consistency_evaluate: '差旅时空一致性评估',
|
||||
risk_clue_collect: '风险线索归集',
|
||||
finance_policy_knowledge_organize: '知识制度整理',
|
||||
knowledge_index_sync: '知识制度整理',
|
||||
@@ -29,10 +51,16 @@ const TASK_TYPE_LABELS = {
|
||||
}
|
||||
|
||||
const TASK_CODE_TO_TYPE = {
|
||||
'task.hermes.finance_dashboard_snapshot': 'finance_dashboard_snapshot',
|
||||
'task.hermes.digital_employee_reminder_scan': 'digital_employee_reminder_scan',
|
||||
'task.hermes.global_risk_scan': 'global_risk_scan',
|
||||
'task.hermes.employee_behavior_profile_scan': 'employee_behavior_profile_scan',
|
||||
'task.hermes.risk_rule_discovery': 'risk_clue_collect',
|
||||
'task.hermes.finance_policy_knowledge_organize': 'finance_policy_knowledge_organize'
|
||||
'task.hermes.department_expense_baseline_accumulate': 'department_expense_baseline_accumulate',
|
||||
'task.hermes.budget_overrun_precontrol_evaluate': 'budget_overrun_precontrol_evaluate',
|
||||
'task.hermes.multi_evidence_consistency_evaluate': 'multi_evidence_consistency_evaluate',
|
||||
'task.hermes.travel_spatiotemporal_consistency_evaluate': 'travel_spatiotemporal_consistency_evaluate',
|
||||
'task.hermes.finance_policy_knowledge_organize': 'finance_policy_knowledge_organize',
|
||||
'task.hermes.risk_rule_discovery': 'risk_clue_collect'
|
||||
}
|
||||
|
||||
function toObject(value) {
|
||||
@@ -52,6 +80,12 @@ function resolveTaskTypeFromToolName(value) {
|
||||
if (name.includes('financial_risk_graph')) {
|
||||
return 'global_risk_scan'
|
||||
}
|
||||
if (name.includes('finance_dashboard_snapshot') || name.includes('finance_dashboard')) {
|
||||
return 'finance_dashboard_snapshot'
|
||||
}
|
||||
if (name.includes('digital_employee_reminder') || name.includes('reminder')) {
|
||||
return 'digital_employee_reminder_scan'
|
||||
}
|
||||
if (name.includes('employee_behavior_profile')) {
|
||||
return 'employee_behavior_profile_scan'
|
||||
}
|
||||
@@ -128,6 +162,43 @@ export function resolveWorkRecordTaskType(run) {
|
||||
return ''
|
||||
}
|
||||
|
||||
export function isVisibleDigitalEmployeeWorkRecord(run) {
|
||||
const taskType = resolveWorkRecordTaskType(run)
|
||||
return VISIBLE_DIGITAL_EMPLOYEE_WORK_TASK_TYPES.has(taskType)
|
||||
}
|
||||
|
||||
function resolveWorkRecordDayKey(run) {
|
||||
const date = new Date(run?.started_at || run?.finished_at || '')
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'unknown'
|
||||
}
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function compactDigitalEmployeeWorkRecords(items = []) {
|
||||
const rows = []
|
||||
const compactedKeys = new Set()
|
||||
|
||||
for (const run of items) {
|
||||
const taskType = resolveWorkRecordTaskType(run)
|
||||
if (!VISIBLE_DIGITAL_EMPLOYEE_WORK_TASK_TYPES.has(taskType)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (DAILY_COMPACT_TASK_TYPES.has(taskType)) {
|
||||
const key = `${taskType}:${resolveWorkRecordDayKey(run)}`
|
||||
if (compactedKeys.has(key)) {
|
||||
continue
|
||||
}
|
||||
compactedKeys.add(key)
|
||||
}
|
||||
|
||||
rows.push(run)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
export function resolveWorkRecordTaskLabel(run) {
|
||||
const taskType = resolveWorkRecordTaskType(run)
|
||||
return TASK_TYPE_LABELS[taskType] || ''
|
||||
@@ -135,6 +206,12 @@ export function resolveWorkRecordTaskLabel(run) {
|
||||
|
||||
export function resolveWorkRecordProductKind(run) {
|
||||
const taskType = resolveWorkRecordTaskType(run)
|
||||
if (taskType === 'finance_dashboard_snapshot') {
|
||||
return 'finance_snapshot'
|
||||
}
|
||||
if (taskType === 'digital_employee_reminder_scan') {
|
||||
return 'reminder_scan'
|
||||
}
|
||||
if (taskType === 'global_risk_scan') {
|
||||
return 'risk_graph'
|
||||
}
|
||||
|
||||
@@ -458,6 +458,7 @@ export function sanitizeRequest(request) {
|
||||
|
||||
const normalized = {
|
||||
claimId: String(request.claimId || request.claim_id || '').trim(),
|
||||
claimNo: String(request.claimNo || request.claim_no || request.documentNo || '').trim(),
|
||||
id: String(request.id || '').trim(),
|
||||
typeLabel: String(request.typeLabel || request.category || '').trim(),
|
||||
reason: String(request.reason || request.title || '').trim(),
|
||||
@@ -468,7 +469,8 @@ export function sanitizeRequest(request) {
|
||||
amount: String(request.amount || '').trim(),
|
||||
node: String(request.node || '').trim(),
|
||||
approval: String(request.approval || '').trim(),
|
||||
travel: String(request.travel || '').trim()
|
||||
travel: String(request.travel || '').trim(),
|
||||
applicationEditMode: Boolean(request.applicationEditMode || request.application_edit_mode)
|
||||
}
|
||||
|
||||
return Object.values(normalized).some(Boolean) ? normalized : null
|
||||
|
||||
@@ -150,7 +150,7 @@ function resolveRequestBusinessStage(request = {}) {
|
||||
|
||||
function normalizeTone(value) {
|
||||
const tone = normalizeText(value).toLowerCase()
|
||||
if (tone === 'pass') return 'pass'
|
||||
if (['pass', 'success', 'ok', 'normal', 'none', 'compliant', 'approved'].includes(tone)) return 'pass'
|
||||
if (tone === 'high') return 'high'
|
||||
if (tone === 'medium') return 'medium'
|
||||
if (tone === 'low') return 'low'
|
||||
|
||||
@@ -2,9 +2,13 @@ import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
APPLICATION_TRANSPORT_MODE_OPTIONS,
|
||||
applyApplicationPolicyEstimateError,
|
||||
applyApplicationPolicyEstimateResult,
|
||||
buildApplicationPreviewRows,
|
||||
buildApplicationPolicyEstimateRequest,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
normalizeApplicationPreview,
|
||||
resolveApplicationDaysFromDateRange,
|
||||
refreshApplicationPreviewTransportEstimate
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
|
||||
@@ -44,6 +48,27 @@ function shouldRefreshTransportEstimate(fieldKey) {
|
||||
return ['transportMode', 'time', 'location', 'days'].includes(fieldKey)
|
||||
}
|
||||
|
||||
function resolveEditorCurrentUser(currentUser) {
|
||||
if (currentUser && typeof currentUser === 'object' && 'value' in currentUser) {
|
||||
return currentUser.value || {}
|
||||
}
|
||||
return currentUser || {}
|
||||
}
|
||||
|
||||
function buildEditedApplicationPreviewFields(fields = {}, editor = {}, nextValue = '') {
|
||||
const nextFields = {
|
||||
...fields,
|
||||
[editor.fieldKey]: nextValue
|
||||
}
|
||||
if (editor.fieldKey === 'time') {
|
||||
const resolvedDays = resolveApplicationDaysFromDateRange(nextValue)
|
||||
if (resolvedDays) {
|
||||
nextFields.days = resolvedDays
|
||||
}
|
||||
}
|
||||
return nextFields
|
||||
}
|
||||
|
||||
function buildTransportEstimatePendingPreview(preview = {}) {
|
||||
const fields = preview?.fields || {}
|
||||
return normalizeApplicationPreview({
|
||||
@@ -57,9 +82,29 @@ function buildTransportEstimatePendingPreview(preview = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useApplicationPreviewEditor({ persistSessionState, toast } = {}) {
|
||||
export function useApplicationPreviewEditor({
|
||||
persistSessionState,
|
||||
toast,
|
||||
calculateTravelReimbursement,
|
||||
currentUser
|
||||
} = {}) {
|
||||
const applicationPreviewEditor = ref(buildEmptyEditor())
|
||||
|
||||
async function refreshApplicationPreviewEstimate(preview = {}) {
|
||||
const user = resolveEditorCurrentUser(currentUser)
|
||||
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, user)
|
||||
if (estimateRequest.canCalculate && typeof calculateTravelReimbursement === 'function') {
|
||||
try {
|
||||
const result = await calculateTravelReimbursement(estimateRequest.payload)
|
||||
return applyApplicationPolicyEstimateResult(preview, result, user)
|
||||
} catch (error) {
|
||||
console.warn('Application preview estimate refresh failed:', error)
|
||||
return applyApplicationPolicyEstimateError(preview, error, user)
|
||||
}
|
||||
}
|
||||
return refreshApplicationPreviewTransportEstimate(preview)
|
||||
}
|
||||
|
||||
function resolveApplicationPreviewRows(message) {
|
||||
return buildApplicationPreviewRows(message?.applicationPreview || {})
|
||||
}
|
||||
@@ -158,25 +203,29 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
|
||||
}
|
||||
const nextPreview = normalizeApplicationPreview({
|
||||
...message.applicationPreview,
|
||||
fields: {
|
||||
...(message.applicationPreview.fields || {}),
|
||||
[editor.fieldKey]: nextValue
|
||||
}
|
||||
fields: buildEditedApplicationPreviewFields(
|
||||
message.applicationPreview.fields || {},
|
||||
editor,
|
||||
nextValue
|
||||
)
|
||||
})
|
||||
const needRefreshTransport = shouldRefreshTransportEstimate(editor.fieldKey) && String(nextPreview.fields?.transportMode || '').trim()
|
||||
message.applicationPreview = needRefreshTransport
|
||||
const needRefreshEstimate = shouldRefreshTransportEstimate(editor.fieldKey)
|
||||
const transportMode = String(nextPreview.fields?.transportMode || '').trim()
|
||||
message.applicationPreview = needRefreshEstimate
|
||||
? buildTransportEstimatePendingPreview(nextPreview)
|
||||
: nextPreview
|
||||
message.text = buildLocalApplicationPreviewMessage(message.applicationPreview)
|
||||
cancelApplicationPreviewEditor()
|
||||
persistSessionState?.()
|
||||
if (needRefreshTransport) {
|
||||
await waitForMockApplicationTransportQuote({
|
||||
transportMode: nextPreview.fields.transportMode,
|
||||
location: nextPreview.fields.matchedCity || nextPreview.fields.location,
|
||||
time: nextPreview.fields.time
|
||||
})
|
||||
const refreshedPreview = refreshApplicationPreviewTransportEstimate(nextPreview)
|
||||
if (needRefreshEstimate) {
|
||||
if (transportMode) {
|
||||
await waitForMockApplicationTransportQuote({
|
||||
transportMode,
|
||||
location: nextPreview.fields.matchedCity || nextPreview.fields.location,
|
||||
time: nextPreview.fields.time
|
||||
})
|
||||
}
|
||||
const refreshedPreview = await refreshApplicationPreviewEstimate(nextPreview)
|
||||
message.applicationPreview = refreshedPreview
|
||||
message.text = buildLocalApplicationPreviewMessage(refreshedPreview)
|
||||
persistSessionState?.()
|
||||
|
||||
@@ -207,6 +207,7 @@ export function useTravelReimbursementSessionState({
|
||||
shouldPersistLocalSnapshot
|
||||
&& props.entrySource !== 'budget'
|
||||
&& !String(props.initialPrompt || '').trim()
|
||||
&& !props.initialApplicationPreview
|
||||
&& !props.initialFiles.length
|
||||
const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType)
|
||||
const persistedInitialState = canRestorePersistedInitialState
|
||||
|
||||
@@ -35,6 +35,10 @@ const assistantSubmitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const assistantSessionStateScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const assistantTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -60,6 +64,17 @@ test('application and reimbursement entries open the same financial assistant mo
|
||||
assert.doesNotMatch(appShellRouteView, /ExpenseApplicationDialog/)
|
||||
})
|
||||
|
||||
test('documents center reloads immediately when entered or clicked again', () => {
|
||||
assert.match(appShellRouteView, /:refresh-token="documentCenterRefreshToken"/)
|
||||
assert.match(appShellRouteView, /@reload="reloadRequests"/)
|
||||
assert.match(appShellComposable, /const documentCenterRefreshToken = ref\(0\)/)
|
||||
assert.match(appShellComposable, /async function reloadDocumentCenterRequests\(\) \{[\s\S]*documentCenterRefreshToken\.value \+= 1[\s\S]*return reloadRequests\(\)/)
|
||||
assert.match(appShellComposable, /if \(view === 'documents'\) \{[\s\S]*void reloadDocumentCenterRequests\(\)/)
|
||||
assert.match(appShellComposable, /shouldRefreshCurrentDocumentCenter[\s\S]*route\.name === 'app-documents'[\s\S]*void reloadDocumentCenterRequests\(\)/)
|
||||
assert.match(appShellComposable, /documentCenterRefreshToken,/)
|
||||
assert.match(appShellComposable, /reloadDocumentCenterRequests,/)
|
||||
})
|
||||
|
||||
test('application entry keeps its own assistant source without creating a separate dialog', () => {
|
||||
assert.match(appShellComposable, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/)
|
||||
assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/)
|
||||
@@ -68,6 +83,32 @@ test('application entry keeps its own assistant source without creating a separa
|
||||
assert.match(assistantScript, /activeSessionType\.value === SESSION_TYPE_APPLICATION[\s\S]*我想先申请一笔差旅费用/)
|
||||
})
|
||||
|
||||
test('application edit prefill opens assistant without auto submit', () => {
|
||||
assert.match(appShellRouteView, /:initial-prompt-auto-submit="smartEntryContext\.initialPromptAutoSubmit"/)
|
||||
assert.match(appShellRouteView, /:initial-application-preview="smartEntryContext\.initialApplicationPreview"/)
|
||||
assert.match(appShellComposable, /initialPromptAutoSubmit:\s*true/)
|
||||
assert.match(appShellComposable, /initialApplicationPreview:\s*null/)
|
||||
assert.match(appShellComposable, /initialPromptAutoSubmit:\s*payload\.initialPromptAutoSubmit !== false/)
|
||||
assert.match(appShellComposable, /initialApplicationPreview:\s*payload\.applicationPreview && typeof payload\.applicationPreview === 'object'/)
|
||||
assert.match(
|
||||
assistantScript,
|
||||
/initialPromptAutoSubmit:\s*\{[\s\S]*type:\s*Boolean[\s\S]*default:\s*true/
|
||||
)
|
||||
assert.match(
|
||||
assistantScript,
|
||||
/initialApplicationPreview:\s*\{[\s\S]*type:\s*Object[\s\S]*default:\s*null/
|
||||
)
|
||||
assert.match(
|
||||
assistantScript,
|
||||
/props\.initialApplicationPreview[\s\S]*normalizeApplicationPreview\(props\.initialApplicationPreview\)[\s\S]*createMessage\('assistant', buildLocalApplicationPreviewMessage\(applicationPreview\)/
|
||||
)
|
||||
assert.match(assistantSessionStateScript, /&& !props\.initialApplicationPreview/)
|
||||
assert.match(
|
||||
assistantScript,
|
||||
/if \(props\.initialPromptAutoSubmit !== false\) \{[\s\S]*submitComposer\(\)/
|
||||
)
|
||||
})
|
||||
|
||||
test('financial assistant toolbar renders four isolated assistant sessions', () => {
|
||||
assert.match(assistantScript, /filterAssistantSessionModes\(ASSISTANT_SESSION_MODE_OPTIONS, currentUser\.value\)/)
|
||||
assert.match(assistantScript, /visibleModes\.map/)
|
||||
|
||||
@@ -4,7 +4,9 @@ import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
compactDigitalEmployeeWorkRecords,
|
||||
extractWorkRecordToolSummary,
|
||||
isVisibleDigitalEmployeeWorkRecord,
|
||||
resolveWorkRecordModuleLabel,
|
||||
resolveWorkRecordProductKind,
|
||||
resolveWorkRecordTaskType,
|
||||
@@ -69,6 +71,55 @@ test('digital employee profile run resolves from tool request when route is spar
|
||||
assert.equal(extractWorkRecordToolSummary(run).snapshot_count, 16)
|
||||
})
|
||||
|
||||
test('digital employee finance snapshot and reminder runs are visible core work records', () => {
|
||||
const financeRun = {
|
||||
run_id: 'finance-1',
|
||||
started_at: '2026-06-02T02:00:00+08:00',
|
||||
route_json: { task_type: 'finance_dashboard_snapshot' },
|
||||
tool_calls: [{ tool_name: 'digital_employee.finance_dashboard.snapshot' }]
|
||||
}
|
||||
const reminderRun = {
|
||||
run_id: 'reminder-1',
|
||||
started_at: '2026-06-02T02:05:00+08:00',
|
||||
route_json: { task_type: 'digital_employee_reminder_scan' },
|
||||
tool_calls: [{ tool_name: 'digital_employee.reminder.scan' }]
|
||||
}
|
||||
|
||||
assert.equal(resolveWorkRecordModuleLabel(financeRun), '财务经营快照沉淀')
|
||||
assert.equal(resolveWorkRecordProductKind(financeRun), 'finance_snapshot')
|
||||
assert.equal(isVisibleDigitalEmployeeWorkRecord(financeRun), true)
|
||||
assert.equal(resolveWorkRecordModuleLabel(reminderRun), '定时提醒与待办扫描')
|
||||
assert.equal(resolveWorkRecordProductKind(reminderRun), 'reminder_scan')
|
||||
assert.equal(isVisibleDigitalEmployeeWorkRecord(reminderRun), true)
|
||||
})
|
||||
|
||||
test('digital employee work records hide support tasks and compact daily finance snapshots', () => {
|
||||
const rows = compactDigitalEmployeeWorkRecords([
|
||||
{
|
||||
run_id: 'finance-new',
|
||||
started_at: '2026-06-02T03:00:00+08:00',
|
||||
route_json: { task_type: 'finance_dashboard_snapshot' }
|
||||
},
|
||||
{
|
||||
run_id: 'finance-old',
|
||||
started_at: '2026-06-02T02:00:00+08:00',
|
||||
route_json: { task_type: 'finance_dashboard_snapshot' }
|
||||
},
|
||||
{
|
||||
run_id: 'profile-1',
|
||||
started_at: '2026-06-02T08:30:00+08:00',
|
||||
route_json: { task_type: 'employee_behavior_profile_scan' }
|
||||
},
|
||||
{
|
||||
run_id: 'support-1',
|
||||
started_at: '2026-06-02T10:00:00+08:00',
|
||||
route_json: { task_type: 'risk_clue_collect' }
|
||||
}
|
||||
])
|
||||
|
||||
assert.deepEqual(rows.map((run) => run.run_id), ['finance-new', 'profile-1'])
|
||||
})
|
||||
|
||||
test('digital employee risk clue run resolves review packet metadata', () => {
|
||||
const run = {
|
||||
route_json: {
|
||||
|
||||
@@ -85,6 +85,15 @@ test('documents center preserves application document type from mapped requests'
|
||||
)
|
||||
})
|
||||
|
||||
test('documents center refresh token reloads supporting approval and archive rows', () => {
|
||||
assert.match(documentsCenterView, /refreshToken:\s*\{\s*type:\s*Number,\s*default:\s*0\s*\}/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/watch\(\s*\(\) => props\.refreshToken,[\s\S]*if \(token && token !== previousToken\) \{[\s\S]*void loadSupportingRows\(\)/
|
||||
)
|
||||
assert.match(documentsCenterView, /function reloadAll\(\) \{[\s\S]*emit\('reload'\)[\s\S]*void loadSupportingRows\(\)/)
|
||||
})
|
||||
|
||||
test('documents center list shows created time and conditional stay time columns', () => {
|
||||
assert.match(documentsCenterView, /import \{[\s\S]*formatDocumentListTime[\s\S]*resolveDocumentStayTimeDisplay[\s\S]*\} from '..\/utils\/documentCenterTime\.js'/)
|
||||
assert.match(documentsCenterView, /<col class="col-created">/)
|
||||
@@ -215,7 +224,7 @@ test('documents center switches filter conditions by category tab', () => {
|
||||
documentsCenterView,
|
||||
/watch\(activeFilterConfig, \(\) => \{[\s\S]*openFilterKey\.value = ''[\s\S]*datePopover\.value = false/
|
||||
)
|
||||
assert.match(documentsCenterView, /<EnterpriseSelect v-model="pageSize"[\s\S]*:options="pageSizeOptions"/)
|
||||
assert.match(documentsCenterView, /<EnterprisePagination[\s\S]*:page-size="pageSize"[\s\S]*:page-size-options="pageSizeOptions"/)
|
||||
assert.doesNotMatch(documentsCenterView, /pageSizeOpen/)
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,13 @@ import {
|
||||
resolveMockApplicationTransportWaitMs,
|
||||
buildSystemApplicationEstimate
|
||||
} from '../src/utils/expenseApplicationEstimate.js'
|
||||
import {
|
||||
TRAVEL_PLANNING_ACTION_GENERATE,
|
||||
TRAVEL_PLANNING_ACTION_SKIP,
|
||||
buildTravelPlanningNudgeMessage,
|
||||
buildTravelPlanningRecommendation,
|
||||
buildTravelPlanningSuggestedActions
|
||||
} from '../src/utils/travelApplicationPlanning.js'
|
||||
import { renderMarkdown } from '../src/utils/markdown.js'
|
||||
import {
|
||||
createMessage as createConversationMessage,
|
||||
@@ -142,6 +149,34 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
||||
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
|
||||
})
|
||||
|
||||
test('travel application submit can continue with conversational planning recommendation', () => {
|
||||
const preview = normalizeApplicationPreview({
|
||||
fields: {
|
||||
applicationType: '差旅费用申请',
|
||||
time: '2026-02-20 至 2026-02-23',
|
||||
location: '上海市',
|
||||
reason: '支撑国网仿生产环境建设',
|
||||
days: '4天',
|
||||
transportMode: '火车'
|
||||
}
|
||||
})
|
||||
const draftPayload = { claim_no: 'AP-202606030001-ABCDE123' }
|
||||
const nudge = buildTravelPlanningNudgeMessage(preview, draftPayload)
|
||||
const actions = buildTravelPlanningSuggestedActions(preview, draftPayload)
|
||||
const recommendation = buildTravelPlanningRecommendation(preview, draftPayload)
|
||||
|
||||
assert.match(nudge, /上海市差旅申请已经提交/)
|
||||
assert.match(nudge, /2026-02-20 至 2026-02-23/)
|
||||
assert.deepEqual(actions.map((item) => item.action_type), [
|
||||
TRAVEL_PLANNING_ACTION_GENERATE,
|
||||
TRAVEL_PLANNING_ACTION_SKIP
|
||||
])
|
||||
assert.match(recommendation, /轻量行程规划/)
|
||||
assert.match(recommendation, /优先看上午到中午抵达 上海市 的火车班次/)
|
||||
assert.match(recommendation, /客户现场周边/)
|
||||
assert.match(recommendation, /AP-202606030001-ABCDE123/)
|
||||
})
|
||||
|
||||
test('application preview renders ordered editable rows and submit text uses edited values', () => {
|
||||
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆,伊犁出差,服务美团业务部署,火车,预计费用1800元', {
|
||||
name: '李文静',
|
||||
@@ -767,3 +802,63 @@ test('application preview editor refreshes transport estimate after mode change'
|
||||
assert.ok(persistCount >= 2)
|
||||
assert.equal(toastMessages.at(-1), '已更新出行方式和费用测算。')
|
||||
})
|
||||
|
||||
test('application preview editor recalculates days and subsidy after date range change', async () => {
|
||||
const preview = normalizeApplicationPreview({
|
||||
fields: {
|
||||
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
|
||||
time: '2026-05-25',
|
||||
location: '\u4e0a\u6d77',
|
||||
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
|
||||
days: '1\u5929',
|
||||
transportMode: '\u706b\u8f66',
|
||||
amount: '',
|
||||
grade: 'P5',
|
||||
applicant: '\u674e\u6587\u9759',
|
||||
department: '\u6280\u672f\u90e8',
|
||||
position: '\u8d22\u52a1\u667a\u80fd\u5316\u4ea7\u54c1\u7ecf\u7406',
|
||||
managerName: '\u5411\u4e07\u7ea2'
|
||||
}
|
||||
})
|
||||
const message = {
|
||||
id: 'application-preview-editor-date-message',
|
||||
applicationPreview: preview,
|
||||
text: ''
|
||||
}
|
||||
const requestedPayloads = []
|
||||
const editor = useApplicationPreviewEditor({
|
||||
persistSessionState: () => {},
|
||||
toast: () => {},
|
||||
currentUser: ref({ grade: 'P5' }),
|
||||
calculateTravelReimbursement: async (payload) => {
|
||||
requestedPayloads.push(payload)
|
||||
return {
|
||||
days: payload.days,
|
||||
location: payload.location,
|
||||
matched_city: payload.location,
|
||||
grade: payload.grade,
|
||||
hotel_rate: 450,
|
||||
hotel_amount: 1800,
|
||||
total_allowance_rate: 100,
|
||||
allowance_amount: 400,
|
||||
total_amount: 2200,
|
||||
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
|
||||
rule_version: 'v1.0.0'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time)
|
||||
editor.setApplicationPreviewDateMode('range')
|
||||
editor.applicationPreviewEditor.value.rangeStartDate = '2026-02-20'
|
||||
editor.applicationPreviewEditor.value.rangeEndDate = '2026-02-23'
|
||||
const committed = await editor.commitApplicationPreviewDateEditor(message)
|
||||
|
||||
assert.equal(committed, true)
|
||||
assert.deepEqual(requestedPayloads.at(-1), { days: 4, location: '\u4e0a\u6d77', grade: 'P5' })
|
||||
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
|
||||
assert.equal(message.applicationPreview.fields.days, '4\u5929')
|
||||
assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\u5929')
|
||||
assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
|
||||
assert.match(message.applicationPreview.fields.policyEstimate, /\u8865\u8d34 400\u5143/)
|
||||
})
|
||||
|
||||
@@ -51,4 +51,10 @@ test('expense application submit uses rich text link and confirm dialog', () =>
|
||||
createViewScript,
|
||||
/emit\('draft-saved', \{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/
|
||||
)
|
||||
assert.match(createViewScript, /buildTravelPlanningNudgeMessage\(applicationPreview, draftPayload\)/)
|
||||
assert.match(createViewScript, /buildTravelPlanningSuggestedActions\(applicationPreview, draftPayload\)/)
|
||||
assert.match(createViewScript, /meta:\s*\['行程规划推荐'\]/)
|
||||
assert.match(createViewScript, /TRAVEL_PLANNING_ACTION_GENERATE/)
|
||||
assert.match(createViewScript, /buildTravelPlanningRecommendation\(sourcePreview, sourceDraftPayload\)/)
|
||||
assert.match(createViewScript, /TRAVEL_PLANNING_ACTION_SKIP/)
|
||||
})
|
||||
|
||||
53
web/tests/finance-dashboard-ranking.test.mjs
Normal file
53
web/tests/finance-dashboard-ranking.test.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { departmentRangeOptions } from '../src/data/metrics.js'
|
||||
|
||||
const overviewView = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const overviewViewModel = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useOverviewView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const analyticsService = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/analytics.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const barChart = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/charts/BarChart.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('finance dashboard ranking range options support month quarter year and all', () => {
|
||||
assert.deepEqual(departmentRangeOptions, ['本月', '本季度', '本年', '全部'])
|
||||
assert.match(analyticsService, /department_employee_mix/)
|
||||
assert.match(analyticsService, /departmentEmployeeMix/)
|
||||
assert.match(analyticsService, /department_range/)
|
||||
})
|
||||
|
||||
test('finance dashboard renders shared ranking filters and department employee mix chart', () => {
|
||||
assert.match(overviewView, /<h3>部门报销排行/)
|
||||
assert.match(overviewView, /aria-label="部门排行时间范围"/)
|
||||
assert.match(overviewView, /<h3>个人报销排行/)
|
||||
assert.match(overviewView, /aria-label="个人排行时间范围"/)
|
||||
assert.doesNotMatch(overviewView, /个人报销排行(本月)/)
|
||||
assert.match(overviewView, /<h3>高额单据/)
|
||||
assert.doesNotMatch(overviewView, /本月高额单据/)
|
||||
assert.match(overviewView, /class="top-claim-split"/)
|
||||
assert.match(overviewView, /departmentEmployeeLegend/)
|
||||
assert.match(overviewView, /departmentEmployeeCenterValue/)
|
||||
assert.match(overviewViewModel, /financeDepartmentEmployeeMix/)
|
||||
assert.match(overviewViewModel, /departmentEmployeeLegend/)
|
||||
assert.match(overviewViewModel, /employeeCount/)
|
||||
})
|
||||
|
||||
test('finance ranking bar chart can display ranking metadata', () => {
|
||||
assert.match(barChart, /rank-meta/)
|
||||
assert.match(barChart, /item\.meta/)
|
||||
assert.match(overviewViewModel, /meta: `\$\{Number\(item\.employeeCount/)
|
||||
assert.match(overviewViewModel, /meta: `\$\{item\.department/)
|
||||
})
|
||||
@@ -121,8 +121,10 @@ test('workbench cards use layered glass material instead of texture-led cards',
|
||||
assert.doesNotMatch(workbenchCardStyles, /background-blend-mode:\s*normal,\s*color,\s*normal;/)
|
||||
assert.match(workbenchResponsiveStyles, /--workbench-glass-noise-opacity:\s*0\.008;/)
|
||||
assert.match(workbenchResponsiveStyles, /--workbench-glass-blur:\s*blur\(14px\) saturate\(1\.2\);/)
|
||||
assert.match(workbenchGlassStyles, /\.todo-row,[\s\S]*\.progress-row\s*\{[\s\S]*background:\s*transparent;[\s\S]*box-shadow:\s*inset 0 1px 0 rgba\(var\(--theme-primary-rgb/)
|
||||
assert.doesNotMatch(workbenchGlassStyles, /\.todo-row\s*\{[\s\S]*border-top:\s*1px solid var\(--workbench-line-soft\)/)
|
||||
assert.match(workbenchGlassStyles, /\.progress-row\s*\{[\s\S]*background:\s*transparent;[\s\S]*box-shadow:\s*inset 0 1px 0 rgba\(var\(--theme-primary-rgb/)
|
||||
assert.doesNotMatch(workbench, /<h2>我的待办<\/h2>/)
|
||||
assert.doesNotMatch(workbench, /<h2>关键动作<\/h2>/)
|
||||
assert.doesNotMatch(workbenchGlassStyles, /\.todo-row/)
|
||||
assert.doesNotMatch(workbenchGlassStyles, /\.progress-row\s*\{[\s\S]*border-top:\s*1px solid var\(--workbench-line-soft\)/)
|
||||
assert.match(workbenchInsightStyles, /\.insight-metric-row,[\s\S]*\.insight-profile-card\s*\{[\s\S]*backdrop-filter:\s*blur\(10px\) saturate\(1\.16\)/)
|
||||
assert.doesNotMatch(workbenchInsightStyles, /background:\s*#ffffff;/)
|
||||
|
||||
@@ -82,3 +82,16 @@ test('risk dashboard wires window filter to trend, ranking, and cards data sourc
|
||||
assert.match(dashboardComponent, /RiskDailyTrendChart/)
|
||||
assert.match(dashboardComponent, /rankingGroups/)
|
||||
})
|
||||
|
||||
test('risk dashboard shows loading overlay and realtime refresh status', () => {
|
||||
assert.match(dashboardComponent, /risk-dashboard-loading-overlay/)
|
||||
assert.match(dashboardComponent, /loadingLabel/)
|
||||
assert.match(dashboardComponent, /lastUpdatedLabel/)
|
||||
assert.match(dashboardComponent, /lastUpdatedAt/)
|
||||
assert.match(overviewViewModel, /riskDashboardLastUpdatedAt/)
|
||||
assert.match(overviewViewModel, /startRiskDashboardRealtimeRefresh/)
|
||||
assert.match(overviewViewModel, /setInterval/)
|
||||
assert.match(overviewViewModel, /document\.visibilityState === 'hidden'/)
|
||||
assert.match(overviewViewModel, /riskDashboardRequestSeq/)
|
||||
assert.match(overviewTemplate, /:last-updated-at="riskDashboardLastUpdatedAt"/)
|
||||
})
|
||||
|
||||
@@ -805,6 +805,29 @@ test('application detail uses application labels instead of reimbursement labels
|
||||
assert.match(detailViewTemplate, /当前申请单已进入流程,详情页仅展示状态与申请信息。/)
|
||||
})
|
||||
|
||||
test('returned application detail can open assistant with editable prefill', () => {
|
||||
assert.match(
|
||||
detailViewTemplate,
|
||||
/v-if="canModifyReturnedApplication"[\s\S]*@click="handleModifyApplication"[\s\S]*修改申请/
|
||||
)
|
||||
assert.match(
|
||||
detailViewScript,
|
||||
/const canModifyReturnedApplication = computed\(\(\) => \([\s\S]*isApplicationDocument\.value[\s\S]*isCurrentApplicant\.value[\s\S]*returned/
|
||||
)
|
||||
assert.match(detailViewScript, /function buildApplicationEditPreview\(\)/)
|
||||
assert.match(detailViewScript, /applicationDetailFactItems\.value[\s\S]*sourceText:\s*'修改申请'/)
|
||||
assert.match(detailViewScript, /fields:\s*\{[\s\S]*applicationType:[\s\S]*reason:[\s\S]*transportMode:/)
|
||||
assert.match(detailViewScript, /function handleModifyApplication\(\)/)
|
||||
assert.match(detailViewScript, /source:\s*'application'/)
|
||||
assert.match(detailViewScript, /sessionType:\s*'application'/)
|
||||
assert.match(detailViewScript, /prompt:\s*''/)
|
||||
assert.match(detailViewScript, /applicationPreview:\s*buildApplicationEditPreview\(\)/)
|
||||
assert.match(detailViewScript, /applicationEditMode:\s*true/)
|
||||
assert.match(detailViewScript, /initialPromptAutoSubmit:\s*false/)
|
||||
assert.match(detailViewScript, /canModifyReturnedApplication,/)
|
||||
assert.match(detailViewScript, /handleModifyApplication,/)
|
||||
})
|
||||
|
||||
test('application detail does not show optional travel receipt reminders', () => {
|
||||
const request = {
|
||||
documentTypeCode: 'application',
|
||||
@@ -943,6 +966,34 @@ test('transport ticket items no longer generate business location completion adv
|
||||
assert.doesNotMatch(detailViewScript, /完善第 1 条费用明细的业务地点/)
|
||||
})
|
||||
|
||||
test('compliant attachment analysis does not create medium risk cards', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
expenseItems: [
|
||||
{
|
||||
id: 'item-001',
|
||||
invoiceId: 'mock/invoice-001.txt',
|
||||
itemReason: 'taxi',
|
||||
itemType: 'transport'
|
||||
}
|
||||
],
|
||||
attachmentMetaByItemId: {
|
||||
'item-001': {
|
||||
analysis: {
|
||||
severity: 'success',
|
||||
label: 'compliant',
|
||||
headline: 'invoice fields match reimbursement item',
|
||||
summary: 'mock OCR fields are consistent with the reimbursement detail',
|
||||
points: ['amount and document type are consistent']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert.deepEqual(riskCards, [])
|
||||
assert.match(detailViewInsights, /success', 'ok', 'normal', 'none', 'compliant', 'approved'/)
|
||||
assert.match(detailViewScript, /tone: normalizeRiskTone\(analysis\.severity \|\| 'low'\)/)
|
||||
})
|
||||
|
||||
test('return reason dialog is wired into approval and detail return actions', () => {
|
||||
assert.match(returnReasonDialog, /missing_attachment/)
|
||||
assert.match(returnReasonDialog, /invoice_mismatch/)
|
||||
|
||||
@@ -94,8 +94,9 @@ test('archived detail delete action is gated by admin-only permission', () => {
|
||||
test('editable detail delete action is limited to applicant or claim manager', () => {
|
||||
assert.match(detailViewScript, /const isCurrentApplicant = computed/)
|
||||
assert.match(detailViewScript, /isPlatformAdminUser/)
|
||||
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return isPlatformAdminUser\(currentUser\.value\)\s*}/)
|
||||
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return isPlatformAdminUser\(currentUser\.value\) \|\| \(isEditableRequest\.value && isCurrentApplicant\.value\)\s*}/)
|
||||
assert.match(detailViewScript, /if \(canManageCurrentClaim\.value\) {\s*return true\s*}/)
|
||||
assert.match(detailViewScript, /return isEditableRequest\.value && isCurrentApplicant\.value/)
|
||||
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return '删除申请'\s*}/)
|
||||
assert.match(detailViewScript, /当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。/)
|
||||
})
|
||||
|
||||
80
web/tests/workbench-summary.test.mjs
Normal file
80
web/tests/workbench-summary.test.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildAdjacentProgressSteps,
|
||||
buildWorkbenchSummary
|
||||
} from '../src/utils/workbenchSummary.js'
|
||||
|
||||
const currentUser = { name: '张三', username: 'zhangsan' }
|
||||
|
||||
function buildStep(label, index, currentIndex) {
|
||||
return {
|
||||
label,
|
||||
done: index < currentIndex,
|
||||
active: index <= currentIndex,
|
||||
current: index === currentIndex,
|
||||
time: index === currentIndex ? '进行中' : '待处理'
|
||||
}
|
||||
}
|
||||
|
||||
test('workbench expense progress keeps only nearby four expense steps', () => {
|
||||
const steps = ['创建单据', '待提交', '直属领导审批', '财务审批', '待付款', '归档入账']
|
||||
.map((label, index) => buildStep(label, index, 3))
|
||||
|
||||
const visibleSteps = buildAdjacentProgressSteps(steps, 4)
|
||||
|
||||
assert.deepEqual(
|
||||
visibleSteps.map((step) => step.label),
|
||||
['直属领导审批', '财务审批', '待付款', '归档入账']
|
||||
)
|
||||
assert.equal(visibleSteps[1].current, true)
|
||||
})
|
||||
|
||||
test('workbench summary builds real user notifications and progress from requests', () => {
|
||||
const summary = buildWorkbenchSummary(
|
||||
[
|
||||
{
|
||||
id: 'BX-001',
|
||||
claimId: 'claim-1',
|
||||
claimNo: 'BX-001',
|
||||
person: '张三',
|
||||
title: '差旅报销',
|
||||
approvalKey: 'draft',
|
||||
approvalStatus: '草稿',
|
||||
status: 'draft',
|
||||
amount: 1280,
|
||||
createdAt: '2026-06-01T10:00:00+08:00',
|
||||
updatedAt: '2026-06-01T10:10:00+08:00',
|
||||
progressSteps: [
|
||||
buildStep('创建单据', 0, 1),
|
||||
buildStep('待提交', 1, 1),
|
||||
buildStep('直属领导审批', 2, 1),
|
||||
buildStep('财务审批', 3, 1),
|
||||
buildStep('待付款', 4, 1)
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'BX-002',
|
||||
claimId: 'claim-2',
|
||||
claimNo: 'BX-002',
|
||||
person: '李四',
|
||||
title: '他人单据',
|
||||
approvalKey: 'draft',
|
||||
amount: 800,
|
||||
progressSteps: []
|
||||
}
|
||||
],
|
||||
currentUser
|
||||
)
|
||||
|
||||
assert.equal(summary.todoItems.length, 1)
|
||||
assert.equal(summary.todoItems[0].target.id, 'claim-1')
|
||||
assert.equal(summary.progressItems.length, 1)
|
||||
assert.deepEqual(
|
||||
summary.progressItems[0].steps.map((step) => step.label),
|
||||
['创建单据', '待提交', '直属领导审批', '财务审批']
|
||||
)
|
||||
assert.equal(summary.notifications.length, 1)
|
||||
assert.equal(summary.unreadNotificationCount, 1)
|
||||
})
|
||||
@@ -817,13 +817,15 @@ async function startBackendAndWait() {
|
||||
return cloneBackendStartState()
|
||||
}
|
||||
|
||||
function localSetupPlugin() {
|
||||
return {
|
||||
name: 'local-setup-api',
|
||||
configureServer(server) {
|
||||
server.watcher.unwatch(envFile)
|
||||
server.watcher.unwatch(envExampleFile)
|
||||
server.watcher.unwatch(path.join(rootDir, 'server', 'logs'))
|
||||
function localSetupPlugin() {
|
||||
return {
|
||||
name: 'local-setup-api',
|
||||
configureServer(server) {
|
||||
server.watcher.unwatch(envFile)
|
||||
server.watcher.unwatch(envExampleFile)
|
||||
server.watcher.unwatch(path.join(rootDir, 'server', 'logs'))
|
||||
server.watcher.unwatch(path.join(rootDir, 'server', 'storage'))
|
||||
server.watcher.unwatch(path.join(rootDir, 'test-results'))
|
||||
|
||||
server.middlewares.use('/__setup/auth/login', async (req, res) => {
|
||||
try {
|
||||
@@ -1030,15 +1032,18 @@ export default defineConfig({
|
||||
? {
|
||||
// Docker bind mounts can miss fs events for Vue SFCs, which leaves Vite serving stale templates.
|
||||
usePolling: true,
|
||||
interval: 250
|
||||
interval: 1000
|
||||
}
|
||||
: {}),
|
||||
ignored: [
|
||||
envFile,
|
||||
envExampleFile,
|
||||
path.join(rootDir, 'server', 'logs', '**')
|
||||
]
|
||||
},
|
||||
path.join(rootDir, 'server', 'logs', '**'),
|
||||
path.join(rootDir, 'server', 'storage', '**'),
|
||||
path.join(rootDir, 'test-results', '**'),
|
||||
path.join(rootDir, '.codex-remote-attachments', '**')
|
||||
]
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: `http://127.0.0.1:${process.env.SERVER_PORT || 8000}`,
|
||||
|
||||
Reference in New Issue
Block a user