feat: 支持数据库运行时配置与多数据库,重构设置页并完善打包
- 数据库支持 SQLite / MySQL 运行时配置与切换,未配置时仍可启动,配置后自动建表 - 新增默认分类与标签初始化,注册用户自动创建 - 新增注册开关(默认关闭,可在系统设置开启;无用户时允许创建首个账号) - 合并数据库、注册与 AI 配置为「系统设置」页,拆分「模型使用情况」独立页面 - AI 一键设置支持新建分类与标签 - 前端构建产物打包进可执行 jar,后端增加 SPA 路由回退 - jar 更名为 finance-app,补全 Gradle Wrapper,移除 archive 任务 - 更新 DeepSeek 模型说明文案
This commit is contained in:
+18
-2
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 におまかせ' },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user