Add vue-router, login/setup flow and backend logging

Refactor frontend to route-based navigation with vue-router, add
system setup and login pages with API integration. Add structured
logging, access-log middleware and startup lifecycle to FastAPI
backend.
This commit is contained in:
2026-05-06 22:23:42 +08:00
parent 83d7da3d62
commit ae63766c91
35 changed files with 3762 additions and 404 deletions

View File

@@ -1,201 +1,17 @@
<template>
<!-- Login Page -->
<LoginView
v-if="!loggedIn"
@login="handleLogin"
@recover-password="handleRecoverPassword"
@sso-login="handleSsoLogin"
/>
<!-- Main App -->
<div v-else class="app">
<SidebarRail
:nav-items="navItems"
:active-view="activeView"
@navigate="handleNavigate"
@open-chat="handleOpenChat"
/>
<main
class="main"
:class="{
'chat-main': activeView === 'chat',
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval',
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'employees-main': activeView === 'employees'
}"
>
<TopBar
:current-view="topBarView"
:search="search"
:active-view="activeView"
:ranges="ranges"
:active-range="activeRange"
:custom-range="customRange"
@update:search="search = $event"
@update:active-range="activeRange = $event"
@update:custom-range="customRange = $event"
@batch-approve="toast('已筛出 23 个低风险单据可进入批量通过确认')"
@open-chat="handleOpenChat"
@new-application="openTravelCreate"
/>
<FilterBar
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
:active-range="activeRange"
@update:active-range="activeRange = $event"
/>
<section
class="workarea"
:class="{
'chat-workarea': activeView === 'chat',
'requests-workarea': activeView === 'requests',
'approval-workarea': activeView === 'approval',
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
'employees-workarea': activeView === 'employees'
}"
>
<OverviewView
v-if="activeView === 'overview'"
:filtered-requests="filteredRequests"
@ask="handleOpenChat"
@approve="handleApprove"
@reject="handleReject"
/>
<PersonalWorkbenchView
v-else-if="activeView === 'workbench'"
@open-assistant="openSmartEntry"
/>
<ChatView
v-else-if="activeView === 'chat'"
:documents="filteredDocuments"
:doc-search="docSearch"
:messages="messages"
:uploaded-files="uploadedFiles"
:active-case="activeCase"
:quick-prompts="travelPrompts"
:draft="draft"
:message-list="messageList"
@send="sendMessage"
@upload="handleUpload"
@draft="draft = $event"
@select-case="handleOpenChat"
@approve-case="toast(`${activeCase?.id} 已生成通过意见`)"
@reject-case="toast(`${activeCase?.id} 已转人工复核`)"
/>
<TravelRequestDetailView
v-else-if="activeView === 'requests' && detailMode"
:request="selectedTravelRequest"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
/>
<RequestsView
v-else-if="activeView === 'requests'"
:filtered-requests="filteredRequests"
@ask="openRequestDetail"
@approve="handleApprove"
@reject="handleReject"
@create-request="openTravelCreate"
/>
<ApprovalCenterView v-else-if="activeView === 'approval'" />
<PoliciesView v-else-if="activeView === 'policies'" />
<AuditView v-else-if="activeView === 'audit'" />
<EmployeeManagementView v-else />
</section>
</main>
<TravelReimbursementCreateView
v-if="smartEntryOpen"
:key="smartEntrySessionId"
:initial-prompt="smartEntryContext.prompt"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
@close="closeSmartEntry"
/>
</div>
<RouterView />
<ToastNotification :toast-text="toastText" />
</template>
<script setup>
import { RouterView } from 'vue-router'
import './assets/styles/global.css'
import SidebarRail from './components/layout/SidebarRail.vue'
import TopBar from './components/layout/TopBar.vue'
import FilterBar from './components/layout/FilterBar.vue'
import ToastNotification from './components/shared/ToastNotification.vue'
import LoginView from './views/LoginView.vue'
import OverviewView from './views/OverviewView.vue'
import PersonalWorkbenchView from './views/PersonalWorkbenchView.vue'
import ChatView from './views/ChatView.vue'
import TravelReimbursementCreateView from './views/TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './views/TravelRequestDetailView.vue'
import RequestsView from './views/RequestsView.vue'
import ApprovalCenterView from './views/ApprovalCenterView.vue'
import PoliciesView from './views/PoliciesView.vue'
import AuditView from './views/AuditView.vue'
import EmployeeManagementView from './views/EmployeeManagementView.vue'
import { useToast } from './composables/useToast.js'
import { useAppShell } from './composables/useAppShell.js'
const {
activeCase,
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
customRange,
detailMode,
docSearch,
draft,
filteredDocuments,
filteredRequests,
filters,
handleApprove,
handleLogin,
handleNavigate,
handleOpenChat,
handleRecoverPassword,
handleReject,
handleSsoLogin,
handleUpload,
loggedIn,
messageList,
messages,
navItems,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
search,
selectedTravelRequest,
sendMessage,
smartEntryContext,
smartEntryOpen,
smartEntrySessionId,
toast,
toastText,
topBarView,
travelPrompts,
uploadedFiles
} = useAppShell()
const { toastText } = useToast()
</script>
<style scoped src="./assets/styles/app.css"></style>
<style src="./assets/styles/app.css"></style>

View File

