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:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user