feat: add employee management, backend health check, and UI improvements
BIN
web/UI/AI助手.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
web/UI/background.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
web/UI/background_2560x1440.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
web/UI/login.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
web/UI/main_page.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
web/UI/发起请求.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
web/UI/审批中心.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
web/UI/审批中心详情.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
web/UI/报销.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
web/UI/知识库.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
web/UI/知识问答界面.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
web/UI/首页工作台.png
Normal file
|
After Width: | Height: | Size: 971 KiB |
BIN
web/UI/首页风险.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
web/src/assets/robot-helper.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
71
web/src/assets/styles/views/backend-unavailable-view.css
Normal file
@@ -0,0 +1,71 @@
|
||||
.backend-unavailable {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(16, 185, 129, 0.16), transparent 32%),
|
||||
linear-gradient(180deg, #08130f 0%, #0f1f18 100%);
|
||||
}
|
||||
|
||||
.backend-card {
|
||||
width: min(520px, 100%);
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 32px 30px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 20px;
|
||||
background: rgba(7, 18, 13, 0.9);
|
||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.35);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.backend-badge {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0 auto;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.28));
|
||||
color: #4ade80;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.backend-card h1 {
|
||||
color: #f8fafc;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.backend-card p {
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.backend-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
min-height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
border-radius: 10px;
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 760;
|
||||
box-shadow: 0 16px 30px rgba(5, 150, 105, 0.2);
|
||||
}
|
||||
|
||||
.retry-btn:disabled {
|
||||
opacity: 0.72;
|
||||
cursor: wait;
|
||||
}
|
||||
@@ -21,9 +21,11 @@
|
||||
}
|
||||
|
||||
.employee-list {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr);
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 16px 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.employee-detail {
|
||||
@@ -34,22 +36,26 @@
|
||||
|
||||
.status-tabs {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
gap: 28px;
|
||||
margin-top: 14px;
|
||||
border-bottom: 1px solid #dbe4ee;
|
||||
}
|
||||
|
||||
.status-tabs button {
|
||||
position: relative;
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 760;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.status-tabs button.active {
|
||||
color: #0f172a;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.status-tabs button.active::after {
|
||||
@@ -57,30 +63,51 @@
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -13px;
|
||||
bottom: -1px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
border-radius: 999px 999px 0 0;
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.status-tabs button small {
|
||||
min-width: 24px;
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-tabs button.active small {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.list-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 0 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.filter-set {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.list-search {
|
||||
position: relative;
|
||||
width: 240px;
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.list-search .mdi {
|
||||
@@ -103,23 +130,156 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filter-btn,
|
||||
.create-btn,
|
||||
.row-action {
|
||||
min-height: 36px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
.list-search input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
.list-search input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(16, 185, 129, 0.6);
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.picker-trigger,
|
||||
.ghost-filter-btn,
|
||||
.create-btn,
|
||||
.row-action {
|
||||
min-height: 38px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.picker-filter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.picker-trigger {
|
||||
min-width: 132px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
justify-content: space-between;
|
||||
gap: 9px;
|
||||
padding: 0 34px 0 12px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picker-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picker-trigger .mdi {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #64748b;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.picker-trigger:hover,
|
||||
.picker-filter.open .picker-trigger {
|
||||
border-color: rgba(16, 185, 129, 0.34);
|
||||
background: #f6fffb;
|
||||
color: #0f9f78;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.picker-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
width: 224px;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.picker-popover header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.picker-popover header strong {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.picker-popover header button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.picker-popover header button:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.picker-option-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.picker-option {
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.picker-option:hover {
|
||||
border-color: rgba(16, 185, 129, 0.28);
|
||||
background: #f0fdf4;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.picker-option.active {
|
||||
border-color: #10b981;
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ghost-filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgba(5, 150, 105, 0.16);
|
||||
background: #f8fffb;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
@@ -137,22 +297,237 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 12px;
|
||||
margin-top: 10px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.active-filter-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.active-filter-chip {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
margin-top: 10px;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.list-foot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.page-summary {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.pager button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.pager button:hover:not(.active) {
|
||||
background: #fff;
|
||||
color: #059669;
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.pager button.active {
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 16px rgba(5, 150, 105, 0.2);
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.page-size {
|
||||
min-height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
min-width: 112px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.page-size:hover {
|
||||
border-color: rgba(16, 185, 129, 0.32);
|
||||
color: #0f9f78;
|
||||
}
|
||||
|
||||
.page-size-wrap {
|
||||
position: relative;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.page-size-dropdown {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-size-dropdown button {
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
padding: 0 20px;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.page-size-dropdown button:hover {
|
||||
background: #f0fdf4;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.page-size-dropdown button.active {
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.table-state {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
padding: 28px 20px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-state i {
|
||||
font-size: 26px;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.table-state.error i {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.table-state.empty i {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.table-state p {
|
||||
max-width: 420px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.state-action {
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgba(5, 150, 105, 0.22);
|
||||
border-radius: 8px;
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
table {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 1320px;
|
||||
min-width: 1180px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
colgroup col.col-employee {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
colgroup col.col-employee-no {
|
||||
width: 11%;
|
||||
}
|
||||
|
||||
colgroup col.col-department {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
colgroup col.col-position {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
colgroup col.col-grade {
|
||||
width: 9%;
|
||||
}
|
||||
|
||||
colgroup col.col-role {
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
colgroup col.col-status {
|
||||
width: 8%;
|
||||
}
|
||||
|
||||
colgroup col.col-updated {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
th,
|
||||
@@ -161,15 +536,25 @@ td {
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
color: #24324a;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f8fafc;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #f7fafc;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
@@ -185,6 +570,10 @@ tbody tr.spotlight {
|
||||
background: linear-gradient(90deg, rgba(16, 185, 129, 0.05), rgba(59, 130, 246, 0.03));
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.employee-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
@@ -646,10 +1035,29 @@ tbody tr.spotlight {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.list-foot {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: stretch;
|
||||
}
|
||||
|
||||
.list-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.picker-filter,
|
||||
.picker-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.picker-popover {
|
||||
width: min(280px, calc(100vw - 64px));
|
||||
}
|
||||
|
||||
.page-size,
|
||||
.pager {
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.hero-stats,
|
||||
.form-grid,
|
||||
.role-grid {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<section class="workbench">
|
||||
<PanelHead
|
||||
v-if="showHeader"
|
||||
@@ -9,9 +9,8 @@
|
||||
|
||||
<article class="panel assistant-hero">
|
||||
<div class="assistant-visual" aria-hidden="true">
|
||||
<div class="assistant-core">
|
||||
<img class="assistant-image" :src="robotAssistant" alt="" />
|
||||
</div>
|
||||
<span class="assistant-glow"></span>
|
||||
<img class="assistant-image" :src="robotAssistant" alt="" />
|
||||
</div>
|
||||
|
||||
<div class="assistant-copy">
|
||||
@@ -26,7 +25,6 @@
|
||||
placeholder="例如:我昨天请客户吃饭花了 860 元,还打车去了客户公司"
|
||||
@keydown.ctrl.enter.prevent="openAssistantWithDraft"
|
||||
/>
|
||||
<button type="button" class="hero-action" @click="openAssistantWithDraft">开始识别</button>
|
||||
</div>
|
||||
|
||||
<div class="assistant-tools">
|
||||
@@ -34,10 +32,10 @@
|
||||
<i class="mdi mdi-upload-outline"></i>
|
||||
<span>上传票据</span>
|
||||
</button>
|
||||
|
||||
<div class="assistant-skills">
|
||||
<span v-for="item in assistantSkills" :key="item">{{ item }}</span>
|
||||
</div>
|
||||
<button type="button" class="hero-action" @click="openAssistantWithDraft">
|
||||
<i class="mdi mdi-magnify-scan"></i>
|
||||
<span>开始识别</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -124,7 +122,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import PanelHead from '../shared/PanelHead.vue'
|
||||
import robotAssistant from '../../assets/robot-assistant.png'
|
||||
import robotAssistant from '../../assets/robot-helper.png'
|
||||
|
||||
defineProps({
|
||||
showHeader: { type: Boolean, default: true }
|
||||
@@ -140,8 +138,6 @@ function openAssistantWithDraft() {
|
||||
})
|
||||
}
|
||||
|
||||
const assistantSkills = ['识别报销类别', '检查缺少材料', '生成报销草稿']
|
||||
|
||||
const todoItems = [
|
||||
{
|
||||
title: '业务招待报销建议补参与人员',
|
||||
@@ -240,9 +236,9 @@ const policyItems = [
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 164px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
padding: 24px 26px;
|
||||
grid-template-columns: 228px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
padding: 20px 24px 20px 18px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.12);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(16, 185, 129, 0.12), transparent 34%),
|
||||
@@ -275,62 +271,65 @@ const policyItems = [
|
||||
|
||||
.assistant-visual {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 196px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
padding: 0 0 10px 8px;
|
||||
}
|
||||
|
||||
.assistant-core {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 36px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #ecfdf5 100%);
|
||||
box-shadow:
|
||||
0 20px 44px rgba(15, 23, 42, 0.08),
|
||||
inset 0 -10px 18px rgba(16, 185, 129, 0.10);
|
||||
color: #0f9f78;
|
||||
}
|
||||
|
||||
.assistant-core::before,
|
||||
.assistant-core::after {
|
||||
.assistant-visual::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: #d1fae5;
|
||||
inset: auto auto -78px -58px;
|
||||
width: 264px;
|
||||
height: 228px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 48% 38%, rgba(255, 255, 255, 0.92) 0%, rgba(220, 252, 231, 0.84) 58%, rgba(220, 252, 231, 0) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.assistant-core::before {
|
||||
top: -12px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
.assistant-visual::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 52px;
|
||||
bottom: 18px;
|
||||
width: 132px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0.10);
|
||||
background: rgba(16, 185, 129, 0.14);
|
||||
filter: blur(12px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.assistant-core::after {
|
||||
top: -4px;
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.assistant-core .mdi {
|
||||
font-size: 68px;
|
||||
.assistant-glow {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
bottom: 22px;
|
||||
width: 176px;
|
||||
height: 176px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.98) 0%, rgba(236, 253, 245, 0.9) 58%, rgba(236, 253, 245, 0) 100%);
|
||||
box-shadow: 0 24px 48px rgba(16, 185, 129, 0.12);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.assistant-image {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 184px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 12px 20px rgba(15, 23, 42, 0.12));
|
||||
object-position: left bottom;
|
||||
filter: drop-shadow(0 22px 28px rgba(15, 23, 42, 0.16));
|
||||
}
|
||||
|
||||
.assistant-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
gap: 10px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
@@ -340,15 +339,16 @@ const policyItems = [
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(16, 185, 129, 0.10);
|
||||
color: #0f9f78;
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(59, 130, 246, 0.12));
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.assistant-copy h3 {
|
||||
color: #0f172a;
|
||||
font-size: 28px;
|
||||
font-size: 26px;
|
||||
line-height: 1.25;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -356,16 +356,15 @@ const policyItems = [
|
||||
.assistant-copy p {
|
||||
max-width: 760px;
|
||||
color: #5b6b83;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.assistant-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 52px;
|
||||
padding: 6px 8px 6px 14px;
|
||||
min-height: 48px;
|
||||
padding: 4px 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
@@ -375,9 +374,9 @@ const policyItems = [
|
||||
.assistant-input textarea {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
max-height: 24px;
|
||||
height: 22px;
|
||||
min-height: 22px;
|
||||
max-height: 22px;
|
||||
resize: none;
|
||||
border: 0;
|
||||
padding: 1px 0;
|
||||
@@ -406,8 +405,12 @@ const policyItems = [
|
||||
}
|
||||
|
||||
.hero-action {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: #fff;
|
||||
@@ -417,10 +420,26 @@ const policyItems = [
|
||||
box-shadow: 0 10px 22px rgba(16, 185, 129, 0.18);
|
||||
}
|
||||
|
||||
.hero-action .mdi,
|
||||
.ghost-action .mdi {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hero-action span,
|
||||
.ghost-action span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.assistant-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -428,39 +447,22 @@ const policyItems = [
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.34);
|
||||
border: 1px solid rgba(15, 118, 110, 0.18);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: #0f9f78;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(244, 250, 247, 0.88));
|
||||
color: #0f766e;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||
0 6px 14px rgba(15, 118, 110, 0.06);
|
||||
}
|
||||
|
||||
.assistant-skills {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
color: #22a06b;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.assistant-skills span {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.assistant-skills span + span::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -7px;
|
||||
top: 50%;
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: rgba(16, 185, 129, 0.22);
|
||||
transform: translateY(-50%);
|
||||
.ghost-action .mdi {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.workbench-grid {
|
||||
@@ -723,10 +725,28 @@ const policyItems = [
|
||||
@media (max-width: 1080px) {
|
||||
.assistant-hero {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-visual {
|
||||
justify-content: flex-start;
|
||||
min-height: 188px;
|
||||
justify-content: center;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.assistant-visual::before,
|
||||
.assistant-visual::after,
|
||||
.assistant-glow {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.assistant-visual::before {
|
||||
inset: auto auto -82px 50%;
|
||||
}
|
||||
|
||||
.assistant-image {
|
||||
width: 176px;
|
||||
}
|
||||
|
||||
.workbench-grid {
|
||||
@@ -747,6 +767,19 @@ const policyItems = [
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.assistant-visual {
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.assistant-glow {
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
}
|
||||
|
||||
.assistant-image {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.assistant-input textarea {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
@@ -800,3 +833,5 @@ const policyItems = [
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -28,14 +28,23 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button class="rail-user" type="button" aria-label="打开用户菜单">
|
||||
<span class="user-avatar">张</span>
|
||||
<span class="user-copy">
|
||||
<strong>张晓明</strong>
|
||||
<span>财务管理员</span>
|
||||
</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div class="rail-user">
|
||||
<div class="user-menu" role="menu" aria-label="用户菜单">
|
||||
<button class="user-menu-item" type="button" @click="emit('logout')">
|
||||
<i class="mdi mdi-logout-variant"></i>
|
||||
<span>退出系统</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="user-summary" tabindex="0" aria-label="用户信息">
|
||||
<span class="user-avatar">{{ displayUser.avatar }}</span>
|
||||
<span class="user-copy">
|
||||
<strong>{{ displayUser.name }}</strong>
|
||||
<span>{{ displayUser.role }}</span>
|
||||
</span>
|
||||
<i class="mdi mdi-chevron-up"></i>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -44,17 +53,25 @@ import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
navItems: { type: Array, required: true },
|
||||
activeView: { type: String, required: true }
|
||||
activeView: { type: String, required: true },
|
||||
currentUser: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
name: '系统管理员',
|
||||
role: '财务管理员',
|
||||
avatar: '管'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['navigate', 'openChat'])
|
||||
const emit = defineEmits(['navigate', 'openChat', 'logout'])
|
||||
|
||||
const sidebarMeta = {
|
||||
overview: { label: '总览' },
|
||||
workbench: { label: '个人工作台' },
|
||||
requests: { label: '差旅申请/报销' },
|
||||
approval: { label: '审批中心', badge: '12' },
|
||||
chat: { label: 'AI助手' },
|
||||
chat: { label: 'AI 助手' },
|
||||
policies: { label: '知识管理' },
|
||||
audit: { label: '技能中心' },
|
||||
employees: { label: '员工管理' }
|
||||
@@ -67,6 +84,12 @@ const decoratedNavItems = computed(() =>
|
||||
badge: sidebarMeta[item.id]?.badge
|
||||
}))
|
||||
)
|
||||
|
||||
const displayUser = computed(() => ({
|
||||
name: props.currentUser?.name || '系统管理员',
|
||||
role: props.currentUser?.role || '财务管理员',
|
||||
avatar: props.currentUser?.avatar || '管'
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -77,10 +100,10 @@ const decoratedNavItems = computed(() =>
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,.98), rgba(248,251,250,.96)),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 251, 250, 0.96)),
|
||||
#fff;
|
||||
border-right: 1px solid #dbe4ee;
|
||||
box-shadow: 1px 0 0 rgba(15,23,42,.02);
|
||||
box-shadow: 1px 0 0 rgba(15, 23, 42, 0.02);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
@@ -164,13 +187,13 @@ const decoratedNavItems = computed(() =>
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: rgba(16,185,129,.07);
|
||||
background: rgba(16, 185, 129, 0.07);
|
||||
color: #0f9f78;
|
||||
}
|
||||
|
||||
.nav-btn.active {
|
||||
background: linear-gradient(90deg, rgba(16,185,129,.16), rgba(16,185,129,.08));
|
||||
border-color: rgba(16,185,129,.10);
|
||||
background: linear-gradient(90deg, rgba(16, 185, 129, 0.16), rgba(16, 185, 129, 0.08));
|
||||
border-color: rgba(16, 185, 129, 0.1);
|
||||
color: #059669;
|
||||
box-shadow: inset 3px 0 0 #10b981;
|
||||
}
|
||||
@@ -221,25 +244,31 @@ const decoratedNavItems = computed(() =>
|
||||
}
|
||||
|
||||
.rail-user {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 74px;
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr) 22px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 78px;
|
||||
margin: 0;
|
||||
padding: 16px 20px 18px;
|
||||
border: 0;
|
||||
border-top: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
transition: background 180ms var(--ease), border-color 180ms var(--ease);
|
||||
border-top: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.rail-user:hover {
|
||||
border-top-color: #e2e8f0;
|
||||
background: rgba(255,255,255,.72);
|
||||
.user-summary {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr) 18px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 0 0;
|
||||
color: #64748b;
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
transition: background 180ms var(--ease);
|
||||
}
|
||||
|
||||
.rail-user:hover .user-summary,
|
||||
.rail-user:focus-within .user-summary {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
@@ -250,7 +279,7 @@ const decoratedNavItems = computed(() =>
|
||||
border: 2px solid #fff;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #0f9f78, #65d6b4);
|
||||
box-shadow: 0 6px 14px rgba(15,159,120,.18);
|
||||
box-shadow: 0 6px 14px rgba(15, 159, 120, 0.18);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
@@ -281,10 +310,88 @@ const decoratedNavItems = computed(() =>
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rail-user .mdi {
|
||||
.user-summary .mdi {
|
||||
justify-self: end;
|
||||
color: #718096;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
transition: transform 180ms var(--ease), color 180ms var(--ease);
|
||||
}
|
||||
|
||||
.rail-user:hover .user-summary .mdi,
|
||||
.rail-user:focus-within .user-summary .mdi {
|
||||
color: #0f9f78;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: calc(100% - 6px);
|
||||
min-width: 132px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.96);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow:
|
||||
0 16px 32px rgba(15, 23, 42, 0.10),
|
||||
0 2px 8px rgba(15, 23, 42, 0.04);
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 180ms var(--ease),
|
||||
transform 180ms var(--ease),
|
||||
box-shadow 180ms var(--ease);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.user-menu::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
bottom: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-right: 1px solid rgba(226, 232, 240, 0.96);
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.96);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.rail-user:hover .user-menu,
|
||||
.rail-user:focus-within .user-menu {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.user-menu-item {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #dc2626;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
transition: background 180ms var(--ease), color 180ms var(--ease);
|
||||
}
|
||||
|
||||
.user-menu-item:hover {
|
||||
background: #fff5f5;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.user-menu-item .mdi {
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
|
||||
@@ -117,6 +117,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isEmployees">
|
||||
<div class="kpi-chips">
|
||||
<div v-for="kpi in employeeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
|
||||
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
|
||||
<span class="chip-label">{{ kpi.label }}</span>
|
||||
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -130,6 +140,10 @@ const props = defineProps({
|
||||
activeView: { type: String, default: '' },
|
||||
ranges: { type: Array, default: () => [] },
|
||||
activeRange: { type: String, default: '' },
|
||||
employeeSummary: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
customRange: {
|
||||
type: Object,
|
||||
default: () => ({ start: '2024-07-06', end: '2024-07-12' })
|
||||
@@ -150,6 +164,7 @@ const isOverview = computed(() => props.activeView === 'overview')
|
||||
const isRequests = computed(() => props.activeView === 'requests')
|
||||
const isApproval = computed(() => props.activeView === 'approval')
|
||||
const isPolicies = computed(() => props.activeView === 'policies')
|
||||
const isEmployees = computed(() => props.activeView === 'employees')
|
||||
|
||||
const requestKpis = [
|
||||
{ label: '全部单据', value: 30, delta: '+8', trend: 'up', arrow: 'mdi mdi-arrow-up', color: '#10b981' },
|
||||
@@ -178,6 +193,51 @@ const knowledgeKpis = [
|
||||
{ label: '问答总量', value: '8,562', meta: '较上周 +321', trend: 'up', icon: 'mdi mdi-comment-text-multiple-outline', color: '#8b5cf6' },
|
||||
{ label: '知识命中率', value: '87.3%', meta: '较上周 +1.2%', trend: 'up', icon: 'mdi mdi-bullseye-arrow', color: '#f59e0b' }
|
||||
]
|
||||
|
||||
const employeeKpis = computed(() => {
|
||||
const summary = props.employeeSummary ?? {}
|
||||
const total = Number(summary.total ?? 0)
|
||||
const active = Number(summary.active ?? 0)
|
||||
const onboarding = Number(summary.onboarding ?? 0)
|
||||
const disabled = Number(summary.disabled ?? 0)
|
||||
const followUp = Number(summary.followUp ?? 0)
|
||||
const departments = Number(summary.departments ?? 0)
|
||||
|
||||
return [
|
||||
{
|
||||
label: '员工总数',
|
||||
value: total,
|
||||
unit: '人',
|
||||
meta: `覆盖 ${departments} 个部门`,
|
||||
trend: 'up',
|
||||
color: '#10b981'
|
||||
},
|
||||
{
|
||||
label: '在职账号',
|
||||
value: active,
|
||||
unit: '人',
|
||||
meta: total ? `占比 ${Math.round((active / total) * 100)}%` : '等待数据',
|
||||
trend: 'up',
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
label: '待处理状态',
|
||||
value: onboarding + disabled,
|
||||
unit: '人',
|
||||
meta: `试用 ${onboarding} / 停用 ${disabled}`,
|
||||
trend: onboarding + disabled > 0 ? 'down' : 'up',
|
||||
color: '#f59e0b'
|
||||
},
|
||||
{
|
||||
label: '同步待处理',
|
||||
value: followUp,
|
||||
unit: '人',
|
||||
meta: followUp > 0 ? '存在待同步账号' : '资料已同步',
|
||||
trend: followUp > 0 ? 'down' : 'up',
|
||||
color: '#8b5cf6'
|
||||
}
|
||||
]
|
||||
})
|
||||
const calendarOpen = ref(false)
|
||||
const draftStart = ref(props.customRange.start)
|
||||
const draftEnd = ref(props.customRange.end)
|
||||
|
||||
47
web/src/composables/useBackendHealth.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { fetchBackendHealth } from '../services/system.js'
|
||||
|
||||
const backendHealthy = ref(true)
|
||||
const backendChecking = ref(false)
|
||||
const backendError = ref('')
|
||||
let lastCheckedAt = 0
|
||||
|
||||
export async function checkBackendHealth(options = {}) {
|
||||
const force = Boolean(options.force)
|
||||
const now = Date.now()
|
||||
|
||||
if (!force && now - lastCheckedAt < 5000) {
|
||||
return backendHealthy.value
|
||||
}
|
||||
|
||||
backendChecking.value = true
|
||||
|
||||
try {
|
||||
const payload = await fetchBackendHealth()
|
||||
const ok = payload?.status === 'ok'
|
||||
|
||||
backendHealthy.value = ok
|
||||
backendError.value = ok
|
||||
? ''
|
||||
: payload?.database?.error || '后端服务尚未准备完成。'
|
||||
lastCheckedAt = now
|
||||
return ok
|
||||
} catch (error) {
|
||||
backendHealthy.value = false
|
||||
backendError.value = error?.message || '无法连接后端服务。'
|
||||
lastCheckedAt = now
|
||||
return false
|
||||
} finally {
|
||||
backendChecking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
export function useBackendHealth() {
|
||||
return {
|
||||
backendHealthy,
|
||||
backendChecking,
|
||||
backendError,
|
||||
checkBackendHealth
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,21 @@ import {
|
||||
import { useToast } from './useToast.js'
|
||||
|
||||
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
|
||||
const AUTH_USERNAME_KEY = 'x-financial-auth-username'
|
||||
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
|
||||
const DEFAULT_USER_NAME = '系统管理员'
|
||||
const DEFAULT_USER_ROLE = '财务管理员'
|
||||
const SESSION_ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart', 'visibilitychange']
|
||||
const authIdleTimeoutMinutes = Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30)
|
||||
const authIdleTimeoutMs =
|
||||
Number.isFinite(authIdleTimeoutMinutes) && authIdleTimeoutMinutes > 0
|
||||
? authIdleTimeoutMinutes * 60 * 1000
|
||||
: 30 * 60 * 1000
|
||||
|
||||
let sessionRouter = null
|
||||
let sessionTimeoutHandle = 0
|
||||
let sessionMonitoringInstalled = false
|
||||
let lastActivityWriteAt = 0
|
||||
|
||||
function readClientBootstrapState() {
|
||||
const env = import.meta.env
|
||||
@@ -51,17 +66,176 @@ function readAuthState() {
|
||||
return window.sessionStorage.getItem(AUTH_STORAGE_KEY) === 'true'
|
||||
}
|
||||
|
||||
function persistAuthState(value) {
|
||||
function readStoredUsername() {
|
||||
if (typeof window === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return window.sessionStorage.getItem(AUTH_USERNAME_KEY) || ''
|
||||
}
|
||||
|
||||
function readLastActivityAt() {
|
||||
if (typeof window === 'undefined') {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0)
|
||||
}
|
||||
|
||||
function buildCurrentUser(username = '') {
|
||||
const normalized = String(username || '').trim()
|
||||
const name = normalized || DEFAULT_USER_NAME
|
||||
|
||||
return {
|
||||
name,
|
||||
role: DEFAULT_USER_ROLE,
|
||||
avatar: name.slice(0, 1).toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
function isSessionExpired(now = Date.now()) {
|
||||
if (!readAuthState()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lastActivityAt = readLastActivityAt()
|
||||
|
||||
if (!lastActivityAt) {
|
||||
return true
|
||||
}
|
||||
|
||||
return now - lastActivityAt > authIdleTimeoutMs
|
||||
}
|
||||
|
||||
function persistAuthState(value, username = '') {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (value) {
|
||||
window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true')
|
||||
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(username || '').trim())
|
||||
return
|
||||
}
|
||||
|
||||
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
|
||||
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
|
||||
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
|
||||
}
|
||||
|
||||
function clearSessionTimeout() {
|
||||
if (typeof window === 'undefined' || !sessionTimeoutHandle) {
|
||||
return
|
||||
}
|
||||
|
||||
window.clearTimeout(sessionTimeoutHandle)
|
||||
sessionTimeoutHandle = 0
|
||||
}
|
||||
|
||||
function redirectToLogin() {
|
||||
if (sessionRouter?.currentRoute?.value?.name === 'login') {
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionRouter) {
|
||||
sessionRouter.replace({ name: 'login' })
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.assign('/login')
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSessionTimeout() {
|
||||
clearSessionTimeout()
|
||||
|
||||
if (typeof window === 'undefined' || !readAuthState()) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastActivityAt = readLastActivityAt()
|
||||
|
||||
if (!lastActivityAt) {
|
||||
return
|
||||
}
|
||||
|
||||
const remaining = authIdleTimeoutMs - (Date.now() - lastActivityAt)
|
||||
|
||||
if (remaining <= 0) {
|
||||
logout('timeout', { notify: true })
|
||||
return
|
||||
}
|
||||
|
||||
sessionTimeoutHandle = window.setTimeout(() => {
|
||||
logout('timeout', { notify: true })
|
||||
}, remaining)
|
||||
}
|
||||
|
||||
function touchAuthActivity(force = false) {
|
||||
if (typeof window === 'undefined' || !readAuthState()) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
if (!force && now - lastActivityWriteAt < 1000) {
|
||||
scheduleSessionTimeout()
|
||||
return
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(AUTH_LAST_ACTIVITY_KEY, String(now))
|
||||
lastActivityWriteAt = now
|
||||
scheduleSessionTimeout()
|
||||
}
|
||||
|
||||
function handleSessionActivity(event) {
|
||||
if (typeof document !== 'undefined' && event?.type === 'visibilitychange' && document.visibilityState !== 'visible') {
|
||||
return
|
||||
}
|
||||
|
||||
touchAuthActivity()
|
||||
}
|
||||
|
||||
function installSessionMonitoring() {
|
||||
if (sessionMonitoringInstalled || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
sessionMonitoringInstalled = true
|
||||
SESSION_ACTIVITY_EVENTS.forEach((eventName) => {
|
||||
window.addEventListener(eventName, handleSessionActivity, { passive: true })
|
||||
})
|
||||
}
|
||||
|
||||
function syncAuthSession(options = {}) {
|
||||
const shouldNotify = Boolean(options.notify)
|
||||
|
||||
if (!readAuthState()) {
|
||||
loggedIn.value = false
|
||||
currentUser.value = buildCurrentUser('')
|
||||
clearSessionTimeout()
|
||||
return false
|
||||
}
|
||||
|
||||
if (isSessionExpired()) {
|
||||
logout('timeout', { notify: shouldNotify, redirect: false })
|
||||
return false
|
||||
}
|
||||
|
||||
loggedIn.value = true
|
||||
currentUser.value = buildCurrentUser(readStoredUsername())
|
||||
scheduleSessionTimeout()
|
||||
return true
|
||||
}
|
||||
|
||||
export function installSessionNavigation(router) {
|
||||
sessionRouter = router
|
||||
installSessionMonitoring()
|
||||
|
||||
if (readAuthState() && !isSessionExpired()) {
|
||||
scheduleSessionTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrapState = ref(readClientBootstrapState())
|
||||
@@ -75,7 +249,12 @@ const runtimeTestMessage = ref('')
|
||||
const databaseTestMessage = ref('')
|
||||
const loginSubmitting = ref(false)
|
||||
const loginError = ref('')
|
||||
const loggedIn = ref(readAuthState())
|
||||
const loggedIn = ref(readAuthState() && !isSessionExpired())
|
||||
const currentUser = ref(buildCurrentUser(readStoredUsername()))
|
||||
|
||||
if (!loggedIn.value && readAuthState()) {
|
||||
persistAuthState(false)
|
||||
}
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -91,8 +270,7 @@ function applyBootstrapState(state) {
|
||||
bootstrapState.value = state
|
||||
|
||||
if (!state.initialized) {
|
||||
loggedIn.value = false
|
||||
persistAuthState(false)
|
||||
logout('reset', { redirect: false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +288,7 @@ function resetFromClientEnv() {
|
||||
applyBootstrapState(readClientBootstrapState())
|
||||
clearSetupRuntimeState()
|
||||
loginError.value = ''
|
||||
currentUser.value = buildCurrentUser(readStoredUsername())
|
||||
}
|
||||
|
||||
async function handleSetupSubmit(payload) {
|
||||
@@ -209,11 +388,12 @@ async function handleLogin(credentials) {
|
||||
})
|
||||
|
||||
loggedIn.value = true
|
||||
persistAuthState(true)
|
||||
persistAuthState(true, credentials.username)
|
||||
currentUser.value = buildCurrentUser(credentials.username)
|
||||
touchAuthActivity(true)
|
||||
return true
|
||||
} catch (error) {
|
||||
loggedIn.value = false
|
||||
persistAuthState(false)
|
||||
logout('invalid', { redirect: false })
|
||||
loginError.value = error.message || '登录失败,请检查管理员账号和密码。'
|
||||
toast(loginError.value)
|
||||
return false
|
||||
@@ -222,9 +402,22 @@ async function handleLogin(credentials) {
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
function logout(reason = 'manual', options = {}) {
|
||||
const notify = options.notify ?? reason === 'timeout'
|
||||
const redirect = options.redirect ?? reason !== 'invalid'
|
||||
|
||||
loggedIn.value = false
|
||||
persistAuthState(false)
|
||||
currentUser.value = buildCurrentUser('')
|
||||
clearSessionTimeout()
|
||||
|
||||
if (notify) {
|
||||
toast(reason === 'timeout' ? '登录已超时,请重新登录。' : '已退出登录。')
|
||||
}
|
||||
|
||||
if (redirect) {
|
||||
redirectToLogin()
|
||||
}
|
||||
}
|
||||
|
||||
function handleRecoverPassword() {
|
||||
@@ -236,6 +429,9 @@ function handleSsoLogin() {
|
||||
}
|
||||
|
||||
function resolveEntryRoute() {
|
||||
loggedIn.value = syncAuthSession()
|
||||
currentUser.value = buildCurrentUser(readStoredUsername())
|
||||
|
||||
if (!isInitialized.value) {
|
||||
return { name: 'setup' }
|
||||
}
|
||||
@@ -251,6 +447,7 @@ export function useSystemState() {
|
||||
return {
|
||||
bootstrapState,
|
||||
companyProfile,
|
||||
currentUser,
|
||||
databaseTestMessage,
|
||||
databaseTestPassed,
|
||||
databaseTesting,
|
||||
@@ -273,6 +470,7 @@ export function useSystemState() {
|
||||
runtimeTestPassed,
|
||||
runtimeTesting,
|
||||
setupError,
|
||||
setupSubmitting
|
||||
setupSubmitting,
|
||||
syncAuthSession
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ import Aura from '@primevue/themes/aura'
|
||||
import 'primeicons/primeicons.css'
|
||||
import App from './App.vue'
|
||||
import router from './router/index.js'
|
||||
import { installSessionNavigation } from './composables/useSystemState.js'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
installSessionNavigation(router)
|
||||
|
||||
app.use(MotionPlugin)
|
||||
app.use(router)
|
||||
app.use(PrimeVue, {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import { checkBackendHealth } from '../composables/useBackendHealth.js'
|
||||
import { appViews } from '../composables/useNavigation.js'
|
||||
import { useSystemState } from '../composables/useSystemState.js'
|
||||
import AppShellRouteView from '../views/AppShellRouteView.vue'
|
||||
import BackendUnavailableRouteView from '../views/BackendUnavailableRouteView.vue'
|
||||
import LoginRouteView from '../views/LoginRouteView.vue'
|
||||
import SetupRouteView from '../views/SetupRouteView.vue'
|
||||
|
||||
@@ -39,6 +41,11 @@ const router = createRouter({
|
||||
name: 'login',
|
||||
component: LoginRouteView
|
||||
},
|
||||
{
|
||||
path: '/backend-unavailable',
|
||||
name: 'backend-unavailable',
|
||||
component: BackendUnavailableRouteView
|
||||
},
|
||||
{
|
||||
path: '/app',
|
||||
redirect: { name: 'app-overview' }
|
||||
@@ -73,7 +80,8 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const { isInitialized, loggedIn, resolveEntryRoute } = useSystemState()
|
||||
const { isInitialized, loggedIn, resolveEntryRoute, syncAuthSession } = useSystemState()
|
||||
const authActive = syncAuthSession({ notify: Boolean(to.meta.requiresAuth) })
|
||||
|
||||
if (!isInitialized.value) {
|
||||
if (to.name !== 'setup') {
|
||||
@@ -87,7 +95,21 @@ router.beforeEach((to) => {
|
||||
return resolveEntryRoute()
|
||||
}
|
||||
|
||||
if (!loggedIn.value && to.meta.requiresAuth) {
|
||||
if (authActive && to.meta.requiresAuth) {
|
||||
return checkBackendHealth().then((ok) => {
|
||||
if (!ok && to.name !== 'backend-unavailable') {
|
||||
return { name: 'backend-unavailable' }
|
||||
}
|
||||
|
||||
if (ok && to.name === 'backend-unavailable') {
|
||||
return resolveEntryRoute()
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if (!authActive && to.meta.requiresAuth) {
|
||||
return {
|
||||
name: 'login',
|
||||
query: {
|
||||
@@ -96,7 +118,7 @@ router.beforeEach((to) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (loggedIn.value && to.name === 'login') {
|
||||
if (authActive && to.name === 'login') {
|
||||
return resolveEntryRoute()
|
||||
}
|
||||
|
||||
|
||||
39
web/src/services/api.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const API_BASE = String(import.meta.env.VITE_API_BASE_URL || '/api/v1').replace(/\/$/, '')
|
||||
|
||||
function buildUrl(path) {
|
||||
if (!path.startsWith('/')) {
|
||||
return `${API_BASE}/${path}`
|
||||
}
|
||||
|
||||
return `${API_BASE}${path}`
|
||||
}
|
||||
|
||||
export async function apiRequest(path, options = {}) {
|
||||
let response
|
||||
|
||||
try {
|
||||
response = await fetch(buildUrl(path), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
},
|
||||
...options
|
||||
})
|
||||
} catch {
|
||||
throw new Error('无法连接后端员工服务,请确认 FastAPI 已启动。')
|
||||
}
|
||||
|
||||
let payload = null
|
||||
|
||||
try {
|
||||
payload = await response.json()
|
||||
} catch {
|
||||
payload = null
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.detail || '接口请求失败,请稍后重试。')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
24
web/src/services/employees.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
export function fetchEmployees(params = {}) {
|
||||
const search = new URLSearchParams()
|
||||
|
||||
if (params.status && params.status !== '全部员工') {
|
||||
search.set('status', params.status)
|
||||
}
|
||||
|
||||
if (params.keyword) {
|
||||
search.set('keyword', params.keyword)
|
||||
}
|
||||
|
||||
const query = search.toString()
|
||||
return apiRequest(`/employees${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
export function fetchEmployeeMeta() {
|
||||
return apiRequest('/employees/meta')
|
||||
}
|
||||
|
||||
export function fetchEmployeeDetail(employeeId) {
|
||||
return apiRequest(`/employees/${employeeId}`)
|
||||
}
|
||||
5
web/src/services/system.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
export function fetchBackendHealth() {
|
||||
return apiRequest('/health')
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
<SidebarRail
|
||||
:nav-items="navItems"
|
||||
:active-view="activeView"
|
||||
:current-user="currentUser"
|
||||
@navigate="handleNavigate"
|
||||
@open-chat="handleOpenChat"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
|
||||
<main
|
||||
@@ -26,6 +28,7 @@
|
||||
:active-view="activeView"
|
||||
:ranges="ranges"
|
||||
:active-range="activeRange"
|
||||
:employee-summary="employeeSummary"
|
||||
:custom-range="customRange"
|
||||
@update:search="search = $event"
|
||||
@update:active-range="activeRange = $event"
|
||||
@@ -105,7 +108,7 @@
|
||||
<ApprovalCenterView v-else-if="activeView === 'approval'" />
|
||||
<PoliciesView v-else-if="activeView === 'policies'" />
|
||||
<AuditView v-else-if="activeView === 'audit'" />
|
||||
<EmployeeManagementView v-else />
|
||||
<EmployeeManagementView v-else @overview-change="employeeSummary = $event" />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -121,6 +124,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import SidebarRail from '../components/layout/SidebarRail.vue'
|
||||
import TopBar from '../components/layout/TopBar.vue'
|
||||
import FilterBar from '../components/layout/FilterBar.vue'
|
||||
@@ -136,6 +141,9 @@ import AuditView from './AuditView.vue'
|
||||
import EmployeeManagementView from './EmployeeManagementView.vue'
|
||||
|
||||
import { useAppShell } from '../composables/useAppShell.js'
|
||||
import { useSystemState } from '../composables/useSystemState.js'
|
||||
|
||||
const employeeSummary = ref(null)
|
||||
|
||||
const {
|
||||
activeCase,
|
||||
@@ -173,4 +181,10 @@ const {
|
||||
travelPrompts,
|
||||
uploadedFiles
|
||||
} = useAppShell()
|
||||
|
||||
const { currentUser, logout } = useSystemState()
|
||||
|
||||
function handleLogout() {
|
||||
logout('manual')
|
||||
}
|
||||
</script>
|
||||
|
||||
27
web/src/views/BackendUnavailableRouteView.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<section class="backend-unavailable">
|
||||
<div class="backend-card">
|
||||
<div class="backend-badge">
|
||||
<i class="mdi mdi-server-network-off"></i>
|
||||
</div>
|
||||
<h1>后端服务不可用</h1>
|
||||
<p>{{ statusMessage }}</p>
|
||||
|
||||
<div class="backend-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="retry-btn"
|
||||
:disabled="retrying || backendChecking"
|
||||
@click="retry"
|
||||
>
|
||||
<i class="mdi" :class="retrying || backendChecking ? 'mdi-loading mdi-spin' : 'mdi-refresh'"></i>
|
||||
<span>{{ retrying || backendChecking ? '重新检测中...' : '重新检测后端' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script src="./scripts/BackendUnavailableRouteView.js"></script>
|
||||
|
||||
<style scoped src="../assets/styles/views/backend-unavailable-view.css"></style>
|
||||
@@ -9,7 +9,10 @@
|
||||
<div class="hero-copy">
|
||||
<div class="hero-tag">{{ selectedEmployee.employeeNo }}</div>
|
||||
<h2>{{ selectedEmployee.name }}</h2>
|
||||
<p>{{ selectedEmployee.department }} / {{ selectedEmployee.position }} / {{ selectedEmployee.grade }}</p>
|
||||
<p>
|
||||
{{ selectedEmployee.department }} / {{ selectedEmployee.position }} /
|
||||
{{ selectedEmployee.grade }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -218,12 +221,13 @@
|
||||
<nav class="status-tabs" aria-label="员工状态筛选">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab"
|
||||
:key="tab.label"
|
||||
type="button"
|
||||
:class="{ active: activeTab === tab }"
|
||||
@click="activeTab = tab"
|
||||
:class="{ active: activeTab === tab.label }"
|
||||
@click="activeTab = tab.label"
|
||||
>
|
||||
{{ tab }}
|
||||
<span>{{ tab.label }}</span>
|
||||
<small>{{ tab.count }}</small>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -231,25 +235,204 @@
|
||||
<div class="filter-set">
|
||||
<div class="list-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input type="search" placeholder="搜索员工姓名、工号、部门或岗位..." />
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="search"
|
||||
placeholder="搜索姓名、工号、部门、岗位"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
|
||||
<span>{{ filter }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div class="picker-filter" :class="{ open: activeFilterPopover === 'department' }">
|
||||
<button
|
||||
class="picker-trigger"
|
||||
type="button"
|
||||
:aria-expanded="activeFilterPopover === 'department'"
|
||||
aria-haspopup="dialog"
|
||||
@click="toggleFilterPopover('department')"
|
||||
>
|
||||
<span class="picker-label">{{ selectedDepartment || '组织部门' }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="activeFilterPopover === 'department'"
|
||||
class="picker-popover"
|
||||
role="dialog"
|
||||
aria-label="选择组织部门"
|
||||
>
|
||||
<header>
|
||||
<strong>选择组织部门</strong>
|
||||
<button type="button" aria-label="关闭组织部门选择" @click="closeFilterPopover">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
<div class="picker-option-list">
|
||||
<button
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: !selectedDepartment }"
|
||||
@click="selectFilter('department', '')"
|
||||
>
|
||||
全部部门
|
||||
</button>
|
||||
<button
|
||||
v-for="department in departmentOptions"
|
||||
:key="department"
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: selectedDepartment === department }"
|
||||
@click="selectFilter('department', department)"
|
||||
>
|
||||
{{ department }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="picker-filter" :class="{ open: activeFilterPopover === 'grade' }">
|
||||
<button
|
||||
class="picker-trigger"
|
||||
type="button"
|
||||
:aria-expanded="activeFilterPopover === 'grade'"
|
||||
aria-haspopup="dialog"
|
||||
@click="toggleFilterPopover('grade')"
|
||||
>
|
||||
<span class="picker-label">{{ selectedGrade || '职级' }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="activeFilterPopover === 'grade'"
|
||||
class="picker-popover"
|
||||
role="dialog"
|
||||
aria-label="选择职级"
|
||||
>
|
||||
<header>
|
||||
<strong>选择职级</strong>
|
||||
<button type="button" aria-label="关闭职级选择" @click="closeFilterPopover">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
<div class="picker-option-list">
|
||||
<button
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: !selectedGrade }"
|
||||
@click="selectFilter('grade', '')"
|
||||
>
|
||||
全部职级
|
||||
</button>
|
||||
<button
|
||||
v-for="grade in gradeOptions"
|
||||
:key="grade"
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: selectedGrade === grade }"
|
||||
@click="selectFilter('grade', grade)"
|
||||
>
|
||||
{{ grade }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="picker-filter" :class="{ open: activeFilterPopover === 'role' }">
|
||||
<button
|
||||
class="picker-trigger"
|
||||
type="button"
|
||||
:aria-expanded="activeFilterPopover === 'role'"
|
||||
aria-haspopup="dialog"
|
||||
@click="toggleFilterPopover('role')"
|
||||
>
|
||||
<span class="picker-label">{{ selectedRole || '系统角色' }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="activeFilterPopover === 'role'"
|
||||
class="picker-popover"
|
||||
role="dialog"
|
||||
aria-label="选择系统角色"
|
||||
>
|
||||
<header>
|
||||
<strong>选择系统角色</strong>
|
||||
<button type="button" aria-label="关闭系统角色选择" @click="closeFilterPopover">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
<div class="picker-option-list">
|
||||
<button
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: !selectedRole }"
|
||||
@click="selectFilter('role', '')"
|
||||
>
|
||||
全部角色
|
||||
</button>
|
||||
<button
|
||||
v-for="role in roleFilterOptions"
|
||||
:key="role"
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: selectedRole === role }"
|
||||
@click="selectFilter('role', role)"
|
||||
>
|
||||
{{ role }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="create-btn" type="button">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
<span>新增员工</span>
|
||||
</button>
|
||||
<div class="toolbar-actions">
|
||||
<button v-if="hasActiveFilters" class="ghost-filter-btn" type="button" @click="resetFilters">
|
||||
<i class="mdi mdi-filter-remove-outline"></i>
|
||||
<span>清空筛选</span>
|
||||
</button>
|
||||
|
||||
<button class="create-btn" type="button">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
<span>新增员工</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击任意员工行可进入基础信息与角色权限编辑界面</p>
|
||||
<p class="hint">
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
点击任意员工行可进入基础信息与角色权限编辑界面
|
||||
</p>
|
||||
|
||||
<div v-if="activeFilterTokens.length" class="active-filter-strip">
|
||||
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
|
||||
{{ token }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<div v-if="loading" class="table-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<p>正在加载员工数据...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="errorMessage" class="table-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<p>{{ errorMessage }}</p>
|
||||
<button type="button" class="state-action" @click="loadEmployees">重新加载</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!visibleEmployees.length" class="table-state empty">
|
||||
<i class="mdi mdi-account-search-outline"></i>
|
||||
<p>没有匹配的员工数据</p>
|
||||
</div>
|
||||
|
||||
<table v-else>
|
||||
<colgroup>
|
||||
<col class="col-employee">
|
||||
<col class="col-employee-no">
|
||||
<col class="col-department">
|
||||
<col class="col-position">
|
||||
<col class="col-grade">
|
||||
<col class="col-role">
|
||||
<col class="col-status">
|
||||
<col class="col-updated">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>员工</th>
|
||||
@@ -257,8 +440,6 @@
|
||||
<th>部门</th>
|
||||
<th>岗位</th>
|
||||
<th>职级</th>
|
||||
<th>直属上级</th>
|
||||
<th>财务归口</th>
|
||||
<th>系统角色</th>
|
||||
<th>状态</th>
|
||||
<th>最近更新</th>
|
||||
@@ -284,20 +465,81 @@
|
||||
<td>{{ employee.department }}</td>
|
||||
<td>{{ employee.position }}</td>
|
||||
<td><span class="level-pill">{{ employee.grade }}</span></td>
|
||||
<td>{{ employee.manager }}</td>
|
||||
<td>{{ employee.financeOwner }}</td>
|
||||
<td>
|
||||
<div class="role-stack">
|
||||
<span v-for="role in employee.roles.slice(0, 2)" :key="role" class="role-pill">{{ role }}</span>
|
||||
<span v-if="employee.roles.length > 2" class="more-pill">+{{ employee.roles.length - 2 }}</span>
|
||||
<span
|
||||
v-for="role in employee.roles.slice(0, 2)"
|
||||
:key="role"
|
||||
class="role-pill"
|
||||
>
|
||||
{{ role }}
|
||||
</span>
|
||||
<span v-if="employee.roles.length > 2" class="more-pill">
|
||||
+{{ employee.roles.length - 2 }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="status-pill" :class="employee.statusTone">{{ employee.status }}</span></td>
|
||||
<td>
|
||||
<span class="status-pill" :class="employee.statusTone">{{ employee.status }}</span>
|
||||
</td>
|
||||
<td>{{ employee.updatedAt }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer v-if="!loading && !errorMessage && totalCount" class="list-foot">
|
||||
<span class="page-summary">共 {{ totalCount }} 条,目前第 {{ currentPage }} 页</span>
|
||||
<div class="pager" aria-label="分页">
|
||||
<button
|
||||
class="page-nav"
|
||||
type="button"
|
||||
:disabled="currentPage === 1"
|
||||
aria-label="上一页"
|
||||
@click="currentPage--"
|
||||
>
|
||||
<i class="mdi mdi-chevron-left"></i>
|
||||
</button>
|
||||
<button
|
||||
v-for="page in totalPages"
|
||||
:key="page"
|
||||
class="page-number"
|
||||
:class="{ active: currentPage === page }"
|
||||
type="button"
|
||||
:aria-current="currentPage === page ? 'page' : undefined"
|
||||
@click="currentPage = page"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<button
|
||||
class="page-nav"
|
||||
type="button"
|
||||
:disabled="currentPage === totalPages"
|
||||
aria-label="下一页"
|
||||
@click="currentPage++"
|
||||
>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="page-size-wrap">
|
||||
<button class="page-size" type="button" @click="togglePageSizeOpen">
|
||||
{{ pageSize }} 条/页 <i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
|
||||
<button
|
||||
v-for="size in pageSizes"
|
||||
:key="size"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="pageSize === size"
|
||||
:class="{ active: pageSize === size }"
|
||||
@click="changePageSize(size)"
|
||||
>
|
||||
{{ size }} 条/页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</Transition>
|
||||
</section>
|
||||
|
||||
39
web/src/views/scripts/BackendUnavailableRouteView.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useBackendHealth } from '../../composables/useBackendHealth.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
|
||||
export default {
|
||||
name: 'BackendUnavailableRouteView',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const { backendChecking, backendError, checkBackendHealth } = useBackendHealth()
|
||||
const { loggedIn, resolveEntryRoute } = useSystemState()
|
||||
const retrying = ref(false)
|
||||
|
||||
const statusMessage = computed(() => {
|
||||
return backendError.value || '后端服务尚未就绪,请先检查 FastAPI 和数据库连接。'
|
||||
})
|
||||
|
||||
async function retry() {
|
||||
retrying.value = true
|
||||
|
||||
try {
|
||||
const ok = await checkBackendHealth({ force: true })
|
||||
if (ok) {
|
||||
await router.replace(loggedIn.value ? resolveEntryRoute() : { name: 'login' })
|
||||
}
|
||||
} finally {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
backendChecking,
|
||||
retrying,
|
||||
statusMessage,
|
||||
retry
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,202 +1,373 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { fetchEmployeeMeta, fetchEmployees } from '../../services/employees.js'
|
||||
|
||||
const DEFAULT_STATUS_TABS = ['全部员工', '在职', '试用中', '停用']
|
||||
const FALLBACK_ROLE_OPTIONS = [
|
||||
{
|
||||
id: 'manager',
|
||||
code: 'manager',
|
||||
label: '管理员',
|
||||
desc: '可以维护员工档案、组织结构和角色权限。'
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
code: 'finance',
|
||||
label: '财务人员',
|
||||
desc: '可以处理复核、查看财务知识与风险校验结果。'
|
||||
},
|
||||
{
|
||||
id: 'approver',
|
||||
code: 'approver',
|
||||
label: '审批负责人',
|
||||
desc: '可以处理审批中心中的待审单据。'
|
||||
},
|
||||
{
|
||||
id: 'executive',
|
||||
code: 'executive',
|
||||
label: '高级管理人员',
|
||||
desc: '可以查看跨部门数据看板与关键审批结果。'
|
||||
},
|
||||
{
|
||||
id: 'auditor',
|
||||
code: 'auditor',
|
||||
label: '审计观察员',
|
||||
desc: '可以查看变更记录和权限调整历史。'
|
||||
},
|
||||
{
|
||||
id: 'user',
|
||||
code: 'user',
|
||||
label: '使用者',
|
||||
desc: '可以发起报销、查看个人单据和使用 AI 助手。'
|
||||
}
|
||||
]
|
||||
|
||||
function matchKeyword(employee, keyword) {
|
||||
if (!keyword) {
|
||||
return true
|
||||
}
|
||||
|
||||
const haystack = [
|
||||
employee.name,
|
||||
employee.employeeNo,
|
||||
employee.department,
|
||||
employee.position,
|
||||
employee.email,
|
||||
employee.manager,
|
||||
employee.financeOwner,
|
||||
employee.syncState,
|
||||
...(employee.roles || [])
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(keyword)
|
||||
}
|
||||
|
||||
function uniqueSorted(values) {
|
||||
return [...new Set(values.filter(Boolean))].sort((left, right) => {
|
||||
return String(left).localeCompare(String(right), 'zh-CN')
|
||||
})
|
||||
}
|
||||
|
||||
function resolveRoleOptions(metaRoles, employees) {
|
||||
const options = Array.isArray(metaRoles) && metaRoles.length ? metaRoles : FALLBACK_ROLE_OPTIONS
|
||||
const existingLabels = new Set(options.map((item) => item.label))
|
||||
const unknownRoles = uniqueSorted(employees.flatMap((item) => item.roles || [])).filter(
|
||||
(label) => !existingLabels.has(label)
|
||||
)
|
||||
|
||||
return [
|
||||
...options,
|
||||
...unknownRoles.map((label) => ({
|
||||
id: label,
|
||||
code: label,
|
||||
label,
|
||||
desc: '该角色来自当前员工数据。'
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
function buildStatusTabs(employees) {
|
||||
return DEFAULT_STATUS_TABS.map((label) => ({
|
||||
label,
|
||||
count:
|
||||
label === '全部员工'
|
||||
? employees.length
|
||||
: employees.filter((item) => item.status === label).length
|
||||
}))
|
||||
}
|
||||
|
||||
function buildEmployeeSummary(employees) {
|
||||
return {
|
||||
total: employees.length,
|
||||
active: employees.filter((item) => item.status === '在职').length,
|
||||
onboarding: employees.filter((item) => item.status === '试用中').length,
|
||||
disabled: employees.filter((item) => item.status === '停用').length,
|
||||
followUp: employees.filter((item) => item.syncState !== '已同步').length,
|
||||
departments: uniqueSorted(employees.map((item) => item.department)).length
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'EmployeeManagementView' ,
|
||||
setup(props, { emit }) {
|
||||
const tabs = ['全部员工', '在职', '试用中', '停用']
|
||||
const filters = ['按部门筛选', '按职级筛选', '按系统角色筛选']
|
||||
const activeTab = ref(tabs[0])
|
||||
name: 'EmployeeManagementView',
|
||||
emits: ['overview-change'],
|
||||
setup(_, { emit }) {
|
||||
const activeTab = ref(DEFAULT_STATUS_TABS[0])
|
||||
const selectedEmployee = ref(null)
|
||||
const roleOptions = ref([...FALLBACK_ROLE_OPTIONS])
|
||||
const employees = ref([])
|
||||
const searchKeyword = ref('')
|
||||
const selectedDepartment = ref('')
|
||||
const selectedGrade = ref('')
|
||||
const selectedRole = ref('')
|
||||
const activeFilterPopover = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const pageSizes = [10, 20, 50]
|
||||
const pageSizeOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const roleOptions = [
|
||||
{ id: 'user', label: '使用者', desc: '可以发起报销、查看个人单据和使用 AI 助手。' },
|
||||
{ id: 'finance', label: '财务人员', desc: '可以处理复核、查看财务知识与风险校验结果。' },
|
||||
{ id: 'manager', label: '管理员', desc: '可以维护员工档案、组织结构和角色权限。' },
|
||||
{ id: 'executive', label: '高级管理人员', desc: '可以查看跨部门数据看板与关键审批结果。' },
|
||||
{ id: 'approver', label: '审批负责人', desc: '可以处理审批中心中的待审单据。' },
|
||||
{ id: 'auditor', label: '审计观察员', desc: '可以查看变更记录和权限调整历史。' }
|
||||
]
|
||||
const tabs = computed(() => buildStatusTabs(employees.value))
|
||||
const employeeSummary = computed(() => buildEmployeeSummary(employees.value))
|
||||
|
||||
const employees = [
|
||||
{
|
||||
id: 'EMP-001',
|
||||
avatar: '张',
|
||||
name: '张晓晴',
|
||||
employeeNo: 'E10234',
|
||||
department: '财务共享中心',
|
||||
position: '费用运营经理',
|
||||
grade: 'M3',
|
||||
manager: '李文静',
|
||||
financeOwner: '华东财务组',
|
||||
roles: ['管理员', '财务人员', '审批负责人'],
|
||||
status: '在职',
|
||||
statusTone: 'success',
|
||||
gender: '女',
|
||||
age: '32',
|
||||
birthDate: '1994-08-12',
|
||||
email: 'xiaoqing.zhang@xfinance.com',
|
||||
phone: '138 1023 4567',
|
||||
joinDate: '2021-03-15',
|
||||
location: '上海',
|
||||
costCenter: 'CC-2108',
|
||||
updatedAt: '2026-05-06 10:24',
|
||||
lastSync: '2026-05-06 10:24',
|
||||
syncState: '待生效',
|
||||
spotlight: true,
|
||||
permissions: [
|
||||
'可查看审批中心全部待审单据',
|
||||
'可配置员工角色与部门归属',
|
||||
'可查看知识管理与技能中心配置'
|
||||
],
|
||||
history: [
|
||||
{ action: '新增“审批负责人”角色', owner: '系统管理员 · 王敏', time: '今天 10:24' },
|
||||
{ action: '调整财务归口为华东财务组', owner: '组织管理员 · 陈硕', time: '昨天 18:10' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'EMP-002',
|
||||
avatar: '李',
|
||||
name: '李文静',
|
||||
employeeNo: 'E10018',
|
||||
department: '总经办',
|
||||
position: '高级财务总监',
|
||||
grade: 'D2',
|
||||
manager: 'CEO',
|
||||
financeOwner: '集团财务',
|
||||
roles: ['高级管理人员', '审批负责人'],
|
||||
status: '在职',
|
||||
statusTone: 'success',
|
||||
gender: '女',
|
||||
age: '39',
|
||||
birthDate: '1987-03-26',
|
||||
email: 'wenjing.li@xfinance.com',
|
||||
phone: '139 0018 7688',
|
||||
joinDate: '2018-06-21',
|
||||
location: '上海',
|
||||
costCenter: 'CC-1001',
|
||||
updatedAt: '2026-05-05 16:20',
|
||||
lastSync: '2026-05-05 16:20',
|
||||
syncState: '已同步',
|
||||
permissions: [
|
||||
'可查看集团层面的审批看板',
|
||||
'可处理高金额报销的最终审批',
|
||||
'可查看部门预算执行情况'
|
||||
],
|
||||
history: [
|
||||
{ action: '更新高级管理人员可见范围', owner: '系统管理员 · 王敏', time: '05-05 16:20' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'EMP-003',
|
||||
avatar: '王',
|
||||
name: '王敏',
|
||||
employeeNo: 'E10867',
|
||||
department: '人力与组织',
|
||||
position: '组织发展主管',
|
||||
grade: 'P6',
|
||||
manager: '陈嘉',
|
||||
financeOwner: '总部财务',
|
||||
roles: ['管理员', '审计观察员'],
|
||||
status: '在职',
|
||||
statusTone: 'success',
|
||||
gender: '女',
|
||||
age: '30',
|
||||
birthDate: '1996-11-05',
|
||||
email: 'min.wang@xfinance.com',
|
||||
phone: '136 8867 1200',
|
||||
joinDate: '2022-08-08',
|
||||
location: '杭州',
|
||||
costCenter: 'CC-3206',
|
||||
updatedAt: '2026-05-05 09:18',
|
||||
lastSync: '2026-05-05 09:18',
|
||||
syncState: '已同步',
|
||||
permissions: [
|
||||
'可维护组织结构与岗位映射',
|
||||
'可查看员工角色分配历史'
|
||||
],
|
||||
history: [
|
||||
{ action: '新增“审计观察员”角色', owner: '系统管理员 · 张晓晴', time: '05-05 09:18' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'EMP-004',
|
||||
avatar: '陈',
|
||||
name: '陈嘉',
|
||||
employeeNo: 'E11602',
|
||||
department: '销售运营',
|
||||
position: '区域销售经理',
|
||||
grade: 'M2',
|
||||
manager: '李文静',
|
||||
financeOwner: '华南财务组',
|
||||
roles: ['使用者', '审批负责人'],
|
||||
status: '试用中',
|
||||
statusTone: 'warning',
|
||||
gender: '男',
|
||||
age: '29',
|
||||
birthDate: '1997-02-18',
|
||||
email: 'jia.chen@xfinance.com',
|
||||
phone: '137 1602 9901',
|
||||
joinDate: '2026-03-01',
|
||||
location: '深圳',
|
||||
costCenter: 'CC-4102',
|
||||
updatedAt: '2026-05-04 14:12',
|
||||
lastSync: '2026-05-04 14:12',
|
||||
syncState: '已同步',
|
||||
permissions: [
|
||||
'可发起个人报销与出差申请',
|
||||
'可处理本部门基础审批'
|
||||
],
|
||||
history: [
|
||||
{ action: '完成试用期角色初始化', owner: '组织管理员 · 王敏', time: '05-04 14:12' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'EMP-005',
|
||||
avatar: '赵',
|
||||
name: '赵雨辰',
|
||||
employeeNo: 'E11991',
|
||||
department: '研发中心',
|
||||
position: '产品经理',
|
||||
grade: 'P5',
|
||||
manager: '陈嘉',
|
||||
financeOwner: '总部财务',
|
||||
roles: ['使用者'],
|
||||
status: '停用',
|
||||
statusTone: 'neutral',
|
||||
gender: '男',
|
||||
age: '27',
|
||||
birthDate: '1999-06-09',
|
||||
email: 'yuchen.zhao@xfinance.com',
|
||||
phone: '135 1991 3300',
|
||||
joinDate: '2023-11-18',
|
||||
location: '北京',
|
||||
costCenter: 'CC-5209',
|
||||
updatedAt: '2026-05-01 11:06',
|
||||
lastSync: '2026-05-01 11:06',
|
||||
syncState: '已同步',
|
||||
permissions: [
|
||||
'当前账号停用,仅保留历史单据查看记录'
|
||||
],
|
||||
history: [
|
||||
{ action: '账号状态变更为停用', owner: '系统管理员 · 王敏', time: '05-01 11:06' }
|
||||
]
|
||||
}
|
||||
]
|
||||
const departmentOptions = computed(() =>
|
||||
uniqueSorted(employees.value.map((item) => item.department))
|
||||
)
|
||||
|
||||
const gradeOptions = computed(() => uniqueSorted(employees.value.map((item) => item.grade)))
|
||||
|
||||
const roleFilterOptions = computed(() =>
|
||||
uniqueSorted(
|
||||
roleOptions.value.map((item) => item.label).concat(
|
||||
employees.value.flatMap((item) => item.roles || [])
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const filteredEmployees = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
|
||||
return employees.value.filter((item) => {
|
||||
const matchesStatus =
|
||||
activeTab.value === '全部员工' ? true : item.status === activeTab.value
|
||||
const matchesDepartment = selectedDepartment.value
|
||||
? item.department === selectedDepartment.value
|
||||
: true
|
||||
const matchesGrade = selectedGrade.value ? item.grade === selectedGrade.value : true
|
||||
const matchesRole = selectedRole.value
|
||||
? (item.roles || []).includes(selectedRole.value)
|
||||
: true
|
||||
|
||||
return (
|
||||
matchesStatus &&
|
||||
matchesDepartment &&
|
||||
matchesGrade &&
|
||||
matchesRole &&
|
||||
matchKeyword(item, keyword)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const totalCount = computed(() => filteredEmployees.value.length)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
|
||||
|
||||
const visibleEmployees = computed(() => {
|
||||
if (activeTab.value === '全部员工') return employees
|
||||
return employees.filter((item) => item.status === activeTab.value)
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return filteredEmployees.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const activeFilterTokens = computed(() => {
|
||||
const tokens = []
|
||||
|
||||
if (selectedDepartment.value) {
|
||||
tokens.push(`部门:${selectedDepartment.value}`)
|
||||
}
|
||||
|
||||
if (selectedGrade.value) {
|
||||
tokens.push(`职级:${selectedGrade.value}`)
|
||||
}
|
||||
|
||||
if (selectedRole.value) {
|
||||
tokens.push(`角色:${selectedRole.value}`)
|
||||
}
|
||||
|
||||
if (searchKeyword.value.trim()) {
|
||||
tokens.push(`搜索:${searchKeyword.value.trim()}`)
|
||||
}
|
||||
|
||||
return tokens
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() => activeFilterTokens.value.length > 0)
|
||||
|
||||
watch(
|
||||
employeeSummary,
|
||||
(summary) => {
|
||||
emit('overview-change', summary)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(filteredEmployees, () => {
|
||||
currentPage.value = 1
|
||||
pageSizeOpen.value = false
|
||||
})
|
||||
|
||||
function resetFilters() {
|
||||
searchKeyword.value = ''
|
||||
selectedDepartment.value = ''
|
||||
selectedGrade.value = ''
|
||||
selectedRole.value = ''
|
||||
activeTab.value = DEFAULT_STATUS_TABS[0]
|
||||
activeFilterPopover.value = ''
|
||||
pageSizeOpen.value = false
|
||||
}
|
||||
|
||||
function changePageSize(size) {
|
||||
pageSize.value = size
|
||||
pageSizeOpen.value = false
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function togglePageSizeOpen() {
|
||||
pageSizeOpen.value = !pageSizeOpen.value
|
||||
}
|
||||
|
||||
function toggleFilterPopover(name) {
|
||||
activeFilterPopover.value = activeFilterPopover.value === name ? '' : name
|
||||
}
|
||||
|
||||
function closeFilterPopover() {
|
||||
activeFilterPopover.value = ''
|
||||
}
|
||||
|
||||
function selectFilter(name, value) {
|
||||
if (name === 'department') {
|
||||
selectedDepartment.value = value
|
||||
}
|
||||
|
||||
if (name === 'grade') {
|
||||
selectedGrade.value = value
|
||||
}
|
||||
|
||||
if (name === 'role') {
|
||||
selectedRole.value = value
|
||||
}
|
||||
|
||||
closeFilterPopover()
|
||||
}
|
||||
|
||||
function handleDocumentClick(event) {
|
||||
const target = event.target
|
||||
|
||||
if (!(target instanceof Element)) {
|
||||
closeFilterPopover()
|
||||
pageSizeOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!target.closest('.picker-filter')) {
|
||||
closeFilterPopover()
|
||||
}
|
||||
|
||||
if (!target.closest('.page-size-wrap')) {
|
||||
pageSizeOpen.value = false
|
||||
}
|
||||
|
||||
if (target.closest('.picker-filter') || target.closest('.page-size-wrap')) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
const [employeesResult, metaResult] = await Promise.allSettled([
|
||||
fetchEmployees(),
|
||||
fetchEmployeeMeta()
|
||||
])
|
||||
|
||||
if (employeesResult.status !== 'fulfilled') {
|
||||
employees.value = []
|
||||
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
|
||||
selectedEmployee.value = null
|
||||
errorMessage.value =
|
||||
employeesResult.reason?.message || '员工数据加载失败,请稍后重试。'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
employees.value = Array.isArray(employeesResult.value) ? employeesResult.value : []
|
||||
|
||||
if (metaResult.status === 'fulfilled') {
|
||||
roleOptions.value = resolveRoleOptions(metaResult.value?.roleOptions, employees.value)
|
||||
} else {
|
||||
roleOptions.value = resolveRoleOptions([], employees.value)
|
||||
}
|
||||
|
||||
if (!DEFAULT_STATUS_TABS.includes(activeTab.value)) {
|
||||
activeTab.value = DEFAULT_STATUS_TABS[0]
|
||||
}
|
||||
|
||||
if (selectedEmployee.value) {
|
||||
selectedEmployee.value =
|
||||
employees.value.find((item) => item.id === selectedEmployee.value.id) || null
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
loadEmployees().catch((error) => {
|
||||
employees.value = []
|
||||
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
|
||||
selectedEmployee.value = null
|
||||
errorMessage.value = error?.message || '员工数据加载失败,请稍后重试。'
|
||||
loading.value = false
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
})
|
||||
|
||||
return {
|
||||
tabs,
|
||||
filters,
|
||||
activeTab,
|
||||
selectedEmployee,
|
||||
roleOptions,
|
||||
employees,
|
||||
visibleEmployees
|
||||
visibleEmployees,
|
||||
searchKeyword,
|
||||
selectedDepartment,
|
||||
selectedGrade,
|
||||
selectedRole,
|
||||
activeFilterPopover,
|
||||
currentPage,
|
||||
pageSize,
|
||||
pageSizes,
|
||||
pageSizeOpen,
|
||||
departmentOptions,
|
||||
gradeOptions,
|
||||
roleFilterOptions,
|
||||
activeFilterTokens,
|
||||
hasActiveFilters,
|
||||
totalCount,
|
||||
totalPages,
|
||||
resetFilters,
|
||||
changePageSize,
|
||||
togglePageSizeOpen,
|
||||
toggleFilterPopover,
|
||||
closeFilterPopover,
|
||||
selectFilter,
|
||||
loading,
|
||||
errorMessage,
|
||||
loadEmployees
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export MSYS_NO_PATHCONV=1
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -43,15 +45,16 @@ is_wsl() {
|
||||
grep -qi microsoft /proc/version 2>/dev/null
|
||||
}
|
||||
|
||||
is_windows_mount() {
|
||||
is_windows_path() {
|
||||
case "$SCRIPT_DIR" in
|
||||
/mnt/*) return 0 ;;
|
||||
/d/*|/c/*|/e/*|/f/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
use_windows_npm() {
|
||||
is_wsl && is_windows_mount && command -v powershell.exe >/dev/null 2>&1 && command -v wslpath >/dev/null 2>&1
|
||||
is_wsl && is_windows_path && command -v powershell.exe >/dev/null 2>&1 && command -v wslpath >/dev/null 2>&1
|
||||
}
|
||||
|
||||
windows_project_path() {
|
||||
|
||||