feat: 增强员工管理与报销单全流程功能

- 新增员工Excel导入服务(employee_spreadsheet)及导入/导出API端点
- 员工服务增加批量创建、邮箱唯一校验、组织架构关联等能力
- 报销单提交补充身份回填、部门信息透传及预审结果展示优化
- 认证流程增加部门信息(departmentName)并在schema中同步扩展
- 用户Agent服务增加部门关联与报销单回填逻辑
- 前端员工管理页面全面重构,新增导入导出、搜索过滤、分页等功能
- 前端审批中心、审计、差旅报销等视图交互与样式优化
- 新增TableLoadingState共享组件及员工导入测试用例
This commit is contained in:
caoxiaozhu
2026-05-20 14:21:56 +08:00
parent 57957d11a0
commit d7e98a58b9
46 changed files with 4022 additions and 305 deletions

View File

@@ -103,3 +103,39 @@ h1 { margin-top: 4px; color: var(--ink); font-size: 24px; line-height: 1.25; fon
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; scroll-behavior: auto !important; }
}
.table-loading__spinner {
width: 38px;
height: 38px;
display: inline-grid;
place-items: center;
border: 3px solid #e2e8f0;
border-top-color: #10b981;
border-radius: 50%;
animation: table-spinner-rotate .8s linear infinite !important;
}
.table-loading.sky .table-loading__spinner {
border-top-color: #0ea5e9;
}
.table-loading.detail .table-loading__spinner {
width: 34px;
height: 34px;
}
.table-loading.banner .table-loading__spinner {
width: 18px;
height: 18px;
border-width: 2px;
}
.table-loading__spinner i {
display: none;
}
@keyframes table-spinner-rotate {
to {
transform: rotate(360deg);
}
}

View File

