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:
196
web/src/App.vue
196
web/src/App.vue
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
607
web/src/assets/styles/views/setup-view.css
Normal file
607
web/src/assets/styles/views/setup-view.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
383
web/src/composables/useSetupView.js
Normal file
383
web/src/composables/useSetupView.js
Normal 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
|
||||
}
|
||||
}
|
||||
278
web/src/composables/useSystemState.js
Normal file
278
web/src/composables/useSystemState.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
110
web/src/router/index.js
Normal 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
78
web/src/services/bootstrap.js
vendored
Normal 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)
|
||||
})
|
||||
}
|
||||
87
web/src/utils/requestViewModel.js
Normal file
87
web/src/utils/requestViewModel.js
Normal 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
|
||||
}
|
||||
}
|
||||
176
web/src/views/AppShellRouteView.vue
Normal file
176
web/src/views/AppShellRouteView.vue
Normal 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>
|
||||
46
web/src/views/LoginRouteView.vue
Normal file
46
web/src/views/LoginRouteView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
51
web/src/views/SetupRouteView.vue
Normal file
51
web/src/views/SetupRouteView.vue
Normal 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
316
web/src/views/SetupView.vue
Normal 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>
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user