@@ -4,6 +4,68 @@
grid-template-columns: 220px minmax(0, 1fr);
background: var(--bg);
}
.boot-state {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.16), transparent 24rem),
radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.14), transparent 28rem),
#f8fafc;
}
.boot-card {
width: min(560px, 100%);
padding: 36px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
display: grid;
gap: 14px;
}
.boot-card h1 {
font-size: 28px;
}
.boot-card p {
color: var(--muted);
line-height: 1.7;
}
.boot-badge {
display: inline-flex;
width: fit-content;
min-height: 28px;
align-items: center;
padding: 0 10px;
border-radius: 999px;
background: rgba(16, 185, 129, 0.12);
color: #059669;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
}
.boot-badge-error {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
.boot-action {
width: fit-content;
min-height: 44px;
padding: 0 18px;
border: 1px solid transparent;
border-radius: 8px;
background: #0f172a;
color: #fff;
font-weight: 700;
}
.main { min-width: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
.main.overview-main {
grid-template-rows: auto minmax(0, 1fr);

View File

@@ -482,6 +482,16 @@
margin-top: 2px;
}
.login-error {
padding: 12px 14px;
border: 1px solid rgba(239, 68, 68, .18);
border-radius: 8px;
background: #fef2f2;
color: #b91c1c;
font-size: 13px;
line-height: 1.55;
}
.remember {
display: inline-flex;
align-items: center;
@@ -528,6 +538,13 @@
background: linear-gradient(135deg, #13c990, #047857);
}
.submit-btn:disabled,
.sso-btn:disabled {
opacity: .6;
cursor: not-allowed;
box-shadow: none;
}
.divider {
position: relative;
display: grid;

View File

@@ -0,0 +1,607 @@
.setup-page {
min-height: 100dvh;
display: grid;
grid-template-columns: minmax(320px, 392px) minmax(0, 1fr);
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.24), transparent 24rem),
radial-gradient(circle at 36% 14%, rgba(16, 185, 129, 0.16), transparent 18rem),
linear-gradient(135deg, #04110d 0%, #0b1f18 26%, #10281f 26%, #eef5f1 26%, #f6fbf8 100%);
}
.setup-context {
padding: 42px 28px 32px;
color: rgba(255, 255, 255, 0.92);
display: grid;
align-content: start;
gap: 22px;
border-right: 1px solid rgba(110, 231, 183, 0.08);
background: linear-gradient(180deg, rgba(4, 17, 13, 0.92), rgba(16, 40, 31, 0.9));
}
.setup-brand {
display: flex;
gap: 18px;
align-items: flex-start;
}
.setup-brand-mark {
position: relative;
flex: 0 0 64px;
width: 64px;
height: 64px;
display: grid;
place-items: center;
}
.setup-brand-ring {
position: absolute;
inset: 0;
border-radius: 18px;
background:
linear-gradient(145deg, rgba(209, 250, 229, 0.96), rgba(52, 211, 153, 0.88)),
linear-gradient(145deg, rgba(16, 185, 129, 0.4), rgba(5, 150, 105, 0.6));
box-shadow:
0 18px 36px rgba(16, 185, 129, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.46);
transform: rotate(-8deg);
}
.setup-brand-ring::before {
content: '';
position: absolute;
inset: 7px;
border-radius: 14px;
border: 1px solid rgba(4, 120, 87, 0.22);
}
.setup-brand-core {
position: relative;
z-index: 1;
width: 42px;
height: 42px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(3, 32, 24, 0.92);
color: #d1fae5;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.14em;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.setup-kicker {
margin-bottom: 8px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(167, 243, 208, 0.86);
}
.setup-kicker-light {
color: rgba(209, 250, 229, 0.82);
}
.setup-context h1 {
color: #f4fff8;
font-size: clamp(1.9rem, 2.4vw, 2.5rem);
line-height: 1.08;
text-shadow: 0 12px 36px rgba(2, 12, 8, 0.34);
}
.setup-lead {
color: rgba(220, 252, 231, 0.84);
font-size: 14px;
line-height: 1.8;
}
.setup-nav {
display: grid;
gap: 10px;
}
.setup-nav-item {
width: 100%;
padding: 14px 14px 14px 12px;
border: 1px solid rgba(110, 231, 183, 0.12);
border-radius: 8px;
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 18px;
align-items: center;
gap: 12px;
background: linear-gradient(160deg, rgba(10, 23, 18, 0.82), rgba(15, 39, 31, 0.72));
color: inherit;
text-align: left;
transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
}
.setup-nav-item:hover {
transform: translateY(-1px);
border-color: rgba(110, 231, 183, 0.22);
}
.setup-nav-item.is-active {
border-color: rgba(16, 185, 129, 0.4);
box-shadow: 0 14px 28px rgba(3, 10, 7, 0.22);
}
.setup-nav-item.is-complete {
background: linear-gradient(160deg, rgba(8, 31, 23, 0.96), rgba(12, 58, 44, 0.86));
}
.setup-nav-index {
width: 40px;
height: 40px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(16, 185, 129, 0.16);
color: #d1fae5;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
}
.setup-nav-copy {
display: grid;
gap: 4px;
}
.setup-nav-copy strong {
color: #f0fdf4;
font-size: 14px;
}
.setup-nav-copy small {
color: rgba(209, 250, 229, 0.72);
font-size: 12px;
line-height: 1.55;
}
.setup-nav-check {
color: #6ee7b7;
font-size: 14px;
}
.setup-progress {
margin-top: 8px;
padding: 16px 18px;
border: 1px solid rgba(110, 231, 183, 0.14);
border-radius: 8px;
background: rgba(7, 33, 25, 0.76);
}
.setup-progress strong {
color: #f0fdf4;
font-size: 15px;
}
.setup-progress p {
margin-top: 8px;
color: rgba(209, 250, 229, 0.72);
font-size: 13px;
line-height: 1.65;
}
.setup-complete {
margin-top: auto;
padding: 16px 18px 0;
border-top: 1px solid rgba(110, 231, 183, 0.12);
display: grid;
gap: 12px;
}
.setup-complete p {
color: rgba(209, 250, 229, 0.76);
font-size: 13px;
line-height: 1.6;
}
.setup-complete-btn {
width: 100%;
}
.setup-panel {
padding: 36px;
display: grid;
align-content: start;
gap: 24px;
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.08), transparent 16rem),
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0));
}
.setup-panel-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 20px 22px;
border: 1px solid rgba(16, 185, 129, 0.14);
border-radius: 8px;
background: linear-gradient(135deg, #063b2e, #0f5f49);
box-shadow: 0 16px 34px rgba(6, 59, 46, 0.16);
}
.setup-panel-head h2 {
color: #ffffff;
font-size: 28px;
}
.setup-panel-desc {
margin-top: 10px;
color: rgba(236, 253, 245, 0.82);
font-size: 14px;
line-height: 1.65;
}
.setup-chip {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: rgba(240, 253, 244, 0.14);
color: #d1fae5;
font-size: 12px;
font-weight: 700;
border: 1px solid rgba(209, 250, 229, 0.18);
}
.setup-chip.is-success {
background: rgba(16, 185, 129, 0.22);
}
.setup-form {
padding: 30px 32px;
border: 1px solid rgba(16, 185, 129, 0.18);
border-radius: 8px;
background: linear-gradient(180deg, rgba(244, 255, 248, 0.98), rgba(255, 255, 255, 0.94));
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12), 0 1px 0 rgba(255, 255, 255, 0.6) inset;
backdrop-filter: blur(14px);
}
.setup-stage {
display: grid;
gap: 22px;
}
.section-head {
display: grid;
gap: 6px;
}
.section-head h3 {
color: #065f46;
font-size: 18px;
}
.section-head p {
color: #5b6f67;
font-size: 13px;
line-height: 1.7;
}
.field-grid {
display: grid;
gap: 16px;
}
.field-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field {
display: grid;
gap: 8px;
}
.field span {
color: #244239;
font-size: 13px;
font-weight: 600;
}
.field-note {
color: #5f7c72;
font-size: 12px;
line-height: 1.5;
}
.field-group-note {
margin-top: -6px;
color: #5f7c72;
font-size: 12px;
line-height: 1.6;
}
.optional-block {
padding: 18px 18px 0;
border: 1px dashed rgba(16, 185, 129, 0.22);
border-radius: 8px;
background: rgba(240, 253, 244, 0.52);
}
.optional-block-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.optional-block-head strong {
color: #065f46;
font-size: 14px;
}
.optional-block-head span {
min-height: 24px;
padding: 0 10px;
border-radius: 999px;
display: inline-flex;
align-items: center;
background: rgba(16, 185, 129, 0.12);
color: #047857;
font-size: 12px;
font-weight: 700;
}
.field input {
width: 100%;
min-height: 46px;
padding: 0 14px;
border: 1px solid rgba(148, 163, 184, 0.78);
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
color: #0f172a;
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.field input:hover {
transform: translateY(-1px);
}
.field input:focus {
border-color: #10b981;
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12);
}
.field-span-2 {
grid-column: span 2;
}
.setup-runtime {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.setup-runtime article {
position: relative;
overflow: hidden;
padding: 16px 18px;
border-radius: 8px;
border: 1px solid rgba(110, 231, 183, 0.14);
display: grid;
gap: 10px;
box-shadow: 0 14px 32px rgba(3, 10, 7, 0.2);
}
.setup-runtime article::before {
content: '';
position: absolute;
inset: 0 auto auto 0;
width: 100%;
height: 3px;
background: linear-gradient(90deg, rgba(209, 250, 229, 0.92), rgba(16, 185, 129, 0.48));
}
.setup-runtime article:nth-child(1) {
background: linear-gradient(150deg, rgba(7, 33, 25, 0.96), rgba(16, 67, 52, 0.9));
}
.setup-runtime article:nth-child(2) {
background: linear-gradient(150deg, rgba(7, 28, 26, 0.96), rgba(11, 59, 61, 0.9));
}
.setup-runtime article:nth-child(3) {
background: linear-gradient(150deg, rgba(16, 28, 19, 0.96), rgba(48, 74, 36, 0.9));
}
.setup-runtime span {
font-size: 12px;
text-transform: uppercase;
color: rgba(167, 243, 208, 0.7);
}
.setup-runtime strong {
color: #f8fffb;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
text-shadow: 0 2px 16px rgba(4, 9, 7, 0.22);
}
.setup-summary-grid {
display: grid;
gap: 12px;
}
.setup-summary-item {
padding: 16px 18px;
border: 1px solid rgba(16, 185, 129, 0.14);
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
background: rgba(248, 250, 252, 0.9);
}
.setup-summary-item strong {
display: block;
color: #0f172a;
font-size: 14px;
}
.setup-summary-item span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.setup-summary-item .pi-check-circle {
color: #10b981;
font-size: 18px;
}
.setup-summary-item .pi-clock {
color: #f59e0b;
font-size: 18px;
}
.setup-error {
margin-top: 22px;
padding: 14px 16px;
border: 1px solid rgba(239, 68, 68, 0.18);
border-radius: 8px;
background: #fef2f2;
color: #b91c1c;
white-space: pre-line;
}
.setup-status {
margin-top: 22px;
padding: 14px 16px;
border-radius: 8px;
white-space: pre-line;
}
.setup-status.is-success {
border: 1px solid rgba(16, 185, 129, 0.18);
background: #ecfdf5;
color: #047857;
}
.setup-status.is-danger {
border: 1px solid rgba(239, 68, 68, 0.18);
background: #fef2f2;
color: #b91c1c;
}
.setup-gate {
margin-top: 14px;
padding: 12px 14px;
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: 8px;
background: #fffbeb;
color: #b45309;
}
.setup-actions {
margin-top: 28px;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
}
.setup-actions-right {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.primary-btn,
.secondary-btn {
min-height: 46px;
padding: 0 18px;
border-radius: 8px;
border: 1px solid transparent;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 700;
transition: transform 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
}
.primary-btn {
background: linear-gradient(135deg, #10b981, #0f766e);
color: #fff;
box-shadow: 0 14px 28px rgba(16, 185, 129, 0.24);
}
.secondary-btn {
background: rgba(240, 253, 244, 0.94);
color: #1f4f41;
border-color: rgba(16, 185, 129, 0.18);
}
.secondary-btn-strong {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(5, 150, 105, 0.12));
color: #065f46;
}
.primary-btn:hover,
.secondary-btn:hover {
transform: translateY(-1px);
}
.primary-btn:disabled,
.secondary-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
@media (max-width: 1180px) {
.setup-page {
grid-template-columns: 1fr;
background:
radial-gradient(circle at top right, rgba(16, 185, 129, 0.2), transparent 22rem),
linear-gradient(180deg, #04110d 0%, #10281f 36%, #eef5f1 36%, #f6fbf8 100%);
}
.setup-context,
.setup-panel {
padding: 28px 24px;
}
.setup-complete {
padding-inline: 0;
}
}
@media (max-width: 820px) {
.field-grid-2,
.setup-runtime {
grid-template-columns: 1fr;
}
.field-span-2 {
grid-column: auto;
}
.setup-actions {
flex-direction: column;
align-items: stretch;
}
.setup-actions-right {
width: 100%;
flex-direction: column;
}
.primary-btn,
.secondary-btn {
width: 100%;
}
}

View File

@@ -1,75 +1,74 @@
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js'
import { useChat } from './useChat.js'
import { useToast } from './useToast.js'
import { documents } from '../data/requests.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
export function useAppShell() {
const loggedIn = ref(false)
const route = useRoute()
const router = useRouter()
const travelCreateMode = ref(false)
const detailMode = ref(false)
const selectedTravelRequest = ref(null)
const smartEntryOpen = ref(false)
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null })
const smartEntrySessionId = ref(0)
const { activeView, currentView, setView } = useNavigation()
const { requests, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest } = useRequests()
const { messages, draft, uploadedFiles, messageList, activeCase, prompts, sendMessage, handleUpload, openChat, openNewChat } = useChat(activeView)
const { toastText, toast } = useToast()
const { requests, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest } =
useRequests()
const { messages, draft, uploadedFiles, messageList, activeCase, prompts, sendMessage, handleUpload, openChat, openNewChat } =
useChat(activeView)
const { toast } = useToast()
const docSearch = ref('')
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
const travelPrompts = ['帮我提交出差申请', '预订机票', '预订酒店', '预订火车票', '查询差旅政策']
const travelPrompts = ['生成差旅摘要', '识别报销风险', '核对审批链', '提取随附票据', '生成沟通建议']
const selectedTravelRequest = computed(() => {
const requestId = String(route.params.requestId || '')
if (!requestId) {
return null
}
const rawRequest = requests.value.find((item) => String(item.id) === requestId)
return normalizeRequestForUi(rawRequest)
})
const detailMode = computed(() => route.name === 'app-request-detail')
const topBarView = computed(() => {
if (detailMode.value) {
return {
title: '差旅报销详情',
desc: '查看报销单据详情、票据识别与审批进度'
title: '差旅申请详情',
desc: '查看申请单、票据、审批意见与风控提示。'
}
}
return currentView.value
})
const filteredDocuments = computed(() => {
const key = docSearch.value.trim().toLowerCase()
return documents.filter((doc) => {
const matchesSearch = !key || `${doc.id}${doc.applicant}${doc.destination}${doc.type}`.toLowerCase().includes(key)
return matchesSearch
})
return documents.filter((doc) => !key || `${doc.id}${doc.applicant}${doc.destination}${doc.type}`.toLowerCase().includes(key))
})
function handleLogin(credentials) {
if (credentials.username && credentials.password) {
loggedIn.value = true
}
}
function handleRecoverPassword() {
toast('请联系系统管理员重置密码。')
}
function handleSsoLogin() {
toast('SSO 登录通道建设中。')
}
function handleApprove(request) {
const msg = approveRequest(request)
toast(msg)
const message = approveRequest(request)
toast(message)
}
function handleReject(request) {
const msg = rejectRequest(request)
toast(msg)
const message = rejectRequest(request)
toast(message)
}
function handleNavigate(view) {
travelCreateMode.value = false
detailMode.value = false
selectedTravelRequest.value = null
smartEntryOpen.value = false
setView(view)
}
@@ -82,8 +81,6 @@ export function useAppShell() {
function openTravelCreate() {
smartEntryOpen.value = true
travelCreateMode.value = false
detailMode.value = false
selectedTravelRequest.value = null
smartEntryContext.value = { prompt: '', source: 'topbar', request: null }
smartEntrySessionId.value += 1
}
@@ -91,10 +88,7 @@ export function useAppShell() {
function openSmartEntry(payload = {}) {
smartEntryOpen.value = true
travelCreateMode.value = false
if (payload.source !== 'detail') {
detailMode.value = false
selectedTravelRequest.value = null
}
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
@@ -108,14 +102,14 @@ export function useAppShell() {
}
function openRequestDetail(request) {
selectedTravelRequest.value = request
detailMode.value = true
activeView.value = 'requests'
router.push({
name: 'app-request-detail',
params: { requestId: request.id }
})
}
function closeRequestDetail() {
detailMode.value = false
selectedTravelRequest.value = null
router.push({ name: 'app-requests' })
}
return {
@@ -133,14 +127,10 @@ export function useAppShell() {
filteredRequests,
filters,
handleApprove,
handleLogin,
handleNavigate,
handleOpenChat,
handleRecoverPassword,
handleReject,
handleSsoLogin,
handleUpload,
loggedIn,
messageList,
messages,
navItems,
@@ -160,7 +150,6 @@ export function useAppShell() {
smartEntryOpen,
smartEntrySessionId,
toast,
toastText,
topBarView,
travelCreateMode,
travelPrompts,

View File

@@ -8,9 +8,24 @@ export function useLoginView() {
const showPassword = ref(false)
const features = [
{ title: '智能审单', desc: 'AI 自动识别票据与规则,提升准确率与效率', icon: 'mdi mdi-file-document-outline', tone: 'green' },
{ title: '异常预警', desc: '多维风险识别与预警,主动防控风险', icon: 'mdi mdi-bell-outline', tone: 'red' },
{ title: 'SLA 监控', desc: '实时监控服务水平协议,保障审批及时性', icon: 'mdi mdi-sync', tone: 'blue' }
{
title: '智能审单',
desc: 'AI 自动识别票据与规则,提升准确率与处理效率',
icon: 'mdi mdi-file-document-outline',
tone: 'green'
},
{
title: '异常预警',
desc: '多维风险识别与预警,主动防控报销风险',
icon: 'mdi mdi-bell-outline',
tone: 'red'
},
{
title: 'SLA 监控',
desc: '实时监控服务水位,保障审批和处理时效',
icon: 'mdi mdi-sync',
tone: 'blue'
}
]
const LogoMark = {

View File

@@ -1,82 +1,113 @@
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { icons } from '../data/icons.js'
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'chat', 'policies', 'audit', 'employees']
export const navItems = [
{
id: 'overview',
label: '总览',
navHint: '运营指标与趋势',
navHint: '查看系统总览与关键指标',
icon: icons.dashboard,
title: '企业报销智能运营台',
desc: '面向财务共享中心的审批、风控、SLA与自动化运营看板'
title: '财务运营总览',
desc: '聚合差旅申请、审批效率、风险信号与 SLA 表现。'
},
{
id: 'workbench',
label: '个人工作台',
navHint: '今日待办与报销进度',
navHint: '集中处理个人待办',
icon: icons.workspace,
title: '个人工作台',
desc: '集中处理今日待办、查看报销进度,并快速进入 AI 报销助手'
desc: '聚焦当前待办、快捷操作与助手入口。'
},
{
id: 'requests',
label: '差旅申请/报销',
navHint: '差旅单据与发起申请',
label: '申请单',
navHint: '查看和管理申请',
icon: icons.list,
title: '差旅申请/报销',
desc: '查看员工差旅报销单据、跟踪进度、发起新申请'
title: '差旅申请与单据',
desc: '集中查看申请单状态、处理进度和风险提示。'
},
{
id: 'approval',
label: '审批中心',
navHint: '待审批单据与批量处理',
navHint: '处理审批任务',
icon: icons.approval,
title: '审批中心',
desc: '统一处理待审批单据,聚焦效率、风险与 SLA'
desc: '按优先级处理待审批事项,控制时效与风险。'
},
{
id: 'chat',
label: 'AI助手',
navHint: '财务知识问答与制度解释',
label: 'AI 助手',
navHint: '进入智能问答',
icon: icons.message,
title: '财务AI助手',
desc: '面向员工与财务场景的智能问答助手,提供制度解读、报销指引与常见问题解答'
title: 'AI 财务助手',
desc: '围绕制度、票据、审批和差旅场景进行快速问答。'
},
{
id: 'policies',
label: '知识管理',
navHint: '制度、文档与知识库',
label: '制度知识',
navHint: '查看制度与知识库',
icon: icons.file,
title: '财务知识管理中心',
desc: '上传制度文档、沉淀财务知识、构建面向员工问答与知识管理的统一知识库'
title: '制度与知识库',
desc: '统一管理制度文档、知识问答和搜索入口。'
},
{
id: 'audit',
label: '技能中心',
navHint: 'Skill 设计与版本配置',
label: '审计追踪',
navHint: '查看日志与追踪记录',
icon: icons.skill,
title: '技能中心',
desc: '统一管理技能的触发规则、提示词结构、输出约束与上线版本'
title: '审计追踪',
desc: '记录关键操作、追踪审批链和系统行为。'
},
{
id: 'employees',
label: '员工管理',
navHint: '员工档案、岗位与角色权限',
navHint: '维护员工与组织信息',
icon: icons.users,
title: '员工管理',
desc: '集中维护员工基础信息、职级部门岗位,以及管理员、财务人员、使用者和高级管理人员等系统角色'
title: '员工与组织管理',
desc: '维护员工账号、组织结构与角色权限。'
}
]
const viewRouteNames = {
overview: 'app-overview',
workbench: 'app-workbench',
requests: 'app-requests',
approval: 'app-approval',
chat: 'app-chat',
policies: 'app-policies',
audit: 'app-audit',
employees: 'app-employees'
}
export function useNavigation() {
const activeView = ref('overview')
const route = useRoute()
const router = useRouter()
const activeView = computed({
get() {
return route.meta.appView || 'overview'
},
set(view) {
setView(view)
}
})
const currentView = computed(
() => navItems.find((item) => item.id === activeView.value) ?? navItems[0]
)
function setView(view) {
activeView.value = view
const targetName = viewRouteNames[view] || viewRouteNames.overview
if (route.name === targetName) {
return
}
router.push({ name: targetName })
}
return { activeView, currentView, setView, navItems }

View File

@@ -0,0 +1,383 @@
import { computed, reactive, ref, watch } from 'vue'
function createForm(initialState) {
return {
company_name: initialState?.company?.name || '',
company_code: initialState?.company?.code || '',
admin_email: initialState?.company?.admin_email || '',
admin_username: '',
admin_password: '',
admin_password_confirm: '',
web_host: initialState?.web?.host || '127.0.0.1',
web_port: initialState?.web?.port || 5173,
server_host: initialState?.server?.host || '127.0.0.1',
server_port: initialState?.server?.port || 8000,
postgres_host: initialState?.database?.host || '127.0.0.1',
postgres_port: initialState?.database?.port || 5432,
postgres_db: initialState?.database?.name || 'x_financial',
postgres_user: initialState?.database?.username || 'postgres',
postgres_password: '',
redis_url: initialState?.redis?.url || ''
}
}
function buildPayload(form) {
return {
company_name: form.company_name.trim(),
company_code: form.company_code.trim(),
admin_email: form.admin_email.trim(),
admin_username: form.admin_username.trim(),
admin_password: String(form.admin_password || ''),
admin_password_confirm: String(form.admin_password_confirm || ''),
web_host: form.web_host.trim(),
web_port: Number(form.web_port),
server_host: form.server_host.trim(),
server_port: Number(form.server_port),
postgres_host: form.postgres_host.trim(),
postgres_port: Number(form.postgres_port),
postgres_db: form.postgres_db.trim(),
postgres_user: form.postgres_user.trim(),
postgres_password: String(form.postgres_password || ''),
redis_url: form.redis_url.trim()
}
}
function buildRuntimeFingerprint(form) {
return JSON.stringify({
web_host: form.web_host.trim(),
web_port: String(form.web_port).trim(),
server_host: form.server_host.trim(),
server_port: String(form.server_port).trim()
})
}
function buildDatabaseFingerprint(form) {
return JSON.stringify({
postgres_host: form.postgres_host.trim(),
postgres_port: String(form.postgres_port).trim(),
postgres_db: form.postgres_db.trim(),
postgres_user: form.postgres_user.trim(),
postgres_password: String(form.postgres_password || ''),
redis_url: form.redis_url.trim()
})
}
function isEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(String(value || '').trim())
}
export function useSetupView(props, emit) {
const form = reactive(createForm(props.initialState))
const activeSection = ref('company')
let syncingFromProps = false
watch(
() => props.initialState,
(state) => {
syncingFromProps = true
Object.assign(form, createForm(state))
queueMicrotask(() => {
syncingFromProps = false
})
},
{ deep: true }
)
watch(
() => buildRuntimeFingerprint(form),
(_value, oldValue) => {
if (oldValue !== undefined && !syncingFromProps) {
emit('runtime-dirty')
}
}
)
watch(
() => buildDatabaseFingerprint(form),
(_value, oldValue) => {
if (oldValue !== undefined && !syncingFromProps) {
emit('database-dirty')
}
}
)
const companyReady = computed(() => form.company_name.trim().length >= 2)
const adminReady = computed(() => {
return Boolean(
isEmail(form.admin_email) &&
form.admin_username.trim().length >= 4 &&
String(form.admin_password || '').length >= 5 &&
form.admin_password === form.admin_password_confirm
)
})
const runtimeInputsReady = computed(() => {
return Boolean(
form.web_host.trim() &&
String(form.web_port).trim() &&
form.server_host.trim() &&
String(form.server_port).trim()
)
})
const databaseInputsReady = computed(() => {
return Boolean(
form.postgres_host.trim() &&
String(form.postgres_port).trim() &&
form.postgres_db.trim() &&
form.postgres_user.trim() &&
String(form.postgres_password || '').length > 0
)
})
const runtimeReady = computed(() => runtimeInputsReady.value && props.runtimeTestPassed)
const databaseReady = computed(() => databaseInputsReady.value && props.databaseTestPassed)
const finalReady = computed(() => companyReady.value && adminReady.value && runtimeReady.value && databaseReady.value)
const sections = computed(() => [
{
id: 'company',
index: '01',
title: '企业信息',
desc: '填写企业名称与识别编码。',
complete: companyReady.value
},
{
id: 'admin',
index: '02',
title: '管理员安全',
desc: '配置管理员邮箱、账号与密码。',
complete: adminReady.value
},
{
id: 'runtime',
index: '03',
title: '运行端口',
desc: '单独检测 Web 与后端端口占用。',
complete: runtimeReady.value
},
{
id: 'database',
index: '04',
title: '数据库',
desc: '检测 PostgreSQL 连接Redis 暂时可选。',
complete: databaseReady.value
}
])
const activeStep = computed(() => sections.value.find((section) => section.id === activeSection.value) || sections.value[0])
const completionCount = computed(() => sections.value.filter((section) => section.complete).length)
const runtimeEndpoints = computed(() => [
{
label: 'Web',
value: `${form.web_host}:${form.web_port}`
},
{
label: 'Server',
value: `${form.server_host}:${form.server_port}`
}
])
const summaryItems = computed(() => [
{
label: '企业信息',
detail: form.company_name.trim() || '未完成',
complete: companyReady.value
},
{
label: '管理员安全',
detail: form.admin_username.trim() || form.admin_email.trim() || '未完成',
complete: adminReady.value
},
{
label: '运行端口',
detail: `${form.web_host}:${form.web_port} / ${form.server_host}:${form.server_port}`,
complete: runtimeReady.value
},
{
label: '数据库',
detail: `${form.postgres_host}:${form.postgres_port}/${form.postgres_db}`,
complete: databaseReady.value
}
])
const currentTestMessage = computed(() => {
if (activeSection.value === 'runtime') {
return props.runtimeTestMessage
}
if (activeSection.value === 'database') {
return props.databaseTestMessage
}
return ''
})
const currentTestPassed = computed(() => {
if (activeSection.value === 'runtime') {
return props.runtimeTestPassed
}
if (activeSection.value === 'database') {
return props.databaseTestPassed
}
return false
})
const showTestAction = computed(() => ['runtime', 'database'].includes(activeSection.value))
const testButtonLabel = computed(() => {
if (activeSection.value === 'runtime') {
return props.runtimeTesting ? '检测中...' : '检测端口占用'
}
if (activeSection.value === 'database') {
return props.databaseTesting ? '检测中...' : '检测数据库连接'
}
return ''
})
const testButtonIcon = computed(() => {
if ((activeSection.value === 'runtime' && props.runtimeTesting) || (activeSection.value === 'database' && props.databaseTesting)) {
return 'pi pi-spin pi-spinner'
}
return activeSection.value === 'runtime' ? 'pi pi-server' : 'pi pi-database'
})
const canRuntimeTest = computed(() => Boolean(runtimeInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
const canDatabaseTest = computed(() => Boolean(databaseInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
const canTest = computed(() => {
if (activeSection.value === 'runtime') {
return canRuntimeTest.value
}
if (activeSection.value === 'database') {
return canDatabaseTest.value
}
return false
})
const submitHint = computed(() => {
if (activeSection.value === 'admin') {
if (!form.admin_email.trim() && !form.admin_username.trim() && !String(form.admin_password || '').length) {
return ''
}
if (!form.admin_email.trim()) {
return '请填写管理员邮箱。'
}
if (!isEmail(form.admin_email)) {
return '管理员邮箱格式不正确。'
}
if (form.admin_username.trim() && form.admin_username.trim().length < 4) {
return '管理员账号至少 4 位。'
}
if (String(form.admin_password || '').length > 0 && String(form.admin_password || '').length < 5) {
return '管理员密码当前至少 5 位。'
}
if (
String(form.admin_password_confirm || '').length > 0 &&
form.admin_password !== form.admin_password_confirm
) {
return '两次输入的管理员密码不一致。'
}
}
if (activeSection.value === 'runtime') {
if (!runtimeInputsReady.value) {
return '请先填写 Web 与 Server 的主机和端口。'
}
if (!props.runtimeTestPassed) {
return '请先完成端口占用检测。'
}
}
if (activeSection.value === 'database') {
if (!databaseInputsReady.value) {
return '请先填写 PostgreSQL 连接信息。'
}
if (!props.databaseTestPassed) {
return '请先完成数据库连接检测。'
}
}
if (activeSection.value === 'company') {
return ''
}
if (!companyReady.value) {
return '请先完成企业信息。'
}
if (!adminReady.value) {
return '请先完成管理员安全配置。'
}
if (!runtimeReady.value) {
return '请先完成运行端口检测。'
}
if (!databaseReady.value) {
return '请先完成数据库连接检测。'
}
return ''
})
function goToSection(id) {
activeSection.value = id
}
function submitForm() {
if (!finalReady.value || props.submitting) {
return
}
emit('submit', buildPayload(form))
}
function testSetup() {
if (!canTest.value) {
return
}
const payload = buildPayload(form)
if (activeSection.value === 'runtime') {
emit('runtime-test', payload)
return
}
if (activeSection.value === 'database') {
emit('database-test', payload)
}
}
return {
activeSection,
activeStep,
canSubmit: finalReady,
canTest,
completionCount,
currentTestMessage,
currentTestPassed,
form,
goToSection,
runtimeEndpoints,
sections,
showTestAction,
submitForm,
submitHint,
summaryItems,
testButtonIcon,
testButtonLabel,
testSetup
}
}

View File

@@ -0,0 +1,278 @@
import { computed, ref } from 'vue'
import {
loginBootstrapAdmin,
saveBootstrapConfig,
testBootstrapDatabase,
testBootstrapRuntime
} from '../services/bootstrap.js'
import { useToast } from './useToast.js'
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
function readClientBootstrapState() {
const env = import.meta.env
return {
initialized: String(env.VITE_SETUP_COMPLETED || '').toLowerCase() === 'true',
company: {
name: env.VITE_COMPANY_NAME || '',
code: env.VITE_COMPANY_CODE || '',
admin_email: env.VITE_ADMIN_EMAIL || ''
},
web: {
host: env.VITE_WEB_HOST || '127.0.0.1',
port: Number(env.VITE_WEB_PORT || 5173)
},
server: {
host: env.VITE_SERVER_HOST || '127.0.0.1',
port: Number(env.VITE_SERVER_PORT || 8000)
},
database: {
driver: 'postgresql',
host: env.VITE_POSTGRES_HOST || '127.0.0.1',
port: Number(env.VITE_POSTGRES_PORT || 5432),
name: env.VITE_POSTGRES_DB || 'x_financial',
username: env.VITE_POSTGRES_USER || 'postgres',
password_configured: false
},
redis: {
enabled: Boolean(env.VITE_REDIS_URL),
url: env.VITE_REDIS_URL || ''
}
}
}
function readAuthState() {
if (typeof window === 'undefined') {
return false
}
return window.sessionStorage.getItem(AUTH_STORAGE_KEY) === 'true'
}
function persistAuthState(value) {
if (typeof window === 'undefined') {
return
}
if (value) {
window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true')
return
}
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
}
const bootstrapState = ref(readClientBootstrapState())
const setupSubmitting = ref(false)
const setupError = ref('')
const runtimeTesting = ref(false)
const databaseTesting = ref(false)
const runtimeTestPassed = ref(false)
const databaseTestPassed = ref(false)
const runtimeTestMessage = ref('')
const databaseTestMessage = ref('')
const loginSubmitting = ref(false)
const loginError = ref('')
const loggedIn = ref(readAuthState())
const { toast } = useToast()
const companyProfile = computed(() => ({
name: bootstrapState.value.company?.name || '',
code: bootstrapState.value.company?.code || '',
adminEmail: bootstrapState.value.company?.admin_email || ''
}))
const isInitialized = computed(() => Boolean(bootstrapState.value.initialized))
function applyBootstrapState(state) {
bootstrapState.value = state
if (!state.initialized) {
loggedIn.value = false
persistAuthState(false)
}
}
function clearSetupRuntimeState() {
runtimeTesting.value = false
databaseTesting.value = false
runtimeTestPassed.value = false
databaseTestPassed.value = false
runtimeTestMessage.value = ''
databaseTestMessage.value = ''
setupError.value = ''
}
function resetFromClientEnv() {
applyBootstrapState(readClientBootstrapState())
clearSetupRuntimeState()
loginError.value = ''
}
async function handleSetupSubmit(payload) {
if (!runtimeTestPassed.value) {
setupError.value = '请先完成运行端口检测。'
toast(setupError.value)
return false
}
if (!databaseTestPassed.value) {
setupError.value = '请先完成数据库连接检测。'
toast(setupError.value)
return false
}
setupSubmitting.value = true
setupError.value = ''
try {
const state = await saveBootstrapConfig(payload)
applyBootstrapState(state)
toast('初始化配置已写入。现在可以进入登录页。')
return true
} catch (error) {
setupError.value = error.message || '初始化配置写入失败,请稍后重试。'
toast(setupError.value)
return false
} finally {
setupSubmitting.value = false
}
}
async function handleRuntimeTest(payload) {
runtimeTesting.value = true
runtimeTestMessage.value = ''
setupError.value = ''
try {
const result = await testBootstrapRuntime(payload)
runtimeTestPassed.value = true
runtimeTestMessage.value = result.detail || '端口占用检测通过。'
toast(runtimeTestMessage.value)
} catch (error) {
runtimeTestPassed.value = false
runtimeTestMessage.value = error.message || '端口占用检测失败。'
toast(runtimeTestMessage.value)
} finally {
runtimeTesting.value = false
}
}
async function handleDatabaseTest(payload) {
databaseTesting.value = true
databaseTestMessage.value = ''
setupError.value = ''
try {
const result = await testBootstrapDatabase(payload)
databaseTestPassed.value = true
databaseTestMessage.value = result.detail || '数据库连接检测通过。'
toast(databaseTestMessage.value)
} catch (error) {
databaseTestPassed.value = false
databaseTestMessage.value = error.message || '数据库连接检测失败。'
toast(databaseTestMessage.value)
} finally {
databaseTesting.value = false
}
}
function handleRuntimeDirty() {
runtimeTestPassed.value = false
runtimeTestMessage.value = ''
if (setupError.value === '请先完成运行端口检测。') {
setupError.value = ''
}
}
function handleDatabaseDirty() {
databaseTestPassed.value = false
databaseTestMessage.value = ''
if (setupError.value === '请先完成数据库连接检测。') {
setupError.value = ''
}
}
async function handleLogin(credentials) {
loginSubmitting.value = true
loginError.value = ''
try {
await loginBootstrapAdmin({
username: credentials.username,
password: credentials.password
})
loggedIn.value = true
persistAuthState(true)
return true
} catch (error) {
loggedIn.value = false
persistAuthState(false)
loginError.value = error.message || '登录失败,请检查管理员账号和密码。'
toast(loginError.value)
return false
} finally {
loginSubmitting.value = false
}
}
function logout() {
loggedIn.value = false
persistAuthState(false)
}
function handleRecoverPassword() {
toast('请联系系统管理员重置密码。管理员密码不会写入 .env。')
}
function handleSsoLogin() {
toast('SSO 登录暂未启用。')
}
function resolveEntryRoute() {
if (!isInitialized.value) {
return { name: 'setup' }
}
if (!loggedIn.value) {
return { name: 'login' }
}
return { name: 'app-overview' }
}
export function useSystemState() {
return {
bootstrapState,
companyProfile,
databaseTestMessage,
databaseTestPassed,
databaseTesting,
handleDatabaseDirty,
handleDatabaseTest,
handleLogin,
handleRecoverPassword,
handleRuntimeDirty,
handleRuntimeTest,
handleSetupSubmit,
handleSsoLogin,
isInitialized,
loggedIn,
loginError,
loginSubmitting,
logout,
resetFromClientEnv,
resolveEntryRoute,
runtimeTestMessage,
runtimeTestPassed,
runtimeTesting,
setupError,
setupSubmitting
}
}

View File

@@ -1,13 +1,15 @@
import { ref } from 'vue'
const toastText = ref('')
function toast(text) {
toastText.value = text
clearTimeout(toast.timer)
toast.timer = setTimeout(() => {
toastText.value = ''
}, 3200)
}
export function useToast() {
const toastText = ref('')
function toast(text) {
toastText.value = text
clearTimeout(toast.timer)
toast.timer = setTimeout(() => { toastText.value = '' }, 3200)
}
return { toastText, toast }
}

View File

@@ -4,10 +4,12 @@ import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura'
import 'primeicons/primeicons.css'
import App from './App.vue'
import router from './router/index.js'
const app = createApp(App)
app.use(MotionPlugin)
app.use(router)
app.use(PrimeVue, {
theme: {
preset: Aura,

110
web/src/router/index.js Normal file
View File

@@ -0,0 +1,110 @@
import { createRouter, createWebHistory } from 'vue-router'
import { appViews } from '../composables/useNavigation.js'
import { useSystemState } from '../composables/useSystemState.js'
import AppShellRouteView from '../views/AppShellRouteView.vue'
import LoginRouteView from '../views/LoginRouteView.vue'
import SetupRouteView from '../views/SetupRouteView.vue'
const appChildRoutes = appViews
.filter((view) => view !== 'requests')
.map((view) => ({
path: view,
name: `app-${view}`,
component: AppShellRouteView,
meta: {
requiresAuth: true,
appView: view
}
}))
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'root',
redirect: () => {
const { resolveEntryRoute } = useSystemState()
return resolveEntryRoute()
}
},
{
path: '/setup',
name: 'setup',
component: SetupRouteView
},
{
path: '/login',
name: 'login',
component: LoginRouteView
},
{
path: '/app',
redirect: { name: 'app-overview' }
},
{
path: '/app/requests',
name: 'app-requests',
component: AppShellRouteView,
meta: {
requiresAuth: true,
appView: 'requests'
}
},
{
path: '/app/requests/:requestId',
name: 'app-request-detail',
component: AppShellRouteView,
meta: {
requiresAuth: true,
appView: 'requests'
}
},
...appChildRoutes.map((route) => ({
...route,
path: `/app/${route.path}`
})),
{
path: '/:pathMatch(.*)*',
redirect: '/'
}
]
})
router.beforeEach((to) => {
const { isInitialized, loggedIn, resolveEntryRoute } = useSystemState()
if (!isInitialized.value) {
if (to.name !== 'setup') {
return { name: 'setup' }
}
return true
}
if (to.name === 'setup') {
return resolveEntryRoute()
}
if (!loggedIn.value && to.meta.requiresAuth) {
return {
name: 'login',
query: {
redirect: to.fullPath
}
}
}
if (loggedIn.value && to.name === 'login') {
return resolveEntryRoute()
}
if (to.name === 'root') {
return resolveEntryRoute()
}
return true
})
export default router

78
web/src/services/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,78 @@
const SETUP_API_BASE = '/__setup'
function formatValidationErrors(detail) {
if (!Array.isArray(detail)) {
return ''
}
return detail
.map((item) => {
const field = Array.isArray(item.loc) ? item.loc[item.loc.length - 1] : 'field'
return `${field}: ${item.msg}`
})
.join('\n')
}
async function request(path, options = {}) {
let response
try {
response = await fetch(`${SETUP_API_BASE}${path}`, {
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
},
...options
})
} catch {
throw new Error('无法连接初始化服务,请确认本地配置桥已启动。')
}
let data = null
try {
data = await response.json()
} catch {
data = null
}
if (!response.ok) {
const validationMessage = formatValidationErrors(data?.detail)
const message = validationMessage || data?.detail || '初始化请求失败,请稍后重试。'
throw new Error(message)
}
return data
}
export function fetchBootstrapState() {
return request('/bootstrap')
}
export function saveBootstrapConfig(payload) {
return request('/bootstrap', {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function testBootstrapRuntime(payload) {
return request('/bootstrap/runtime', {
method: 'PUT',
body: JSON.stringify(payload)
})
}
export function testBootstrapDatabase(payload) {
return request('/bootstrap/database', {
method: 'PUT',
body: JSON.stringify(payload)
})
}
export function loginBootstrapAdmin(payload) {
return request('/auth/login', {
method: 'POST',
body: JSON.stringify(payload)
})
}

View File

@@ -0,0 +1,87 @@
function parseRequestDateFromId(id) {
const match = String(id || '').match(/^REQ-(\d{4})-(\d{2})(\d{2})$/)
if (!match) {
return ''
}
const [, year, month, day] = match
return `${year}-${month}-${day}`
}
function formatTripWindow(range) {
const normalized = String(range || '')
if (!normalized) {
return '待补充'
}
if (normalized.includes('本月')) {
return '本月申请'
}
if (normalized.includes('本周')) {
return '本周申请'
}
if (normalized.includes('今天')) {
return '今日申请'
}
return normalized
}
function mapApproval(status) {
if (status === 'success') {
return {
node: '已完成归档',
approval: '已完成',
approvalTone: 'success',
travel: '已完成行程',
travelTone: 'success'
}
}
if (status === 'danger') {
return {
node: '异常待复核',
approval: '待处理',
approvalTone: 'danger',
travel: '存在异常',
travelTone: 'danger'
}
}
return {
node: '财务审核中',
approval: '审批中',
approvalTone: 'info',
travel: '待安排行程',
travelTone: 'warning'
}
}
export function normalizeRequestForUi(request) {
if (!request) {
return null
}
const applyTime = parseRequestDateFromId(request.id) || '2026-04-18'
const reason = `${request.category || '差旅'}申请`
const city = request.entity || '待补充'
const period = formatTripWindow(request.range)
const approvalState = mapApproval(request.status)
return {
...request,
reason,
city,
period,
applyTime,
node: approvalState.node,
approval: approvalState.approval,
approvalTone: approvalState.approvalTone,
travel: approvalState.travel,
travelTone: approvalState.travelTone
}
}

View File

@@ -0,0 +1,176 @@
<template>
<div class="app">
<SidebarRail
:nav-items="navItems"
:active-view="activeView"
@navigate="handleNavigate"
@open-chat="handleOpenChat"
/>
<main
class="main"
:class="{
'chat-main': activeView === 'chat',
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval',
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'employees-main': activeView === 'employees'
}"
>
<TopBar
:current-view="topBarView"
:search="search"
:active-view="activeView"
:ranges="ranges"
:active-range="activeRange"
:custom-range="customRange"
@update:search="search = $event"
@update:active-range="activeRange = $event"
@update:custom-range="customRange = $event"
@batch-approve="toast('已批量通过 23 条审批任务')"
@open-chat="handleOpenChat"
@new-application="openTravelCreate"
/>
<FilterBar
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
:active-range="activeRange"
@update:active-range="activeRange = $event"
/>
<section
class="workarea"
:class="{
'chat-workarea': activeView === 'chat',
'requests-workarea': activeView === 'requests',
'approval-workarea': activeView === 'approval',
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
'employees-workarea': activeView === 'employees'
}"
>
<OverviewView
v-if="activeView === 'overview'"
:filtered-requests="filteredRequests"
@ask="handleOpenChat"
@approve="handleApprove"
@reject="handleReject"
/>
<PersonalWorkbenchView
v-else-if="activeView === 'workbench'"
@open-assistant="openSmartEntry"
/>
<ChatView
v-else-if="activeView === 'chat'"
:documents="filteredDocuments"
:doc-search="docSearch"
:messages="messages"
:uploaded-files="uploadedFiles"
:active-case="activeCase"
:quick-prompts="travelPrompts"
:draft="draft"
:message-list="messageList"
@send="sendMessage"
@upload="handleUpload"
@draft="draft = $event"
@select-case="handleOpenChat"
@approve-case="toast(`${activeCase?.id || '当前单据'} 已标记为通过`)"
@reject-case="toast(`${activeCase?.id || '当前单据'} 已标记为驳回`)"
/>
<TravelRequestDetailView
v-else-if="activeView === 'requests' && detailMode && selectedTravelRequest"
:request="selectedTravelRequest"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
/>
<RequestsView
v-else-if="activeView === 'requests'"
:filtered-requests="filteredRequests"
@ask="openRequestDetail"
@approve="handleApprove"
@reject="handleReject"
@create-request="openTravelCreate"
/>
<ApprovalCenterView v-else-if="activeView === 'approval'" />
<PoliciesView v-else-if="activeView === 'policies'" />
<AuditView v-else-if="activeView === 'audit'" />
<EmployeeManagementView v-else />
</section>
</main>
<TravelReimbursementCreateView
v-if="smartEntryOpen"
:key="smartEntrySessionId"
:initial-prompt="smartEntryContext.prompt"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
@close="closeSmartEntry"
/>
</div>
</template>
<script setup>
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
import OverviewView from './OverviewView.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
import ChatView from './ChatView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import RequestsView from './RequestsView.vue'
import ApprovalCenterView from './ApprovalCenterView.vue'
import PoliciesView from './PoliciesView.vue'
import AuditView from './AuditView.vue'
import EmployeeManagementView from './EmployeeManagementView.vue'
import { useAppShell } from '../composables/useAppShell.js'
const {
activeCase,
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
customRange,
detailMode,
docSearch,
draft,
filteredDocuments,
filteredRequests,
filters,
handleApprove,
handleNavigate,
handleOpenChat,
handleReject,
handleUpload,
messageList,
messages,
navItems,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
search,
selectedTravelRequest,
sendMessage,
smartEntryContext,
smartEntryOpen,
smartEntrySessionId,
toast,
topBarView,
travelPrompts,
uploadedFiles
} = useAppShell()
</script>

View File

@@ -0,0 +1,46 @@
<template>
<LoginView
:company-name="companyProfile.name"
:submitting="loginSubmitting"
:error-message="loginError"
@login="submitLogin"
@recover-password="handleRecoverPassword"
@sso-login="handleSsoLogin"
/>
</template>
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { useSystemState } from '../composables/useSystemState.js'
import LoginView from './LoginView.vue'
const route = useRoute()
const router = useRouter()
const {
companyProfile,
handleLogin,
handleRecoverPassword,
handleSsoLogin,
loginError,
loginSubmitting,
resolveEntryRoute
} = useSystemState()
async function submitLogin(credentials) {
const passed = await handleLogin(credentials)
if (!passed) {
return
}
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : ''
if (redirect.startsWith('/app/')) {
router.replace(redirect)
return
}
router.replace(resolveEntryRoute())
}
</script>

View File

@@ -2,7 +2,7 @@
<main class="login-page">
<header class="page-brand">
<LogoMark />
<strong>星海科技</strong>
<strong>{{ displayCompanyName }}</strong>
</header>
<section class="hero">
@@ -18,8 +18,8 @@
<div class="metric-card amount">
<span>报销金额趋势</span>
<strong>361,600</strong>
<small>较昨日 <b class="up"> 8.3%</b></small>
<strong>¥ 61,600</strong>
<small>较昨日 <b class="up">+8.3%</b></small>
<div class="mini-bars"><i></i><i></i><i></i><i></i></div>
</div>
@@ -36,19 +36,19 @@
<div class="metric-card risk">
<span>风险预警</span>
<strong><i class="mdi mdi-alert"></i> 14 </strong>
<small>较昨日 <b class="danger"> 16.7%</b></small>
<small>较昨日 <b class="danger">+16.7%</b></small>
</div>
<div class="metric-card audit">
<span>审批效率</span>
<strong>78%</strong>
<small>较昨日 <b class="up"> 6.2%</b></small>
<small>较昨日 <b class="up">+6.2%</b></small>
</div>
<div class="metric-card sla">
<span>SLA 达成率</span>
<strong>96%</strong>
<small>较昨日 <b class="up"> 3.1%</b></small>
<small>较昨日 <b class="up">+3.1%</b></small>
</div>
</div>
@@ -66,18 +66,19 @@
<section class="login-card" aria-label="登录表单">
<div class="card-brand">
<LogoMark />
<strong>星海科技</strong>
<strong>{{ displayCompanyName }}</strong>
</div>
<header class="card-head">
<h2>欢迎登录</h2>
<p>登录企业报销智能运营台</p>
<p>使用初始化时创建的管理员账号进入系统</p>
</header>
<form class="login-form" @submit.prevent="emit('login', { username, password })">
<label class="field">
<span class="sr-only">账号</span>
<i class="mdi mdi-account-outline"></i>
<input v-model="username" type="text" placeholder="请输入账号 / 邮箱 / 手机号" autocomplete="username" required />
<input v-model="username" type="text" placeholder="请输入管理员账号" autocomplete="username" required />
</label>
<label class="field">
@@ -86,7 +87,7 @@
<input
v-model="password"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
placeholder="请输入管理员密码"
autocomplete="current-password"
required
/>
@@ -96,7 +97,7 @@
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
@click="showPassword = !showPassword"
>
<i :class="showPassword ? 'mdi mdi-eye' : 'mdi mdi-eye-slash'"></i>
<i :class="showPassword ? 'mdi mdi-eye' : 'mdi mdi-eye-off'"></i>
</button>
</label>
@@ -112,16 +113,20 @@
<div class="form-meta">
<label class="remember">
<input v-model="remember" type="checkbox" />
<span>记住</span>
<span>记住账号</span>
</label>
<button type="button" class="link-btn" @click="emit('recover-password')">忘记密码?</button>
</div>
<button class="submit-btn" type="submit">登录</button>
<p v-if="errorMessage" class="login-error">{{ errorMessage }}</p>
<button class="submit-btn" type="submit" :disabled="submitting">
{{ submitting ? '登录中...' : '登录' }}
</button>
<div class="divider"><span></span></div>
<button class="sso-btn" type="button" @click="emit('sso-login')">
<button class="sso-btn" type="button" :disabled="submitting" @click="emit('sso-login')">
<i class="mdi mdi-shield-outline"></i>
<span>SSO 单点登录</span>
</button>
@@ -129,26 +134,37 @@
<footer class="security-note">
<i class="mdi mdi-lock-outline"></i>
<span>安全登录 · 数据加密传输 · 如需帮助请联系管理员</span>
<span>安全登录 · 数据加密传输 · 如需帮助请联系系统管理员</span>
</footer>
</section>
</main>
</template>
<script setup>
import { computed } from 'vue'
import { useLoginView } from '../composables/useLoginView.js'
const props = defineProps({
companyName: {
type: String,
default: ''
},
submitting: {
type: Boolean,
default: false
},
errorMessage: {
type: String,
default: ''
}
})
const emit = defineEmits(['login', 'recover-password', 'sso-login'])
const {
features,
LogoMark,
password,
remember,
showPassword,
tenant,
username
} = useLoginView()
const displayCompanyName = computed(() => props.companyName || 'X-Financial')
const { features, LogoMark, password, remember, showPassword, tenant, username } = useLoginView()
</script>
<style scoped src="../assets/styles/views/login-view.css"></style>

View File

@@ -0,0 +1,51 @@
<template>
<SetupView
:initial-state="bootstrapState || {}"
:submitting="setupSubmitting"
:runtime-testing="runtimeTesting"
:database-testing="databaseTesting"
:runtime-test-passed="runtimeTestPassed"
:database-test-passed="databaseTestPassed"
:runtime-test-message="runtimeTestMessage"
:database-test-message="databaseTestMessage"
:error-message="setupError"
@submit="submitSetup"
@runtime-test="handleRuntimeTest"
@database-test="handleDatabaseTest"
@runtime-dirty="handleRuntimeDirty"
@database-dirty="handleDatabaseDirty"
/>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useSystemState } from '../composables/useSystemState.js'
import SetupView from './SetupView.vue'
const router = useRouter()
const {
bootstrapState,
databaseTestMessage,
databaseTestPassed,
databaseTesting,
handleDatabaseDirty,
handleDatabaseTest,
handleRuntimeDirty,
handleRuntimeTest,
handleSetupSubmit,
runtimeTestMessage,
runtimeTestPassed,
runtimeTesting,
setupError,
setupSubmitting
} = useSystemState()
async function submitSetup(payload) {
const completed = await handleSetupSubmit(payload)
if (completed) {
router.replace({ name: 'login' })
}
}
</script>

316
web/src/views/SetupView.vue Normal file
View File

@@ -0,0 +1,316 @@
<template>
<main class="setup-page">
<aside class="setup-context">
<div class="setup-brand">
<div class="setup-brand-mark" aria-hidden="true">
<span class="setup-brand-ring"></span>
<span class="setup-brand-core">XF</span>
</div>
<div>
<p class="setup-kicker">INITIAL SETUP</p>
<h1>初始化配置</h1>
</div>
</div>
<p class="setup-lead">
先完成 4 个必要步骤再进入主登录界面扩展服务当前不参与初始化完成条件
</p>
<nav class="setup-nav" aria-label="初始化步骤">
<button
v-for="section in sections"
:key="section.id"
class="setup-nav-item"
:class="{ 'is-active': activeSection === section.id, 'is-complete': section.complete }"
type="button"
@click="goToSection(section.id)"
>
<span class="setup-nav-index">{{ section.index }}</span>
<span class="setup-nav-copy">
<strong>{{ section.title }}</strong>
<small>{{ section.desc }}</small>
</span>
<i v-if="section.complete" class="pi pi-check setup-nav-check"></i>
</button>
</nav>
<div class="setup-progress">
<strong>{{ completionCount }} / {{ sections.length }} 已完成</strong>
<p>企业信息管理员安全运行端口数据库连接都通过后左下角会自动出现完成初始化按钮</p>
</div>
<div v-if="canSubmit" class="setup-complete">
<p>所有必要步骤已通过检测可以写入配置并进入登录界面</p>
<button class="primary-btn setup-complete-btn" type="button" :disabled="submitting" @click="submitForm">
<i class="pi pi-check"></i>
<span>{{ submitting ? '写入配置中...' : '完成初始化并进入登录' }}</span>
</button>
</div>
</aside>
<section class="setup-panel">
<header class="setup-panel-head">
<div>
<p class="setup-kicker setup-kicker-light">{{ activeStep.index }}</p>
<h2>{{ activeStep.title }}</h2>
<p class="setup-panel-desc">{{ activeStep.desc }}</p>
</div>
<span class="setup-chip" :class="{ 'is-success': activeStep.complete }">
{{ activeStep.complete ? '已完成' : '待配置' }}
</span>
</header>
<div class="setup-form">
<section v-if="activeSection === 'company'" class="setup-stage">
<div class="section-head">
<h3>企业基础信息</h3>
<p>这里仅保留企业名称与企业编码不放管理员邮箱</p>
</div>
<div class="field-grid field-grid-2">
<label class="field">
<span>企业名称</span>
<input v-model.trim="form.company_name" type="text" placeholder="请输入企业名称" required />
</label>
<label class="field">
<span>企业编码</span>
<input v-model.trim="form.company_code" type="text" placeholder="例如 FIN" />
</label>
</div>
</section>
<section v-else-if="activeSection === 'admin'" class="setup-stage">
<div class="section-head">
<h3>管理员安全</h3>
<p>管理员邮箱账号和密码在这里配置密码不会写入 `.env`只会保存哈希后的密文</p>
</div>
<div class="field-grid field-grid-2">
<label class="field">
<span>管理员邮箱</span>
<input v-model.trim="form.admin_email" type="email" placeholder="admin@company.com" />
</label>
<label class="field">
<span>管理员账号</span>
<input v-model.trim="form.admin_username" type="text" placeholder="例如 superadmin" required />
</label>
<label class="field">
<span>管理员密码</span>
<input
v-model="form.admin_password"
type="password"
placeholder="请输入管理员密码"
autocomplete="new-password"
required
/>
</label>
<label class="field">
<span>确认密码</span>
<input
v-model="form.admin_password_confirm"
type="password"
placeholder="请再次输入管理员密码"
autocomplete="new-password"
required
/>
</label>
</div>
<p class="field-group-note">管理员密码当前暂定至少 5 </p>
</section>
<section v-else-if="activeSection === 'runtime'" class="setup-stage">
<div class="section-head">
<h3>运行端口配置</h3>
<p>这一步只检测 Web Server 端口占用情况不检测数据库</p>
</div>
<div class="field-grid field-grid-2">
<label class="field">
<span>Web Host</span>
<input v-model.trim="form.web_host" type="text" placeholder="127.0.0.1" required />
</label>
<label class="field">
<span>Web Port</span>
<input v-model.number="form.web_port" type="number" min="1" max="65535" required />
</label>
<label class="field">
<span>Server Host</span>
<input v-model.trim="form.server_host" type="text" placeholder="127.0.0.1" required />
</label>
<label class="field">
<span>Server Port</span>
<input v-model.number="form.server_port" type="number" min="1" max="65535" required />
</label>
</div>
<div class="setup-runtime">
<article v-for="item in runtimeEndpoints" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</div>
</section>
<section v-else class="setup-stage">
<div class="section-head">
<h3>数据库连接</h3>
<p>这里检测 PostgreSQL 连接Redis 作为扩展服务暂时可选不影响完成初始化</p>
</div>
<div class="field-grid field-grid-2">
<label class="field">
<span>PostgreSQL Host</span>
<input v-model.trim="form.postgres_host" type="text" placeholder="127.0.0.1" required />
</label>
<label class="field">
<span>PostgreSQL Port</span>
<input v-model.number="form.postgres_port" type="number" min="1" max="65535" required />
</label>
<label class="field">
<span>数据库名称</span>
<input v-model.trim="form.postgres_db" type="text" placeholder="x_financial" required />
</label>
<label class="field">
<span>数据库用户</span>
<input v-model.trim="form.postgres_user" type="text" placeholder="postgres" required />
</label>
<label class="field field-span-2">
<span>数据库密码</span>
<input
v-model="form.postgres_password"
type="password"
placeholder="请输入数据库密码"
autocomplete="new-password"
required
/>
</label>
</div>
<div class="optional-block">
<div class="optional-block-head">
<strong>扩展服务</strong>
<span>可选</span>
</div>
<label class="field">
<span>Redis URL</span>
<input v-model.trim="form.redis_url" type="text" placeholder="redis://127.0.0.1:6379/0" />
</label>
</div>
<div class="setup-summary-grid">
<article v-for="item in summaryItems" :key="item.label" class="setup-summary-item">
<div>
<strong>{{ item.label }}</strong>
<span>{{ item.detail }}</span>
</div>
<i :class="['pi', item.complete ? 'pi-check-circle' : 'pi-clock']"></i>
</article>
</div>
</section>
<p v-if="currentTestMessage" :class="['setup-status', currentTestPassed ? 'is-success' : 'is-danger']">
{{ currentTestMessage }}
</p>
<p v-if="errorMessage" class="setup-error">{{ errorMessage }}</p>
<p v-if="submitHint" class="setup-gate">{{ submitHint }}</p>
<footer class="setup-actions">
<div class="setup-actions-right">
<button
v-if="showTestAction"
class="secondary-btn secondary-btn-strong"
type="button"
:disabled="!canTest"
@click="testSetup"
>
<i :class="testButtonIcon"></i>
<span>{{ testButtonLabel }}</span>
</button>
</div>
</footer>
</div>
</section>
</main>
</template>
<script setup>
import { useSetupView } from '../composables/useSetupView.js'
const props = defineProps({
initialState: {
type: Object,
default: () => ({})
},
submitting: {
type: Boolean,
default: false
},
runtimeTesting: {
type: Boolean,
default: false
},
databaseTesting: {
type: Boolean,
default: false
},
runtimeTestPassed: {
type: Boolean,
default: false
},
databaseTestPassed: {
type: Boolean,
default: false
},
runtimeTestMessage: {
type: String,
default: ''
},
databaseTestMessage: {
type: String,
default: ''
},
errorMessage: {
type: String,
default: ''
}
})
const emit = defineEmits(['submit', 'runtime-test', 'database-test', 'runtime-dirty', 'database-dirty'])
const {
activeSection,
activeStep,
canSubmit,
canTest,
completionCount,
currentTestMessage,
currentTestPassed,
form,
goToSection,
runtimeEndpoints,
sections,
showTestAction,
submitForm,
submitHint,
summaryItems,
testButtonIcon,
testButtonLabel,
testSetup
} = useSetupView(props, emit)
</script>
<style scoped src="../assets/styles/views/setup-view.css"></style>

View File

@@ -1,11 +1,13 @@
import { computed, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
export default {
name: 'RequestsView',
props: {
filteredRequests: { type: Array, required: true }
},
emits: ['ask', 'approve', 'reject', 'create-request'] ,
filteredRequests: { type: Array, required: true }
},
emits: ['ask', 'approve', 'reject', 'create-request'],
setup(props, { emit }) {
const activeTab = ref('全部')
const tabs = ['全部', '待提交', '审批中', '待出行', '已完成']
@@ -18,49 +20,28 @@ export default {
const appliedEnd = ref('')
const dateRangeLabel = computed(() => {
if (appliedStart.value && appliedEnd.value) return `${appliedStart.value} ~ ${appliedEnd.value}`
if (appliedStart.value && appliedEnd.value) {
return `${appliedStart.value} ~ ${appliedEnd.value}`
}
return '选择时间段'
})
function applyDateRange() {
if (!rangeStart.value || !rangeEnd.value) return
if (!rangeStart.value || !rangeEnd.value) {
return
}
appliedStart.value = rangeStart.value
appliedEnd.value = rangeEnd.value
datePopover.value = false
}
const rows = [
{ id: 'BR240715001', reason: '华东区域客户拜访', city: '上海、苏州、杭州', period: '07-14~07-17 (4天)', applyTime: '2024-07-13', amount: '¥4,280.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
{ id: 'BR240714010', reason: '年度战略合作伙伴会议', city: '北京', period: '07-15~07-16 (2天)', applyTime: '2024-07-12', amount: '¥1,860.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
{ id: 'BR240713008', reason: '产品培训与交流', city: '深圳', period: '07-10~07-12 (3天)', applyTime: '2024-07-09', amount: '¥2,150.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240712001', reason: '客户方案汇报', city: '上海', period: '07-08~07-11 (4天)', applyTime: '2024-07-07', amount: '¥3,680.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240711005', reason: '华南区域市场调研', city: '广州、佛山', period: '07-09~07-11 (3天)', applyTime: '2024-07-06', amount: '¥1,920.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240710003', reason: '供应商现场考察', city: '东莞', period: '07-06~07-07 (2天)', applyTime: '2024-07-05', amount: '¥680.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240709005', reason: '客户方案汇报', city: '北京', period: '07-06~07-08 (3天)', applyTime: '2024-07-05', amount: '¥1,980.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240708012', reason: '供应商现场考察', city: '广州', period: '07-04~07-05 (2天)', applyTime: '2024-07-03', amount: '¥860.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240707003', reason: '项目启动会', city: '成都', period: '07-01~07-03 (3天)', applyTime: '2024-06-29', amount: '¥2,420.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
{ id: 'BR240706009', reason: '客户拜访与市场调研', city: '南京、合肥', period: '06-28~06-30 (3天)', applyTime: '2024-06-26', amount: '¥1,750.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240705007', reason: '技术交流会', city: '武汉', period: '06-25~06-26 (2天)', applyTime: '2024-06-23', amount: '¥1,120.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240704004', reason: '渠道合作洽谈', city: '西安', period: '06-20~06-21 (2天)', applyTime: '2024-06-18', amount: '¥780.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240703011', reason: '新员工入职培训', city: '长沙', period: '06-18~06-19 (2天)', applyTime: '2024-06-16', amount: '¥920.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240702006', reason: '季度业绩复盘会', city: '杭州', period: '06-15~06-16 (2天)', applyTime: '2024-06-13', amount: '¥1,350.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240701002', reason: '智慧金融峰会参展', city: '上海', period: '06-12~06-14 (3天)', applyTime: '2024-06-10', amount: '¥5,680.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240630009', reason: '西南区域渠道拓展', city: '重庆、贵阳', period: '06-10~06-13 (4天)', applyTime: '2024-06-08', amount: '¥3,450.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240629003', reason: '信息安全合规审计', city: '深圳', period: '06-08~06-09 (2天)', applyTime: '2024-06-06', amount: '¥1,180.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240628007', reason: '产学研合作对接', city: '南京', period: '06-05~06-07 (3天)', applyTime: '2024-06-03', amount: '¥2,260.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240627001', reason: 'ERP系统上线支持', city: '青岛', period: '06-03~06-05 (3天)', applyTime: '2024-06-01', amount: '¥1,960.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240626004', reason: '大客户续约洽谈', city: '天津', period: '06-01~06-02 (2天)', applyTime: '2024-05-29', amount: '¥890.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240625010', reason: '区域销售团队建设', city: '厦门', period: '05-28~05-30 (3天)', applyTime: '2024-05-26', amount: '¥2,780.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240624002', reason: '供应链管理系统演示', city: '苏州', period: '05-25~05-26 (2天)', applyTime: '2024-05-23', amount: '¥650.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240623008', reason: '行业白皮书发布会', city: '北京', period: '05-22~05-23 (2天)', applyTime: '2024-05-20', amount: '¥1,560.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
{ id: 'BR240622005', reason: '跨部门协同工作坊', city: '大连', period: '05-20~05-22 (3天)', applyTime: '2024-05-18', amount: '¥2,340.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240621003', reason: '数字化转型的客户交流', city: '深圳、珠海', period: '05-16~05-18 (3天)', applyTime: '2024-05-14', amount: '¥3,120.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
{ id: 'BR240620006', reason: '年中预算评审会', city: '上海', period: '05-13~05-14 (2天)', applyTime: '2024-05-11', amount: '¥1,480.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240619001', reason: '医疗行业解决方案展', city: '成都', period: '05-10~05-12 (3天)', applyTime: '2024-05-08', amount: '¥3,860.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240618009', reason: '东北区域客户回访', city: '沈阳、长春', period: '05-06~05-09 (4天)', applyTime: '2024-05-04', amount: '¥4,520.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
{ id: 'BR240617007', reason: '大数据平台技术对接', city: '杭州', period: '05-03~05-05 (3天)', applyTime: '2024-05-01', amount: '¥2,180.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240616004', reason: '国际业务合规培训', city: '北京', period: '04-28~04-30 (3天)', applyTime: '2024-04-26', amount: '¥2,960.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' }
]
const rows = computed(() =>
props.filteredRequests
.map((item) => normalizeRequestForUi(item))
.filter(Boolean)
)
const currentPage = ref(1)
const pageSize = ref(10)
@@ -74,8 +55,27 @@ export default {
}
const filteredRows = computed(() => {
if (activeTab.value === '全部') return rows
return rows.filter((row) => row.approval === activeTab.value || row.travel.includes(activeTab.value.replace('待出行', '待订')))
if (activeTab.value === '全部') {
return rows.value
}
if (activeTab.value === '待提交') {
return rows.value.filter((row) => row.approval === '待提交')
}
if (activeTab.value === '审批中') {
return rows.value.filter((row) => row.approval === '审批中')
}
if (activeTab.value === '待出行') {
return rows.value.filter((row) => row.travel.includes('待'))
}
if (activeTab.value === '已完成') {
return rows.value.filter((row) => row.approval === '已完成')
}
return rows.value
})
const totalCount = computed(() => filteredRows.value.length)
@@ -86,7 +86,9 @@ export default {
return filteredRows.value.slice(start, start + pageSize.value)
})
watch(activeTab, () => { currentPage.value = 1 })
watch([activeTab, rows], () => {
currentPage.value = 1
})
return {
emit,
@@ -113,4 +115,3 @@ export default {
}
}
}