@@ -359,6 +359,11 @@
text-align: left;
}
.table-state > .table-loading,
.detail-inline-state > .table-loading {
width: 100%;
}
.table-state i,
.detail-inline-state i {
font-size: 28px;
@@ -455,11 +460,7 @@ tbody tr:hover {
background: #f8fbff;
}
tbody tr.spotlight {
background: linear-gradient(90deg, rgba(16, 185, 129, 0.05), rgba(59, 130, 246, 0.03));
}
.skill-name-cell {
.skill-name-cell {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
gap: 10px;
@@ -942,7 +943,7 @@ tbody tr.spotlight {
line-height: 1.5;
}
.spreadsheet-change-center {
.spreadsheet-change-center {
min-height: 0;
height: 100%;
align-self: stretch;
@@ -956,20 +957,20 @@ tbody tr.spotlight {
overflow: hidden;
}
.change-center-head h3,
.change-center-head p,
.change-center-section header,
.change-center-section p {
.change-center-head h3,
.change-center-head p,
.change-center-section header,
.change-center-section p {
margin: 0;
}
.change-center-head h3 {
.change-center-head h3 {
color: #0f172a;
font-size: 15px;
font-weight: 900;
}
.change-center-head p {
.change-center-head p {
margin-top: 3px;
color: #64748b;
font-size: 12px;
@@ -1030,37 +1031,37 @@ tbody tr.spotlight {
color: #2563eb;
}
.change-center-section {
.change-center-section {
display: grid;
gap: 8px;
}
.change-history-section {
.change-history-section {
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
}
.change-center-section > header {
.change-center-section > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.change-center-section > header strong {
.change-center-section > header strong {
color: #0f172a;
font-size: 13px;
font-weight: 900;
}
.change-center-section > header small,
.change-center-section > header button {
.change-center-section > header small,
.change-center-section > header button {
color: #64748b;
font-size: 11px;
font-weight: 800;
}
.change-center-section > header button {
.change-center-section > header button {
padding: 0;
border: 0;
background: transparent;
@@ -1068,7 +1069,7 @@ tbody tr.spotlight {
cursor: pointer;
}
.change-center-list {
.change-center-list {
display: grid;
align-content: start;
gap: 8px;
@@ -1077,7 +1078,7 @@ tbody tr.spotlight {
padding-right: 2px;
}
.change-center-item {
.change-center-item {
display: grid;
gap: 8px;
padding: 10px;
@@ -1086,12 +1087,12 @@ tbody tr.spotlight {
background: #fff;
}
.change-center-item.active {
.change-center-item.active {
border-color: rgba(16, 185, 129, 0.35);
background: rgba(16, 185, 129, 0.05);
}
.change-center-item > button {
.change-center-item > button {
display: grid;
gap: 5px;
padding: 0;
@@ -1101,31 +1102,31 @@ tbody tr.spotlight {
cursor: pointer;
}
.change-center-item > button:disabled {
.change-center-item > button:disabled {
cursor: default;
}
.change-center-item > button div {
.change-center-item > button div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.change-center-item > button strong {
.change-center-item > button strong {
color: #0f172a;
font-size: 13px;
font-weight: 900;
}
.change-center-item > button span,
.change-center-item > button p,
.change-center-item > button small {
.change-center-item > button span,
.change-center-item > button p,
.change-center-item > button small {
color: #64748b;
font-size: 11px;
}
.change-center-item > button p {
.change-center-item > button p {
margin: 0;
line-height: 1.45;
}
@@ -1250,7 +1251,7 @@ tbody tr.spotlight {
font-weight: 900;
}
.change-flow-empty {
.change-flow-empty {
color: #64748b;
font-size: 11px;
}
@@ -1746,11 +1747,19 @@ tbody tr.spotlight {
place-items: center;
gap: 8px;
padding: 24px;
background: rgba(248, 250, 252, 0.94);
background:
radial-gradient(circle at 50% 38%, rgba(224, 242, 254, 0.72), rgba(248, 250, 252, 0) 58%),
rgba(248, 250, 252, 0.94);
color: #475569;
font-size: 13px;
font-weight: 800;
text-align: center;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.rule-spreadsheet-state > .table-loading {
width: 100%;
}
.rule-spreadsheet-state i {
@@ -1857,6 +1866,14 @@ tbody tr.spotlight {
font-weight: 700;
}
.subtle-banner > .table-loading {
width: 100%;
}
.rule-drawer-state > .table-loading {
width: 100%;
}
.editor-foot {
margin-top: 12px;
color: #64748b;

View File

@@ -142,6 +142,8 @@
.picker-trigger,
.ghost-filter-btn,
.template-btn,
.export-btn,
.create-btn,
.row-action {
min-height: 38px;
@@ -282,6 +284,24 @@
color: #047857;
}
.template-btn,
.export-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 14px;
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.template-btn:hover,
.export-btn:hover {
border-color: rgba(16, 185, 129, 0.34);
background: #f6fffb;
color: #0f9f78;
}
.create-btn {
display: inline-flex;
align-items: center;
@@ -293,6 +313,50 @@
box-shadow: 0 8px 18px rgba(5, 150, 105, 0.18);
}
.create-btn:disabled,
.template-btn:disabled,
.export-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.import-file-input {
display: none;
}
.import-error-table-wrap {
max-height: 280px;
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
.import-error-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.import-error-table th,
.import-error-table td {
padding: 10px 12px;
border-bottom: 1px solid #eef2f7;
text-align: left;
vertical-align: top;
}
.import-error-table th {
position: sticky;
top: 0;
background: #f8fafc;
color: #475569;
font-weight: 700;
}
.import-error-table td:last-child {
color: #b45309;
}
.hint {
display: inline-flex;
align-items: center;
@@ -333,13 +397,14 @@
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
align-items: stretch;
justify-content: flex-start;
}
.table-wrap table {
width: 100%;
align-self: flex-start;
flex: 0 0 auto;
align-self: stretch;
}
.list-foot {
@@ -503,7 +568,7 @@
}
table {
height: 100%;
height: auto;
width: 100%;
min-width: 1180px;
border-collapse: collapse;
@@ -659,9 +724,12 @@ tbody tr:last-child td {
}
.role-stack {
display: flex;
gap: 6px;
display: inline-flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 6px;
max-width: 100%;
}
.role-pill {
@@ -770,6 +838,7 @@ tbody tr:last-child td {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.82fr);
gap: 16px;
align-items: start;
}
.detail-main,
@@ -777,6 +846,7 @@ tbody tr:last-child td {
display: grid;
gap: 16px;
align-content: start;
align-items: start;
}
.detail-card,
@@ -821,6 +891,7 @@ tbody tr:last-child td {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
overflow: visible;
}
.field {
@@ -850,6 +921,118 @@ tbody tr:last-child td {
color: #64748b;
}
.manager-picker,
.department-picker {
position: relative;
z-index: 2;
}
.manager-picker.open,
.department-picker.open {
z-index: 12;
}
.manager-picker-trigger {
width: 100%;
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
color: #0f172a;
font-size: 13px;
text-align: left;
}
.manager-picker.open .manager-picker-trigger,
.manager-picker-trigger:hover {
border-color: rgba(16, 185, 129, 0.34);
background: #f6fffb;
}
.manager-picker-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.manager-picker-panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: min(420px, 100%);
z-index: 30;
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #d7e0ea;
border-radius: 12px;
background: #fff;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
}
.manager-picker-panel input[type='search'] {
width: 100%;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
color: #0f172a;
font-size: 13px;
padding: 10px 12px;
}
.manager-picker-panel input[type='search']:focus {
outline: none;
border-color: rgba(16, 185, 129, 0.6);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
}
.manager-picker-options {
max-height: 240px;
overflow: auto;
display: grid;
gap: 8px;
}
.manager-picker-option {
display: grid;
gap: 4px;
width: 100%;
padding: 10px 12px;
border: 1px solid #edf2f7;
border-radius: 10px;
background: #fbfdff;
text-align: left;
}
.manager-picker-option strong {
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.manager-picker-option span {
color: #64748b;
font-size: 12px;
}
.manager-picker-option:hover,
.manager-picker-option.active {
border-color: rgba(16, 185, 129, 0.32);
background: linear-gradient(180deg, rgba(240, 253, 244, 0.85), #ffffff);
}
.manager-picker-empty {
margin: 0;
padding: 8px 4px;
color: #64748b;
font-size: 12px;
}
.role-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -912,9 +1095,9 @@ tbody tr:last-child td {
.history-row {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 10px;
gap: 12px;
padding: 12px 0;
border-top: 1px solid #edf2f7;
}
@@ -925,19 +1108,47 @@ tbody tr:last-child td {
}
.history-row strong {
display: block;
flex: 1 1 auto;
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 800;
line-height: 1.45;
}
.history-row span,
.history-row small {
display: block;
margin-top: 4px;
.history-row-meta {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 20px;
flex-shrink: 0;
margin-left: 16px;
padding-left: 16px;
border-left: 1px solid #e2e8f0;
}
.history-row-owner,
.history-row-time {
display: inline-block;
margin-top: 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
line-height: 1.45;
white-space: nowrap;
}
.history-row-owner {
color: #475569;
font-weight: 700;
}
.history-row-time {
color: #64748b;
}
td.cell-updated {
vertical-align: middle;
white-space: nowrap;
}
.publish-card {

View File

@@ -707,6 +707,15 @@
text-align: center;
}
.inline-empty.is-loading {
padding: 0;
background: transparent;
}
.inline-empty.is-loading > .table-loading {
min-height: 220px;
}
.inspector-empty {
display: grid;
align-content: center;

View File

@@ -418,12 +418,20 @@ th {
justify-content: center;
}
.empty-row {
color: #64748b;
text-align: center;
}
.list-foot {
.empty-row {
color: #64748b;
text-align: center;
}
.table-loading-row {
padding: 0;
}
.table-loading-row > .table-loading {
min-height: 220px;
}
.list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;

View File

@@ -2246,10 +2246,78 @@
display: grid;
gap: 8px;
margin: 0;
padding-left: 16px;
color: #475569;
}
.review-side-risk-item {
width: 100%;
display: grid;
grid-template-columns: 30px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 66px;
padding: 10px;
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 14px;
background: rgba(255, 255, 255, 0.76);
color: #334155;
text-align: left;
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.review-side-risk-item:hover {
border-color: rgba(249, 115, 22, 0.38);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.08);
transform: translateY(-1px);
}
.review-side-risk-icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 10px;
background: rgba(14, 165, 233, 0.12);
color: #0284c7;
font-size: 16px;
}
.review-side-risk-item.warning .review-side-risk-icon {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.review-side-risk-item.high .review-side-risk-icon {
background: rgba(239, 68, 68, 0.12);
color: #dc2626;
}
.review-side-risk-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.review-side-risk-copy strong {
color: #0f172a;
font-size: 12px;
line-height: 1.6;
font-weight: 900;
}
.review-side-risk-copy p {
margin: 0;
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.review-side-risk-meta {
display: inline-flex;
align-items: center;
gap: 2px;
color: #64748b;
font-size: 11px;
font-weight: 850;
white-space: nowrap;
}
.review-side-link {
@@ -4133,6 +4201,93 @@
flex: 1 1 168px;
}
.review-risk-detail-modal {
width: min(560px, calc(100vw - 40px));
max-height: min(760px, calc(100vh - 48px));
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
border-radius: 24px;
border: 1px solid #e7eef6;
background:
radial-gradient(circle at top right, rgba(245, 158, 11, 0.10), transparent 28%),
linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%);
box-shadow:
0 24px 80px rgba(15, 23, 42, 0.22),
0 2px 12px rgba(15, 23, 42, 0.08);
}
.review-risk-detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 22px 24px 18px;
border-bottom: 1px solid #eef2f7;
}
.review-risk-detail-head h3 {
margin: 12px 0 0;
color: #0f172a;
font-size: 21px;
font-weight: 900;
line-height: 1.35;
}
.review-risk-detail-body {
min-height: 0;
display: grid;
gap: 14px;
padding: 18px 24px 24px;
overflow-y: auto;
}
.review-risk-detail-level {
width: fit-content;
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 0 11px;
border-radius: 999px;
background: rgba(14, 165, 233, 0.12);
color: #0284c7;
font-size: 12px;
font-weight: 900;
}
.review-risk-detail-level.warning {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.review-risk-detail-level.high {
background: rgba(239, 68, 68, 0.12);
color: #dc2626;
}
.review-risk-detail-section {
display: grid;
gap: 8px;
padding: 14px;
border: 1px solid rgba(226, 232, 240, 0.92);
border-radius: 16px;
background: rgba(255, 255, 255, 0.72);
}
.review-risk-detail-section strong {
color: #0f172a;
font-size: 13px;
font-weight: 900;
}
.review-risk-detail-section p {
margin: 0;
color: #475569;
font-size: 13px;
line-height: 1.7;
}
.review-edit-modal {
max-height: min(860px, calc(100vh - 48px));
display: grid;

View File

@@ -1061,6 +1061,13 @@
color: #ef4444;
}
.return-action {
min-width: 98px;
border: 1px solid #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.detail-action-hint {
color: #64748b;
font-size: 13px;
@@ -1583,6 +1590,7 @@
.ai-preview-secondary:disabled,
.ai-preview-primary:disabled,
.approve-action:disabled,
.return-action:disabled,
.ai-send-btn:disabled {
opacity: .45;
cursor: not-allowed;

View File

@@ -0,0 +1,176 @@
<template>
<div
class="table-loading"
:class="[variant, tone]"
role="status"
:aria-label="ariaLabel"
aria-live="polite"
>
<span class="table-loading__spinner" aria-hidden="true">
<i :class="icon"></i>
</span>
<div v-if="hasCopy" class="table-loading__copy">
<strong v-if="title">{{ title }}</strong>
<p v-if="message">{{ message }}</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
variant: {
type: String,
default: 'panel',
validator: (value) => ['panel', 'detail', 'overlay', 'drawer', 'banner'].includes(value)
},
tone: {
type: String,
default: 'emerald',
validator: (value) => ['emerald', 'sky'].includes(value)
},
title: { type: String, default: '' },
message: { type: String, default: '' },
icon: { type: String, default: 'mdi mdi-loading' },
showSkeleton: { type: Boolean, default: true },
skeletonRows: { type: Number, default: 5 }
})
const hasCopy = computed(() => Boolean(props.title || props.message))
const ariaLabel = computed(() => [props.title, props.message].filter(Boolean).join(', ') || 'Loading')
</script>
<style scoped>
.table-loading {
--accent: #10b981;
--accent-deep: #059669;
width: 100%;
color: #64748b;
}
.table-loading.sky {
--accent: #0ea5e9;
--accent-deep: #0284c7;
}
.table-loading.panel {
min-height: 220px;
display: grid;
place-items: center;
gap: 12px;
padding: 28px 24px;
text-align: center;
}
.table-loading.detail {
min-height: 180px;
display: flex;
align-items: center;
gap: 14px;
padding: 22px 24px;
text-align: left;
}
.table-loading.overlay,
.table-loading.drawer {
display: grid;
place-items: center;
gap: 12px;
text-align: center;
}
.table-loading.overlay {
min-height: 0;
}
.table-loading.drawer {
min-height: 160px;
}
.table-loading.banner {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 0;
padding: 0;
color: #0369a1;
}
.table-loading__spinner {
width: 38px;
height: 38px;
display: inline-grid;
place-items: center;
border: 3px solid #e2e8f0;
border-top-color: var(--accent);
border-radius: 50%;
color: var(--accent-deep);
animation: table-spinner-rotate 0.8s linear infinite !important;
}
.table-loading.detail .table-loading__spinner {
width: 34px;
height: 34px;
}
.table-loading.banner .table-loading__spinner {
width: 18px;
height: 18px;
border-width: 2px;
}
.table-loading__spinner i {
display: none;
}
.table-loading__copy {
display: grid;
gap: 6px;
min-width: 0;
}
.table-loading.panel .table-loading__copy,
.table-loading.overlay .table-loading__copy,
.table-loading.drawer .table-loading__copy {
max-width: 360px;
}
.table-loading.detail .table-loading__copy {
flex: 1;
}
.table-loading.banner .table-loading__copy {
display: inline;
}
.table-loading__copy strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
line-height: 1.4;
}
.table-loading__copy p {
margin: 0;
font-size: 13px;
line-height: 1.65;
}
.table-loading.banner .table-loading__copy strong {
display: none;
}
.table-loading.banner .table-loading__copy p {
font-size: 12px;
font-weight: 700;
}
@keyframes table-spinner-rotate {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -94,10 +94,16 @@ function buildAnonymousUser() {
departmentName: '',
position: '',
grade: '',
employeeNo: '',
managerName: '',
location: '',
costCenter: '',
financeOwnerName: '',
riskProfile: {},
roleCodes: [],
email: '',
avatar: '',
isAdmin: false
email: '',
avatar: '',
isAdmin: false
}
}
@@ -113,10 +119,16 @@ function buildLegacyAdminUser(username = '') {
departmentName: '',
position: DEFAULT_USER_ROLE,
grade: '',
employeeNo: '',
managerName: '',
location: '',
costCenter: '',
financeOwnerName: '',
riskProfile: {},
roleCodes: ['manager'],
email: '',
avatar: name.slice(0, 1).toUpperCase(),
isAdmin: true
email: '',
avatar: name.slice(0, 1).toUpperCase(),
isAdmin: true
}
}
@@ -143,10 +155,16 @@ function readStoredUser() {
departmentName: String(payload.departmentName || payload.department || ''),
position: String(payload.position || ''),
grade: String(payload.grade || ''),
employeeNo: String(payload.employeeNo || payload.employee_no || ''),
managerName: String(payload.managerName || payload.manager_name || ''),
location: String(payload.location || ''),
costCenter: String(payload.costCenter || payload.cost_center || ''),
financeOwnerName: String(payload.financeOwnerName || payload.finance_owner_name || ''),
riskProfile: payload.riskProfile && typeof payload.riskProfile === 'object' ? payload.riskProfile : {},
roleCodes,
email: String(payload.email || ''),
avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()),
isAdmin: Boolean(payload.isAdmin)
email: String(payload.email || ''),
avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()),
isAdmin: Boolean(payload.isAdmin)
}
}
} catch {

View File

@@ -1,6 +1,6 @@
import { apiRequest } from './api.js'
export function fetchEmployees(params = {}) {
function buildEmployeesQuery(params = {}) {
const search = new URLSearchParams()
if (params.status && params.status !== '全部员工') {
@@ -11,10 +11,54 @@ export function fetchEmployees(params = {}) {
search.set('keyword', params.keyword)
}
const query = search.toString()
return search
}
export function fetchEmployees(params = {}) {
const query = buildEmployeesQuery(params).toString()
return apiRequest(`/employees${query ? `?${query}` : ''}`)
}
function triggerBlobDownload(blob, filename) {
const objectUrl = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = objectUrl
anchor.download = filename
anchor.rel = 'noopener'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(objectUrl)
}
export async function downloadEmployeeImportTemplate() {
const blob = await apiRequest('/employees/import-template', {
responseType: 'blob',
contentType: null
})
triggerBlobDownload(blob, '员工导入模板.xlsx')
}
export async function exportEmployees(params = {}) {
const query = buildEmployeesQuery(params).toString()
const blob = await apiRequest(`/employees/export${query ? `?${query}` : ''}`, {
responseType: 'blob',
contentType: null
})
triggerBlobDownload(blob, '员工目录导出.xlsx')
}
export function importEmployees(file) {
const formData = new FormData()
formData.append('file', file)
return apiRequest('/employees/import', {
method: 'POST',
body: formData,
contentType: null
})
}
export function fetchEmployeeMeta() {
return apiRequest('/employees/meta')
}

View File

@@ -87,6 +87,13 @@ export function submitExpenseClaim(claimId) {
})
}
export function returnExpenseClaim(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/return`, {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function deleteExpenseClaim(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`, {
method: 'DELETE'

View File

@@ -11,22 +11,25 @@ export const DEFAULT_APP_VIEW_ORDER = [
]
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
approval: ['approver'],
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
approval: ['approver', 'finance', 'executive'],
audit: ['auditor', 'finance'],
logs: ['manager'],
employees: ['manager'],
settings: ['manager']
}
const CLAIM_MANAGER_ROLE_CODES = new Set(['finance', 'executive'])
function normalizedRoleCodes(user) {
if (!user) {
return []
}
return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : []
}
return Array.isArray(user.roleCodes)
? user.roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
: []
}
export function isManagerUser(user) {
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
@@ -35,8 +38,20 @@ export function isManagerUser(user) {
export function isFinanceUser(user) {
return normalizedRoleCodes(user).includes('finance')
}
export function canAccessAppView(user, viewId) {
export function isExecutiveUser(user) {
return normalizedRoleCodes(user).includes('executive')
}
export function canManageExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false
}

View File

@@ -391,9 +391,34 @@
<span>退回列表</span>
</button>
<div class="approval-action-group" aria-label="审批操作">
<button class="approve-action" type="button"><i class="mdi mdi-check-circle-outline"></i> 通过</button>
<button class="reject-action" type="button"><i class="mdi mdi-close-circle-outline"></i> 驳回</button>
<button class="supplement-action" type="button"><i class="mdi mdi-undo"></i> 补充</button>
<button class="approve-action" type="button" :disabled="actionBusy">
<i class="mdi mdi-check-circle-outline"></i> 通过
</button>
<button
class="reject-action"
type="button"
:disabled="!canManageClaims || actionBusy"
@click="handleReturnSelected"
>
<i class="mdi mdi-close-circle-outline"></i> 驳回
</button>
<button
class="supplement-action"
type="button"
:disabled="!canManageClaims || actionBusy"
@click="handleReturnSelected"
>
<i class="mdi mdi-undo"></i> 补充
</button>
<button
v-if="canManageClaims"
class="reject-action"
type="button"
:disabled="actionBusy"
@click="handleDeleteSelected"
>
<i class="mdi mdi-trash-can-outline"></i> 删除
</button>
</div>
</footer>
</div>
@@ -430,9 +455,11 @@
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="loading" class="table-state">
<i class="mdi mdi-loading mdi-spin"></i>
<strong>正在加载审批待办</strong>
<p>直属领导和财务节点下可处理的报销单据会直接展示在这里</p>
<TableLoadingState
title="审批待办同步中"
message="正在加载当前可见的待审报销单据"
icon="mdi mdi-clipboard-check-outline"
/>
</div>
<div v-else-if="error" class="table-state error">
@@ -502,6 +529,38 @@
</table>
</div>
</article>
<ConfirmDialog
:open="returnDialogOpen"
badge="退回单据"
badge-tone="warning"
:title="`确认退回 ${selectedRow?.id || ''} 吗?`"
description="退回后该单据会进入待补充状态,申请人需要补充后重新提交。"
cancel-text="取消"
confirm-text="确认退回"
busy-text="退回中..."
confirm-tone="primary"
confirm-icon="mdi mdi-undo"
:busy="actionBusy"
@close="closeReturnDialog"
@confirm="confirmReturnSelected"
/>
<ConfirmDialog
:open="deleteDialogOpen"
badge="删除单据"
badge-tone="danger"
:title="`确认删除 ${selectedRow?.id || ''} 吗?`"
description="删除后该报销单及费用明细将不可恢复,请确认本次操作。"
cancel-text="取消"
confirm-text="确认删除"
busy-text="删除中..."
confirm-tone="danger"
confirm-icon="mdi mdi-trash-can-outline"
:busy="actionBusy"
@close="closeDeleteDialog"
@confirm="confirmDeleteSelected"
/>
</section>
</template>

View File

@@ -64,6 +64,40 @@
<div class="hero-stat">
<span>{{ selectedSkillIsRule ? '当前展示版本' : '当前版本' }}</span>
<strong>{{ selectedSkill.displayVersion || selectedSkill.version }}</strong>
</div>
<div class="hero-stat">
<span>最近更新</span>
<strong>{{ selectedSkill.updatedAt }}</strong>
</div>
</div>
</section>
<section v-if="detailError" class="detail-inline-state panel error">
<i class="mdi mdi-alert-circle-outline"></i>
<div>
<strong>资产详情加载失败</strong>
<p>{{ detailError }}</p>
</div>
</section>
<TableLoadingState
v-else-if="detailLoading && selectedSkill.loading"
class="detail-inline-state panel"
variant="detail"
title="正在加载资产详情"
message="列表数据已就绪,正在补充版本、审核和运行信息"
icon="mdi mdi-file-document-outline"
:show-skeleton="false"
/>
<section
v-else-if="selectedSkill.usesSpreadsheetRule"
class="spreadsheet-editor-shell panel"
>
<header class="spreadsheet-editor-head">
<div class="spreadsheet-editor-title">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
<div>
<h2>{{ selectedSkill.name }}</h2>
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
</div>
@@ -71,7 +105,7 @@
<div class="spreadsheet-editor-actions">
<span class="spreadsheet-mode-pill">
{{ selectedSpreadsheetModeLabel }}
{{ selectedSpreadsheetModeLabel }}
</span>
</div>
</header>
@@ -99,10 +133,15 @@
class="rule-spreadsheet-host"
:class="{ hidden: !spreadsheetOnlyOfficeReady && !spreadsheetOnlyOfficeError }"
></div>
<div v-if="spreadsheetOnlyOfficeLoading" class="rule-spreadsheet-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在加载 Excel 规则表...</span>
</div>
<TableLoadingState
v-if="spreadsheetOnlyOfficeLoading"
class="rule-spreadsheet-state"
variant="overlay"
tone="sky"
message="正在加载 Excel 规则表"
icon="mdi mdi-table-large"
:show-skeleton="false"
/>
<div v-else-if="spreadsheetOnlyOfficeError" class="rule-spreadsheet-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ spreadsheetOnlyOfficeError }}</span>
@@ -121,34 +160,34 @@
</footer>
</section>
<aside class="spreadsheet-change-center">
<header class="change-center-head">
<div>
<h3>最近修改</h3>
<p>展示最近 30 次保存后的具体改动</p>
</div>
</header>
<section class="change-center-section change-history-section">
<div v-if="selectedSpreadsheetChangeRecords.length" class="change-center-list">
<button
v-for="item in selectedSpreadsheetChangeRecords"
:key="`spreadsheet-change-${item.id || item.changed_at}-${item.actor}`"
type="button"
class="change-center-item change-record-item"
@click="openSpreadsheetChangeDetail(item)"
>
<aside class="spreadsheet-change-center">
<header class="change-center-head">
<div>
<h3>最近修改</h3>
<p>展示最近 30 次保存后的具体改动</p>
</div>
</header>
<section class="change-center-section change-history-section">
<div v-if="selectedSpreadsheetChangeRecords.length" class="change-center-list">
<button
v-for="item in selectedSpreadsheetChangeRecords"
:key="`spreadsheet-change-${item.id || item.changed_at}-${item.actor}`"
type="button"
class="change-center-item change-record-item"
@click="openSpreadsheetChangeDetail(item)"
>
<div class="change-record-head">
<div>
<strong>{{ item.actor }}</strong>
<span>{{ item.time }}</span>
</div>
<b>{{ item.changeCountLabel }}</b>
</div>
<p>{{ item.summary }}</p>
<small v-if="item.sheetPreview.length">
涉及工作表{{ item.sheetPreview.join('') }}
<template v-if="item.remainingSheetCount"> {{ item.changedSheetNames.length }} </template>
</div>
<p>{{ item.summary }}</p>
<small v-if="item.sheetPreview.length">
涉及工作表{{ item.sheetPreview.join('') }}
<template v-if="item.remainingSheetCount"> {{ item.changedSheetNames.length }} </template>
</small>
<div v-if="item.previewChanges.length" class="change-record-preview">
<span
@@ -164,9 +203,9 @@
</small>
</button>
</div>
<p v-else class="change-flow-empty">暂无修改记录</p>
</section>
</aside>
<p v-else class="change-flow-empty">暂无修改记录</p>
</section>
</aside>
</div>
</section>
@@ -319,10 +358,15 @@
</button>
</div>
<div v-if="detailLoading" class="subtle-banner">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在刷新规则详情...</span>
</div>
<TableLoadingState
v-if="detailLoading"
class="subtle-banner"
variant="banner"
tone="sky"
message="正在刷新规则详情"
icon="mdi mdi-refresh"
:show-skeleton="false"
/>
<label class="field">
<span>{{ selectedSkill.code }}</span>
@@ -799,11 +843,11 @@
</div>
</div>
<div
v-if="showStatusFilter"
class="picker-filter"
:class="{ open: activeFilterPopover === 'status' }"
>
<div
v-if="showStatusFilter"
class="picker-filter"
:class="{ open: activeFilterPopover === 'status' }"
>
<button
class="picker-trigger"
@@ -866,8 +910,12 @@
<div class="table-wrap" :class="{ 'is-empty': !loading && !errorMessage && !visibleSkills.length }">
<div v-if="loading" class="table-state">
<i class="mdi mdi-loading mdi-spin"></i>
<p>正在加载{{ activeTabLabel }}资产...</p>
<TableLoadingState
variant="panel"
:title="`${activeTabLabel}资产同步中`"
:message="`正在加载${activeTabLabel}资产`"
icon="mdi mdi-view-list-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
@@ -904,12 +952,11 @@
</tr>
</thead>
<tbody>
<tr
v-for="skill in visibleSkills"
:key="skill.id"
:class="{ spotlight: skill.spotlight }"
@click="openAssetDetail(skill)"
>
<tr
v-for="skill in visibleSkills"
:key="skill.id"
@click="openAssetDetail(skill)"
>
<td>
<div class="skill-name-cell">
<span class="skill-avatar" :class="skill.badgeTone">{{ skill.short }}</span>
@@ -1032,10 +1079,14 @@
</button>
</header>
<div v-if="versionTimelineLoading" class="rule-drawer-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在加载操作记录...</span>
</div>
<TableLoadingState
v-if="versionTimelineLoading"
class="rule-drawer-state"
variant="drawer"
message="正在加载操作记录"
icon="mdi mdi-history"
:show-skeleton="false"
/>
<div v-else-if="versionTimelineError" class="rule-drawer-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ versionTimelineError }}</span>
@@ -1053,9 +1104,9 @@
<span>{{ item.timeLabel }}</span>
</header>
<p>{{ item.description || item.note || '暂无补充说明' }}</p>
<small>
操作人{{ item.actor }}
</small>
<small>
操作人{{ item.actor }}
</small>
</div>
</article>
</div>
@@ -1094,8 +1145,8 @@
<span>修改时间</span>
<strong>{{ selectedSpreadsheetChangeRecord.time }}</strong>
</article>
<article>
<span>修改工作表</span>
<article>
<span>修改工作表</span>
<strong>{{ selectedSpreadsheetChangeRecord.changed_sheet_count }}</strong>
</article>
<article>

View File

@@ -61,11 +61,23 @@
</label>
<label class="field">
<span>年龄</span>
<input :value="detailAge" readonly />
<input
v-model="employeeForm.age"
type="number"
min="0"
max="120"
inputmode="numeric"
placeholder="请输入年龄"
@input="syncBirthDateFromAge"
/>
</label>
<label class="field">
<span>出生日期</span>
<input v-model="employeeForm.birthDate" type="date" />
<input
v-model="employeeForm.birthDate"
type="date"
@change="syncAgeFromBirthDate"
/>
</label>
<label class="field">
<span>手机号</span>
@@ -104,9 +116,50 @@
</div>
<div class="form-grid">
<label class="field">
<label
class="field manager-picker department-picker"
:class="{ open: departmentPickerOpen }"
>
<span>所属部门</span>
<input v-model="employeeForm.department" readonly />
<button
class="manager-picker-trigger"
type="button"
@click.stop="toggleDepartmentPicker"
>
<span class="manager-picker-label">{{ departmentDisplayLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="departmentPickerOpen"
class="manager-picker-panel"
@click.stop
>
<input
v-model="departmentSearchKeyword"
type="search"
placeholder="输入部门名称或编码搜索"
@keydown.enter.prevent="resolveDepartmentSelectionFromKeyword"
/>
<div class="manager-picker-options">
<button
v-for="option in filteredDepartmentOptions"
:key="option.id"
type="button"
class="manager-picker-option"
:class="{ active: employeeForm.organizationUnitCode === option.code }"
@click="selectDepartment(option)"
>
<strong>{{ option.name }}{{ option.code }}</strong>
<span v-if="option.unitType">{{ option.unitType }}</span>
</button>
<p
v-if="!filteredDepartmentOptions.length"
class="manager-picker-empty"
>
没有匹配的部门请调整搜索关键词
</p>
</div>
</div>
</label>
<label class="field">
<span>岗位</span>
@@ -116,9 +169,56 @@
<span>职级</span>
<input v-model="employeeForm.grade" />
</label>
<label class="field">
<label class="field manager-picker" :class="{ open: managerPickerOpen }">
<span>直属上级</span>
<input v-model="employeeForm.manager" readonly />
<button
class="manager-picker-trigger"
type="button"
@click.stop="toggleManagerPicker"
>
<span class="manager-picker-label">{{ managerDisplayLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="managerPickerOpen"
class="manager-picker-panel"
@click.stop
>
<input
v-model="managerSearchKeyword"
type="search"
placeholder="输入姓名、工号或部门搜索"
@keydown.enter.prevent="resolveManagerSelectionFromKeyword"
/>
<div class="manager-picker-options">
<button
type="button"
class="manager-picker-option"
:class="{ active: !hasManagerAssignment }"
@click="selectManager(null)"
>
<strong>无直属上级</strong>
<span>清空当前设置</span>
</button>
<button
v-for="option in filteredManagerOptions"
:key="option.id"
type="button"
class="manager-picker-option"
:class="{ active: employeeForm.managerEmployeeNo === option.employeeNo }"
@click="selectManager(option)"
>
<strong>{{ option.name }}{{ option.employeeNo }}</strong>
<span>{{ option.department }} / {{ option.position }}</span>
</button>
<p
v-if="!filteredManagerOptions.length"
class="manager-picker-empty"
>
没有匹配的员工请调整搜索关键词
</p>
</div>
</div>
</label>
<label class="field">
<span>财务归口</span>
@@ -177,15 +277,26 @@
<div class="card-head">
<div>
<h3>最近变更</h3>
<p>查看角色与档案调整记录</p>
<p>仅保留最近 5 角色与档案调整记录</p>
</div>
</div>
<div class="history-list">
<div v-for="item in selectedEmployee.history" :key="item.time" class="history-row">
<div
v-for="item in recentEmployeeHistory"
:key="`${item.occurredAt || item.time}-${item.action}`"
class="history-row"
>
<strong>{{ item.action }}</strong>
<span>{{ item.owner }}</span>
<small>{{ item.time }}</small>
<div class="history-row-meta">
<span class="history-row-owner">{{ item.owner }}</span>
<small class="history-row-time">{{
formatEmployeeHistoryTime(item.time || item.occurredAt)
}}</small>
</div>
</div>
<p v-if="!recentEmployeeHistory.length" class="manager-picker-empty">
暂无变更记录
</p>
</div>
</article>
@@ -392,16 +503,34 @@
<span>清空筛选</span>
</button>
<button class="create-btn" type="button">
<i class="mdi mdi-plus"></i>
<span>新增员工</span>
<button class="template-btn" type="button" :disabled="importExportBusy" @click="handleDownloadTemplate">
<i class="mdi mdi-file-download-outline"></i>
<span>下载模板</span>
</button>
<button class="export-btn" type="button" :disabled="importExportBusy" @click="handleExportEmployees">
<i class="mdi mdi-export"></i>
<span>{{ actionState === 'export' ? '导出中...' : '导出员工' }}</span>
</button>
<button class="create-btn" type="button" :disabled="importExportBusy" @click="openImportFilePicker">
<i class="mdi mdi-file-upload-outline"></i>
<span>{{ actionState === 'import' ? '导入中...' : '导入员工' }}</span>
</button>
<input
ref="importFileInput"
class="import-file-input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
@change="handleImportFileChange"
/>
</div>
</div>
<p class="hint">
<i class="mdi mdi-information-outline"></i>
点击任意员工行可进入基础信息与角色权限编辑界面
点击任意员工行可进入基础信息与角色权限编辑界面导入将按员工编号覆盖已有档案任一行校验失败则整批不写入
</p>
<div v-if="activeFilterTokens.length" class="active-filter-strip">
@@ -412,8 +541,11 @@
<div class="table-wrap">
<div v-if="loading" class="table-state">
<i class="mdi mdi-loading mdi-spin"></i>
<p>正在加载员工数据...</p>
<TableLoadingState
title="员工数据同步中"
message="正在加载员工档案与角色权限"
icon="mdi mdi-account-group-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
@@ -496,7 +628,7 @@
<td>
<span class="status-pill" :class="employee.statusTone">{{ employee.status }}</span>
</td>
<td>{{ employee.updatedAt }}</td>
<td class="cell-updated">{{ employee.updatedAt }}</td>
</tr>
</tbody>
</table>
@@ -572,9 +704,62 @@
@close="closeDisableDialog"
@confirm="confirmDisableEmployeeAccount"
/>
<ConfirmDialog
:open="importConfirmDialogOpen"
badge="导入确认"
badge-tone="warning"
title="确认导入员工 Excel"
description="系统将先校验全部数据,全部通过后才写入数据库。若存在错误,将不会修改任何现有员工信息。"
cancel-text="取消"
confirm-text="开始导入"
busy-text="导入中..."
confirm-tone="primary"
confirm-icon="mdi mdi-file-upload-outline"
:busy="actionState === 'import'"
@close="closeImportConfirmDialog"
@confirm="confirmImportEmployees"
/>
<ConfirmDialog
:open="importErrorDialogOpen"
badge="导入失败"
badge-tone="danger"
title="导入未执行"
:description="importResultMessage"
cancel-text="关闭"
confirm-text="下载模板"
confirm-tone="primary"
confirm-icon="mdi mdi-file-download-outline"
@close="closeImportErrorDialog"
@confirm="handleDownloadTemplate"
>
<div class="import-error-table-wrap">
<table class="import-error-table">
<thead>
<tr>
<th>行号</th>
<th>字段</th>
<th>工号</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in importErrors" :key="`${item.row}-${item.column}-${index}`">
<td>{{ item.row || '-' }}</td>
<td>{{ item.column }}</td>
<td>{{ item.employeeNo || '-' }}</td>
<td>{{ item.message }}</td>
</tr>
</tbody>
</table>
</div>
</ConfirmDialog>
</section>
</template>
<script src="./scripts/EmployeeManagementView.js"></script>
<style scoped src="../assets/styles/views/employee-management-view.css"></style>
<style scoped>
@import "../assets/styles/views/employee-management-view.css";
</style>

View File

@@ -128,8 +128,18 @@
</tbody>
</table>
<div v-if="!filteredHermesRuns.length" class="inline-empty">
{{ hermesLoading ? '正在加载 Hermes 运行日志...' : '当前筛选条件下没有 Hermes 记录' }}
<div
v-if="!filteredHermesRuns.length"
class="inline-empty"
:class="{ 'is-loading': hermesLoading }"
>
<TableLoadingState
v-if="hermesLoading"
title="Hermes 日志同步中"
message="正在加载运行日志记录"
icon="mdi mdi-text-box-search-outline"
/>
<span v-else>当前筛选条件下没有 Hermes 记录</span>
</div>
</div>
@@ -174,8 +184,18 @@
</tbody>
</table>
<div v-if="!filteredSystemLogEntries.length" class="inline-empty">
{{ systemLogLoading ? '正在加载系统日志...' : '当前筛选条件下没有系统日志记录' }}
<div
v-if="!filteredSystemLogEntries.length"
class="inline-empty"
:class="{ 'is-loading': systemLogLoading }"
>
<TableLoadingState
v-if="systemLogLoading"
title="系统日志同步中"
message="正在加载系统日志记录"
icon="mdi mdi-text-box-search-outline"
/>
<span v-else>当前筛选条件下没有系统日志记录</span>
</div>
</div>

View File

@@ -129,11 +129,20 @@
</div>
</td>
</tr>
<tr v-if="!visibleDocuments.length">
<td colspan="7" class="empty-row">
{{ loading ? '正在加载知识库文件...' : '当前文件夹暂无文件' }}
</td>
</tr>
<tr v-if="loading && !visibleDocuments.length">
<td colspan="7" class="empty-row table-loading-row">
<TableLoadingState
title="知识库文件同步中"
message="正在加载当前文件夹的知识库文件"
icon="mdi mdi-folder-table-outline"
/>
</td>
</tr>
<tr v-else-if="!visibleDocuments.length">
<td colspan="7" class="empty-row">
当前文件夹暂无文件
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -62,9 +62,11 @@
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="loading" class="table-state">
<i class="mdi mdi-loading mdi-spin"></i>
<strong>正在加载真实报销数据</strong>
<p>列表将直接展示后端返回的个人报销单据</p>
<TableLoadingState
title="真实报销数据同步中"
message="正在加载后端返回的个人报销单据"
icon="mdi mdi-file-document-outline"
/>
</div>
<div v-else-if="error" class="table-state error">

View File

@@ -511,18 +511,23 @@
<div v-if="composerDateMode === 'single'" class="composer-date-fields">
<label class="composer-date-field">
<span>日期</span>
<input v-model="composerSingleDate" type="date" />
<input v-model="composerSingleDate" type="date" @change="handleComposerDateInputChange" />
</label>
</div>
<div v-else class="composer-date-fields composer-date-fields-range">
<label class="composer-date-field">
<span>开始</span>
<input v-model="composerRangeStartDate" type="date" />
<input v-model="composerRangeStartDate" type="date" @change="handleComposerDateInputChange" />
</label>
<span class="composer-date-range-sep"></span>
<label class="composer-date-field">
<span>结束</span>
<input v-model="composerRangeEndDate" type="date" :min="composerRangeStartDate" />
<input
v-model="composerRangeEndDate"
type="date"
:min="composerRangeStartDate"
@change="handleComposerDateInputChange"
/>
</label>
</div>
<p v-if="composerDateMode === 'range' && !composerCanApplyDateSelection" class="composer-date-hint">
@@ -1078,9 +1083,28 @@
</span>
</div>
<p class="review-side-risk-summary">{{ reviewRiskSummary }}</p>
<ul v-if="reviewRiskItems.length" class="review-side-risk-list">
<li v-for="item in reviewRiskItems" :key="item">{{ item }}</li>
</ul>
<div v-if="reviewRiskItems.length" class="review-side-risk-list">
<button
v-for="item in reviewRiskItems"
:key="item.key"
type="button"
class="review-side-risk-item"
:class="item.level"
@click="openReviewRiskDetail(item)"
>
<span class="review-side-risk-icon">
<i :class="item.icon"></i>
</span>
<span class="review-side-risk-copy">
<strong>{{ item.title }}</strong>
<p>{{ item.summary }}</p>
</span>
<span class="review-side-risk-meta">
{{ item.levelLabel }}
<i class="mdi mdi-chevron-right"></i>
</span>
</button>
</div>
<div v-else-if="reviewRiskEmpty" class="review-side-empty">
<span class="review-side-empty-icon">
<i class="mdi mdi-shield-check-outline"></i>
@@ -1088,16 +1112,6 @@
<strong>暂无风险评分</strong>
<p>当前版本还没有返回结构化风险评分结果这里先不展示虚拟分数</p>
</div>
<button
v-if="reviewRiskActionAvailable"
type="button"
class="review-side-link"
:disabled="submitting || reviewActionBusy"
@click="explainCurrentReviewRisk"
>
查看全部风险项
<i class="mdi mdi-chevron-right"></i>
</button>
</section>
</template>
@@ -1192,6 +1206,41 @@
@confirm="confirmCancelReview"
/>
<Transition name="assistant-modal">
<div v-if="reviewRiskDetailDialog.open" class="assistant-overlay review-overlay">
<section class="review-risk-detail-modal">
<header class="review-risk-detail-head">
<div>
<span class="assistant-badge warning">{{ reviewRiskDetailDialog.item?.sourceLabel || 'AI预审' }}</span>
<h3>{{ reviewRiskDetailDialog.item?.title || '风险提示' }}</h3>
</div>
<button class="close-btn" type="button" aria-label="关闭风险说明" @click="closeReviewRiskDetail">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="review-risk-detail-body">
<div class="review-risk-detail-level" :class="reviewRiskDetailDialog.item?.level">
<i :class="reviewRiskDetailDialog.item?.icon || 'mdi mdi-information-outline'"></i>
<span>{{ reviewRiskDetailDialog.item?.levelLabel || '提示' }}</span>
</div>
<article class="review-risk-detail-section">
<strong>提示情况</strong>
<p>{{ reviewRiskDetailDialog.item?.summary }}</p>
</article>
<article class="review-risk-detail-section">
<strong>详细解释</strong>
<p>{{ reviewRiskDetailDialog.item?.detail }}</p>
</article>
<article class="review-risk-detail-section">
<strong>处理建议</strong>
<p>{{ reviewRiskDetailDialog.item?.suggestion }}</p>
</article>
</div>
</section>
</div>
</Transition>
<Transition name="assistant-modal">
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
<section class="review-confirm-modal review-upload-decision-modal">

View File

@@ -394,7 +394,7 @@
<span>返回报销列表</span>
</button>
<div v-if="isDraftRequest" class="approval-action-group" aria-label="申请操作">
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteDraft">
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteRequest">
<i class="mdi mdi-trash-can-outline"></i>
{{ deleteBusy ? '删除中' : '删除草稿' }}
</button>
@@ -403,6 +403,22 @@
{{ submitBusy ? '提交中' : '提交审批' }}
</button>
</div>
<div v-else-if="canManageCurrentClaim" class="approval-action-group" aria-label="单据管理操作">
<button
v-if="canReturnRequest"
class="return-action"
type="button"
:disabled="actionBusy"
@click="handleReturnRequest"
>
<i class="mdi mdi-undo"></i>
{{ returnBusy ? '退回中' : '退回单据' }}
</button>
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteRequest">
<i class="mdi mdi-trash-can-outline"></i>
{{ deleteBusy ? '删除中' : '删除单据' }}
</button>
</div>
<p v-else class="detail-action-hint">当前单据已进入流程详情页仅展示状态与费用明细</p>
</footer>
</div>
@@ -465,10 +481,10 @@
<ConfirmDialog
:open="deleteDialogOpen"
badge="删除草稿"
:badge="deleteActionLabel"
badge-tone="danger"
:title="`确认删除草稿 ${request.id} 吗?`"
description="删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。"
:title="deleteDialogTitle"
:description="deleteDialogDescription"
cancel-text="取消"
confirm-text="确认删除"
busy-text="删除中..."
@@ -476,7 +492,23 @@
confirm-icon="mdi mdi-trash-can-outline"
:busy="deleteBusy"
@close="closeDeleteDialog"
@confirm="confirmDeleteDraft"
@confirm="confirmDeleteRequest"
/>
<ConfirmDialog
:open="returnDialogOpen"
badge="退回单据"
badge-tone="warning"
:title="`确认退回 ${request.id} 吗?`"
description="退回后该单据会进入待补充状态,申请人需要补充后重新提交。"
cancel-text="取消"
confirm-text="确认退回"
busy-text="退回中..."
confirm-tone="primary"
confirm-icon="mdi mdi-undo"
:busy="returnBusy"
@close="closeReturnDialog"
@confirm="confirmReturnRequest"
/>
</section>
</template>

View File

@@ -1,9 +1,13 @@
import { computed, ref } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
import { useSystemState } from '../../composables/useSystemState.js'
import { fetchExpenseClaims } from '../../services/reimbursements.js'
import { useToast } from '../../composables/useToast.js'
import { deleteExpenseClaim, fetchExpenseClaims, returnExpenseClaim } from '../../services/reimbursements.js'
import { canManageExpenseClaims } from '../../utils/accessControl.js'
const DEFAULT_SLA_HOURS = 24
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
@@ -195,7 +199,6 @@ function buildFlowItems(request) {
function canCurrentUserProcessRequest(request, currentUser) {
const node = String(request?.workflowNode || '').trim()
const roleCodes = Array.isArray(currentUser?.roleCodes) ? currentUser.roleCodes.filter(Boolean) : []
const currentName = String(currentUser?.name || '').trim()
const applicantName = String(request?.person || request?.employeeName || '').trim()
@@ -203,8 +206,8 @@ function canCurrentUserProcessRequest(request, currentUser) {
return false
}
if (currentUser?.isAdmin || roleCodes.includes('finance')) {
return node.includes('财务')
if (canManageExpenseClaims(currentUser)) {
return true
}
return (
@@ -251,10 +254,13 @@ function buildApprovalRow(request) {
export default {
name: 'ApprovalCenterView',
components: {
ConfirmDialog,
TableLoadingState,
TableEmptyState
},
setup() {
const { currentUser } = useSystemState()
const { toast } = useToast()
const activeTab = ref('全部待审')
const selectedClaimId = ref('')
const expandedExpenseId = ref(null)
@@ -262,6 +268,9 @@ export default {
const rows = ref([])
const loading = ref(false)
const error = ref('')
const actionBusy = ref(false)
const returnDialogOpen = ref(false)
const deleteDialogOpen = ref(false)
const selectedRow = computed({
get() {
@@ -303,6 +312,7 @@ export default {
})
const showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0)
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
const canManageClaims = computed(() => canManageExpenseClaims(currentUser.value))
const approvalEmptyState = computed(() => {
if (!rows.value.length) {
return {
@@ -381,6 +391,76 @@ export default {
activeTab.value = '全部待审'
}
function handleReturnSelected() {
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
return
}
returnDialogOpen.value = true
}
function handleDeleteSelected() {
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
return
}
deleteDialogOpen.value = true
}
function closeReturnDialog() {
if (!actionBusy.value) {
returnDialogOpen.value = false
}
}
function closeDeleteDialog() {
if (!actionBusy.value) {
deleteDialogOpen.value = false
}
}
async function confirmReturnSelected() {
const row = selectedRow.value
if (!row?.claimId || actionBusy.value) {
return
}
actionBusy.value = true
try {
await returnExpenseClaim(row.claimId, {
reason: '审批中心退回,请申请人补充后重新提交。'
})
toast(`${row.id} 已退回待补充。`)
returnDialogOpen.value = false
selectedClaimId.value = ''
await reload()
} catch (nextError) {
toast(nextError?.message || '退回单据失败,请稍后重试。')
} finally {
actionBusy.value = false
}
}
async function confirmDeleteSelected() {
const row = selectedRow.value
if (!row?.claimId || actionBusy.value) {
return
}
actionBusy.value = true
try {
const payload = await deleteExpenseClaim(row.claimId)
toast(payload?.message || `${row.id} 报销单已删除。`)
deleteDialogOpen.value = false
selectedClaimId.value = ''
await reload()
} catch (nextError) {
toast(nextError?.message || '删除单据失败,请稍后重试。')
} finally {
actionBusy.value = false
}
}
async function reload() {
loading.value = true
error.value = ''
@@ -420,8 +500,15 @@ export default {
visibleRows,
showTable,
showEmpty,
actionBusy,
approvalEmptyState,
approvalSteps,
canManageClaims,
closeDeleteDialog,
closeReturnDialog,
confirmDeleteSelected,
confirmReturnSelected,
deleteDialogOpen,
summaryItems,
heroSummaryItems,
currentProgressRingMotion,
@@ -434,8 +521,11 @@ export default {
riskItems,
flowItems,
handleEmptyAction,
handleDeleteSelected,
handleReturnSelected,
loading,
error,
returnDialogOpen,
reload
}
}

View File

@@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import { fetchEmployees } from '../../services/employees.js'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
@@ -1130,7 +1131,6 @@ function buildListItem(asset) {
changeCount,
updatedAt: formatDateTime(asset.updated_at),
badgeTone: tabMeta.badgeTone,
spotlight: asset.status === 'active',
domainValue: asset.domain
}
}
@@ -1653,6 +1653,7 @@ export default {
name: 'AuditView',
components: {
ConfirmDialog,
TableLoadingState,
TableEmptyState
},
emits: ['detail-open-change'],

View File

@@ -1,13 +1,18 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { useToast } from '../../composables/useToast.js'
import {
disableEmployee,
downloadEmployeeImportTemplate,
enableEmployee,
exportEmployees,
fetchEmployeeDetail,
fetchEmployeeMeta,
fetchEmployees,
importEmployees,
updateEmployee
} from '../../services/employees.js'
@@ -56,6 +61,7 @@ function createEmployeeForm() {
name: '',
employeeNo: '',
gender: '',
age: '',
birthDate: '',
phone: '',
email: '',
@@ -64,7 +70,9 @@ function createEmployeeForm() {
position: '',
grade: '',
department: '',
organizationUnitCode: '',
manager: '',
managerEmployeeNo: '',
financeOwner: '',
costCenter: '',
roleCodes: [],
@@ -72,24 +80,120 @@ function createEmployeeForm() {
}
}
function buildEmployeeForm(employee) {
function isPlaceholderManagerName(name) {
const normalized = normalizeText(name)
return !normalized || normalized === 'CEO' || normalized === '无'
}
function resolveManagerEmployeeNo(employee, roster = []) {
const fromApi = normalizeText(employee?.managerEmployeeNo)
if (fromApi) {
return fromApi
}
const managerName = normalizeText(employee?.manager)
if (isPlaceholderManagerName(managerName)) {
return ''
}
const matches = roster.filter((item) => normalizeText(item.name) === managerName)
if (matches.length === 1) {
return matches[0].employeeNo
}
return ''
}
function enrichEmployeeRecord(employee, roster = []) {
if (!employee) {
return employee
}
const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster)
if (!managerEmployeeNo || managerEmployeeNo === employee.managerEmployeeNo) {
return employee
}
return {
...employee,
managerEmployeeNo
}
}
function mergeEmployeeRecords(listItem, detailItem, roster = []) {
if (!listItem && !detailItem) {
return null
}
if (!listItem) {
return enrichEmployeeRecord(detailItem, roster)
}
if (!detailItem) {
return enrichEmployeeRecord(listItem, roster)
}
const managerEmployeeNo =
normalizeText(detailItem.managerEmployeeNo) ||
normalizeText(listItem.managerEmployeeNo) ||
resolveManagerEmployeeNo(detailItem, roster) ||
resolveManagerEmployeeNo(listItem, roster)
const history =
Array.isArray(detailItem.history) && detailItem.history.length
? detailItem.history
: listItem.history || []
const permissions =
Array.isArray(detailItem.permissions) && detailItem.permissions.length
? detailItem.permissions
: listItem.permissions || []
return enrichEmployeeRecord(
{
...listItem,
...detailItem,
manager: detailItem.manager || listItem.manager,
managerEmployeeNo: managerEmployeeNo || null,
history,
permissions,
roleCodes: detailItem.roleCodes?.length ? detailItem.roleCodes : listItem.roleCodes,
roles: detailItem.roles?.length ? detailItem.roles : listItem.roles,
organization: detailItem.organization || listItem.organization,
department: detailItem.department || listItem.department
},
roster
)
}
function buildEmployeeForm(employee, roster = []) {
if (!employee) {
return createEmployeeForm()
}
const birthDate = employee.birthDate || ''
const managerName = employee.manager || ''
const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster)
return {
name: employee.name || '',
employeeNo: employee.employeeNo || '',
gender: employee.gender || '',
birthDate: employee.birthDate || '',
age:
employee.age !== null && employee.age !== undefined && employee.age !== ''
? String(employee.age)
: calculateAgeFromDate(birthDate),
birthDate,
phone: employee.phone || '',
email: employee.email || '',
joinDate: employee.joinDate || '',
location: employee.location || '',
position: employee.position || '',
grade: employee.grade || '',
department: employee.department || '',
manager: employee.manager || '',
department: resolveOrganizationUnitName(employee),
organizationUnitCode: resolveOrganizationUnitCode(employee),
manager: managerName,
managerEmployeeNo,
financeOwner: employee.financeOwner || '',
costCenter: employee.costCenter || '',
roleCodes: [...(employee.roleCodes || [])],
@@ -154,6 +258,60 @@ function sameValues(left, right) {
return left.every((value, index) => value === right[index])
}
function formatEmployeeHistoryTime(value) {
const raw = normalizeText(value)
if (!raw) {
return ''
}
const matched = raw.match(
/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/
)
if (!matched) {
return raw
}
const year = Number.parseInt(matched[1], 10)
const month = Number.parseInt(matched[2], 10)
const day = Number.parseInt(matched[3], 10)
const hour = Number.parseInt(matched[4] || '0', 10)
const minute = Number.parseInt(matched[5] || '0', 10)
const second = Number.parseInt(matched[6] || '0', 10)
return `${year}${month}${day}${hour}${minute}${second}`
}
function resolveOrganizationUnitCode(employee) {
return normalizeText(employee?.organization?.code)
}
function resolveOrganizationUnitName(employee) {
return normalizeText(employee?.department) || normalizeText(employee?.organization?.name)
}
function captureEmployeeDetailSnapshot(form) {
return {
roleCodes: [...(form.roleCodes || [])].sort(),
organizationUnitCode: normalizeText(form.organizationUnitCode) || ''
}
}
function resolveOrganizationOptions(metaOrganizations) {
if (!Array.isArray(metaOrganizations) || !metaOrganizations.length) {
return []
}
return metaOrganizations
.map((item) => ({
id: item.id,
code: item.code,
name: item.name,
unitType: item.unitType,
label: `${item.name}${item.code}`
}))
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
}
function calculateAgeFromDate(dateString) {
if (!dateString) {
return ''
@@ -177,6 +335,33 @@ function calculateAgeFromDate(dateString) {
return age >= 0 ? String(age) : ''
}
function calculateBirthDateFromAge(ageValue, existingBirthDate = '') {
const age = Number.parseInt(String(ageValue ?? '').trim(), 10)
if (Number.isNaN(age) || age < 0 || age > 120) {
return existingBirthDate || ''
}
const today = new Date()
let month = '01'
let day = '01'
if (existingBirthDate && isValidIsoDate(existingBirthDate)) {
const [, monthText, dayText] = existingBirthDate.split('-')
month = monthText
day = dayText
}
let birthYear = today.getFullYear() - age
let candidate = `${birthYear}-${month}-${day}`
if (Number(calculateAgeFromDate(candidate)) > age) {
birthYear -= 1
candidate = `${birthYear}-${month}-${day}`
}
return candidate
}
function matchKeyword(employee, keyword) {
if (!keyword) {
return true
@@ -249,6 +434,7 @@ export default {
name: 'EmployeeManagementView',
components: {
ConfirmDialog,
TableLoadingState,
TableEmptyState
},
emits: ['overview-change'],
@@ -272,17 +458,37 @@ export default {
const loading = ref(false)
const errorMessage = ref('')
const disableDialogOpen = ref(false)
const importFileInput = ref(null)
const pendingImportFile = ref(null)
const importConfirmDialogOpen = ref(false)
const importErrorDialogOpen = ref(false)
const importErrors = ref([])
const importResultMessage = ref('')
const managerPickerOpen = ref(false)
const managerSearchKeyword = ref('')
const departmentPickerOpen = ref(false)
const departmentSearchKeyword = ref('')
const organizationUnitOptions = ref([])
const employeeDetailSnapshot = ref(null)
const tabs = computed(() => buildStatusTabs(employees.value))
const employeeSummary = computed(() => buildEmployeeSummary(employees.value))
const detailAge = computed(() => calculateAgeFromDate(employeeForm.value.birthDate))
const roleCount = computed(() => employeeForm.value.roleCodes.length)
const selectedRoleLabels = computed(() =>
roleOptions.value
.filter((role) => employeeForm.value.roleCodes.includes(role.code))
.map((role) => role.label)
)
const actionBusy = computed(() => actionState.value === 'save' || actionState.value === 'disable')
const actionBusy = computed(
() =>
actionState.value === 'save' ||
actionState.value === 'disable' ||
actionState.value === 'import' ||
actionState.value === 'export'
)
const importExportBusy = computed(
() => actionState.value === 'import' || actionState.value === 'export'
)
const disableActionDisabled = computed(() => actionBusy.value || !selectedEmployee.value)
const selectedEmployeeDisabled = computed(() => selectedEmployee.value?.status === '停用')
const statusActionCopy = computed(() => {
@@ -333,6 +539,94 @@ export default {
)
)
const managerOptions = computed(() => {
const currentId = selectedEmployee.value?.id
return employees.value.filter((item) => item.id !== currentId)
})
const filteredManagerOptions = computed(() => {
const keyword = managerSearchKeyword.value.trim().toLowerCase()
if (!keyword) {
return managerOptions.value.slice(0, 20)
}
return managerOptions.value
.filter((item) => {
const haystack = [
item.name,
item.employeeNo,
item.department,
item.position,
item.email
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(keyword)
})
.slice(0, 20)
})
const managerDisplayLabel = computed(() => {
const managerNo = normalizeText(employeeForm.value.managerEmployeeNo)
const managerName = normalizeText(employeeForm.value.manager)
if (managerNo) {
const matched =
managerOptions.value.find((item) => item.employeeNo === managerNo) ||
employees.value.find((item) => item.employeeNo === managerNo)
if (matched) {
return `${matched.name}${matched.employeeNo}`
}
return managerName ? `${managerName}${managerNo}` : managerNo
}
if (!isPlaceholderManagerName(managerName)) {
return managerName
}
return '未设置直属上级'
})
const filteredDepartmentOptions = computed(() => {
const keyword = departmentSearchKeyword.value.trim().toLowerCase()
const options = organizationUnitOptions.value
if (!keyword) {
return options.slice(0, 20)
}
return options
.filter((item) => {
const haystack = [item.name, item.code, item.unitType, item.label]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(keyword)
})
.slice(0, 20)
})
const departmentDisplayLabel = computed(() => {
const code = normalizeText(employeeForm.value.organizationUnitCode)
const name = normalizeText(employeeForm.value.department)
if (code) {
const matched = organizationUnitOptions.value.find((item) => item.code === code)
if (matched) {
return matched.label
}
return name ? `${name}${code}` : code
}
return name || '请选择所属部门'
})
const filteredEmployees = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
@@ -431,14 +725,66 @@ export default {
{ immediate: true }
)
function syncFormFromEmployee(employee) {
if (!employee) {
employeeForm.value = createEmployeeForm()
employeeDetailSnapshot.value = null
return
}
const preservedPassword = employeeForm.value.password
employeeForm.value = buildEmployeeForm(employee, employees.value)
employeeForm.value.password = preservedPassword
employeeDetailSnapshot.value = captureEmployeeDetailSnapshot(employeeForm.value)
}
watch(
selectedEmployee,
(employee) => {
employeeForm.value = buildEmployeeForm(employee)
() => selectedEmployee.value?.id ?? null,
(employeeId, previousId) => {
if (!employeeId) {
syncFormFromEmployee(null)
return
}
if (employeeId === previousId) {
return
}
syncFormFromEmployee(selectedEmployee.value)
},
{ immediate: true }
)
watch(employees, () => {
if (!selectedEmployee.value?.id) {
return
}
const preserved = selectedEmployee.value
const fromList = employees.value.find((item) => item.id === preserved.id)
if (!fromList) {
return
}
selectedEmployee.value = mergeEmployeeRecords(fromList, preserved, employees.value)
})
const hasManagerAssignment = computed(() => {
return (
Boolean(normalizeText(employeeForm.value.managerEmployeeNo)) ||
!isPlaceholderManagerName(employeeForm.value.manager)
)
})
const recentEmployeeHistory = computed(() => {
const history = selectedEmployee.value?.history
if (!Array.isArray(history)) {
return []
}
return history.slice(0, 5)
})
watch(filteredEmployees, () => {
currentPage.value = 1
pageSizeOpen.value = false
@@ -510,15 +856,105 @@ export default {
closeFilterPopover()
}
if (!target.closest('.manager-picker')) {
closeManagerPicker()
}
if (!target.closest('.department-picker')) {
closeDepartmentPicker()
}
if (!target.closest('.page-size-wrap')) {
pageSizeOpen.value = false
}
if (target.closest('.picker-filter') || target.closest('.page-size-wrap')) {
if (
target.closest('.picker-filter') ||
target.closest('.page-size-wrap') ||
target.closest('.manager-picker') ||
target.closest('.department-picker')
) {
return
}
}
function toggleDepartmentPicker() {
departmentPickerOpen.value = !departmentPickerOpen.value
if (!departmentPickerOpen.value) {
departmentSearchKeyword.value = ''
}
}
function closeDepartmentPicker() {
departmentPickerOpen.value = false
departmentSearchKeyword.value = ''
}
function selectDepartment(option) {
if (!option) {
return
}
employeeForm.value.organizationUnitCode = option.code
employeeForm.value.department = option.name
closeDepartmentPicker()
}
function resolveDepartmentSelectionFromKeyword() {
const keyword = normalizeText(departmentSearchKeyword.value)
if (!keyword || normalizeText(employeeForm.value.organizationUnitCode)) {
return
}
const exactMatches = organizationUnitOptions.value.filter(
(item) => item.code === keyword || item.name === keyword
)
if (exactMatches.length === 1) {
selectDepartment(exactMatches[0])
}
}
function toggleManagerPicker() {
managerPickerOpen.value = !managerPickerOpen.value
if (!managerPickerOpen.value) {
managerSearchKeyword.value = ''
}
}
function closeManagerPicker() {
managerPickerOpen.value = false
managerSearchKeyword.value = ''
}
function selectManager(option) {
if (!option) {
employeeForm.value.managerEmployeeNo = ''
employeeForm.value.manager = ''
closeManagerPicker()
return
}
employeeForm.value.managerEmployeeNo = option.employeeNo
employeeForm.value.manager = option.name
closeManagerPicker()
}
function resolveManagerSelectionFromKeyword() {
const keyword = normalizeText(managerSearchKeyword.value)
if (!keyword || normalizeText(employeeForm.value.managerEmployeeNo)) {
return
}
const exactMatches = managerOptions.value.filter(
(item) => item.employeeNo === keyword || item.name === keyword
)
if (exactMatches.length === 1) {
selectManager(exactMatches[0])
}
}
function openEmployeeDetail(employee) {
selectedEmployee.value = employee
}
@@ -527,6 +963,24 @@ export default {
selectedEmployee.value = null
employeeForm.value = createEmployeeForm()
actionState.value = ''
closeManagerPicker()
closeDepartmentPicker()
}
function syncAgeFromBirthDate() {
employeeForm.value.age = calculateAgeFromDate(employeeForm.value.birthDate)
}
function syncBirthDateFromAge() {
const ageText = normalizeText(employeeForm.value.age)
if (!ageText) {
return
}
employeeForm.value.birthDate = calculateBirthDateFromAge(
ageText,
employeeForm.value.birthDate
)
}
function buildUpdatePayload() {
@@ -583,6 +1037,15 @@ export default {
payload.grade = nextGrade
}
const nextOrganizationCode = normalizeText(form.organizationUnitCode)
const currentOrganizationCode =
normalizeText(employeeDetailSnapshot.value?.organizationUnitCode) ||
resolveOrganizationUnitCode(current) ||
''
if (nextOrganizationCode !== currentOrganizationCode) {
payload.organization_unit_code = nextOrganizationCode
}
const nextFinanceOwner = normalizeNullableText(form.financeOwner)
if (nextFinanceOwner !== (current.financeOwner || null)) {
payload.finance_owner_name = nextFinanceOwner
@@ -593,10 +1056,19 @@ export default {
payload.cost_center = nextCostCenter
}
const nextManagerEmployeeNo = normalizeNullableText(form.managerEmployeeNo)
const currentManagerEmployeeNo =
normalizeNullableText(current.managerEmployeeNo) ||
resolveManagerEmployeeNo(current, employees.value) ||
null
if (nextManagerEmployeeNo !== currentManagerEmployeeNo) {
payload.manager_employee_no = nextManagerEmployeeNo || ''
}
const nextRoleCodes = [...form.roleCodes].sort()
const currentRoleCodes = [...(current.roleCodes || [])].sort()
const currentRoleCodes = [...(employeeDetailSnapshot.value?.roleCodes || current.roleCodes || [])].sort()
if (!sameValues(nextRoleCodes, currentRoleCodes)) {
payload.role_codes = form.roleCodes
payload.role_codes = [...form.roleCodes]
}
const nextPassword = normalizeText(form.password)
@@ -637,6 +1109,16 @@ export default {
return
}
const ageText = normalizeText(employeeForm.value.age)
if (ageText) {
const age = Number.parseInt(ageText, 10)
if (Number.isNaN(age) || age < 0 || age > 120) {
toast('年龄请输入 0 到 120 之间的整数。')
return
}
syncBirthDateFromAge()
}
const birthDate = normalizeNullableText(employeeForm.value.birthDate)
if (birthDate && !isValidIsoDate(birthDate)) {
toast('出生日期格式不正确,请使用 YYYY-MM-DD。')
@@ -654,18 +1136,56 @@ export default {
return
}
resolveManagerSelectionFromKeyword()
resolveDepartmentSelectionFromKeyword()
if (!normalizeText(employeeForm.value.organizationUnitCode)) {
toast('请选择所属部门。')
return
}
const payload = buildUpdatePayload()
if (!Object.keys(payload).length) {
toast('未检测到需要保存的变更。')
return
}
if (normalizeText(employeeForm.value.managerEmployeeNo) === selectedEmployee.value.employeeNo) {
toast('直属上级不能设置为员工本人。')
return
}
actionState.value = 'save'
try {
const updated = await updateEmployee(selectedEmployee.value.id, payload)
const employeeId = selectedEmployee.value.id
const updated = await updateEmployee(employeeId, payload)
selectedEmployee.value = updated
await loadEmployees()
let refreshed = updated
try {
refreshed = await fetchEmployeeDetail(employeeId)
} catch {
refreshed = updated
}
const fromList = employees.value.find((item) => item.id === employeeId)
const merged = mergeEmployeeRecords(fromList, refreshed, employees.value)
selectedEmployee.value = merged
const listIndex = employees.value.findIndex((item) => item.id === employeeId)
if (listIndex >= 0) {
employees.value[listIndex] = {
...employees.value[listIndex],
...merged
}
}
closeManagerPicker()
closeDepartmentPicker()
syncFormFromEmployee(selectedEmployee.value)
employeeDetailSnapshot.value = captureEmployeeDetailSnapshot(employeeForm.value)
toast('员工信息已保存并生效。')
} catch (error) {
toast(error?.message || '员工信息保存失败,请稍后重试。')
@@ -723,6 +1243,95 @@ export default {
}
}
function openImportFilePicker() {
importFileInput.value?.click()
}
function handleImportFileChange(event) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) {
return
}
pendingImportFile.value = file
importConfirmDialogOpen.value = true
}
function closeImportConfirmDialog() {
if (actionState.value === 'import') {
return
}
importConfirmDialogOpen.value = false
pendingImportFile.value = null
}
function closeImportErrorDialog() {
importErrorDialogOpen.value = false
importErrors.value = []
importResultMessage.value = ''
}
async function handleDownloadTemplate() {
try {
await downloadEmployeeImportTemplate()
toast('员工导入模板已开始下载。')
} catch (error) {
toast(error?.message || '模板下载失败,请稍后重试。')
}
}
async function handleExportEmployees() {
actionState.value = 'export'
try {
await exportEmployees({
status: activeTab.value,
keyword: searchKeyword.value.trim()
})
toast('员工目录已开始导出。')
} catch (error) {
toast(error?.message || '员工导出失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function confirmImportEmployees() {
const file = pendingImportFile.value
if (!file) {
closeImportConfirmDialog()
return
}
actionState.value = 'import'
try {
const result = await importEmployees(file)
if (!result?.success) {
importErrors.value = Array.isArray(result?.errors) ? result.errors : []
importResultMessage.value =
result?.message || '导入未执行,请根据下方错误提示修正 Excel 后重试。'
importConfirmDialogOpen.value = false
importErrorDialogOpen.value = true
pendingImportFile.value = null
return
}
importConfirmDialogOpen.value = false
pendingImportFile.value = null
await loadEmployees()
toast(result.message || '员工导入成功。')
} catch (error) {
toast(error?.message || '员工导入失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function loadEmployees() {
loading.value = true
errorMessage.value = ''
@@ -735,6 +1344,7 @@ export default {
if (employeesResult.status !== 'fulfilled') {
employees.value = []
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
organizationUnitOptions.value = []
selectedEmployee.value = null
errorMessage.value =
employeesResult.reason?.message || '员工数据加载失败,请稍后重试。'
@@ -742,12 +1352,17 @@ export default {
return
}
employees.value = Array.isArray(employeesResult.value) ? employeesResult.value : []
const roster = Array.isArray(employeesResult.value) ? employeesResult.value : []
employees.value = roster.map((item) => enrichEmployeeRecord(item, roster))
if (metaResult.status === 'fulfilled') {
roleOptions.value = resolveRoleOptions(metaResult.value?.roleOptions, employees.value)
organizationUnitOptions.value = resolveOrganizationOptions(
metaResult.value?.organizationOptions
)
} else {
roleOptions.value = resolveRoleOptions([], employees.value)
organizationUnitOptions.value = []
}
if (!DEFAULT_STATUS_TABS.includes(activeTab.value)) {
@@ -755,8 +1370,9 @@ export default {
}
if (selectedEmployee.value) {
selectedEmployee.value =
employees.value.find((item) => item.id === selectedEmployee.value.id) || null
const preserved = selectedEmployee.value
const fromList = employees.value.find((item) => item.id === preserved.id) || null
selectedEmployee.value = mergeEmployeeRecords(fromList, preserved, employees.value)
}
loading.value = false
@@ -767,6 +1383,7 @@ export default {
loadEmployees().catch((error) => {
employees.value = []
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
organizationUnitOptions.value = []
selectedEmployee.value = null
errorMessage.value = error?.message || '员工数据加载失败,请稍后重试。'
loading.value = false
@@ -781,13 +1398,27 @@ export default {
tabs,
activeTab,
employeeForm,
detailAge,
roleCount,
syncAgeFromBirthDate,
syncBirthDateFromAge,
selectedRoleLabels,
selectedEmployeeDisabled,
statusActionCopy,
actionState,
actionBusy,
importExportBusy,
importFileInput,
importConfirmDialogOpen,
importErrorDialogOpen,
importErrors,
importResultMessage,
openImportFilePicker,
handleImportFileChange,
closeImportConfirmDialog,
closeImportErrorDialog,
handleDownloadTemplate,
handleExportEmployees,
confirmImportEmployees,
disableActionDisabled,
selectedEmployee,
roleOptions,
@@ -806,6 +1437,25 @@ export default {
departmentOptions,
gradeOptions,
roleFilterOptions,
managerPickerOpen,
managerSearchKeyword,
managerDisplayLabel,
hasManagerAssignment,
departmentPickerOpen,
departmentSearchKeyword,
departmentDisplayLabel,
filteredDepartmentOptions,
toggleDepartmentPicker,
closeDepartmentPicker,
selectDepartment,
resolveDepartmentSelectionFromKeyword,
recentEmployeeHistory,
formatEmployeeHistoryTime,
filteredManagerOptions,
toggleManagerPicker,
closeManagerPicker,
selectManager,
resolveManagerSelectionFromKeyword,
activeFilterTokens,
hasActiveFilters,
hasEmployeeFilters,

View File

@@ -3,6 +3,7 @@ import { useRouter } from 'vue-router'
import LogTrendChart from '../../components/charts/LogTrendChart.vue'
import DonutChart from '../../components/charts/DonutChart.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { fetchAgentRuns } from '../../services/agentAssets.js'
import { fetchSystemLogEntries } from '../../services/systemLogs.js'
import { useSystemState } from '../../composables/useSystemState.js'
@@ -217,10 +218,11 @@ function buildTrendSeries(runs) {
export default {
name: 'LogsView',
components: {
LogTrendChart,
DonutChart
},
components: {
LogTrendChart,
DonutChart,
TableLoadingState
},
emits: ['summary-change'],
setup(_, { emit }) {
const router = useRouter()

View File

@@ -1,6 +1,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import {
@@ -79,10 +80,11 @@ function setBodyScrollLocked(isLocked) {
}
export default {
name: 'PoliciesView',
components: {
ConfirmDialog
},
name: 'PoliciesView',
components: {
ConfirmDialog,
TableLoadingState
},
emits: ['summary-change'],
setup(_, { emit }) {
const { currentUser } = useSystemState()

View File

@@ -1,5 +1,6 @@
import { computed, ref, watch } from 'vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
@@ -11,6 +12,7 @@ function extractRowDate(value) {
export default {
name: 'RequestsView',
components: {
TableLoadingState,
TableEmptyState
},
props: {

View File

@@ -43,6 +43,24 @@ const INTENT_LABELS = {
operate: '动作请求'
}
const REVIEW_RISK_LEVEL_META = {
high: {
label: '高风险',
icon: 'mdi mdi-alert-octagon-outline',
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
},
warning: {
label: '需关注',
icon: 'mdi mdi-alert-circle-outline',
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
},
info: {
label: '提示',
icon: 'mdi mdi-information-outline',
suggestion: '该项主要用于辅助判断,可结合当前单据情况继续核对。'
}
}
const DOCUMENT_TYPE_LABELS = {
travel_ticket: '行程单/机票/车票',
flight_itinerary: '机票/航班行程单',
@@ -1503,7 +1521,7 @@ function buildDraftSavedPayload({
secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态',
secondaryStatusValue: documents.length ? '待继续完善' : '待上传票据',
secondaryStatusTone: documents.length ? 'warning' : 'neutral',
riskSummary: riskItems[0] || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'),
riskSummary: riskItems[0]?.summary || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'),
attachmentSummary,
expenseTableSummary: documents.length
? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认`
@@ -2451,16 +2469,43 @@ function buildReviewRiskSummary(reviewPayload) {
return '当前版本暂未生成风险评分结果。'
}
function normalizeReviewRiskLevel(level) {
const normalized = String(level || '').trim().toLowerCase()
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
if (normalized === 'warn' || normalized === 'medium') return 'warning'
if (normalized === 'high' || normalized === 'warning' || normalized === 'info') return normalized
return 'info'
}
function buildReviewRiskItems(reviewPayload) {
return resolveReviewRiskBriefs(reviewPayload)
.map((brief) => {
.map((brief, index) => {
const title = String(brief?.title || '').trim()
const content = String(brief?.content || '').trim()
if (title && content) return `${title}${content}`
return content || title
const detail = String(brief?.detail || '').trim()
const suggestion = String(brief?.suggestion || '').trim()
const level = normalizeReviewRiskLevel(brief?.level)
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.info
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
const normalizedTitle = title || fallbackTitle
const summary = content || normalizedTitle
if (!normalizedTitle && !summary) return null
return {
key: `${level}-${normalizedTitle}-${index}`,
title: normalizedTitle,
summary,
detail: detail || content || '当前风险项没有返回更长解释,建议结合票据、报销事由和规则要求进行复核。',
level,
levelLabel: meta.label,
icon: meta.icon,
sourceLabel: title === '历史报销画像' ? '历史记录' : 'AI预审',
suggestion: suggestion || meta.suggestion
}
})
.filter(Boolean)
.slice(0, 4)
.slice(0, 6)
}
function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) {
@@ -2904,6 +2949,7 @@ export default {
const composerRangeStartDate = ref(formatDateInputValue())
const composerRangeEndDate = ref(formatDateInputValue())
const composerBusinessTimeTags = ref([])
const composerBusinessTimeDraftTouched = ref(false)
const attachedFiles = ref([])
const composerFilesExpanded = ref(false)
const submitting = ref(false)
@@ -2947,6 +2993,10 @@ export default {
const activeReviewDocumentIndex = ref(0)
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
const insightPanelCollapsed = ref(false)
const reviewRiskDetailDialog = ref({
open: false,
item: null
})
const documentPreviewDialog = ref({
open: false,
filename: '',
@@ -3107,7 +3157,6 @@ export default {
const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length)
const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
const reviewRiskActionAvailable = computed(() => reviewRiskItems.value.length > 0)
const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0)
const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value))
const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value))
@@ -3932,6 +3981,91 @@ export default {
return `业务发生时间:${composerRangeStartDate.value}${composerRangeEndDate.value}`
}
function hasComposerBusinessTimeSelection() {
return composerBusinessTimeTags.value.length > 0 || composerBusinessTimeDraftTouched.value
}
function buildComposerBusinessTimeContext() {
if (!hasComposerBusinessTimeSelection()) {
return null
}
const mode = composerDateMode.value === 'range' ? 'range' : 'single'
const startDate = String(mode === 'range' ? composerRangeStartDate.value : composerSingleDate.value).trim()
const endDate = String(mode === 'range' ? composerRangeEndDate.value : startDate).trim()
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
return null
}
const displayValue = mode === 'range' && startDate !== endDate
? `${startDate}${endDate}`
: startDate
return {
mode,
start_date: startDate,
end_date: endDate,
occurred_date: startDate,
time_range: displayValue,
business_time: displayValue,
time_range_raw: buildComposerBusinessTimeLabel()
}
}
function mergeBusinessTimeIntoExtraContext(extraContext, businessTimeContext) {
if (!businessTimeContext) {
return extraContext
}
const baseReviewFormValues =
extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
? extraContext.review_form_values
: {}
return {
...extraContext,
occurred_date: businessTimeContext.occurred_date,
business_time: businessTimeContext.business_time,
business_time_context: {
mode: businessTimeContext.mode,
start_date: businessTimeContext.start_date,
end_date: businessTimeContext.end_date,
display_value: businessTimeContext.business_time
},
review_form_values: {
...baseReviewFormValues,
occurred_date: businessTimeContext.occurred_date,
time_range: businessTimeContext.time_range,
business_time: businessTimeContext.business_time,
time_range_raw: businessTimeContext.time_range_raw
}
}
}
function syncComposerBusinessTimeToReviewCard(businessTimeContext) {
if (!businessTimeContext || !activeReviewPayload.value) {
return
}
const nextInlineState = {
...reviewInlineForm.value,
occurred_date: businessTimeContext.occurred_date
}
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState)
reviewInlineForm.value = nextInlineState
if (latestReviewMessage.value) {
latestReviewMessage.value.reviewPayload = nextReviewPayload
}
if (currentInsight.value?.agent) {
currentInsight.value = {
...currentInsight.value,
agent: {
...currentInsight.value.agent,
reviewPayload: nextReviewPayload
}
}
}
}
function resolveComposerSubmitText(explicitRawText) {
const draftPart = String(explicitRawText ?? composerDraft.value).trim()
const tagPart = composerBusinessTimeTags.value.map((item) => item.label).join('')
@@ -3956,8 +4090,16 @@ export default {
composerDateMode.value = mode === 'range' ? 'range' : 'single'
}
function handleComposerDateInputChange() {
composerBusinessTimeDraftTouched.value = true
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
}
function removeComposerBusinessTimeTag(tagId) {
composerBusinessTimeTags.value = composerBusinessTimeTags.value.filter((item) => item.id !== tagId)
if (!composerBusinessTimeTags.value.length) {
composerBusinessTimeDraftTouched.value = false
}
}
function handleComposerDatePickerOutside(event) {
@@ -3975,12 +4117,14 @@ export default {
return
}
composerBusinessTimeDraftTouched.value = true
composerBusinessTimeTags.value = [
{
id: `biz-time-${Date.now()}`,
label: buildComposerBusinessTimeLabel()
}
]
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
composerDatePickerOpen.value = false
await nextTick()
adjustComposerTextareaHeight()
@@ -4432,13 +4576,19 @@ export default {
})
}
function explainCurrentReviewRisk() {
if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return
submitComposer({
rawText: '请解释一下当前这笔报销的合规风险和待补充项。',
userText: '查看全部风险项',
systemGenerated: true
})
function openReviewRiskDetail(item) {
if (!item) return
reviewRiskDetailDialog.value = {
open: true,
item
}
}
function closeReviewRiskDetail() {
reviewRiskDetailDialog.value = {
...reviewRiskDetailDialog.value,
open: false
}
}
function goReviewDocument(direction) {
@@ -4642,9 +4792,13 @@ export default {
}
if (!rawText && !files.length) return
const extraContext = options.extraContext && typeof options.extraContext === 'object'
const initialExtraContext = options.extraContext && typeof options.extraContext === 'object'
? { ...options.extraContext }
: {}
const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext()
const extraContext = isKnowledgeSession.value
? initialExtraContext
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
const reviewAction = String(extraContext.review_action || '').trim()
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
const hasExistingDocumentEvent =
@@ -4699,6 +4853,7 @@ export default {
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
@@ -4769,6 +4924,12 @@ export default {
department_name: user.department || user.departmentName || '',
position: user.position || '',
grade: user.grade || '',
employee_no: user.employeeNo || user.employee_no || '',
manager_name: user.managerName || user.manager_name || '',
employee_location: user.location || '',
cost_center: user.costCenter || user.cost_center || '',
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {},
...buildClientTimeContext(),
session_type: activeSessionType.value,
entry_source: props.entrySource,
@@ -4802,16 +4963,6 @@ export default {
? ''
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
try {
await syncComposerFilesToDraft(resolvedDraftClaimId, files)
} catch (error) {
console.warn('Failed to persist composer attachments to draft claim:', error)
toast(error?.message || '票据已识别,但附件持久化失败,请重试上传。')
}
}
replaceMessage(
pendingMessage.id,
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
@@ -4832,6 +4983,14 @@ export default {
mergeFilePreviews(filePreviews, ocrFilePreviews)
)
completeFlowResult(payload, flowRunDetail)
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
syncComposerFilesToDraft(resolvedDraftClaimId, files).catch((error) => {
console.warn('Failed to persist composer attachments to draft claim:', error)
toast(error?.message || '票据已识别,但附件原件保存失败,请重试上传。')
})
}
} catch (error) {
clearFlowSimulationTimers()
failCurrentFlowStep(error)
@@ -5144,6 +5303,7 @@ export default {
toggleComposerDatePicker,
closeComposerDatePicker,
setComposerDateMode,
handleComposerDateInputChange,
removeComposerBusinessTimeTag,
flowSteps,
flowRunId,
@@ -5213,7 +5373,7 @@ export default {
reviewRiskSummary,
reviewRiskItems,
reviewRiskEmpty,
reviewRiskActionAvailable,
reviewRiskDetailDialog,
recognizedNarratives,
reviewRecognitionNotes,
reviewDocumentSummaries,
@@ -5298,7 +5458,8 @@ export default {
selectReviewCategory,
selectReviewOtherCategory,
queryDraftByClaimNo,
explainCurrentReviewRisk,
openReviewRiskDetail,
closeReviewRiskDetail,
goReviewDocument,
openActiveReviewDocumentPreview,
closeDocumentPreview,

View File

@@ -1,5 +1,6 @@
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import {
@@ -9,10 +10,12 @@ import {
deleteExpenseClaim,
fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimItemAttachmentPreview,
returnExpenseClaim,
submitExpenseClaim,
uploadExpenseClaimItemAttachment,
updateExpenseClaimItem
} from '../../services/reimbursements.js'
import { canManageExpenseClaims } from '../../utils/accessControl.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
const EXPENSE_TYPE_OPTIONS = [
@@ -380,6 +383,7 @@ export default {
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
setup(props, { emit }) {
const { toast } = useToast()
const { currentUser } = useSystemState()
const editingExpenseId = ref('')
const savingExpenseId = ref('')
const creatingExpense = ref(false)
@@ -390,6 +394,8 @@ export default {
const submitBusy = ref(false)
const deleteBusy = ref(false)
const deleteDialogOpen = ref(false)
const returnBusy = ref(false)
const returnDialogOpen = ref(false)
const expenseUploadInput = ref(null)
const expenseAttachmentMeta = reactive({})
const attachmentPreviewOpen = ref(false)
@@ -448,10 +454,25 @@ export default {
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const canDeleteRequest = computed(() => isDraftRequest.value || canManageCurrentClaim.value)
const canReturnRequest = computed(() =>
canManageCurrentClaim.value
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
)
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
const deleteDialogDescription = computed(() =>
isDraftRequest.value
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
: '删除后该报销单及费用明细将不可恢复,请确认本次操作。'
)
const actionBusy = computed(() =>
Boolean(savingExpenseId.value)
|| submitBusy.value
|| deleteBusy.value
|| returnBusy.value
|| creatingExpense.value
|| Boolean(uploadingExpenseId.value)
|| Boolean(deletingAttachmentId.value)
@@ -1105,9 +1126,14 @@ export default {
}
}
async function handleDeleteDraft() {
async function handleDeleteRequest() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法删除。')
toast('当前单据缺少 claimId暂时无法删除。')
return
}
if (!canDeleteRequest.value) {
toast('当前单据已进入流程,只有财务人员或高级管理人员可以删除。')
return
}
@@ -1122,9 +1148,9 @@ export default {
deleteDialogOpen.value = false
}
async function confirmDeleteDraft() {
async function confirmDeleteRequest() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法删除。')
toast('当前单据缺少 claimId暂时无法删除。')
return
}
@@ -1132,15 +1158,58 @@ export default {
try {
const payload = await deleteExpenseClaim(request.value.claimId)
deleteDialogOpen.value = false
toast(payload?.message || `${request.value.id} 草稿已删除。`)
toast(payload?.message || `${request.value.id} 报销单已删除。`)
emit('request-deleted', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '删除草稿失败,请稍后重试。')
toast(error?.message || '删除单据失败,请稍后重试。')
} finally {
deleteBusy.value = false
}
}
function handleReturnRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法退回。')
return
}
if (!canReturnRequest.value) {
toast('当前状态不支持退回。')
return
}
returnDialogOpen.value = true
}
function closeReturnDialog() {
if (returnBusy.value) {
return
}
returnDialogOpen.value = false
}
async function confirmReturnRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法退回。')
return
}
returnBusy.value = true
try {
await returnExpenseClaim(request.value.claimId, {
reason: '详情页退回,请申请人补充后重新提交。'
})
returnDialogOpen.value = false
toast(`${request.value.id} 已退回待补充。`)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '退回单据失败,请稍后重试。')
} finally {
returnBusy.value = false
}
}
function openAiEntry() {
emit('openAssistant', {
source: 'detail',
@@ -1164,14 +1233,22 @@ export default {
attachmentPreviewName,
attachmentPreviewOpen,
attachmentPreviewUrl,
canDeleteRequest,
canManageCurrentClaim,
canReturnRequest,
canSubmit,
canPreviewAttachment,
closeDeleteDialog,
closeAttachmentPreview,
confirmDeleteDraft,
closeReturnDialog,
confirmDeleteRequest,
confirmReturnRequest,
currentProgressRingMotion,
deleteActionLabel,
deleteBusy,
deleteDialogDescription,
deleteDialogOpen,
deleteDialogTitle,
deletingAttachmentId,
deletingExpenseId,
detailNote,
@@ -1186,8 +1263,9 @@ export default {
expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
handleAddExpenseItem,
handleDeleteDraft,
handleDeleteRequest,
handleExpenseFileChange,
handleReturnRequest,
handleSubmit,
hasExpenseRiskColumn,
heroFactItems,
@@ -1205,6 +1283,8 @@ export default {
resolveAttachmentRecognition,
resolveExpenseRiskState,
resolveExpenseIssues,
returnBusy,
returnDialogOpen,
savingExpenseId,
showExpenseRisk,
startExpenseEdit,