feat: 支持数据库运行时配置与多数据库,重构设置页并完善打包

- 数据库支持 SQLite / MySQL 运行时配置与切换,未配置时仍可启动,配置后自动建表
- 新增默认分类与标签初始化,注册用户自动创建
- 新增注册开关(默认关闭,可在系统设置开启;无用户时允许创建首个账号)
- 合并数据库、注册与 AI 配置为「系统设置」页,拆分「模型使用情况」独立页面
- AI 一键设置支持新建分类与标签
- 前端构建产物打包进可执行 jar,后端增加 SPA 路由回退
- jar 更名为 finance-app,补全 Gradle Wrapper,移除 archive 任务
- 更新 DeepSeek 模型说明文案
This commit is contained in:
2026-09-18 12:33:41 +08:00
parent 16e4c30456
commit c1f5d02564
63 changed files with 3783 additions and 573 deletions
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
// AI 模型配置面板:配置 DeepSeek 的启用开关、API Key、基础地址与模型,并提供使用情况入口。
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NForm, NFormItem, NInput, NSwitch, NButton, NSpace, NAlert, NText, NTag, NIcon,
} from 'naive-ui'
import {
SettingsOutline, KeyOutline, LinkOutline, CubeOutline, SaveOutline, FlashOutline, StatsChartOutline,
} from '@vicons/ionicons5'
import { getAiConfig, saveAiConfig, testAiConfig } from '../api/ai'
const router = useRouter()
const message = useMessage()
const { t } = useI18n()
const saving = ref(false)
const testing = ref(false)
const hasApiKey = ref(false)
// 表单数据
const form = ref({ enabled: false, apiKey: '', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' })
/** 加载当前用户的 AI 配置 */
async function loadConfig() {
try {
const res: any = await getAiConfig()
const data = res.data
form.value = {
enabled: !!data.enabled,
apiKey: '',
baseUrl: data.baseUrl || 'https://api.deepseek.com',
model: data.model || 'deepseek-chat',
}
hasApiKey.value = !!data.hasApiKey
} catch (e: any) {
message.error(e.message || t('ai.loadFailed'))
}
}
/** 构建提交数据,apiKey 为空时不覆盖已保存的 Key */
function buildPayload() {
return {
enabled: form.value.enabled,
apiKey: form.value.apiKey || undefined,
baseUrl: form.value.baseUrl,
model: form.value.model,
}
}
/** 保存配置 */
async function handleSave() {
saving.value = true
try {
await saveAiConfig(buildPayload())
message.success(t('ai.saveSuccess'))
await loadConfig()
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
saving.value = false
}
}
/** 测试连接:先保存当前表单,再向 DeepSeek 发起测试请求 */
async function handleTest() {
testing.value = true
try {
await saveAiConfig(buildPayload())
await testAiConfig()
message.success(t('ai.testSuccess'))
await loadConfig()
} catch (e: any) {
message.error(e.message || t('ai.testFailed'))
} finally {
testing.value = false
}
}
/** 跳转到模型使用情况页面 */
function goUsage() {
router.push('/ai-usage')
}
onMounted(loadConfig)
</script>
<template>
<NCard :bordered="false" class="config-card">
<template #header>
<NSpace align="center" :size="8">
<NIcon :component="SettingsOutline" />
<span>{{ t('settings.aiSection') }}</span>
</NSpace>
</template>
<template #header-extra>
<NSpace align="center" :size="8">
<NTag :type="form.enabled ? 'success' : 'default'" size="small" :bordered="false">
{{ form.enabled ? t('ai.enabledOn') : t('ai.enabledOff') }}
</NTag>
<NButton size="small" quaternary @click="goUsage">
<template #icon>
<NIcon :component="StatsChartOutline" />
</template>
{{ t('settings.viewUsage') }}
</NButton>
</NSpace>
</template>
<NAlert type="info" :show-icon="true" style="margin-bottom: 20px;">
{{ t('ai.intro') }}
</NAlert>
<NForm label-placement="left" label-width="130">
<NFormItem :label="t('ai.enabled')">
<div class="switch-row">
<NSwitch v-model:value="form.enabled" />
<NText depth="3" style="font-size: 12px;">{{ t('ai.enabledHint') }}</NText>
</div>
</NFormItem>
<NFormItem :label="t('ai.apiKey')">
<NInput
v-model:value="form.apiKey"
type="password"
show-password-on="click"
:placeholder="hasApiKey ? t('ai.apiKeyKeep') : t('ai.apiKeyPlaceholder')"
>
<template #prefix>
<NIcon :component="KeyOutline" />
</template>
</NInput>
</NFormItem>
<NFormItem :label="t('ai.baseUrl')">
<NInput v-model:value="form.baseUrl" placeholder="https://api.deepseek.com">
<template #prefix>
<NIcon :component="LinkOutline" />
</template>
</NInput>
</NFormItem>
<NFormItem :label="t('ai.model')">
<NInput v-model:value="form.model" placeholder="deepseek-chat">
<template #prefix>
<NIcon :component="CubeOutline" />
</template>
</NInput>
</NFormItem>
<NFormItem>
<NSpace>
<NButton type="primary" :loading="saving" @click="handleSave">
<template #icon>
<NIcon :component="SaveOutline" />
</template>
{{ t('ai.save') }}
</NButton>
<NButton :loading="testing" :disabled="!form.enabled" @click="handleTest">
<template #icon>
<NIcon :component="FlashOutline" />
</template>
{{ t('ai.test') }}
</NButton>
</NSpace>
</NFormItem>
</NForm>
</NCard>
</template>
<style scoped>
.switch-row {
display: flex;
align-items: center;
gap: 12px;
}
</style>
+222
View File
@@ -0,0 +1,222 @@
<script setup lang="ts">
// AI 使用情况面板:展示累计调用汇总、Token 明细与最近调用记录。
import { ref, onMounted, computed, h } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NSpace, NButton, NIcon, NStatistic, NDataTable, NTag,
NGrid, NGridItem, NEmpty,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import {
PulseOutline, CheckmarkCircleOutline, CloseCircleOutline, StatsChartOutline,
CloudUploadOutline, CloudDownloadOutline, FlashOutline, TimeOutline,
} from '@vicons/ionicons5'
import { getAiUsage } from '../api/ai'
import type { AiUsageResult } from '../api/ai'
const message = useMessage()
const { t } = useI18n()
const usage = ref<AiUsageResult | null>(null) // AI 使用情况
const usageLoading = ref(false) // 使用情况加载状态
/** 加载 AI 使用情况(累计汇总 + 最近明细) */
async function loadUsage() {
usageLoading.value = true
try {
const res: any = await getAiUsage()
usage.value = res.data
} catch (e: any) {
message.error(e.message || t('ai.usage.loadFailed'))
} finally {
usageLoading.value = false
}
}
/** 数字千分位格式化 */
function formatNumber(value: number | null | undefined) {
return value == null ? '0' : Number(value).toLocaleString()
}
/** 使用概览卡片配置 */
const overviewStats = computed(() => [
{ key: 'calls', label: t('ai.usage.totalCalls'), value: usage.value?.summary.totalCalls ?? 0, icon: PulseOutline, color: '#2080f0' },
{ key: 'success', label: t('ai.usage.successCalls'), value: usage.value?.summary.successCalls ?? 0, icon: CheckmarkCircleOutline, color: '#18a058' },
{ key: 'failed', label: t('ai.usage.failedCalls'), value: usage.value?.summary.failedCalls ?? 0, icon: CloseCircleOutline, color: '#d03050' },
{ key: 'total', label: t('ai.usage.totalTokens'), value: usage.value?.summary.totalTokens ?? 0, icon: StatsChartOutline, color: '#7c3aed' },
])
/** Token 明细卡片配置 */
const tokenStats = computed(() => [
{ key: 'prompt', label: t('ai.usage.promptTokens'), value: usage.value?.summary.promptTokens ?? 0, icon: CloudUploadOutline, color: '#2080f0' },
{ key: 'completion', label: t('ai.usage.completionTokens'), value: usage.value?.summary.completionTokens ?? 0, icon: CloudDownloadOutline, color: '#f0a020' },
{ key: 'cached', label: t('ai.usage.cachedTokens'), value: usage.value?.summary.cachedTokens ?? 0, icon: FlashOutline, color: '#18a058' },
])
/** 使用明细表格列定义(随语言切换响应式更新) */
const usageColumns = computed<DataTableColumns<any>>(() => [
{
title: t('ai.usage.column.time'), key: 'createdAt', width: 160,
render: (row) => {
if (!row.createdAt) return '-'
const [date, time] = String(row.createdAt).replace('T', ' ').split(' ')
return h('div', { class: 'cell-time' }, [
h('div', null, date),
h('div', { class: 'cell-time-sub' }, (time || '').substring(0, 8)),
])
},
},
{
title: t('ai.usage.column.type'), key: 'type', width: 120,
render: (row) => h(NTag, { type: row.type === 'SUGGEST' ? 'info' : 'default', size: 'small', bordered: false },
{ default: () => t(`ai.usage.type.${String(row.type).toLowerCase()}`) }),
},
{
title: t('ai.usage.column.count'), key: 'transactionCount', width: 100, align: 'right', titleAlign: 'right',
render: (row) => formatNumber(row.transactionCount),
},
{
title: t('ai.usage.column.promptTokens'), key: 'promptTokens', width: 100, align: 'right', titleAlign: 'right',
render: (row) => h('span', { class: 'cell-num' }, formatNumber(row.promptTokens)),
},
{
title: t('ai.usage.column.completionTokens'), key: 'completionTokens', width: 100, align: 'right', titleAlign: 'right',
render: (row) => h('span', { class: 'cell-num' }, formatNumber(row.completionTokens)),
},
{
title: t('ai.usage.column.totalTokens'), key: 'totalTokens', width: 100, align: 'right', titleAlign: 'right',
render: (row) => h('span', { class: 'cell-num cell-total' }, formatNumber(row.totalTokens)),
},
{
title: t('ai.usage.column.duration'), key: 'durationMs', width: 100, align: 'right', titleAlign: 'right',
render: (row) => (row.durationMs != null ? `${formatNumber(row.durationMs)} ms` : '-'),
},
{
title: t('ai.usage.column.status'), key: 'success', width: 110, align: 'center', titleAlign: 'center',
render: (row) => (row.success
? h(NTag, { type: 'success', size: 'small', bordered: false }, { default: () => t('ai.usage.success') })
: h('span', { title: row.errorMessage || '' },
h(NTag, { type: 'error', size: 'small', bordered: false }, { default: () => t('ai.usage.failed') }))),
},
])
onMounted(loadUsage)
</script>
<template>
<NCard :bordered="false" class="usage-card">
<template #header>
<NSpace justify="space-between" align="center" style="width: 100%;">
<NSpace align="center" :size="8">
<NIcon :component="StatsChartOutline" />
<span>{{ t('ai.usage.title') }}</span>
</NSpace>
<NButton size="small" :loading="usageLoading" @click="loadUsage">
<template #icon>
<NIcon :component="TimeOutline" />
</template>
{{ t('ai.usage.refresh') }}
</NButton>
</NSpace>
</template>
<!-- 调用概览 -->
<NGrid cols="2 m:4" :x-gap="12" :y-gap="12">
<NGridItem v-for="item in overviewStats" :key="item.key">
<div class="stat-card">
<div class="stat-icon" :style="{ background: item.color + '1a', color: item.color }">
<NIcon size="20"><component :is="item.icon" /></NIcon>
</div>
<NStatistic
:label="item.label"
:value="formatNumber(item.value)"
:value-style="{ color: item.color, fontSize: '20px', fontWeight: 600 }"
/>
</div>
</NGridItem>
</NGrid>
<!-- Token 明细 -->
<NGrid cols="1 s:3" :x-gap="12" :y-gap="12" style="margin-top: 12px;">
<NGridItem v-for="item in tokenStats" :key="item.key">
<div class="stat-card">
<div class="stat-icon" :style="{ background: item.color + '1a', color: item.color }">
<NIcon size="20"><component :is="item.icon" /></NIcon>
</div>
<NStatistic
:label="item.label"
:value="formatNumber(item.value)"
:value-style="{ color: item.color, fontSize: '20px', fontWeight: 600 }"
/>
</div>
</NGridItem>
</NGrid>
<!-- 最近调用明细 -->
<div class="table-title">{{ t('ai.usage.recentTitle') }}</div>
<NDataTable
:columns="usageColumns"
:data="usage?.recent ?? []"
:loading="usageLoading"
size="small"
:bordered="false"
:striped="true"
class="usage-table"
>
<template #empty>
<NEmpty :description="t('ai.usage.empty')" />
</template>
</NDataTable>
</NCard>
</template>
<style scoped>
.table-title {
margin: 22px 0 10px;
font-size: 13px;
font-weight: 600;
color: #666;
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.02);
transition: background 0.2s ease;
}
.stat-card:hover {
background: rgba(0, 0, 0, 0.04);
}
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 12px;
flex-shrink: 0;
}
.cell-time {
line-height: 1.35;
}
.cell-time-sub {
color: #999;
font-size: 12px;
}
.cell-num {
font-variant-numeric: tabular-nums;
}
.cell-total {
font-weight: 600;
}
.usage-table :deep(.n-data-table-th) {
background-color: rgba(0, 0, 0, 0.025);
font-weight: 600;
font-size: 12px;
}
.usage-table :deep(.n-data-table-td) {
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,237 @@
<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>
@@ -0,0 +1,91 @@
<script setup lang="ts">
// 注册设置面板:控制系统是否开放注册。系统无任何用户时始终允许创建首个账号。
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NSpace, NSwitch, NButton, NIcon, NText, NTag, NAlert,
} from 'naive-ui'
import { PersonAddOutline, SaveOutline } from '@vicons/ionicons5'
import { getRegistrationStatus, updateRegistration } from '../api/user'
import { useRegistrationStore } from '../store/registration'
const message = useMessage()
const { t } = useI18n()
const registrationStore = useRegistrationStore()
const enabled = ref(false)
const saving = ref(false)
const loading = ref(false)
/** 加载当前注册状态并回显开关 */
async function loadStatus() {
loading.value = true
try {
const res: any = await getRegistrationStatus()
registrationStore.setStatus(res.data)
enabled.value = !!res.data?.enabled
} catch (e: any) {
message.error(e.message || t('registration.loadFailed'))
} finally {
loading.value = false
}
}
/** 保存注册开关 */
async function handleSave() {
saving.value = true
try {
const res: any = await updateRegistration(enabled.value)
registrationStore.setStatus(res.data)
message.success(t('registration.saveSuccess'))
} catch (e: any) {
message.error(e.message || t('registration.saveFailed'))
} finally {
saving.value = false
}
}
onMounted(loadStatus)
</script>
<template>
<NCard :bordered="false" class="config-card">
<template #header>
<NSpace align="center" :size="8">
<NIcon :component="PersonAddOutline" />
<span>{{ t('settings.registrationSection') }}</span>
</NSpace>
</template>
<template #header-extra>
<NTag :type="enabled ? 'success' : 'default'" size="small" :bordered="false">
{{ enabled ? t('registration.statusOn') : t('registration.statusOff') }}
</NTag>
</template>
<NAlert v-if="registrationStore.status?.firstUser" type="warning" :show-icon="true" style="margin-bottom: 16px;">
{{ t('registration.firstUserHint') }}
</NAlert>
<div class="switch-row">
<NSpace align="center" :size="12">
<NSwitch v-model:value="enabled" :disabled="loading" />
<NText depth="3" style="font-size: 12px;">{{ t('registration.enabledHint') }}</NText>
</NSpace>
</div>
<NSpace style="margin-top: 16px;">
<NButton type="primary" :loading="saving" @click="handleSave">
<template #icon>
<NIcon :component="SaveOutline" />
</template>
{{ t('registration.save') }}
</NButton>
</NSpace>
</NCard>
</template>
<style scoped>
.switch-row {
display: block;
}
</style>