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

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)