Files
ledgerly/frontend/src/components/DatabaseConfigPanel.vue
T
rosercode c1f5d02564 feat: 支持数据库运行时配置与多数据库,重构设置页并完善打包
- 数据库支持 SQLite / MySQL 运行时配置与切换,未配置时仍可启动,配置后自动建表
- 新增默认分类与标签初始化,注册用户自动创建
- 新增注册开关(默认关闭,可在系统设置开启;无用户时允许创建首个账号)
- 合并数据库、注册与 AI 配置为「系统设置」页,拆分「模型使用情况」独立页面
- AI 一键设置支持新建分类与标签
- 前端构建产物打包进可执行 jar,后端增加 SPA 路由回退
- jar 更名为 finance-app,补全 Gradle Wrapper,移除 archive 任务
- 更新 DeepSeek 模型说明文案
2026-09-18 12:33:41 +08:00

238 lines
7.6 KiB
Vue

<script setup lang="ts">
// 数据库配置面板:可复用于「系统设置」页与首次配置引导页。
// setup 为 true 时表示首次引导,保存成功后跳转登录页。
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NForm, NFormItem, NInput, NInputNumber, NButton, NSpace,
NAlert, NTag, NRadioGroup, NRadioButton, NIcon, NText, NDescriptions, NDescriptionsItem,
} from 'naive-ui'
import { ServerOutline, SaveOutline, FlashOutline, FolderOpenOutline } from '@vicons/ionicons5'
import { getDatabaseStatus, testDatabaseConnection, saveDatabaseConfig } from '../api/database'
import type { DatabaseConfigPayload, DatabaseType } from '../api/database'
import { useDatabaseStore } from '../store/database'
const props = defineProps<{ setup?: boolean }>()
const router = useRouter()
const message = useMessage()
const { t } = useI18n()
const databaseStore = useDatabaseStore()
const testing = ref(false)
const saving = ref(false)
const configured = ref(false)
const hasPassword = ref(false)
// 表单数据
const form = ref({
type: 'SQLITE' as DatabaseType,
sqlitePath: 'data/finance.db',
host: 'localhost',
port: 3306,
database: '',
username: '',
password: '',
})
/** 加载当前数据库状态并回显到表单 */
async function loadStatus() {
try {
const res: any = await getDatabaseStatus()
const data = res.data
configured.value = !!data?.configured
if (data?.configured) {
form.value.type = data.type
if (data.type === 'SQLITE') {
form.value.sqlitePath = data.sqlitePath || 'data/finance.db'
} else {
form.value.host = data.host || 'localhost'
form.value.port = data.port || 3306
form.value.database = data.database || ''
form.value.username = data.username || ''
}
hasPassword.value = data.type === 'MYSQL'
}
form.value.password = ''
} catch (e: any) {
message.error(e.message || t('database.loadFailed'))
}
}
/** 构建提交给后端的配置对象 */
function buildPayload(): DatabaseConfigPayload {
if (form.value.type === 'SQLITE') {
return { type: 'SQLITE', sqlitePath: form.value.sqlitePath }
}
return {
type: 'MYSQL',
host: form.value.host,
port: form.value.port,
database: form.value.database,
username: form.value.username,
// 留空表示沿用已保存的密码
password: form.value.password || undefined,
}
}
/** 前端必填校验 */
function validate(): boolean {
if (form.value.type === 'SQLITE') {
if (!form.value.sqlitePath?.trim()) {
message.warning(t('database.sqlitePathRequired'))
return false
}
return true
}
if (!form.value.host?.trim() || !form.value.database?.trim() || !form.value.username?.trim()) {
message.warning(t('database.mysqlRequired'))
return false
}
return true
}
/** 测试数据库连接 */
async function handleTest() {
if (!validate()) return
testing.value = true
try {
await testDatabaseConnection(buildPayload())
message.success(t('database.testSuccess'))
} catch (e: any) {
message.error(e.message || t('database.testFailed'))
} finally {
testing.value = false
}
}
/** 保存配置并切换到新数据库 */
async function handleSave() {
if (!validate()) return
saving.value = true
try {
const res: any = await saveDatabaseConfig(buildPayload())
databaseStore.setStatus(res.data)
message.success(t('database.saveSuccess'))
if (props.setup) {
// 首次配置完成后引导用户登录
router.push('/login')
} else {
await loadStatus()
}
} catch (e: any) {
message.error(e.message || t('database.saveFailed'))
} finally {
saving.value = false
}
}
onMounted(loadStatus)
</script>
<template>
<NCard :bordered="false" class="config-card">
<template #header>
<NSpace align="center" :size="8">
<NIcon :component="ServerOutline" />
<span>{{ props.setup ? t('database.setupTitle') : t('settings.databaseSection') }}</span>
</NSpace>
</template>
<template #header-extra>
<NTag :type="configured ? 'success' : 'warning'" size="small" :bordered="false">
{{ configured ? t('database.configured') : t('database.notConfigured') }}
</NTag>
</template>
<NAlert type="info" :show-icon="true" style="margin-bottom: 20px;">
{{ props.setup ? t('database.setupIntro') : t('database.intro') }}
</NAlert>
<!-- 当前配置概览 -->
<NDescriptions v-if="configured" :column="1" label-placement="left" size="small" class="db-current">
<NDescriptionsItem :label="t('database.currentType')">
<NTag size="small" :bordered="false">{{ databaseStore.status?.typeName }}</NTag>
</NDescriptionsItem>
<NDescriptionsItem :label="t('database.currentSummary')">
<NText code>{{ databaseStore.status?.summary }}</NText>
</NDescriptionsItem>
<NDescriptionsItem :label="t('database.configFile')">
<NText depth="3" style="font-size: 12px;">{{ databaseStore.status?.configFile }}</NText>
</NDescriptionsItem>
</NDescriptions>
<NForm label-placement="left" label-width="120">
<NFormItem :label="t('database.type')">
<NRadioGroup v-model:value="form.type">
<NRadioButton value="SQLITE">SQLite</NRadioButton>
<NRadioButton value="MYSQL">MySQL</NRadioButton>
</NRadioGroup>
</NFormItem>
<!-- SQLite 配置 -->
<template v-if="form.type === 'SQLITE'">
<NFormItem :label="t('database.sqlitePath')">
<NInput v-model:value="form.sqlitePath" :placeholder="t('database.sqlitePathPlaceholder')">
<template #prefix>
<NIcon :component="FolderOpenOutline" />
</template>
</NInput>
</NFormItem>
<NFormItem>
<NText depth="3" style="font-size: 12px;">{{ t('database.sqliteHint') }}</NText>
</NFormItem>
</template>
<!-- MySQL 配置 -->
<template v-else>
<NFormItem :label="t('database.host')">
<NInput v-model:value="form.host" placeholder="localhost" />
</NFormItem>
<NFormItem :label="t('database.port')">
<NInputNumber v-model:value="form.port" :min="1" :max="65535" style="width: 100%;" />
</NFormItem>
<NFormItem :label="t('database.database')">
<NInput v-model:value="form.database" placeholder="finance" />
</NFormItem>
<NFormItem :label="t('database.username')">
<NInput v-model:value="form.username" placeholder="root" />
</NFormItem>
<NFormItem :label="t('database.password')">
<NInput
v-model:value="form.password"
type="password"
show-password-on="click"
:placeholder="hasPassword ? t('database.passwordKeep') : t('database.passwordPlaceholder')"
/>
</NFormItem>
</template>
<NFormItem>
<NSpace>
<NButton :loading="testing" @click="handleTest">
<template #icon>
<NIcon :component="FlashOutline" />
</template>
{{ t('database.test') }}
</NButton>
<NButton type="primary" :loading="saving" @click="handleSave">
<template #icon>
<NIcon :component="SaveOutline" />
</template>
{{ t('database.save') }}
</NButton>
</NSpace>
</NFormItem>
</NForm>
</NCard>
</template>
<style scoped>
.db-current {
margin-bottom: 16px;
padding: 12px 16px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.02);
}
</style>