feat: add employee management, backend health check, and UI improvements

This commit is contained in:
2026-05-07 11:50:10 +08:00
parent a5db09f41e
commit c00db75c13
59 changed files with 3926 additions and 5796 deletions

BIN
web/UI/AI助手.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
web/UI/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
web/UI/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
web/UI/main_page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
web/UI/发起请求.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
web/UI/审批中心.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
web/UI/报销.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
web/UI/知识库.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
web/UI/首页工作台.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

BIN
web/UI/首页风险.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View 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;
}

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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)

View 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
}
}

View File

@@ -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
}
}

View File

@@ -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, {

View File

@@ -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
View 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
}

View 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}`)
}

View File

@@ -0,0 +1,5 @@
import { apiRequest } from './api.js'
export function fetchBackendHealth() {
return apiRequest('/health')
}

View File

@@ -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>

View 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>

View File

@@ -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>

View 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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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() {