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