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