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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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