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
+18 -2
View File
@@ -10,6 +10,12 @@ export interface AiConfig {
model: string
}
/** AI 建议新建的标签 */
export interface AiNewTag {
name: string
color: string
}
/** 单条 AI 建议(按类型、备注、支付方式聚合后的一组记录) */
export interface AiSuggestion {
transactionId: number
@@ -17,8 +23,11 @@ export interface AiSuggestion {
count: number
categoryId: number | null
categoryName: string | null
newCategoryName: string | null
newCategoryIcon: string | null
tagIds: number[]
tagNames: string[]
newTags: AiNewTag[]
reason: string
}
@@ -83,8 +92,15 @@ export function suggestColor(name: string) {
return request.post('/ai/color', { name }, { timeout: 60000 })
}
/** 一键应用建议:覆盖分类并追加标签 */
export function applyAiSuggestions(suggestions: { transactionId: number; categoryId: number | null; tagIds: number[] }[]) {
/** 一键应用建议:覆盖分类并追加标签,标记为新建的分类/标签会先创建再关联 */
export function applyAiSuggestions(suggestions: {
transactionId: number
categoryId: number | null
newCategoryName?: string | null
newCategoryIcon?: string | null
tagIds: number[]
newTags?: AiNewTag[]
}[]) {
return request.post('/ai/apply', { suggestions })
}
+45
View File
@@ -0,0 +1,45 @@
// 数据库配置接口封装:状态查询、连接测试与配置保存
import request from './request'
/** 数据库类型 */
export type DatabaseType = 'MYSQL' | 'SQLITE'
/** 数据库配置请求参数 */
export interface DatabaseConfigPayload {
type: DatabaseType
sqlitePath?: string
host?: string
port?: number
database?: string
username?: string
password?: string
}
/** 数据库状态 */
export interface DatabaseStatus {
configured: boolean
type?: DatabaseType
typeName?: string
summary?: string
configFile?: string
host?: string
port?: number
database?: string
username?: string
sqlitePath?: string
}
/** 查询当前数据库配置状态 */
export function getDatabaseStatus() {
return request.get('/database/status')
}
/** 测试数据库连接是否可用 */
export function testDatabaseConnection(data: DatabaseConfigPayload) {
return request.post('/database/test', data)
}
/** 保存数据库配置并切换到新数据源 */
export function saveDatabaseConfig(data: DatabaseConfigPayload) {
return request.post('/database/config', data)
}
+10
View File
@@ -24,6 +24,16 @@ request.interceptors.response.use(
const data = response.data
// 如果业务状态码不是 200,视为请求失败
if (data.code !== 200) {
// 5031:数据库未配置,更新本地状态并跳转到配置引导页
if (data.code === 5031) {
import('../store/database').then(({ useDatabaseStore }) => {
const databaseStore = useDatabaseStore()
databaseStore.setStatus(null)
if (router.currentRoute.value.path !== '/database-setup') {
router.push('/database-setup')
}
})
}
return Promise.reject(new Error(data.message || '请求失败'))
}
return data
+17
View File
@@ -15,3 +15,20 @@ export function register(data: { username: string; password: string; nickname?:
export function getUserInfo() {
return request.get('/user/info')
}
/** 注册状态 */
export interface RegistrationStatus {
enabled: boolean
firstUser: boolean
allowed: boolean
}
/** 查询注册状态(无需登录) */
export function getRegistrationStatus() {
return request.get('/user/registration-status')
}
/** 更新注册开关(需登录) */
export function updateRegistration(enabled: boolean) {
return request.put('/user/registration', { enabled })
}
+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>
+67 -5
View File
@@ -82,11 +82,72 @@ export default {
transactions: { zh: '收支记录', en: 'Transactions', ja: '収支記録' },
categories: { zh: '分类管理', en: 'Categories', ja: 'カテゴリ管理' },
tags: { zh: '标签管理', en: 'Tags', ja: 'タグ管理' },
aiSettings: { zh: 'AI 设置', en: 'AI Settings', ja: 'AI 設定' },
settings: { zh: '系统设置', en: 'Settings', ja: 'システム設定' },
user: { zh: '用户', en: 'User', ja: 'ユーザー' },
logout: { zh: '退出', en: 'Sign Out', ja: 'ログアウト' },
},
// 数据库设置
database: {
title: { zh: '数据库设置', en: 'Database Settings', ja: 'データベース設定' },
setupTitle: { zh: '初始化数据库', en: 'Initialize Database', ja: 'データベースの初期化' },
intro: { zh: '可选择本地 SQLite 文件数据库或 MySQL 数据库。保存后立即生效并自动建表,原有数据库的数据保持不变。', en: 'Choose a local SQLite file database or a MySQL database. Changes take effect immediately with automatic table creation; data in the previous database is preserved.', ja: 'ローカルの SQLite ファイルデータベースまたは MySQL を選択できます。保存すると即時に反映され、テーブルが自動作成されます。以前のデータベースのデータは保持されます。' },
setupIntro: { zh: '首次使用请先完成数据库配置。推荐零配置的 SQLite(本地文件),也可以连接已有的 MySQL。', en: 'Please configure a database before first use. SQLite (local file) requires no setup; you may also connect to an existing MySQL.', ja: '初回利用の前にデータベースを設定してください。設定不要の SQLite(ローカルファイル)を推奨します。既存の MySQL に接続することもできます。' },
setupHint: { zh: '配置完成后即可登录使用;如需修改,可稍后在「数据库设置」中调整。', en: 'After configuration you can sign in. You can adjust it later in Database Settings.', ja: '設定後はログインして利用できます。後で「データベース設定」から変更できます。' },
configured: { zh: '已配置', en: 'Configured', ja: '設定済み' },
notConfigured: { zh: '未配置', en: 'Not configured', ja: '未設定' },
currentType: { zh: '当前类型', en: 'Current type', ja: '現在の種類' },
currentSummary: { zh: '连接地址', en: 'Connection', ja: '接続先' },
configFile: { zh: '配置文件', en: 'Config file', ja: '設定ファイル' },
type: { zh: '数据库类型', en: 'Database type', ja: 'データベースの種類' },
sqlitePath: { zh: '数据库文件', en: 'Database file', ja: 'データベースファイル' },
sqlitePathPlaceholder: { zh: '例如 data/finance.db', en: 'e.g. data/finance.db', ja: '例:data/finance.db' },
sqliteHint: { zh: 'SQLite 为本地单文件数据库,路径相对于服务运行目录;建议使用 data 目录并做好持久化挂载。', en: 'SQLite is a single local file; the path is relative to the server working directory. Use a data directory with persistent storage.', ja: 'SQLite はローカルの単一ファイルです。パスはサーバーの作業ディレクトリからの相対パスです。data ディレクトリと永続化ストレージの利用を推奨します。' },
sqlitePathRequired: { zh: '请填写 SQLite 数据库文件路径', en: 'Please enter the SQLite database file path', ja: 'SQLite データベースファイルのパスを入力してください' },
mysqlRequired: { zh: '请填写主机地址、数据库名与用户名', en: 'Please fill in host, database and username', ja: 'ホスト、データベース名、ユーザー名を入力してください' },
host: { zh: '主机地址', en: 'Host', ja: 'ホスト' },
port: { zh: '端口', en: 'Port', ja: 'ポート' },
database: { zh: '数据库名', en: 'Database', ja: 'データベース名' },
username: { zh: '用户名', en: 'Username', ja: 'ユーザー名' },
password: { zh: '密码', en: 'Password', ja: 'パスワード' },
passwordPlaceholder: { zh: '请输入密码', en: 'Enter password', ja: 'パスワードを入力してください' },
passwordKeep: { zh: '已配置,留空表示不修改', en: 'Configured; leave blank to keep unchanged', ja: '設定済み。空欄のままにすると変更されません' },
test: { zh: '测试连接', en: 'Test connection', ja: '接続テスト' },
save: { zh: '保存并应用', en: 'Save & Apply', ja: '保存して適用' },
testSuccess: { zh: '连接成功', en: 'Connection succeeded', ja: '接続に成功しました' },
testFailed: { zh: '连接失败', en: 'Connection failed', ja: '接続に失敗しました' },
saveSuccess: { zh: '数据库配置已保存并生效', en: 'Database configuration saved and applied', ja: 'データベース設定を保存して適用しました' },
saveFailed: { zh: '数据库配置失败', en: 'Failed to configure database', ja: 'データベース設定に失敗しました' },
loadFailed: { zh: '加载数据库状态失败', en: 'Failed to load database status', ja: 'データベース状態の読み込みに失敗しました' },
},
// 系统设置
settings: {
title: { zh: '系统设置', en: 'Settings', ja: 'システム設定' },
databaseSection: { zh: '数据库配置', en: 'Database', ja: 'データベース' },
aiSection: { zh: 'AI 模型配置', en: 'AI Model', ja: 'AI モデル' },
viewUsage: { zh: '使用情况', en: 'Usage', ja: '利用状況' },
registrationSection: { zh: '注册设置', en: 'Registration', ja: '登録設定' },
},
// 注册设置
registration: {
statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' },
statusOff: { zh: '已关闭', en: 'Disabled', ja: '無効' },
enabledHint: { zh: '关闭后新用户无法注册;系统无任何用户时仍可创建首个账号', en: 'When off, new users cannot sign up; the first account can still be created when there are no users.', ja: 'オフにすると新規登録できません。ユーザーが一人もいない場合は最初のアカウントを作成できます。' },
firstUserHint: { zh: '当前系统暂无用户,可直接注册首个账号', en: 'No users yet — you can register the first account directly.', ja: '現在ユーザーがいないため、最初のアカウントを直接登録できます。' },
save: { zh: '保存', en: 'Save', ja: '保存' },
saveSuccess: { zh: '注册设置已保存', en: 'Registration settings saved', ja: '登録設定を保存しました' },
saveFailed: { zh: '保存注册设置失败', en: 'Failed to save registration settings', ja: '登録設定の保存に失敗しました' },
loadFailed: { zh: '加载注册状态失败', en: 'Failed to load registration status', ja: '登録状態の読み込みに失敗しました' },
},
// 模型使用情况
aiUsage: {
title: { zh: '模型使用情况', en: 'Model Usage', ja: 'モデル利用状況' },
back: { zh: '返回设置', en: 'Back to Settings', ja: '設定に戻る' },
},
// 统计面板
dashboard: {
title: { zh: '统计面板', en: 'Dashboard', ja: 'ダッシュボード' },
@@ -312,9 +373,7 @@ export default {
// AI 智能建议
ai: {
title: { zh: 'AI 智能建议', en: 'AI Suggestions', ja: 'AI 提案' },
intro: { zh: '配置 DeepSeek 后,可在收支记录页的「一键设置」中让 AI 根据记录内容推荐分类与标签。', en: 'After configuring DeepSeek, you can let AI recommend categories and tags for records in the "Batch Set" dialog on the Transactions page.', ja: 'DeepSeek を設定すると、収支記録ページの「一括設定」で AI が記録内容に応じてカテゴリとタグを提案できます。' },
configTitle: { zh: '模型配置', en: 'Model Configuration', ja: 'モデル設定' },
intro: { zh: '启用后,可在收支记录页使用 AI 智能建议(一键设置中推荐分类与标签)、智能记账与 AI 查询;调用量与 Token 消耗可在「模型使用情况」查看。', en: 'Once enabled, you can use AI suggestions (categories & tags in Batch Set), Smart Entry, and AI Query on the Transactions page; call counts and token usage are available under "Model Usage".', ja: '有効にすると、収支記録ページで AI 提案(一括設定でカテゴリとタグを推薦)、スマート入力、AI 検索を利用できます。呼び出し回数とトークン消費は「モデル利用状況」で確認できます。' },
enabledOn: { zh: '已启用', en: 'Enabled', ja: '有効' },
enabledOff: { zh: '未启用', en: 'Disabled', ja: '無効' },
enabled: { zh: '启用 AI 建议', en: 'Enable AI suggestions', ja: 'AI 提案を有効にする' },
@@ -349,7 +408,10 @@ export default {
oneApplied: { zh: '已应用 AI 推荐分类', en: 'AI category applied', ja: 'AI が提案したカテゴリを適用しました' },
tagApplied: { zh: '已应用 AI 推荐标签', en: 'AI tags applied', ja: 'AI が提案したタグを適用しました' },
applyFailed: { zh: '应用建议失败', en: 'Failed to apply suggestions', ja: '提案の適用に失敗しました' },
applyHint: { zh: '将覆盖分类并追加标签,确认后立即写入', en: 'Categories will be overwritten and tags appended; changes are written immediately.', ja: 'カテゴリは上書きされ、タグは追加されます。確定するとすぐに保存されます。' },
applyHint: { zh: '将覆盖分类并追加标签;标记「新建」的分类/标签会在确认后创建', en: 'Categories will be overwritten and tags appended; items marked "New" will be created after confirmation.', ja: 'カテゴリは上書き、タグは追加されます。「新規」の項目は確定後に作成されます。' },
newBadge: { zh: '新建', en: 'New', ja: '新規' },
applyNewTitle: { zh: '确认新建', en: 'Confirm creation', ja: '新規作成の確認' },
applyNewConfirm: { zh: '将新建 {categories} 个分类、{tags} 个标签,确认应用?', en: 'Will create {categories} category(ies) and {tags} tag(s). Continue?', ja: '{categories} 件のカテゴリと {tags} 件のタグを作成します。適用しますか?' },
needConfig: { zh: 'AI 未启用,请先在「AI 设置」中配置', en: 'AI is not enabled. Please configure it in AI Settings first.', ja: 'AI が有効になっていません。「AI 設定」で設定してください。' },
decorate: {
emojiButton: { zh: 'AI 推荐', en: 'AI Pick', ja: 'AI におまかせ' },
+52 -5
View File
@@ -1,11 +1,18 @@
// 路由配置文件:定义应用的所有路由及导航守卫
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '../store/user'
import { useDatabaseStore } from '../store/database'
import { useRegistrationStore } from '../store/registration'
// 创建路由实例,使用 HTML5 History 模式
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/database-setup',
name: 'DatabaseSetup',
component: () => import('../views/DatabaseSettings.vue'), // 首次数据库配置引导页(无需登录)
},
{
path: '/login',
name: 'Login',
@@ -42,17 +49,57 @@ const router = createRouter({
component: () => import('../views/TagManage.vue'), // 标签管理
},
{
path: 'ai-settings',
name: 'AiSettings',
component: () => import('../views/AiSettings.vue'), // AI 设置
path: 'settings',
name: 'Settings',
component: () => import('../views/Settings.vue'), // 系统设置(数据库 + AI 模型)
},
{
path: 'ai-usage',
name: 'AiUsage',
component: () => import('../views/AiUsage.vue'), // 模型使用情况
},
],
},
],
})
// 全局前置守卫:未登录时拦截跳转至登录页
router.beforeEach((to, _from, next) => {
// 全局前置守卫:优先校验数据库配置,再校验登录状态
router.beforeEach(async (to, _from, next) => {
const databaseStore = useDatabaseStore()
// 首次进入时从后端加载数据库配置状态
if (!databaseStore.loaded) {
await databaseStore.load()
}
// 数据库未配置:强制引导到配置页,避免用户卡在无法登录的状态
if (!databaseStore.configured) {
if (to.path === '/database-setup') {
next()
} else {
next('/database-setup')
}
return
}
// 已配置数据库:配置引导页不再需要,跳转到登录或首页
if (to.path === '/database-setup') {
const userStore = useUserStore()
next(userStore.token ? '/dashboard' : '/login')
return
}
// 注册功能关闭且已有用户时,不允许访问注册页
if (to.path === '/register') {
const registrationStore = useRegistrationStore()
if (!registrationStore.loaded) {
await registrationStore.load()
}
if (!registrationStore.status?.allowed) {
next('/login')
return
}
}
const userStore = useUserStore()
// 如果目标页面不是登录/注册页,且没有 token,则重定向到登录页
if (to.path !== '/login' && to.path !== '/register' && !userStore.token) {
+32
View File
@@ -0,0 +1,32 @@
// 数据库状态管理模块:记录后端是否已完成数据库配置,供路由守卫与页面使用
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getDatabaseStatus } from '../api/database'
import type { DatabaseStatus } from '../api/database'
export const useDatabaseStore = defineStore('database', () => {
const configured = ref(false) // 后端是否已完成数据库配置
const status = ref<DatabaseStatus | null>(null) // 当前数据库状态
const loaded = ref(false) // 是否已从后端加载过状态
/** 从后端加载数据库状态 */
async function load() {
try {
const res: any = await getDatabaseStatus()
setStatus(res.data)
} catch (e) {
// 状态查询失败时按未配置处理,引导用户前往配置页
configured.value = false
loaded.value = true
}
}
/** 更新本地保存的数据库状态 */
function setStatus(next: DatabaseStatus | null) {
status.value = next
configured.value = !!next?.configured
loaded.value = true
}
return { configured, status, loaded, load, setStatus }
})
+30
View File
@@ -0,0 +1,30 @@
// 注册状态管理模块:缓存后端注册开关与是否允许注册,供登录页与路由守卫复用
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getRegistrationStatus } from '../api/user'
import type { RegistrationStatus } from '../api/user'
export const useRegistrationStore = defineStore('registration', () => {
const status = ref<RegistrationStatus | null>(null) // 注册状态
const loaded = ref(false) // 是否已从后端加载过
/** 从后端加载注册状态,失败时按不允许注册处理 */
async function load() {
try {
const res: any = await getRegistrationStatus()
status.value = res.data
} catch (e) {
status.value = null
} finally {
loaded.value = true
}
}
/** 更新本地缓存的注册状态 */
function setStatus(next: RegistrationStatus | null) {
status.value = next
loaded.value = true
}
return { status, loaded, load, setStatus }
})
-379
View File
@@ -1,379 +0,0 @@
<script setup lang="ts">
// AI 设置页:配置 DeepSeek 的启用开关、API Key、基础地址与模型,并展示 AI 使用情况
import { ref, onMounted, computed, h } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NForm, NFormItem, NInput, NSwitch, NButton, NSpace, NAlert, NText,
NStatistic, NDataTable, NTag, NGrid, NGridItem, NIcon, NEmpty,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import {
PulseOutline, CheckmarkCircleOutline, CloseCircleOutline, StatsChartOutline,
CloudUploadOutline, CloudDownloadOutline, FlashOutline, TimeOutline,
SettingsOutline, KeyOutline, LinkOutline, CubeOutline, SaveOutline,
} from '@vicons/ionicons5'
import { getAiConfig, saveAiConfig, testAiConfig, getAiUsage } from '../api/ai'
import type { AiUsageResult } from '../api/ai'
const message = useMessage() // 消息提示
const { t } = useI18n() // 国际化
const saving = ref(false) // 保存状态
const testing = ref(false) // 测试连接状态
const hasApiKey = ref(false) // 是否已配置过 API Key
const usage = ref<AiUsageResult | null>(null) // AI 使用情况
const usageLoading = 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'))
}
}
/** 加载 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
}
}
/** 提交表单数据,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
// 测试连接也会产生使用记录,刷新使用情况
await loadUsage()
}
}
/** 数字千分位格式化 */
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(() => {
loadConfig()
loadUsage()
})
</script>
<template>
<div>
<h2 style="margin: 0 0 16px;">{{ t('ai.title') }}</h2>
<!-- 模型配置 -->
<NCard :bordered="false" class="config-card">
<template #header>
<NSpace align="center" :size="8">
<NIcon :component="SettingsOutline" />
<span>{{ t('ai.configTitle') }}</span>
</NSpace>
</template>
<template #header-extra>
<NTag :type="form.enabled ? 'success' : 'default'" size="small" :bordered="false">
{{ form.enabled ? t('ai.enabledOn') : t('ai.enabledOff') }}
</NTag>
</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>
<!-- 使用情况:累计汇总 + 最近调用明细 -->
<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>
</div>
</template>
<style scoped>
.config-card {
max-width: 720px;
}
.usage-card {
max-width: 960px;
margin-top: 16px;
}
.switch-row {
display: flex;
align-items: center;
gap: 12px;
}
.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>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
// 模型使用情况页:展示 AI 调用的累计汇总与最近明细
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { NSpace, NButton, NIcon } from 'naive-ui'
import { ArrowBackOutline } from '@vicons/ionicons5'
import AiUsagePanel from '../components/AiUsagePanel.vue'
const router = useRouter()
const { t } = useI18n()
</script>
<template>
<div class="usage-page">
<NSpace align="center" justify="space-between" style="margin-bottom: 16px;">
<h2 class="page-title">{{ t('aiUsage.title') }}</h2>
<NButton size="small" @click="router.push('/settings')">
<template #icon>
<NIcon :component="ArrowBackOutline" />
</template>
{{ t('aiUsage.back') }}
</NButton>
</NSpace>
<AiUsagePanel />
</div>
</template>
<style scoped>
.usage-page {
max-width: 960px;
}
.page-title {
margin: 0;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
// 首次数据库配置引导页:居中展示数据库配置面板,保存成功后跳转登录
import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
</script>
<template>
<div class="setup-wrapper">
<div class="setup-card">
<DatabaseConfigPanel setup />
</div>
</div>
</template>
<style scoped>
.setup-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
box-sizing: border-box;
}
.setup-card {
width: 100%;
max-width: 720px;
}
.setup-card :deep(.n-card) {
border-radius: 12px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.08);
}
</style>
+5 -5
View File
@@ -5,7 +5,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui'
import type { MenuOption } from 'naive-ui'
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, SparklesOutline, LogOutOutline, ChevronDownOutline } from '@vicons/ionicons5'
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, SettingsOutline, LogOutOutline, ChevronDownOutline } from '@vicons/ionicons5'
import { useUserStore } from '../store/user'
import LocaleSwitch from '../components/LocaleSwitch.vue'
@@ -45,14 +45,14 @@ function handleMenuUpdate(key: string) {
// 顶部用户下拉菜单配置项(随语言切换响应式更新)
const userMenuOptions = computed(() => [
{ label: t('layout.aiSettings'), key: 'ai-settings', icon: renderIcon(SparklesOutline) },
{ label: t('layout.settings'), key: 'settings', icon: renderIcon(SettingsOutline) },
{ label: t('layout.logout'), key: 'logout', icon: renderIcon(LogOutOutline) },
])
/** 用户下拉菜单选择事件:跳转 AI 设置或退出登录 */
/** 用户下拉菜单选择事件:跳转系统设置或退出登录 */
function handleUserMenuSelect(key: string) {
if (key === 'ai-settings') {
router.push('/ai-settings')
if (key === 'settings') {
router.push('/settings')
} else if (key === 'logout') {
handleLogout()
}
+13 -2
View File
@@ -1,18 +1,29 @@
<script setup lang="ts">
// 登录页面组件:提供用户名/密码表单,验证后跳转至首页
import { ref } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace } from 'naive-ui'
import { login } from '../api/user'
import { useUserStore } from '../store/user'
import { useRegistrationStore } from '../store/registration'
import LocaleSwitch from '../components/LocaleSwitch.vue'
const router = useRouter()
const message = useMessage() // 消息提示工具
const userStore = useUserStore() // 用户状态管理
const registrationStore = useRegistrationStore() // 注册状态管理
const { t } = useI18n() // 国际化
// 是否显示注册入口:注册开关开启或系统尚无用户时显示
const canRegister = computed(() => registrationStore.status?.allowed ?? false)
onMounted(async () => {
if (!registrationStore.loaded) {
await registrationStore.load()
}
})
// 登录表单数据
const form = ref({
username: '',
@@ -56,7 +67,7 @@ async function handleLogin() {
</NFormItem>
<NSpace vertical>
<NButton type="primary" block :loading="loading" @click="handleLogin">{{ t('login.submit') }}</NButton>
<NButton text type="info" @click="router.push('/register')">{{ t('login.toRegister') }}</NButton>
<NButton v-if="canRegister" text type="info" @click="router.push('/register')">{{ t('login.toRegister') }}</NButton>
</NSpace>
</NForm>
</NCard>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
// 系统设置页:上下分区展示数据库配置与 AI 模型配置
import { useI18n } from 'vue-i18n'
import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
import AiConfigPanel from '../components/AiConfigPanel.vue'
const { t } = useI18n()
</script>
<template>
<div class="settings-page">
<h2 class="page-title">{{ t('settings.title') }}</h2>
<DatabaseConfigPanel />
<RegistrationConfigPanel />
<AiConfigPanel />
</div>
</template>
<style scoped>
.settings-page {
max-width: 860px;
}
.page-title {
margin: 0 0 16px;
}
.settings-page > :deep(.n-card) {
margin-bottom: 16px;
}
</style>
+95 -4
View File
@@ -1007,17 +1007,47 @@ async function handleAiApply() {
message.warning(t('ai.applyNone'))
return
}
// 勾选中包含新建分类/标签时,先经用户确认再落库
if (aiNewCategoryNames.value.length > 0 || aiNewTagNames.value.length > 0) {
const confirmed = await new Promise<boolean>((resolve) => {
dialog.warning({
title: t('ai.applyNewTitle'),
content: t('ai.applyNewConfirm', {
categories: aiNewCategoryNames.value.length,
tags: aiNewTagNames.value.length,
}),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
})
})
if (!confirmed) {
return
}
}
aiApplying.value = true
try {
// 仅提交勾选的建议,聚合建议按组展开为逐条记录
const payload = chosen.flatMap((s) => {
const ids = s.transactionIds?.length ? s.transactionIds : [s.transactionId]
return ids.map((id) => ({ transactionId: id, categoryId: s.categoryId, tagIds: s.tagIds || [] }))
return ids.map((id) => ({
transactionId: id,
categoryId: s.categoryId,
newCategoryName: s.newCategoryName,
newCategoryIcon: s.newCategoryIcon,
tagIds: s.tagIds || [],
newTags: s.newTags || [],
}))
})
const res: any = await applyAiSuggestions(payload)
message.success(t('ai.applySuccess', { count: res.data }))
showAiModal.value = false
await fetchData()
// 应用过程中可能新建了分类或标签,刷新以保持一致
await fetchCategories()
await fetchTags()
} catch (e: any) {
message.error(e.message || t('ai.applyFailed'))
} finally {
@@ -1344,6 +1374,30 @@ const aiTotalCount = computed(() => {
.reduce((sum, s) => sum + (s.transactionIds?.length || 1), 0)
})
/** 勾选建议中需要新建的分类名称(去重) */
const aiNewCategoryNames = computed(() => {
const selected = new Set(checkedAiRowKeys.value)
const names = new Set<string>()
aiSuggestions.value.forEach((s) => {
if (selected.has(s.transactionId) && s.newCategoryName) {
names.add(s.newCategoryName)
}
})
return Array.from(names)
})
/** 勾选建议中需要新建的标签名称(去重) */
const aiNewTagNames = computed(() => {
const selected = new Set(checkedAiRowKeys.value)
const names = new Set<string>()
aiSuggestions.value.forEach((s) => {
if (selected.has(s.transactionId) && s.newTags) {
s.newTags.forEach((tag) => names.add(tag.name))
}
})
return Array.from(names)
})
/** AI 建议表格列定义(随语言切换与建议范围响应式更新) */
const aiColumns = computed<DataTableColumns<any>>(() => {
const columns: DataTableColumns<any> = [
@@ -1365,18 +1419,55 @@ const aiColumns = computed<DataTableColumns<any>>(() => {
]
// 按建议范围展示对应列
if (aiScope.value !== 'TAG') {
columns.push({ title: t('ai.column.category'), key: 'category', width: 130, render: (row) => row.categoryName || '-' })
columns.push({
title: t('ai.column.category'), key: 'category', minWidth: 150,
render: (row) => renderCategoryCell(row),
})
}
if (aiScope.value !== 'CATEGORY') {
columns.push({
title: t('ai.column.tags'), key: 'tags', minWidth: 150,
render: (row) => (row.tagNames && row.tagNames.length > 0 ? row.tagNames.join('、') : '-'),
title: t('ai.column.tags'), key: 'tags', minWidth: 180,
render: (row) => renderTagsCell(row),
})
}
columns.push({ title: t('ai.column.reason'), key: 'reason', minWidth: 180, ellipsis: { tooltip: true } })
return columns
})
/** 渲染 AI 建议的分类单元格,新建分类带「新建」标记 */
function renderCategoryCell(row: AiSuggestion) {
if (row.newCategoryName) {
return h(NSpace, { size: 4, align: 'center', wrap: false }, {
default: () => [
h(NTag, { size: 'small', type: 'info' }, { default: () => t('ai.newBadge') }),
h('span', null, `${row.newCategoryIcon || ''}${row.newCategoryName}`),
],
})
}
return row.categoryName || '-'
}
/** 渲染 AI 建议的标签单元格,新建标签带「新建」标记并使用建议颜色 */
function renderTagsCell(row: AiSuggestion) {
const items: any[] = []
if (row.tagNames && row.tagNames.length > 0) {
items.push(...row.tagNames.map((name) => h(NTag, { size: 'small' }, { default: () => name })))
}
if (row.newTags && row.newTags.length > 0) {
items.push(...row.newTags.map((tag) =>
h(NTag, { size: 'small', color: { color: tag.color, textColor: '#fff' } }, {
default: () => h(NSpace, { size: 2, align: 'center', wrap: false }, {
default: () => [
h('span', null, t('ai.newBadge')),
h('span', null, tag.name),
],
}),
}),
))
}
return items.length > 0 ? h(NSpace, { size: 4, wrap: true }, { default: () => items }) : '-'
}
/** 打开批量设置分类弹窗 */
function handleBatchOpen() {
if (transactions.value.length === 0) {