mirror of
https://github.com/Smile-QWQ/SubTracker.git
synced 2026-06-05 10:29:48 +08:00
feat: add dashboard summary settings capability
- extend shared AI config with dashboard summary fields and DTOs - update settings validation and AI config helpers for summary-specific toggles - align settings page copy and form state with AI capability wording - cover schema, settings route, and form cloning changes with tests
This commit is contained in:
@@ -98,8 +98,8 @@ function validateSettingsPayload(settings: Awaited<ReturnType<typeof getAppSetti
|
||||
.filter(([, value]) => !String(value ?? '').trim())
|
||||
.map(([label]) => label)
|
||||
|
||||
if (settings.aiConfig.enabled && missingAiFields.length) {
|
||||
throw new Error(`启用 AI 识别时必须填写:${missingAiFields.join('、')}`)
|
||||
if ((settings.aiConfig.enabled || settings.aiConfig.dashboardSummaryEnabled) && missingAiFields.length) {
|
||||
throw new Error(`启用 AI 能力时必须填写:${missingAiFields.join('、')}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,16 +21,24 @@ const visionTestImageBase64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAKUlEQVR4nO3OIQEAAAACIP+f1hkWWEB6FgEBAQEBAQEBAQEBAQEBgXdgl/rw4unIZ5cAAAAASUVORK5CYII='
|
||||
const jsonOnlySuffix = '必须只返回合法 JSON 对象,不要返回 Markdown、代码块或额外解释。'
|
||||
|
||||
function ensureAiConfig(aiConfig: AiSettings, options?: { requireEnabled?: boolean }) {
|
||||
export function ensureAiConfig(aiConfig: AiSettings, options?: { requireEnabled?: boolean; featureLabel?: string }) {
|
||||
if (options?.requireEnabled !== false && !aiConfig.enabled) {
|
||||
throw new Error('AI 识别未启用')
|
||||
throw new Error(`${options?.featureLabel ?? 'AI 功能'}未启用`)
|
||||
}
|
||||
|
||||
if (!aiConfig.baseUrl || !aiConfig.apiKey || !aiConfig.model) {
|
||||
throw new Error('AI 配置不完整')
|
||||
throw new Error(`${options?.featureLabel ?? 'AI'}配置不完整`)
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureAiSummaryConfig(aiConfig: AiSettings) {
|
||||
if (!aiConfig.dashboardSummaryEnabled) {
|
||||
throw new Error('AI 总结未启用')
|
||||
}
|
||||
|
||||
ensureAiConfig(aiConfig, { requireEnabled: false, featureLabel: 'AI 总结' })
|
||||
}
|
||||
|
||||
export function looksLikeImageFormatUnsupported(errorText: string) {
|
||||
const normalized = errorText.toLowerCase()
|
||||
return (
|
||||
|
||||
@@ -70,6 +70,7 @@ vi.mock('../../src/services/settings.service', () => ({
|
||||
},
|
||||
aiConfig: {
|
||||
enabled: false,
|
||||
dashboardSummaryEnabled: false,
|
||||
providerPreset: 'custom',
|
||||
providerName: 'DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
@@ -77,6 +78,7 @@ vi.mock('../../src/services/settings.service', () => ({
|
||||
model: 'deepseek-chat',
|
||||
timeoutMs: 30000,
|
||||
promptTemplate: '',
|
||||
dashboardSummaryPromptTemplate: '',
|
||||
capabilities: {
|
||||
vision: false,
|
||||
structuredOutput: true
|
||||
@@ -89,6 +91,12 @@ vi.mock('../../src/services/settings.service', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../../src/services/worker-lite-repository.service', () => ({
|
||||
setSettingLite: vi.fn(async (key: string, value: unknown) => {
|
||||
store.set(key, value)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../../src/services/subtracker-backup.service', () => ({
|
||||
createSubtrackerBackupArchive: createSubtrackerBackupArchiveMock
|
||||
}))
|
||||
@@ -108,13 +116,14 @@ describe('settings routes validation', () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('rejects incomplete AI config when enabling AI recognition', async () => {
|
||||
it('rejects incomplete AI config when enabling AI capability', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/settings',
|
||||
payload: {
|
||||
aiConfig: {
|
||||
enabled: true,
|
||||
dashboardSummaryEnabled: true,
|
||||
providerPreset: 'custom',
|
||||
providerName: '',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
@@ -122,6 +131,7 @@ describe('settings routes validation', () => {
|
||||
model: '',
|
||||
timeoutMs: 30000,
|
||||
promptTemplate: '',
|
||||
dashboardSummaryPromptTemplate: '',
|
||||
capabilities: {
|
||||
vision: false,
|
||||
structuredOutput: true
|
||||
@@ -131,7 +141,37 @@ describe('settings routes validation', () => {
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(res.json().error.message).toContain('启用 AI 识别时必须填写')
|
||||
expect(res.json().error.message).toContain('启用 AI 能力时必须填写')
|
||||
})
|
||||
|
||||
it('accepts dashboard summary switch without forcing AI recognition to be enabled', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/settings',
|
||||
payload: {
|
||||
aiConfig: {
|
||||
enabled: false,
|
||||
dashboardSummaryEnabled: true,
|
||||
providerPreset: 'custom',
|
||||
providerName: 'DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
apiKey: 'token',
|
||||
model: 'deepseek-chat',
|
||||
timeoutMs: 30000,
|
||||
promptTemplate: '',
|
||||
dashboardSummaryPromptTemplate: '你是一个统计摘要助手。',
|
||||
capabilities: {
|
||||
vision: false,
|
||||
structuredOutput: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json().data.aiConfig.dashboardSummaryEnabled).toBe(true)
|
||||
expect(res.json().data.aiConfig.enabled).toBe(false)
|
||||
expect(res.json().data.aiConfig.dashboardSummaryPromptTemplate).toBe('你是一个统计摘要助手。')
|
||||
})
|
||||
|
||||
it('rejects smtp email notifications in worker runtime', async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ const mockedSettings: {
|
||||
aiConfig: {
|
||||
...DEFAULT_AI_CONFIG,
|
||||
enabled: true,
|
||||
dashboardSummaryEnabled: false,
|
||||
apiKey: 'test-key',
|
||||
capabilities: {
|
||||
...DEFAULT_AI_CONFIG.capabilities
|
||||
@@ -34,6 +35,7 @@ describe('ai service', () => {
|
||||
mockedSettings.aiConfig = {
|
||||
...DEFAULT_AI_CONFIG,
|
||||
enabled: true,
|
||||
dashboardSummaryEnabled: false,
|
||||
apiKey: 'test-key',
|
||||
capabilities: {
|
||||
...DEFAULT_AI_CONFIG.capabilities
|
||||
|
||||
@@ -77,6 +77,7 @@ vi.mock('../../src/services/settings.service', () => ({
|
||||
},
|
||||
aiConfig: {
|
||||
enabled: false,
|
||||
dashboardSummaryEnabled: false,
|
||||
providerPreset: 'custom',
|
||||
providerName: 'DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
@@ -84,6 +85,7 @@ vi.mock('../../src/services/settings.service', () => ({
|
||||
model: 'deepseek-chat',
|
||||
timeoutMs: 30000,
|
||||
promptTemplate: '',
|
||||
dashboardSummaryPromptTemplate: '',
|
||||
capabilities: {
|
||||
vision: false,
|
||||
structuredOutput: true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<page-header
|
||||
title="系统设置"
|
||||
subtitle="管理基础参数、预算、汇率、通知与 AI 识别"
|
||||
subtitle="管理基础参数、预算、汇率、通知与 AI 能力"
|
||||
:icon="settingsOutline"
|
||||
icon-background="linear-gradient(135deg, #64748b 0%, #334155 100%)"
|
||||
/>
|
||||
@@ -441,12 +441,15 @@
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item>
|
||||
<n-card title="AI 识别设置" class="settings-card">
|
||||
<n-card title="AI 能力设置" class="settings-card">
|
||||
<n-form :model="settingsForm.aiConfig" label-placement="top">
|
||||
<n-form-item>
|
||||
<n-switch v-model:value="settingsForm.aiConfig.enabled" />
|
||||
<span class="switch-label">启用 AI 识别</span>
|
||||
<span class="switch-label">启用 AI 能力</span>
|
||||
</n-form-item>
|
||||
<n-alert type="info" :show-icon="false" style="margin-bottom: 12px">
|
||||
AI 能力总开关控制识别与连接测试;AI 总结可单独开启或关闭。
|
||||
</n-alert>
|
||||
|
||||
<n-grid :cols="formCols" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item>
|
||||
@@ -468,12 +471,20 @@
|
||||
<n-input v-model:value="settingsForm.aiConfig.model" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item>
|
||||
<n-switch v-model:value="settingsForm.aiConfig.capabilities.vision" />
|
||||
<span class="switch-label">模型视觉输入</span>
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="能力开关">
|
||||
<div class="switch-group switch-group--ai-capabilities-inline">
|
||||
<div class="switch-group__item">
|
||||
<n-switch v-model:value="settingsForm.aiConfig.capabilities.vision" />
|
||||
<span class="switch-label switch-label--compact">模型视觉输入</span>
|
||||
</div>
|
||||
<div class="switch-group__item">
|
||||
<n-switch v-model:value="settingsForm.aiConfig.dashboardSummaryEnabled" :disabled="!settingsForm.aiConfig.enabled" />
|
||||
<span class="switch-label switch-label--compact">AI 总结</span>
|
||||
</div>
|
||||
</div>
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item label="API Base URL">
|
||||
@@ -494,12 +505,20 @@
|
||||
<n-form-item label="请求超时(毫秒)">
|
||||
<n-input-number v-model:value="settingsForm.aiConfig.timeoutMs" :min="5000" :max="120000" style="width: 100%" />
|
||||
</n-form-item>
|
||||
<n-form-item label="自定义提示词">
|
||||
<n-form-item label="自定义识别提示词">
|
||||
<n-input
|
||||
v-model:value="aiPromptInput"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 6, maxRows: 12 }"
|
||||
placeholder="未修改或为空时,会继续使用系统预设提示词"
|
||||
placeholder="留空时,订阅识别使用系统识别提示词;仪表盘 AI 总结始终使用系统总结提示词"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="自定义总结提示词">
|
||||
<n-input
|
||||
v-model:value="dashboardSummaryPromptInput"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 6, maxRows: 12 }"
|
||||
placeholder="留空时,AI 总结使用系统预设总结提示词"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-collapse-item>
|
||||
@@ -538,7 +557,7 @@
|
||||
<n-space vertical style="width: 100%">
|
||||
<n-card size="small" embedded title="备份">
|
||||
<n-space vertical style="width: 100%">
|
||||
<div class="card-muted">支持通过 ZIP 进行导出备份与恢复备份,包含订阅、标签、支付记录、排序、系统设置与本地 Logo</div>
|
||||
<div class="card-muted">支持通过 ZIP 进行备份与恢复,包含订阅、标签、支付记录、排序、系统设置与本地 Logo</div>
|
||||
<n-space wrap>
|
||||
<n-button type="primary" @click="exportBackup">导出备份</n-button>
|
||||
<n-button type="success" ghost @click="showSubtrackerBackupModal = true">恢复备份</n-button>
|
||||
@@ -583,6 +602,7 @@ import { useQueryClient } from '@tanstack/vue-query'
|
||||
import {
|
||||
DEFAULT_ADVANCE_REMINDER_RULES,
|
||||
DEFAULT_AI_CONFIG,
|
||||
DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT,
|
||||
DEFAULT_AI_SUBSCRIPTION_PROMPT,
|
||||
DEFAULT_NOTIFICATION_WEBHOOK_PAYLOAD_TEMPLATE,
|
||||
DEFAULT_OVERDUE_REMINDER_RULES,
|
||||
@@ -764,6 +784,7 @@ const webhookForm = reactive<NotificationWebhookSettings>({
|
||||
|
||||
const snapshot = ref<ExchangeRateSnapshot | null>(null)
|
||||
const aiPromptInput = ref(DEFAULT_AI_SUBSCRIPTION_PROMPT)
|
||||
const dashboardSummaryPromptInput = ref(DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT)
|
||||
const savingBasicSettings = ref(false)
|
||||
const savingEmailSettings = ref(false)
|
||||
const savingPushplusSettings = ref(false)
|
||||
@@ -930,7 +951,7 @@ function validateWebhookSettings(action: 'save' | 'test') {
|
||||
}
|
||||
|
||||
function validateAiSettings(action: 'save' | 'connection-test' | 'vision-test') {
|
||||
if (action === 'save' && !settingsForm.aiConfig.enabled) {
|
||||
if (action === 'save' && !settingsForm.aiConfig.enabled && !settingsForm.aiConfig.dashboardSummaryEnabled) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -942,7 +963,7 @@ function validateAiSettings(action: 'save' | 'connection-test' | 'vision-test')
|
||||
])
|
||||
|
||||
if (!missing.length) return true
|
||||
message.error(`AI 识别缺少必填项:${missing.join('、')}`)
|
||||
message.error(`AI 能力缺少必填项:${missing.join('、')}`)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -953,6 +974,8 @@ watch(
|
||||
Object.assign(settingsForm, cloneSettingsForForm(settings))
|
||||
normalizeWorkerEmailProvider()
|
||||
aiPromptInput.value = settings.aiConfig.promptTemplate.trim() || DEFAULT_AI_SUBSCRIPTION_PROMPT
|
||||
dashboardSummaryPromptInput.value =
|
||||
settings.aiConfig.dashboardSummaryPromptTemplate.trim() || DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT
|
||||
credentialsForm.oldUsername = authStore.username
|
||||
credentialsForm.newUsername = authStore.username
|
||||
targetCurrency.value = settings.baseCurrency
|
||||
@@ -980,6 +1003,9 @@ watch(
|
||||
function applySavedSettings(result: Settings) {
|
||||
Object.assign(settingsForm, cloneSettingsForForm(result))
|
||||
normalizeWorkerEmailProvider()
|
||||
aiPromptInput.value = result.aiConfig.promptTemplate.trim() || DEFAULT_AI_SUBSCRIPTION_PROMPT
|
||||
dashboardSummaryPromptInput.value =
|
||||
result.aiConfig.dashboardSummaryPromptTemplate.trim() || DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT
|
||||
queryClient.setQueryData(SETTINGS_QUERY_KEY, result)
|
||||
}
|
||||
|
||||
@@ -1101,8 +1127,11 @@ async function saveAiSettings() {
|
||||
if (savingAiSettings.value) return
|
||||
if (!validateAiSettings('save')) return
|
||||
const promptTemplate = normalizeAiPrompt(aiPromptInput.value)
|
||||
const dashboardSummaryPromptTemplate = normalizeDashboardSummaryPrompt(dashboardSummaryPromptInput.value)
|
||||
settingsForm.aiConfig.promptTemplate = promptTemplate
|
||||
settingsForm.aiConfig.dashboardSummaryPromptTemplate = dashboardSummaryPromptTemplate
|
||||
aiPromptInput.value = promptTemplate || DEFAULT_AI_SUBSCRIPTION_PROMPT
|
||||
dashboardSummaryPromptInput.value = dashboardSummaryPromptTemplate || DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT
|
||||
savingAiSettings.value = true
|
||||
try {
|
||||
const result = await api.updateSettings({
|
||||
@@ -1111,12 +1140,12 @@ async function saveAiSettings() {
|
||||
capabilities: {
|
||||
...settingsForm.aiConfig.capabilities
|
||||
},
|
||||
promptTemplate
|
||||
promptTemplate,
|
||||
dashboardSummaryPromptTemplate
|
||||
}
|
||||
})
|
||||
applySavedSettings(result)
|
||||
aiPromptInput.value = result.aiConfig.promptTemplate.trim() || DEFAULT_AI_SUBSCRIPTION_PROMPT
|
||||
message.success(settingsForm.aiConfig.enabled ? 'AI 识别配置已保存' : 'AI 识别已关闭')
|
||||
message.success(settingsForm.aiConfig.enabled || settingsForm.aiConfig.dashboardSummaryEnabled ? 'AI 能力配置已保存' : 'AI 能力已关闭')
|
||||
} finally {
|
||||
savingAiSettings.value = false
|
||||
}
|
||||
@@ -1126,9 +1155,11 @@ async function testAiConnectionSettings() {
|
||||
if (!validateAiSettings('connection-test')) return
|
||||
try {
|
||||
const promptTemplate = normalizeAiPrompt(aiPromptInput.value)
|
||||
const dashboardSummaryPromptTemplate = normalizeDashboardSummaryPrompt(dashboardSummaryPromptInput.value)
|
||||
const result = await api.testAiConfigurationWithPayload({
|
||||
...settingsForm.aiConfig,
|
||||
promptTemplate,
|
||||
dashboardSummaryPromptTemplate,
|
||||
capabilities: {
|
||||
...settingsForm.aiConfig.capabilities
|
||||
}
|
||||
@@ -1143,9 +1174,11 @@ async function testAiVisionSettings() {
|
||||
if (!validateAiSettings('vision-test')) return
|
||||
try {
|
||||
const promptTemplate = normalizeAiPrompt(aiPromptInput.value)
|
||||
const dashboardSummaryPromptTemplate = normalizeDashboardSummaryPrompt(dashboardSummaryPromptInput.value)
|
||||
const result = await api.testAiVisionConfigurationWithPayload({
|
||||
...settingsForm.aiConfig,
|
||||
promptTemplate,
|
||||
dashboardSummaryPromptTemplate,
|
||||
capabilities: {
|
||||
...settingsForm.aiConfig.capabilities
|
||||
}
|
||||
@@ -1163,6 +1196,13 @@ function normalizeAiPrompt(value: string) {
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeDashboardSummaryPrompt(value: string) {
|
||||
const normalized = value.trim()
|
||||
if (!normalized) return ''
|
||||
if (normalized === DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT.trim()) return ''
|
||||
return value
|
||||
}
|
||||
|
||||
function handleAiPresetChange(value: AiProviderPreset) {
|
||||
settingsForm.aiConfig.providerPreset = value
|
||||
if (value === 'custom') {
|
||||
@@ -1262,9 +1302,9 @@ async function exportBackup() {
|
||||
try {
|
||||
const result = await api.exportBackup()
|
||||
downloadBlob(result.blob, result.filename)
|
||||
message.success('备份导出已开始')
|
||||
message.success('ZIP 导出已开始')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '备份导出失败')
|
||||
message.error(error instanceof Error ? error.message : 'ZIP 导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1440,6 +1480,11 @@ function formatTime(value: string) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch-group--ai-capabilities-inline {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.switch-group--single {
|
||||
min-height: 34px;
|
||||
}
|
||||
@@ -1451,6 +1496,10 @@ function formatTime(value: string) {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.switch-label--compact {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.label-with-tip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -205,6 +205,7 @@ export interface AiCapabilities {
|
||||
|
||||
export interface AiConfig {
|
||||
enabled: boolean
|
||||
dashboardSummaryEnabled: boolean
|
||||
providerPreset: AiProviderPreset
|
||||
providerName: string
|
||||
baseUrl: string
|
||||
@@ -212,6 +213,7 @@ export interface AiConfig {
|
||||
model: string
|
||||
timeoutMs: number
|
||||
promptTemplate: string
|
||||
dashboardSummaryPromptTemplate: string
|
||||
capabilities: AiCapabilities
|
||||
}
|
||||
|
||||
@@ -222,6 +224,23 @@ export interface AiTestResponse {
|
||||
response: string
|
||||
}
|
||||
|
||||
export type AiDashboardSummaryStatus = 'idle' | 'unconfigured' | 'generating' | 'success' | 'failed'
|
||||
|
||||
export interface AiDashboardSummary {
|
||||
scope: 'dashboard-overview'
|
||||
status: AiDashboardSummaryStatus
|
||||
content: string | null
|
||||
previewContent: string | null
|
||||
errorMessage: string | null
|
||||
generatedAt: string | null
|
||||
updatedAt: string | null
|
||||
sourceDataHash: string | null
|
||||
fromCache: boolean
|
||||
isStale: boolean
|
||||
canGenerate: boolean
|
||||
needsGeneration: boolean
|
||||
}
|
||||
|
||||
export interface StorageCapabilities {
|
||||
runtime: 'worker-lite'
|
||||
r2Enabled: boolean
|
||||
|
||||
@@ -13,6 +13,8 @@ export function cloneSettingsForForm(settings: Settings): Settings {
|
||||
gotifyConfig: { ...settings.gotifyConfig },
|
||||
aiConfig: {
|
||||
...settings.aiConfig,
|
||||
dashboardSummaryEnabled: settings.aiConfig.dashboardSummaryEnabled,
|
||||
dashboardSummaryPromptTemplate: settings.aiConfig.dashboardSummaryPromptTemplate,
|
||||
capabilities: {
|
||||
...settings.aiConfig.capabilities
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('settings import export copy', () => {
|
||||
expect(source).toContain('embedded title="迁移"')
|
||||
expect(source).toContain('从第三方同类项目导入数据')
|
||||
expect(source).toContain('导入 Wallos')
|
||||
expect(source).toContain('支持通过 ZIP 进行导出备份与恢复备份')
|
||||
expect(source).toContain('支持通过 ZIP 进行备份与恢复')
|
||||
expect(source).toContain('当前运行环境为 Cloudflare Worker + D1')
|
||||
expect(source).not.toContain('导出 CSV')
|
||||
expect(source).not.toContain('导出 JSON')
|
||||
@@ -24,11 +24,27 @@ describe('settings import export copy', () => {
|
||||
expect(backupModal).not.toContain('title="导入 ZIP"')
|
||||
expect(backupModal).toContain('预览备份')
|
||||
expect(backupModal).toContain('确认恢复')
|
||||
expect(backupModal).toContain('恢复模式')
|
||||
expect(backupModal).toContain('恢复预览')
|
||||
expect(backupModal).toContain('备份 ZIP 无法解析')
|
||||
expect(backupModal).toContain('未导入任何新数据,重复项已自动跳过')
|
||||
expect(backupModal).toContain('按备份中的唯一标识(CUID)幂等跳过')
|
||||
expect(backupModal).toContain('现有同唯一标识(CUID)订阅')
|
||||
expect(backupModal).toContain('现有同唯一标识(CUID)支付记录')
|
||||
expect(backupModal).not.toContain('生成预览')
|
||||
expect(backupModal).not.toContain('确认导入')
|
||||
})
|
||||
|
||||
it('expands ai settings wording from recognition-only to shared ai capability', () => {
|
||||
const source = readFileSync('src/pages/SettingsPage.vue', 'utf8')
|
||||
|
||||
expect(source).toContain('title="AI 能力设置"')
|
||||
expect(source).toContain('启用 AI 能力')
|
||||
expect(source).toContain('AI 总结')
|
||||
expect(source).toContain('AI 能力总开关控制识别与连接测试;AI 总结可单独开启或关闭')
|
||||
expect(source).toContain('自定义识别提示词')
|
||||
expect(source).toContain('自定义总结提示词')
|
||||
expect(source).not.toContain('title="AI 识别设置"')
|
||||
expect(source).not.toContain('启用 AI 识别')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('cloneSettingsForForm', () => {
|
||||
},
|
||||
aiConfig: {
|
||||
enabled: true,
|
||||
dashboardSummaryEnabled: true,
|
||||
providerPreset: 'custom',
|
||||
providerName: 'OpenAI',
|
||||
baseUrl: 'https://example.com',
|
||||
@@ -65,6 +66,7 @@ describe('cloneSettingsForForm', () => {
|
||||
model: 'gpt',
|
||||
timeoutMs: 30000,
|
||||
promptTemplate: 'prompt',
|
||||
dashboardSummaryPromptTemplate: 'summary prompt',
|
||||
capabilities: {
|
||||
vision: true,
|
||||
structuredOutput: true
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AiDashboardSummaryStatusSchema,
|
||||
CreateSubscriptionSchema,
|
||||
DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT,
|
||||
DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT,
|
||||
DEFAULT_ADVANCE_REMINDER_RULES,
|
||||
DEFAULT_OVERDUE_REMINDER_RULES,
|
||||
formatAiSummaryPreviewText,
|
||||
SettingsSchema,
|
||||
SubtrackerBackupCommitSchema,
|
||||
SubtrackerBackupInspectSchema
|
||||
@@ -34,6 +38,8 @@ describe('shared schema', () => {
|
||||
expect(parsed.overdueReminderDays).toEqual([1, 2, 3])
|
||||
expect(parsed.defaultOverdueReminderRules).toBe(DEFAULT_OVERDUE_REMINDER_RULES)
|
||||
expect(parsed.timezone).toBe('Asia/Shanghai')
|
||||
expect(parsed.aiConfig.dashboardSummaryEnabled).toBe(false)
|
||||
expect(parsed.aiConfig.dashboardSummaryPromptTemplate).toBe('')
|
||||
expect(parsed.telegramNotificationsEnabled).toBe(false)
|
||||
expect(parsed.telegramConfig).toEqual({
|
||||
botToken: '',
|
||||
@@ -76,4 +82,19 @@ describe('shared schema', () => {
|
||||
expect(parsed.mode).toBe('append')
|
||||
expect(parsed.restoreSettings).toBe(true)
|
||||
})
|
||||
|
||||
it('should expose dashboard summary prompt and status schema', () => {
|
||||
expect(DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT).toContain('订阅运营摘要助手')
|
||||
expect(DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT).toContain('摘要压缩助手')
|
||||
expect(AiDashboardSummaryStatusSchema.parse('success')).toBe('success')
|
||||
expect(() => AiDashboardSummaryStatusSchema.parse('unknown')).toThrow()
|
||||
})
|
||||
|
||||
it('formats ai summary preview text into readable plain text', () => {
|
||||
expect(
|
||||
formatAiSummaryPreviewText(
|
||||
'## 总览\n- 当前 13 个活跃订阅\n- 月支出 1254.61 元\n\n## 近期风险\n1. 未来 30 天续订密集'
|
||||
)
|
||||
).toBe('总览 当前 13 个活跃订阅 月支出 1254.61 元 近期风险 未来 30 天续订密集')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,65 @@ export const DEFAULT_AI_SUBSCRIPTION_PROMPT = `你是订阅账单信息提取助
|
||||
4. 周期单位必须在 day/week/month/quarter/year 中。
|
||||
5. 只返回 JSON,不要返回 Markdown。`
|
||||
|
||||
export const DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT = `你是订阅运营摘要助手。请基于用户当前的订阅统计数据,输出一份简洁、准确、可执行的 Markdown 总结。
|
||||
|
||||
目标:
|
||||
1. 帮助用户快速理解当前订阅规模、支出结构、预算压力和近期续订风险。
|
||||
2. 总结数据中的明显模式、异常点和需要关注的事项。
|
||||
3. 给出中性、可执行、但不依赖具体服务功能知识的建议。
|
||||
|
||||
硬性要求:
|
||||
- 只能基于输入数据分析,不要虚构事实。
|
||||
- 不要假设你了解某个订阅服务的功能细节。
|
||||
- 不要输出“取消某服务更省钱”“某两个服务功能重叠”之类的建议。
|
||||
- 不要臆测用户偏好、使用频率或用途。
|
||||
- 不要输出 JSON,不要输出代码块,只输出 Markdown 正文。
|
||||
|
||||
输出建议结构:
|
||||
## 总览
|
||||
## 支出结构
|
||||
## 近期风险
|
||||
## 值得注意的模式
|
||||
## 中性建议
|
||||
|
||||
写作要求:
|
||||
- 使用简体中文。
|
||||
- 结论明确,少空话。
|
||||
- 每个小节控制在 2~5 条要点内。
|
||||
- 如果某部分没有明显异常,直接说明“暂无显著异常”或“整体平稳”。`
|
||||
|
||||
export const DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT = `你是订阅统计摘要压缩助手。请根据已经生成好的完整 AI 总结,提炼出一个默认折叠展示用的超简短摘要。
|
||||
|
||||
硬性要求:
|
||||
- 只输出简体中文纯文本,不要输出 Markdown,不要输出代码块。
|
||||
- 输出 2 到 3 行,每行一句,自然换行。
|
||||
- 不要输出标题,不要输出项目符号,不要编号。
|
||||
- 只保留最重要的结论:订阅规模、预算压力、近期风险。
|
||||
- 不要发散,不要补充原文没有的信息。
|
||||
- 如果原文信息有限,就直接给出 1 到 2 句自然语言摘要。`
|
||||
|
||||
function normalizePreviewSource(text: string) {
|
||||
return String(text ?? '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
.map((line) =>
|
||||
line
|
||||
.replace(/^#{1,6}\s*/g, '')
|
||||
.replace(/^\s*[-*+]\s*/g, '')
|
||||
.replace(/^\s*\d+[.)]\s*/g, '')
|
||||
.replace(/[`>*_]/g, '')
|
||||
.trim()
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function formatAiSummaryPreviewText(text: string) {
|
||||
return normalizePreviewSource(text)
|
||||
}
|
||||
|
||||
export const SubscriptionStatusSchema = z.enum(['active', 'paused', 'cancelled', 'expired'])
|
||||
export const BillingIntervalUnitSchema = z.enum(['day', 'week', 'month', 'quarter', 'year'])
|
||||
export const WebhookRequestMethodSchema = z.enum(['POST', 'PUT', 'PATCH', 'DELETE'])
|
||||
@@ -163,6 +222,7 @@ export const DEFAULT_AI_CAPABILITIES = {
|
||||
|
||||
export const DEFAULT_AI_CONFIG = {
|
||||
enabled: false,
|
||||
dashboardSummaryEnabled: false,
|
||||
providerPreset: 'custom',
|
||||
providerName: 'DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
@@ -170,6 +230,7 @@ export const DEFAULT_AI_CONFIG = {
|
||||
model: 'deepseek-chat',
|
||||
timeoutMs: 30000,
|
||||
promptTemplate: '',
|
||||
dashboardSummaryPromptTemplate: '',
|
||||
capabilities: {
|
||||
...DEFAULT_AI_CAPABILITIES
|
||||
}
|
||||
@@ -191,6 +252,7 @@ export const AiCapabilitiesSchema = z.object({
|
||||
|
||||
export const AiConfigSchema = z.object({
|
||||
enabled: z.boolean().default(DEFAULT_AI_CONFIG.enabled),
|
||||
dashboardSummaryEnabled: z.boolean().default(DEFAULT_AI_CONFIG.dashboardSummaryEnabled),
|
||||
providerPreset: AiProviderPresetSchema.default(DEFAULT_AI_CONFIG.providerPreset),
|
||||
providerName: z.string().max(100).default(DEFAULT_AI_CONFIG.providerName),
|
||||
baseUrl: z.string().url().default(DEFAULT_AI_CONFIG.baseUrl),
|
||||
@@ -198,6 +260,7 @@ export const AiConfigSchema = z.object({
|
||||
model: z.string().max(100).default(DEFAULT_AI_CONFIG.model),
|
||||
timeoutMs: z.number().int().min(5000).max(120000).default(DEFAULT_AI_CONFIG.timeoutMs),
|
||||
promptTemplate: z.string().max(5000).default(DEFAULT_AI_CONFIG.promptTemplate),
|
||||
dashboardSummaryPromptTemplate: z.string().max(5000).default(DEFAULT_AI_CONFIG.dashboardSummaryPromptTemplate),
|
||||
capabilities: AiCapabilitiesSchema.default({
|
||||
...DEFAULT_AI_CAPABILITIES
|
||||
})
|
||||
@@ -273,6 +336,14 @@ export const AiRecognizeSubscriptionSchema = z.object({
|
||||
mimeType: z.string().max(100).optional()
|
||||
})
|
||||
|
||||
export const AiDashboardSummaryStatusSchema = z.enum([
|
||||
'idle',
|
||||
'unconfigured',
|
||||
'generating',
|
||||
'success',
|
||||
'failed'
|
||||
])
|
||||
|
||||
const WallosImportSummarySchema = z.object({
|
||||
fileType: z.enum(['json', 'db', 'zip']),
|
||||
subscriptionsTotal: z.number().int().min(0),
|
||||
@@ -393,6 +464,7 @@ export type StorageCapabilitiesInput = z.infer<typeof StorageCapabilitiesSchema>
|
||||
export type LogoSearchInput = z.infer<typeof LogoSearchSchema>
|
||||
export type LogoUploadInput = z.infer<typeof LogoUploadSchema>
|
||||
export type AiRecognizeSubscriptionInput = z.infer<typeof AiRecognizeSubscriptionSchema>
|
||||
export type AiDashboardSummaryStatus = z.infer<typeof AiDashboardSummaryStatusSchema>
|
||||
export type WallosImportInspectInput = z.infer<typeof WallosImportInspectSchema>
|
||||
export type WallosImportCommitInput = z.infer<typeof WallosImportCommitSchema>
|
||||
export type SubtrackerBackupScope = z.infer<typeof SubtrackerBackupScopeSchema>
|
||||
@@ -446,6 +518,21 @@ export interface AiRecognitionResultDto {
|
||||
rawText?: string
|
||||
}
|
||||
|
||||
export interface AiDashboardSummaryDto {
|
||||
scope: 'dashboard-overview'
|
||||
status: AiDashboardSummaryStatus
|
||||
content: string | null
|
||||
previewContent: string | null
|
||||
errorMessage: string | null
|
||||
generatedAt: string | null
|
||||
updatedAt: string | null
|
||||
sourceDataHash: string | null
|
||||
fromCache: boolean
|
||||
isStale: boolean
|
||||
canGenerate: boolean
|
||||
needsGeneration: boolean
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
activeSubscriptions: number
|
||||
upcoming7Days: number
|
||||
|
||||
Reference in New Issue
Block a user