feat: 完善系统配置、安全增强与知识库功能
- .env.example: API基础路径改为相对路径 /api/v1,支持代理转发 - README.md: 完善项目结构与启动说明文档 - docker-compose.yml: 新增Docker编排配置,支持容器化部署 - docker/: 新增Docker部署相关文档与配置 - server_start.sh: 重构启动脚本,添加容器环境检测、隔离虚拟环境路径、环境变量覆盖机制 - deps.py: 完善API依赖注入,增强权限验证逻辑 - admin_secret.py: 优化管理员密钥加密存储与验证 - config.py: 扩展配置管理,支持多环境变量绑定 - security.py: 增强安全模块,完善加密与认证机制 - db/base.py: 优化数据库基础架构与连接管理 - main.py: 更新应用入口,整合新模块路由 - models/: 完善系统模型配置,支持模型设置持久化 - repositories/settings.py: 优化设置仓储层,增强数据持久化 - services/settings.py: 重构设置服务,精简代码结构 - router.py: 更新API路由配置 - endpoints/knowledge.py: 新增知识库API端点 - schemas/knowledge.py: 新增知识库数据模型 - services/knowledge.py: 新增知识库业务逻辑 - storage/knowledge/.index.json: 知识库索引存储 - api.js: 完善API服务层,增强错误处理 - bootstrap.js: 优化前端初始化与引导流程 - useSetupView.js / useSystemState.js: 重构组合式函数 - TopBar.vue: 优化顶部导航栏组件 - SettingsView.vue: 重构设置页面UI,增强用户体验 - SetupView.vue / SetupRouteView.vue: 完善引导流程页面 - PoliciesView.vue: 优化策略视图组件 - vite.config.js: 更新Vite构建配置 - web_start.sh: 完善前端启动脚本 - views/scripts/: 优化各业务视图JS逻辑 - settings-view.css: 重构设置页面样式 - setup-view.css: 完善引导页样式 - policies-view.css: 优化策略页样式 - test_auth_service.py: 完善认证服务测试 - test_settings_persistence.py: 增强设置持久化测试 - document/: 新增开发文档与工作日志
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -833,5 +833,5 @@ const policyItems = [
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,403 +1,403 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
function readCurrentWebEndpoint(initialState) {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
host: initialState?.web?.host || '0.0.0.0',
|
||||
port: Number(initialState?.web?.port || 5173)
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackPort = Number(initialState?.web?.port || 5173)
|
||||
const port = Number(window.location.port || fallbackPort)
|
||||
|
||||
return {
|
||||
host: window.location.hostname || initialState?.web?.host || '0.0.0.0',
|
||||
port: Number.isInteger(port) && port > 0 ? port : fallbackPort
|
||||
}
|
||||
}
|
||||
|
||||
function shouldExposeServerHost() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
const host = String(window.location.hostname || '').toLowerCase()
|
||||
return Boolean(host && host !== '127.0.0.1' && host !== 'localhost' && host !== '::1')
|
||||
}
|
||||
|
||||
function resolveInitialServerHost(initialState) {
|
||||
const host = String(initialState?.server?.host || '0.0.0.0').trim()
|
||||
const normalized = host.toLowerCase()
|
||||
|
||||
if (shouldExposeServerHost() && (normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1')) {
|
||||
return '0.0.0.0'
|
||||
}
|
||||
|
||||
return host || '0.0.0.0'
|
||||
}
|
||||
|
||||
function createForm(initialState) {
|
||||
const currentWeb = readCurrentWebEndpoint(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: currentWeb.host,
|
||||
web_port: currentWeb.port,
|
||||
server_host: resolveInitialServerHost(initialState),
|
||||
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) {
|
||||
const currentWeb = readCurrentWebEndpoint({
|
||||
web: {
|
||||
host: form.web_host,
|
||||
port: form.web_port
|
||||
}
|
||||
})
|
||||
|
||||
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: currentWeb.host,
|
||||
web_port: currentWeb.port,
|
||||
server_host: shouldExposeServerHost() && ['127.0.0.1', 'localhost', '::1'].includes(form.server_host.trim().toLowerCase())
|
||||
? '0.0.0.0'
|
||||
: 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({
|
||||
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.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 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 '请先填写 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,
|
||||
testButtonIcon,
|
||||
testButtonLabel,
|
||||
testSetup
|
||||
}
|
||||
}
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
function readCurrentWebEndpoint(initialState) {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
host: initialState?.web?.host || '0.0.0.0',
|
||||
port: Number(initialState?.web?.port || 5173)
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackPort = Number(initialState?.web?.port || 5173)
|
||||
const port = Number(window.location.port || fallbackPort)
|
||||
|
||||
return {
|
||||
host: window.location.hostname || initialState?.web?.host || '0.0.0.0',
|
||||
port: Number.isInteger(port) && port > 0 ? port : fallbackPort
|
||||
}
|
||||
}
|
||||
|
||||
function shouldExposeServerHost() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
const host = String(window.location.hostname || '').toLowerCase()
|
||||
return Boolean(host && host !== '127.0.0.1' && host !== 'localhost' && host !== '::1')
|
||||
}
|
||||
|
||||
function resolveInitialServerHost(initialState) {
|
||||
const host = String(initialState?.server?.host || '0.0.0.0').trim()
|
||||
const normalized = host.toLowerCase()
|
||||
|
||||
if (shouldExposeServerHost() && (normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1')) {
|
||||
return '0.0.0.0'
|
||||
}
|
||||
|
||||
return host || '0.0.0.0'
|
||||
}
|
||||
|
||||
function createForm(initialState) {
|
||||
const currentWeb = readCurrentWebEndpoint(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: currentWeb.host,
|
||||
web_port: currentWeb.port,
|
||||
server_host: resolveInitialServerHost(initialState),
|
||||
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) {
|
||||
const currentWeb = readCurrentWebEndpoint({
|
||||
web: {
|
||||
host: form.web_host,
|
||||
port: form.web_port
|
||||
}
|
||||
})
|
||||
|
||||
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: currentWeb.host,
|
||||
web_port: currentWeb.port,
|
||||
server_host: shouldExposeServerHost() && ['127.0.0.1', 'localhost', '::1'].includes(form.server_host.trim().toLowerCase())
|
||||
? '0.0.0.0'
|
||||
: 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({
|
||||
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.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 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 '请先填写 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,
|
||||
testButtonIcon,
|
||||
testButtonLabel,
|
||||
testSetup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,4 +206,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
176
web/src/services/bootstrap.js
vendored
176
web/src/services/bootstrap.js
vendored
@@ -1,88 +1,88 @@
|
||||
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 startBootstrapBackend() {
|
||||
return request('/bootstrap/backend', {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchBootstrapBackendStatus() {
|
||||
return request('/bootstrap/backend')
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
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 startBootstrapBackend() {
|
||||
return request('/bootstrap/backend', {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchBootstrapBackendStatus() {
|
||||
return request('/bootstrap/backend')
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
33
web/src/services/knowledge.js
Normal file
33
web/src/services/knowledge.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
export function fetchKnowledgeLibrary() {
|
||||
return apiRequest('/knowledge/library')
|
||||
}
|
||||
|
||||
export function fetchKnowledgeDocument(documentId) {
|
||||
return apiRequest(`/knowledge/documents/${documentId}`)
|
||||
}
|
||||
|
||||
export function uploadKnowledgeDocument({ folder, file }) {
|
||||
return apiRequest(
|
||||
`/knowledge/documents?folder=${encodeURIComponent(folder)}&filename=${encodeURIComponent(file.name)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: file,
|
||||
contentType: file.type || 'application/octet-stream'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteKnowledgeDocument(documentId) {
|
||||
return apiRequest(`/knowledge/documents/${documentId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchKnowledgeDocumentBlob(documentId, disposition = 'inline') {
|
||||
return apiRequest(`/knowledge/documents/${documentId}/content?disposition=${disposition}`, {
|
||||
responseType: 'blob',
|
||||
contentType: null
|
||||
})
|
||||
}
|
||||
@@ -10,11 +10,10 @@ export const DEFAULT_APP_VIEW_ORDER = [
|
||||
'settings'
|
||||
]
|
||||
|
||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'chat'])
|
||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'chat', 'policies'])
|
||||
const VIEW_ROLE_RULES = {
|
||||
overview: ['finance', 'executive'],
|
||||
approval: ['approver'],
|
||||
policies: ['manager'],
|
||||
audit: ['auditor'],
|
||||
employees: ['manager'],
|
||||
settings: ['manager']
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,61 +1,61 @@
|
||||
<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"
|
||||
:startup-countdown-seconds="setupCountdownSeconds"
|
||||
:startup-log="setupStartupLog"
|
||||
:startup-steps="setupStartupSteps"
|
||||
:startup-visible="setupStartupVisible"
|
||||
:progress-message="setupProgressMessage"
|
||||
@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,
|
||||
setupCountdownSeconds,
|
||||
setupError,
|
||||
setupProgressMessage,
|
||||
setupStartupLog,
|
||||
setupStartupSteps,
|
||||
setupStartupVisible,
|
||||
setupSubmitting
|
||||
} = useSystemState()
|
||||
|
||||
async function submitSetup(payload) {
|
||||
const completed = await handleSetupSubmit(payload)
|
||||
|
||||
if (completed) {
|
||||
router.replace({ name: 'login' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<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"
|
||||
:startup-countdown-seconds="setupCountdownSeconds"
|
||||
:startup-log="setupStartupLog"
|
||||
:startup-steps="setupStartupSteps"
|
||||
:startup-visible="setupStartupVisible"
|
||||
:progress-message="setupProgressMessage"
|
||||
@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,
|
||||
setupCountdownSeconds,
|
||||
setupError,
|
||||
setupProgressMessage,
|
||||
setupStartupLog,
|
||||
setupStartupSteps,
|
||||
setupStartupVisible,
|
||||
setupSubmitting
|
||||
} = useSystemState()
|
||||
|
||||
async function submitSetup(payload) {
|
||||
const completed = await handleSetupSubmit(payload)
|
||||
|
||||
if (completed) {
|
||||
router.replace({ name: 'login' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,376 +1,376 @@
|
||||
<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', submitting ? 'pi-spin pi-spinner' : 'pi-check']"></i>
|
||||
<span>{{ submitting ? '写入配置中...' : '完成初始化并进入登录' }}</span>
|
||||
</button>
|
||||
<p v-if="progressMessage" class="setup-complete-progress">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
<span>{{ progressMessage }}</span>
|
||||
</p>
|
||||
</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 地址由当前已启动的前端实例自动确定,这一步只需要配置并检测后端端口。</p>
|
||||
</div>
|
||||
|
||||
<div class="field-grid field-grid-2">
|
||||
<label class="field">
|
||||
<span>Server Host</span>
|
||||
<input v-model.trim="form.server_host" type="text" placeholder="0.0.0.0" 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>
|
||||
|
||||
</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>
|
||||
|
||||
<div v-if="startupVisible" class="setup-modal-backdrop" role="alertdialog" aria-modal="true">
|
||||
<section class="setup-startup-modal" aria-label="后端启动进度">
|
||||
<header class="setup-startup-head">
|
||||
<div>
|
||||
<p class="setup-kicker setup-kicker-light">BACKEND STARTUP</p>
|
||||
<h2>正在完成系统启动</h2>
|
||||
<span>{{ progressMessage || '正在准备后端服务...' }}</span>
|
||||
</div>
|
||||
<div class="setup-startup-spinner" aria-hidden="true">
|
||||
<i v-if="!startupCountdownSeconds" class="pi pi-spin pi-spinner"></i>
|
||||
<strong v-else>{{ startupCountdownSeconds }}</strong>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setup-startup-body">
|
||||
<ol class="setup-startup-steps">
|
||||
<li
|
||||
v-for="step in startupSteps"
|
||||
:key="step.id"
|
||||
:class="['setup-startup-step', `is-${step.status || 'pending'}`]"
|
||||
>
|
||||
<i :class="startupStepIcon(step.status)"></i>
|
||||
<div>
|
||||
<strong>{{ step.label }}</strong>
|
||||
<span>{{ step.detail }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<section class="setup-startup-console" aria-label="后端启动日志">
|
||||
<div class="setup-startup-console-head">
|
||||
<strong>执行日志</strong>
|
||||
<span>server/logs/bootstrap-backend.log</span>
|
||||
</div>
|
||||
<pre class="setup-startup-log">{{ startupLog || '等待后端启动输出...' }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</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: ''
|
||||
},
|
||||
progressMessage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
startupCountdownSeconds: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
startupLog: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
startupSteps: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
startupVisible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
testButtonIcon,
|
||||
testButtonLabel,
|
||||
testSetup
|
||||
} = useSetupView(props, emit)
|
||||
|
||||
function startupStepIcon(status) {
|
||||
if (status === 'success') {
|
||||
return 'pi pi-check-circle'
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return 'pi pi-times-circle'
|
||||
}
|
||||
|
||||
if (status === 'running') {
|
||||
return 'pi pi-spin pi-spinner'
|
||||
}
|
||||
|
||||
return 'pi pi-circle'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/views/setup-view.css"></style>
|
||||
<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', submitting ? 'pi-spin pi-spinner' : 'pi-check']"></i>
|
||||
<span>{{ submitting ? '写入配置中...' : '完成初始化并进入登录' }}</span>
|
||||
</button>
|
||||
<p v-if="progressMessage" class="setup-complete-progress">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
<span>{{ progressMessage }}</span>
|
||||
</p>
|
||||
</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 地址由当前已启动的前端实例自动确定,这一步只需要配置并检测后端端口。</p>
|
||||
</div>
|
||||
|
||||
<div class="field-grid field-grid-2">
|
||||
<label class="field">
|
||||
<span>Server Host</span>
|
||||
<input v-model.trim="form.server_host" type="text" placeholder="0.0.0.0" 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>
|
||||
|
||||
</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>
|
||||
|
||||
<div v-if="startupVisible" class="setup-modal-backdrop" role="alertdialog" aria-modal="true">
|
||||
<section class="setup-startup-modal" aria-label="后端启动进度">
|
||||
<header class="setup-startup-head">
|
||||
<div>
|
||||
<p class="setup-kicker setup-kicker-light">BACKEND STARTUP</p>
|
||||
<h2>正在完成系统启动</h2>
|
||||
<span>{{ progressMessage || '正在准备后端服务...' }}</span>
|
||||
</div>
|
||||
<div class="setup-startup-spinner" aria-hidden="true">
|
||||
<i v-if="!startupCountdownSeconds" class="pi pi-spin pi-spinner"></i>
|
||||
<strong v-else>{{ startupCountdownSeconds }}</strong>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setup-startup-body">
|
||||
<ol class="setup-startup-steps">
|
||||
<li
|
||||
v-for="step in startupSteps"
|
||||
:key="step.id"
|
||||
:class="['setup-startup-step', `is-${step.status || 'pending'}`]"
|
||||
>
|
||||
<i :class="startupStepIcon(step.status)"></i>
|
||||
<div>
|
||||
<strong>{{ step.label }}</strong>
|
||||
<span>{{ step.detail }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<section class="setup-startup-console" aria-label="后端启动日志">
|
||||
<div class="setup-startup-console-head">
|
||||
<strong>执行日志</strong>
|
||||
<span>server/logs/bootstrap-backend.log</span>
|
||||
</div>
|
||||
<pre class="setup-startup-log">{{ startupLog || '等待后端启动输出...' }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</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: ''
|
||||
},
|
||||
progressMessage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
startupCountdownSeconds: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
startupLog: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
startupSteps: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
startupVisible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
testButtonIcon,
|
||||
testButtonLabel,
|
||||
testSetup
|
||||
} = useSetupView(props, emit)
|
||||
|
||||
function startupStepIcon(status) {
|
||||
if (status === 'success') {
|
||||
return 'pi pi-check-circle'
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return 'pi pi-times-circle'
|
||||
}
|
||||
|
||||
if (status === 'running') {
|
||||
return 'pi pi-spin pi-spinner'
|
||||
}
|
||||
|
||||
return 'pi pi-circle'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/views/setup-view.css"></style>
|
||||
|
||||
@@ -281,4 +281,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -246,4 +246,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -98,4 +98,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -39,4 +39,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -127,4 +127,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -440,4 +440,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -448,4 +448,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user