feat: add web i18n with shared messages

- add vue-i18n to the web app and load zh-CN and en-US strings from the shared message catalog

- replace hardcoded UI copy across login, subscriptions, settings, calendar, statistics and supporting dialogs and utilities with translation keys

- send locale headers with API requests, reuse localized message helpers and cover the localized web flows with regression tests
This commit is contained in:
SmileQWQ
2026-05-11 08:17:15 +08:00
parent 477fb25682
commit ca78052a0b
42 changed files with 1216 additions and 748 deletions

View File

@@ -25,6 +25,7 @@
"pinia": "^3.0.2",
"vue": "^3.5.13",
"vue-echarts": "^7.0.3",
"vue-i18n": "^11.4.2",
"vue-router": "^4.5.0"
},
"devDependencies": {

View File

@@ -6,8 +6,8 @@
</n-icon>
</div>
<div>
<h1 class="page-title">{{ title }}</h1>
<p class="page-subtitle">{{ subtitle }}</p>
<h1 class="page-title">{{ props.title }}</h1>
<p class="page-subtitle">{{ props.subtitle }}</p>
</div>
</div>
</template>
@@ -16,7 +16,7 @@
import type { Component } from 'vue'
import { NIcon } from 'naive-ui'
withDefaults(
const props = withDefaults(
defineProps<{
title: string
subtitle: string

View File

@@ -11,13 +11,13 @@
<template #icon>
<n-icon :component="eyeOutline" />
</template>
{{ isPreviewVisible ? '收起提醒预览' : '预览提醒规则' }}
{{ isPreviewVisible ? t('common.actions.collapse') : t('settings.buttons.previewReminderRules') }}
</n-button>
<transition name="reminder-preview">
<div v-if="isPreviewVisible && (advanceEvaluation || overdueEvaluation)" class="reminder-rules-preview__panel">
<div class="reminder-rules-preview__section">
<div class="reminder-rules-preview__title">到期前提醒</div>
<div class="reminder-rules-preview__title">{{ t('subscriptions.labels.advanceReminders') }}</div>
<div v-if="advanceEvaluation?.error" class="reminder-rules-preview__error">
{{ advanceEvaluation.error }}
</div>
@@ -26,11 +26,11 @@
{{ entry.description }}
</li>
</ul>
<div v-else class="reminder-rules-preview__empty">暂无到期前提醒规则</div>
<div v-else class="reminder-rules-preview__empty">{{ t('validation.reminderRules.noAdvance') }}</div>
</div>
<div class="reminder-rules-preview__section">
<div class="reminder-rules-preview__title">过期提醒</div>
<div class="reminder-rules-preview__title">{{ t('subscriptions.labels.overdueReminders') }}</div>
<div v-if="overdueEvaluation?.error" class="reminder-rules-preview__error">
{{ overdueEvaluation.error }}
</div>
@@ -39,7 +39,7 @@
{{ entry.description }}
</li>
</ul>
<div v-else class="reminder-rules-preview__empty">暂无过期提醒规则</div>
<div v-else class="reminder-rules-preview__empty">{{ t('validation.reminderRules.noOverdue') }}</div>
</div>
</div>
</transition>
@@ -47,9 +47,10 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { NButton, NIcon } from 'naive-ui'
import { EyeOutline } from '@vicons/ionicons5'
import { t } from '@/locales'
import { evaluateReminderRules, type ReminderRulesEvaluation } from '@/utils/reminder-rules'
const props = withDefaults(
@@ -94,14 +95,64 @@ function close() {
function preview() {
advanceEvaluation.value = evaluateReminderRules(props.advanceValue, 'advance', {
fallbackValue: props.defaultAdvanceValue,
fallbackLabel: '系统默认到期前规则',
emptyTitle: '暂无到期前提醒规则'
fallbackLabel: t('validation.reminderRules.defaultAdvanceRulesLabel'),
emptyTitle: t('validation.reminderRules.noAdvance'),
i18n: {
defaultAdvanceRulesLabel: t('validation.reminderRules.defaultAdvanceRulesLabel'),
defaultOverdueRulesLabel: t('validation.reminderRules.defaultOverdueRulesLabel'),
noAdvance: t('validation.reminderRules.noAdvance'),
noOverdue: t('validation.reminderRules.noOverdue'),
fallback: t('validation.reminderRules.fallback'),
emptyTitle: t('validation.reminderRules.emptyTitle'),
resultTitle: t('validation.reminderRules.resultTitle'),
invalidTitle: t('validation.reminderRules.invalidTitle'),
defaultRulesLabel: t('validation.reminderRules.defaultRulesLabel'),
fallbackPreviewTitle: t('validation.reminderRules.fallbackPreviewTitle'),
fallbackInvalidTitle: t('validation.reminderRules.fallbackInvalidTitle'),
parseFailed: t('validation.reminderRules.parseFailed'),
invalidSegmentFormat: t('validation.reminderRules.invalidSegmentFormat', { segment: '{segment}' }),
invalidDaysInteger: t('validation.reminderRules.invalidDaysInteger', { segment: '{segment}' }),
invalidOverdueDays: t('validation.reminderRules.invalidOverdueDays', { segment: '{segment}' }),
invalidAdvanceDays: t('validation.reminderRules.invalidAdvanceDays', { segment: '{segment}' }),
invalidTime: t('validation.reminderRules.invalidTime', { segment: '{segment}' }),
inlineAdvanceSameDay: t('validation.reminderRules.inlineAdvanceSameDay', { time: '{time}' }),
inlineAdvanceBefore: t('validation.reminderRules.inlineAdvanceBefore', { days: '{days}', time: '{time}' }),
inlineOverdue: t('validation.reminderRules.inlineOverdue', { days: '{days}', time: '{time}' }),
evalAdvanceSameDay: t('validation.reminderRules.evalAdvanceSameDay', { time: '{time}' }),
evalAdvanceBefore: t('validation.reminderRules.evalAdvanceBefore', { days: '{days}', time: '{time}' }),
evalOverdue: t('validation.reminderRules.evalOverdue', { days: '{days}', time: '{time}' })
}
})
overdueEvaluation.value = evaluateReminderRules(props.overdueValue, 'overdue', {
fallbackValue: props.defaultOverdueValue,
fallbackLabel: '系统默认过期规则',
emptyTitle: '暂无过期提醒规则'
fallbackLabel: t('validation.reminderRules.defaultOverdueRulesLabel'),
emptyTitle: t('validation.reminderRules.noOverdue'),
i18n: {
defaultAdvanceRulesLabel: t('validation.reminderRules.defaultAdvanceRulesLabel'),
defaultOverdueRulesLabel: t('validation.reminderRules.defaultOverdueRulesLabel'),
noAdvance: t('validation.reminderRules.noAdvance'),
noOverdue: t('validation.reminderRules.noOverdue'),
fallback: t('validation.reminderRules.fallback'),
emptyTitle: t('validation.reminderRules.emptyTitle'),
resultTitle: t('validation.reminderRules.resultTitle'),
invalidTitle: t('validation.reminderRules.invalidTitle'),
defaultRulesLabel: t('validation.reminderRules.defaultRulesLabel'),
fallbackPreviewTitle: t('validation.reminderRules.fallbackPreviewTitle'),
fallbackInvalidTitle: t('validation.reminderRules.fallbackInvalidTitle'),
parseFailed: t('validation.reminderRules.parseFailed'),
invalidSegmentFormat: t('validation.reminderRules.invalidSegmentFormat', { segment: '{segment}' }),
invalidDaysInteger: t('validation.reminderRules.invalidDaysInteger', { segment: '{segment}' }),
invalidOverdueDays: t('validation.reminderRules.invalidOverdueDays', { segment: '{segment}' }),
invalidAdvanceDays: t('validation.reminderRules.invalidAdvanceDays', { segment: '{segment}' }),
invalidTime: t('validation.reminderRules.invalidTime', { segment: '{segment}' }),
inlineAdvanceSameDay: t('validation.reminderRules.inlineAdvanceSameDay', { time: '{time}' }),
inlineAdvanceBefore: t('validation.reminderRules.inlineAdvanceBefore', { days: '{days}', time: '{time}' }),
inlineOverdue: t('validation.reminderRules.inlineOverdue', { days: '{days}', time: '{time}' }),
evalAdvanceSameDay: t('validation.reminderRules.evalAdvanceSameDay', { time: '{time}' }),
evalAdvanceBefore: t('validation.reminderRules.evalAdvanceBefore', { days: '{days}', time: '{time}' }),
evalOverdue: t('validation.reminderRules.evalOverdue', { days: '{days}', time: '{time}' })
}
})
isPreviewVisible.value = true
emit('visibilityChange', true)

View File

@@ -2,7 +2,7 @@
<n-modal
:show="show"
preset="card"
title="AI 识别订阅"
:title="t('subscriptions.aiModal.title')"
style="width: min(720px, calc(100vw - 24px))"
:mask-closable="!loading"
:closable="!loading"
@@ -11,38 +11,38 @@
>
<n-space vertical>
<n-alert type="info" :show-icon="false">
支持输入文本上传图片或直接粘贴截图若当前模型不支持图片识别将自动回退到本地 OCR 提取文本后再交给模型清洗识别结果只会回填表单不会自动保存
{{ t('subscriptions.aiModal.description') }}
</n-alert>
<n-spin :show="loading" size="small">
<template #description>
<div class="ai-loading-copy">
<div class="ai-loading-copy__primary">{{ loadingStatusText }}</div>
<div class="ai-loading-copy__secondary">完成后会自动展示识别结果无需重复点击</div>
<div class="ai-loading-copy__secondary">{{ t('subscriptions.aiModal.loadingHint') }}</div>
</div>
</template>
<n-form label-placement="top">
<n-form-item label="文本内容">
<n-form-item :label="t('subscriptions.aiModal.textLabel')">
<n-input
v-model:value="text"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="粘贴订阅邮件、支付记录、订单文本等"
:placeholder="t('subscriptions.aiModal.textPlaceholder')"
:disabled="loading"
/>
</n-form-item>
<n-form-item label="图片">
<n-form-item :label="t('subscriptions.aiModal.imageLabel')">
<div class="ai-upload-box" @paste="handlePaste">
<input ref="fileInputRef" type="file" accept="image/*" class="hidden-input" :disabled="loading" @change="handleFileChange" />
<n-space vertical>
<n-space>
<n-button :disabled="loading" @click="pickFile">上传图片</n-button>
<n-button quaternary :disabled="loading || !imagePreview" @click="clearImage">清空图片</n-button>
<n-button :disabled="loading" @click="pickFile">{{ t('subscriptions.aiModal.uploadImage') }}</n-button>
<n-button quaternary :disabled="loading || !imagePreview" @click="clearImage">{{ t('subscriptions.aiModal.clearImage') }}</n-button>
</n-space>
<div class="card-muted">也可以直接在此区域粘贴截图</div>
<img v-if="imagePreview" :src="imagePreview" alt="识别图片预览" class="ai-upload-box__preview" />
<div class="card-muted">{{ t('subscriptions.aiModal.pasteHint') }}</div>
<img v-if="imagePreview" :src="imagePreview" :alt="t('subscriptions.aiModal.imagePreviewAlt')" class="ai-upload-box__preview" />
</n-space>
</div>
</n-form-item>
@@ -50,21 +50,21 @@
</n-spin>
<n-space justify="space-between">
<div v-if="result" class="card-muted">置信度{{ ((result.confidence ?? 0) * 100).toFixed(0) }}%</div>
<div v-if="result" class="card-muted">{{ t('subscriptions.aiModal.confidenceWithValue', { value: ((result.confidence ?? 0) * 100).toFixed(0) }) }}</div>
<n-space>
<n-button :disabled="loading" @click="emit('close')">关闭</n-button>
<n-button :disabled="loading" @click="emit('close')">{{ t('common.actions.close') }}</n-button>
<n-button type="primary" :loading="loading" :disabled="loading" @click="recognize">
{{ loading ? '识别中' : '开始识别' }}
{{ loading ? t('subscriptions.aiModal.recognizing') : t('subscriptions.aiModal.recognize') }}
</n-button>
<n-button type="success" :disabled="!result || loading" @click="applyResult">应用结果</n-button>
<n-button type="success" :disabled="!result || loading" @click="applyResult">{{ t('subscriptions.aiModal.applyResult') }}</n-button>
</n-space>
</n-space>
<n-card v-if="result" size="small" embedded title="识别结果">
<n-card v-if="result" size="small" embedded :title="t('subscriptions.aiModal.resultTitle')">
<n-data-table :columns="resultColumns" :data="resultRows" :pagination="false" size="small" />
<div v-if="result.rawText" class="ai-raw-text">
<div class="ai-raw-text__title">原始提取文本</div>
<div class="ai-raw-text__title">{{ t('subscriptions.aiModal.rawTextTitle') }}</div>
<pre class="ai-result">{{ result.rawText }}</pre>
</div>
</n-card>
@@ -74,10 +74,12 @@
<script setup lang="ts">
import { computed, h, onBeforeUnmount, ref } from 'vue'
import { NAlert, NButton, NCard, NDataTable, NForm, NFormItem, NInput, NModal, NSpace, NSpin, useMessage } from 'naive-ui'
import { NAlert, NButton, NCard, NDataTable, NForm, NFormItem, NInput, NModal, NSpace, NSpin } from 'naive-ui'
import { t } from '@/locales'
import { api } from '@/composables/api'
import type { AiRecognitionResult } from '@/types/api'
import { getAiRecognitionStatusText } from '@/utils/ai-recognition-status'
import { useLocalizedMessage } from '@/utils/localized-message'
defineProps<{
show: boolean
@@ -88,7 +90,7 @@ const emit = defineEmits<{
apply: [result: AiRecognitionResult]
}>()
const message = useMessage()
const message = useLocalizedMessage()
const fileInputRef = ref<HTMLInputElement | null>(null)
const text = ref('')
const imageBase64 = ref('')
@@ -99,28 +101,27 @@ const loading = ref(false)
const result = ref<AiRecognitionResult | null>(null)
const loadingElapsedMs = ref(0)
let loadingTimer: ReturnType<typeof setInterval> | null = null
const fieldLabels: Record<string, string> = {
name: '名称',
description: '描述',
amount: '金额',
currency: '币种',
billingIntervalCount: '频率',
billingIntervalUnit: '周期单位',
startDate: '开始日期',
nextRenewalDate: '下次续订',
notifyDaysBefore: '提醒天数',
websiteUrl: '官网 / 平台地址',
notes: '备注'
}
const intervalLabelMap: Record<string, string> = {
day: '天',
week: '周',
month: '月',
quarter: '季度',
year: '年'
}
const intervalUnitLabel = (unit: string) =>
({
day: t('common.units.day'),
week: t('common.units.week'),
month: t('common.units.month'),
quarter: t('common.units.quarter'),
year: t('common.units.year')
})[unit] ?? unit
const fieldLabelMap = computed<Record<string, string>>(() => ({
name: t('subscriptions.aiModal.fields.name'),
description: t('subscriptions.aiModal.fields.description'),
amount: t('subscriptions.aiModal.fields.amount'),
currency: t('subscriptions.aiModal.fields.currency'),
billingIntervalCount: t('subscriptions.aiModal.fields.billingIntervalCount'),
billingIntervalUnit: t('subscriptions.aiModal.fields.billingIntervalUnit'),
startDate: t('subscriptions.aiModal.fields.startDate'),
nextRenewalDate: t('subscriptions.aiModal.fields.nextRenewalDate'),
notifyDaysBefore: t('subscriptions.aiModal.fields.notifyDaysBefore'),
websiteUrl: t('subscriptions.form.websiteLabel'),
notes: t('common.labels.notes')
}))
const resultRows = computed(() => {
if (!result.value) return []
@@ -139,31 +140,34 @@ const resultRows = computed(() => {
push(
'billingIntervalUnit',
result.value.billingIntervalUnit
? intervalLabelMap[result.value.billingIntervalUnit] ?? result.value.billingIntervalUnit
? intervalUnitLabel(result.value.billingIntervalUnit)
: undefined
)
push('startDate', result.value.startDate)
push('nextRenewalDate', result.value.nextRenewalDate)
push('notifyDaysBefore', result.value.notifyDaysBefore !== undefined ? `${result.value.notifyDaysBefore}` : undefined)
push(
'notifyDaysBefore',
result.value.notifyDaysBefore !== undefined ? `${result.value.notifyDaysBefore} ${intervalUnitLabel('day')}` : undefined
)
push('websiteUrl', result.value.websiteUrl)
push('notes', result.value.notes)
return rows
})
const resultColumns = [
const resultColumns = computed(() => [
{
title: '字段',
title: t('subscriptions.aiModal.field'),
key: 'field',
width: 150,
render: (row: { field: string }) => fieldLabels[row.field] ?? row.field
render: (row: { field: string }) => fieldLabelMap.value[row.field] ?? row.field
},
{
title: '识别结果',
title: t('subscriptions.aiModal.recognizedResult'),
key: 'value',
render: (row: { value: string }) => h('span', { class: 'ai-result-value' }, row.value)
}
]
])
const loadingStatusText = computed(() =>
getAiRecognitionStatusText({
@@ -236,7 +240,7 @@ async function handlePaste(event: ClipboardEvent) {
async function recognize() {
if (!text.value.trim() && !imageBase64.value) {
message.warning('请先输入文本或上传图片')
message.warning(t('subscriptions.aiModal.pleaseProvideInput'))
return
}
@@ -249,10 +253,10 @@ async function recognize() {
filename: imageFilename.value || undefined,
mimeType: imageMimeType.value || undefined
})
message.success('识别完成')
message.success(t('subscriptions.aiModal.recognitionCompleted'))
} catch (error) {
result.value = null
message.error(error instanceof Error ? error.message : 'AI 识别失败')
message.error(error instanceof Error ? error.message : t('subscriptions.aiModal.recognitionFailed'))
} finally {
loading.value = false
stopLoadingTimer()

View File

@@ -1,7 +1,7 @@
<template>
<n-drawer :show="show" :width="drawerWidth" @mask-click="emit('close')" @update:show="handleShowUpdate">
<n-drawer-content title="订阅详情" closable>
<n-empty v-if="!detail" description="暂无数据" />
<n-drawer-content :title="t('subscriptions.detail.title')" closable>
<n-empty v-if="!detail" :description="t('common.empty.noData')" />
<template v-else>
<n-space vertical :size="16">
<n-space align="center">
@@ -16,11 +16,11 @@
</n-space>
<n-descriptions class="detail-descriptions" label-placement="left" :column="descriptionColumns" bordered>
<n-descriptions-item label="名称">{{ detail.name }}</n-descriptions-item>
<n-descriptions-item label="状态">
<n-descriptions-item :label="t('common.labels.name')">{{ detail.name }}</n-descriptions-item>
<n-descriptions-item :label="t('common.labels.status')">
<n-tag :type="getSubscriptionStatusTagType(detail.status)">{{ getSubscriptionStatusText(detail.status) }}</n-tag>
</n-descriptions-item>
<n-descriptions-item label="标签">
<n-descriptions-item :label="t('common.labels.tags')">
<n-space size="small" wrap>
<n-tag
v-for="item in detail.tags ?? []"
@@ -31,55 +31,63 @@
>
{{ item.name }}
</n-tag>
<span v-if="!(detail.tags?.length)">未打标签</span>
<span v-if="!(detail.tags?.length)">{{ t('common.empty.noTags') }}</span>
</n-space>
</n-descriptions-item>
<n-descriptions-item label="自动续订" :label-style="middleAlignedCellStyle" :content-style="middleAlignedCellStyle">
{{ detail.autoRenew ? '已启用' : '未启用' }}
<n-descriptions-item :label="t('common.labels.autoRenew')">
{{ detail.autoRenew ? t('common.status.enabled') : t('common.status.disabled') }}
</n-descriptions-item>
<n-descriptions-item label="订阅频率"> {{ detail.billingIntervalCount }} {{ unitLabel(detail.billingIntervalUnit) }}</n-descriptions-item>
<n-descriptions-item label="开始日期">{{ formatDate(detail.startDate) }}</n-descriptions-item>
<n-descriptions-item label="下次续订">{{ formatDate(detail.nextRenewalDate) }}</n-descriptions-item>
<n-descriptions-item label="原始金额">{{ formatMoney(detail.amount, detail.currency) }}</n-descriptions-item>
<n-descriptions-item label="当前周期" :label-style="middleAlignedCellStyle" :content-style="middleAlignedCellStyle">
<n-descriptions-item :label="t('subscriptions.labels.interval')">
{{ formatInterval(detail.billingIntervalCount, detail.billingIntervalUnit) }}
</n-descriptions-item>
<n-descriptions-item :label="t('common.labels.startDate')">{{ formatDate(detail.startDate) }}</n-descriptions-item>
<n-descriptions-item :label="t('common.labels.nextRenewal')">{{ formatDate(detail.nextRenewalDate) }}</n-descriptions-item>
<n-descriptions-item :label="t('subscriptions.labels.originalAmount')">{{ formatMoney(detail.amount, detail.currency) }}</n-descriptions-item>
<n-descriptions-item
:label="t('subscriptions.labels.currentCycle')"
:label-style="middleAlignedCellStyle"
:content-style="middleAlignedCellStyle"
>
{{ detail.currentCycleStartDate }} ~ {{ detail.currentCycleEndDate }}
</n-descriptions-item>
<n-descriptions-item label="剩余价值">
<n-descriptions-item :label="t('subscriptions.labels.remainingValue')">
<div class="detail-value-block">
<div class="detail-value-block__amount">
{{ formatMoney(detail.remainingValue, detail.remainingValueCurrency) }}
</div>
<div class="detail-value-block__meta">剩余 {{ detail.remainingDays }} / {{ formatRatio(detail.remainingRatio) }}</div>
<div class="detail-value-block__meta">
{{ t('subscriptions.detail.remainingDays', { days: detail.remainingDays, ratio: formatRatio(detail.remainingRatio) }) }}
</div>
</div>
</n-descriptions-item>
<n-descriptions-item label="到期前提醒">
<n-descriptions-item :label="t('subscriptions.labels.advanceReminders')">
<n-space v-if="advanceReminderRuleItems.length" vertical size="small">
<n-tag v-for="item in advanceReminderRuleItems" :key="item.key" size="small" type="info" :bordered="false">
{{ item.description }}
</n-tag>
</n-space>
<span v-else>{{ formatReminderRulesText(detail.advanceReminderRules, 'advance') }}</span>
<span v-else>{{ formatReminderRulesText(detail.advanceReminderRules, 'advance', t('validation.reminderRules.fallback'), { i18n: reminderRulesI18n }) }}</span>
</n-descriptions-item>
<n-descriptions-item label="过期提醒">
<n-descriptions-item :label="t('subscriptions.labels.overdueReminders')">
<n-space v-if="overdueReminderRuleItems.length" vertical size="small">
<n-tag v-for="item in overdueReminderRuleItems" :key="item.key" size="small" type="warning" :bordered="false">
{{ item.description }}
</n-tag>
</n-space>
<span v-else>{{ formatReminderRulesText(detail.overdueReminderRules, 'overdue') }}</span>
<span v-else>{{ formatReminderRulesText(detail.overdueReminderRules, 'overdue', t('validation.reminderRules.fallback'), { i18n: reminderRulesI18n }) }}</span>
</n-descriptions-item>
<n-descriptions-item label="提醒通知" :label-style="middleAlignedCellStyle" :content-style="middleAlignedCellStyle">
{{ detail.webhookEnabled ? '已启用' : '未启用' }}
<n-descriptions-item :label="t('common.labels.notifications')">
{{ detail.webhookEnabled ? t('common.status.enabled') : t('common.status.disabled') }}
</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ formatDateTime(detail.createdAt) }}</n-descriptions-item>
<n-descriptions-item :label="t('common.labels.createdAt')">{{ formatDateTime(detail.createdAt) }}</n-descriptions-item>
</n-descriptions>
<n-card title="描述">
{{ detail.description || '暂无描述' }}
<n-card :title="t('common.labels.description')">
{{ detail.description || t('common.empty.noDescription') }}
</n-card>
<n-card title="备注">
{{ detail.notes || '暂无备注' }}
<n-card :title="t('common.labels.notes')">
{{ detail.notes || t('common.empty.noNotes') }}
</n-card>
</n-space>
</template>
@@ -91,6 +99,7 @@
import { computed } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { NCard, NDescriptions, NDescriptionsItem, NDrawer, NDrawerContent, NEmpty, NSpace, NTag } from 'naive-ui'
import { t } from '@/locales'
import { useSettingsQuery } from '@/composables/settings-query'
import type { SubscriptionDetail } from '@/types/api'
import { resolveLogoUrl } from '@/utils/logo'
@@ -112,11 +121,40 @@ const descriptionColumns = computed(() => (width.value < 760 ? 1 : 2))
const middleAlignedCellStyle = {
verticalAlign: 'middle'
} as const
const reminderRulesI18n = computed(() => ({
fallback: t('validation.reminderRules.fallback'),
emptyTitle: t('validation.reminderRules.emptyTitle'),
resultTitle: t('validation.reminderRules.resultTitle'),
invalidTitle: t('validation.reminderRules.invalidTitle'),
defaultRulesLabel: t('validation.reminderRules.defaultRulesLabel'),
defaultAdvanceRulesLabel: t('validation.reminderRules.defaultAdvanceRulesLabel'),
defaultOverdueRulesLabel: t('validation.reminderRules.defaultOverdueRulesLabel'),
fallbackPreviewTitle: t('validation.reminderRules.fallbackPreviewTitle'),
fallbackInvalidTitle: t('validation.reminderRules.fallbackInvalidTitle'),
noAdvance: t('validation.reminderRules.noAdvance'),
noOverdue: t('validation.reminderRules.noOverdue'),
parseFailed: t('validation.reminderRules.parseFailed'),
invalidSegmentFormat: t('validation.reminderRules.invalidSegmentFormat', { segment: '{segment}' }),
invalidDaysInteger: t('validation.reminderRules.invalidDaysInteger', { segment: '{segment}' }),
invalidOverdueDays: t('validation.reminderRules.invalidOverdueDays', { segment: '{segment}' }),
invalidAdvanceDays: t('validation.reminderRules.invalidAdvanceDays', { segment: '{segment}' }),
invalidTime: t('validation.reminderRules.invalidTime', { segment: '{segment}' }),
inlineAdvanceSameDay: t('validation.reminderRules.inlineAdvanceSameDay', { time: '{time}' }),
inlineAdvanceBefore: t('validation.reminderRules.inlineAdvanceBefore', { days: '{days}', time: '{time}' }),
inlineOverdue: t('validation.reminderRules.inlineOverdue', { days: '{days}', time: '{time}' }),
evalAdvanceSameDay: t('validation.reminderRules.evalAdvanceSameDay', { time: '{time}' }),
evalAdvanceBefore: t('validation.reminderRules.evalAdvanceBefore', { days: '{days}', time: '{time}' }),
evalOverdue: t('validation.reminderRules.evalOverdue', { days: '{days}', time: '{time}' })
}))
const advanceReminderRuleItems = computed(() =>
listReminderRuleDescriptions(props.detail?.advanceReminderRules, 'advance')
listReminderRuleDescriptions(props.detail?.advanceReminderRules, 'advance', undefined, {
i18n: reminderRulesI18n.value
})
)
const overdueReminderRuleItems = computed(() =>
listReminderRuleDescriptions(props.detail?.overdueReminderRules, 'overdue')
listReminderRuleDescriptions(props.detail?.overdueReminderRules, 'overdue', undefined, {
i18n: reminderRulesI18n.value
})
)
function formatMoney(amount: number, currency: string) {
@@ -135,16 +173,20 @@ function formatDateTime(value: string) {
return formatDateTimeInTimezone(value, settings.value?.timezone)
}
function unitLabel(unit: string) {
function intervalUnitLabel(unit: string) {
return {
day: '天',
week: '周',
month: '月',
quarter: '季',
year: '年'
day: t('common.units.day'),
week: t('common.units.week'),
month: t('common.units.month'),
quarter: t('common.units.quarter'),
year: t('common.units.year')
}[unit] ?? unit
}
function formatInterval(count: number, unit: string) {
return t('subscriptions.values.interval', { count, unit: intervalUnitLabel(unit) })
}
function handleShowUpdate(value: boolean) {
if (!value) {
emit('close')

View File

@@ -1,10 +1,17 @@
<template>
<n-modal :show="show" preset="card" title="订阅信息" style="width: min(920px, calc(100vw - 24px))" @mask-click="close" @update:show="handleUpdateShow">
<n-spin :show="saving" description="保存中,请稍候...">
<n-modal
:show="show"
preset="card"
:title="model ? t('subscriptions.form.titleEdit') : t('subscriptions.form.titleCreate')"
style="width: min(920px, calc(100vw - 24px))"
@mask-click="close"
@update:show="handleUpdateShow"
>
<n-spin :show="saving" :description="t('subscriptions.form.savingDescription')">
<n-form :model="form" label-placement="top">
<div class="name-logo-row">
<n-form-item label="名称" class="name-logo-row__name" :validation-status="validationStatusOf('name')" :feedback="formErrors.name">
<n-input v-model:value="form.name" placeholder="例如GitHub Pro" />
<n-form-item :label="t('common.labels.name')" class="name-logo-row__name" :validation-status="validationStatusOf('name')" :feedback="formErrors.name">
<n-input v-model:value="form.name" :placeholder="t('subscriptions.form.namePlaceholder')" />
</n-form-item>
<div class="logo-dock">
@@ -13,7 +20,7 @@
<button type="button" class="logo-dock__preview" @click="pickLogoFile">
<img v-if="resolvedLogoUrl" :src="resolvedLogoUrl" alt="logo" class="logo-dock__image" />
<div v-else class="logo-dock__placeholder">
<span>{{ form.name.trim() ? '点击上传' : 'Logo' }}</span>
<span>{{ form.name.trim() ? t('subscriptions.form.logo.upload') : t('subscriptions.form.logo.placeholder') }}</span>
</div>
</button>
@@ -33,17 +40,17 @@
<div v-if="showLogoPanel" class="logo-panel">
<div class="logo-panel__header">
<span>选择 Logo</span>
<span>{{ t('subscriptions.form.logo.panelTitle') }}</span>
<button type="button" class="logo-panel__close" @click="showLogoPanel = false">
<n-icon :component="CloseOutline" />
</button>
</div>
<n-tabs v-model:value="logoPanelTab" type="segment" animated class="logo-panel__tabs">
<n-tab-pane :name="LOGO_TAB_WEB" :tab="`网络搜索 (${logoCandidates.length})`">
<n-tab-pane :name="LOGO_TAB_WEB" :tab="t('subscriptions.form.logo.webTab', { count: logoCandidates.length })">
<div v-if="searchingLogoCandidates" class="logo-panel__state">
<n-spin size="small" />
<span>正在搜索 Logo...</span>
<span>{{ t('subscriptions.form.logo.searching') }}</span>
</div>
<div v-else-if="logoCandidates.length" class="logo-panel__list">
@@ -63,13 +70,13 @@
</button>
</div>
<n-empty v-else description="当前没有可用的网络搜索结果" size="small" class="logo-panel__empty" />
<n-empty v-else :description="t('subscriptions.form.logo.noSearchResults')" size="small" class="logo-panel__empty" />
</n-tab-pane>
<n-tab-pane :name="LOGO_TAB_LIBRARY" :tab="`本地已保存 (${localLogoLibrary.length})`">
<n-tab-pane :name="LOGO_TAB_LIBRARY" :tab="t('subscriptions.form.logo.libraryTab', { count: localLogoLibrary.length })">
<div v-if="loadingLocalLogoLibrary" class="logo-panel__state">
<n-spin size="small" />
<span>正在加载本地 Logo...</span>
<span>{{ t('subscriptions.form.logo.loadingLocal') }}</span>
</div>
<div v-else-if="localLogoLibrary.length" class="logo-panel__list">
@@ -88,7 +95,7 @@
<span class="logo-panel__item-label">{{ item.label }}</span>
<span class="logo-panel__item-meta">
{{ formatLogoSource(item.source) }}
<template v-if="item.usageCount !== undefined"> · 已用 {{ item.usageCount }} </template>
<template v-if="item.usageCount !== undefined"> · {{ t('subscriptions.form.logo.usedCount', { count: item.usageCount }) }}</template>
</span>
<span v-if="item.relatedSubscriptionNames?.length" class="logo-panel__item-related">
{{ item.relatedSubscriptionNames.join(' / ') }}
@@ -97,7 +104,7 @@
</div>
</div>
<n-empty v-else description="本地还没有可复用的 Logo" size="small" class="logo-panel__empty" />
<n-empty v-else :description="t('subscriptions.form.logo.noLocalResults')" size="small" class="logo-panel__empty" />
</n-tab-pane>
</n-tabs>
</div>
@@ -106,53 +113,53 @@
<n-grid :cols="layoutCols" :x-gap="16" :y-gap="8">
<n-grid-item>
<n-form-item label="标签">
<n-select v-model:value="form.tagIds" :options="tagOptions" multiple filterable clearable placeholder="选择标签" />
<n-form-item :label="t('common.labels.tags')">
<n-select v-model:value="form.tagIds" :options="tagOptions" multiple filterable clearable :placeholder="t('subscriptions.form.tagPlaceholder')" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="官网 / 平台地址" :validation-status="validationStatusOf('websiteUrl')" :feedback="formErrors.websiteUrl">
<n-form-item :label="t('subscriptions.form.websiteLabel')" :validation-status="validationStatusOf('websiteUrl')" :feedback="formErrors.websiteUrl">
<n-input v-model:value="form.websiteUrl" placeholder="https://example.com" @blur="handleWebsiteUrlBlur" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item label="描述" :validation-status="validationStatusOf('description')" :feedback="formErrors.description">
<n-form-item :label="t('common.labels.description')" :validation-status="validationStatusOf('description')" :feedback="formErrors.description">
<n-input
v-model:value="form.description"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
:maxlength="500"
placeholder="可选,简单记录订阅用途"
:placeholder="t('subscriptions.form.descriptionPlaceholder')"
/>
</n-form-item>
<n-grid :cols="moneyCols" :x-gap="16" :y-gap="8">
<n-grid-item>
<n-form-item label="金额" :validation-status="validationStatusOf('amount')" :feedback="formErrors.amount">
<n-input-number v-model:value="form.amount" :min="0" :precision="2" style="width: 100%" placeholder="输入金额,免费可填 0" />
<n-form-item :label="t('common.labels.amount')" :validation-status="validationStatusOf('amount')" :feedback="formErrors.amount">
<n-input-number v-model:value="form.amount" :min="0" :precision="2" style="width: 100%" :placeholder="t('subscriptions.form.amountPlaceholder')" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="货币" :validation-status="validationStatusOf('currency')" :feedback="formErrors.currency">
<n-select v-model:value="form.currency" :options="currencyOptions" filterable placeholder="选择货币" />
<n-form-item :label="t('common.labels.currency')" :validation-status="validationStatusOf('currency')" :feedback="formErrors.currency">
<n-select v-model:value="form.currency" :options="currencyOptions" filterable :placeholder="t('subscriptions.form.currencyPlaceholder')" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="频率" :validation-status="validationStatusOf('billingIntervalCount')" :feedback="formErrors.billingIntervalCount">
<n-select v-model:value="form.billingIntervalCount" :options="frequencyOptions" placeholder="选择频率" />
<n-form-item :label="t('common.labels.frequency')" :validation-status="validationStatusOf('billingIntervalCount')" :feedback="formErrors.billingIntervalCount">
<n-select v-model:value="form.billingIntervalCount" :options="frequencyOptions" :placeholder="t('subscriptions.form.frequencyPlaceholder')" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="单位" :validation-status="validationStatusOf('billingIntervalUnit')" :feedback="formErrors.billingIntervalUnit">
<n-select v-model:value="form.billingIntervalUnit" :options="intervalOptions" placeholder="选择单位" />
<n-form-item :label="t('common.labels.unit')" :validation-status="validationStatusOf('billingIntervalUnit')" :feedback="formErrors.billingIntervalUnit">
<n-select v-model:value="form.billingIntervalUnit" :options="intervalOptions" :placeholder="t('subscriptions.form.unitPlaceholder')" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-grid :cols="dateCols" :x-gap="16" :y-gap="8">
<n-grid-item>
<n-form-item label="开始日期" :validation-status="validationStatusOf('startDateTs')" :feedback="formErrors.startDateTs">
<n-form-item :label="t('common.labels.startDate')" :validation-status="validationStatusOf('startDateTs')" :feedback="formErrors.startDateTs">
<n-date-picker
v-model:value="form.startDateTs"
type="date"
@@ -166,7 +173,7 @@
<n-form-item :validation-status="validationStatusOf('nextRenewalDateTs')" :feedback="formErrors.nextRenewalDateTs">
<template #label>
<span class="label-with-action">
<span>下次续订</span>
<span>{{ t('subscriptions.form.nextRenewalLabel') }}</span>
<n-tooltip trigger="hover">
<template #trigger>
<n-button
@@ -182,7 +189,7 @@
</template>
</n-button>
</template>
<span>按开始日期和频率重新计算下次续订</span>
<span>{{ t('subscriptions.form.recalculateNextRenewal') }}</span>
</n-tooltip>
</span>
</template>
@@ -199,32 +206,32 @@
<n-form-item>
<template #label>
<span class="label-with-tip">
<span>到期前提醒规则</span>
<span>{{ t('settings.labels.advanceReminderRules') }}</span>
<n-tooltip trigger="hover">
<template #trigger>
<n-icon class="label-with-tip__icon" :component="helpCircleOutline" />
</template>
<span>格式说明天数&时间;例如 3&09:30; 表示提前 3 天在 09:30 提醒0&09:30; 表示到期当天提醒多条规则用 ; 分隔留空则沿用系统默认</span>
<span>{{ t('settings.helps.advanceReminderRules') }}</span>
</n-tooltip>
</span>
</template>
<n-input v-model:value="form.advanceReminderRules" placeholder="留空则沿用系统默认例如3&09:30;0&09:30;" />
<n-input v-model:value="form.advanceReminderRules" :placeholder="t('subscriptions.form.advanceReminderRulesPlaceholder')" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item>
<template #label>
<span class="label-with-tip">
<span>过期提醒规则</span>
<span>{{ t('settings.labels.overdueReminderRules') }}</span>
<n-tooltip trigger="hover">
<template #trigger>
<n-icon class="label-with-tip__icon" :component="helpCircleOutline" />
</template>
<span>格式说明天数&时间;例如 1&09:30; 表示过期 1 天后在 09:30 提醒多条规则用 ; 分隔留空则沿用系统默认</span>
<span>{{ t('settings.helps.overdueReminderRules') }}</span>
</n-tooltip>
</span>
</template>
<n-input v-model:value="form.overdueReminderRules" placeholder="留空则沿用系统默认例如1&09:30;2&09:30;" />
<n-input v-model:value="form.overdueReminderRules" :placeholder="t('subscriptions.form.overdueReminderRulesPlaceholder')" />
</n-form-item>
</n-grid-item>
</n-grid>
@@ -240,36 +247,36 @@
@visibility-change="subscriptionReminderPreviewVisible = $event"
/>
<n-form-item label="备注" :validation-status="validationStatusOf('notes')" :feedback="formErrors.notes">
<n-form-item :label="t('common.labels.notes')" :validation-status="validationStatusOf('notes')" :feedback="formErrors.notes">
<n-input
v-model:value="form.notes"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
:maxlength="1000"
placeholder="可选,记录账号、套餐或特别说明"
:placeholder="t('subscriptions.form.notesPlaceholder')"
/>
</n-form-item>
<div class="form-footer">
<n-space>
<n-switch v-model:value="form.webhookEnabled" />
<span>启用提醒通知</span>
<span>{{ t('subscriptions.form.notificationEnabledLabel') }}</span>
<n-switch v-model:value="form.autoRenew" />
<span>自动续订</span>
<span>{{ t('common.labels.autoRenew') }}</span>
</n-space>
<n-space wrap>
<n-button :disabled="saving" @click="showAiModal = true">AI 识别</n-button>
<n-button :disabled="saving" @click="showAiModal = true">{{ t('subscriptions.form.actions.aiRecognize') }}</n-button>
<n-button
:disabled="saving"
:type="subscriptionReminderPreviewVisible ? 'primary' : 'default'"
:secondary="subscriptionReminderPreviewVisible"
@click="previewSubscriptionReminderRules"
>
{{ subscriptionReminderPreviewVisible ? '收起提醒预览' : '预览提醒规则' }}
{{ subscriptionReminderPreviewVisible ? t('subscriptions.form.actions.collapseReminderPreview') : t('subscriptions.form.actions.previewReminderRules') }}
</n-button>
<n-button :disabled="saving" @click="handleReset">重置</n-button>
<n-button :disabled="saving" @click="close">取消</n-button>
<n-button type="primary" :loading="saving" :disabled="saving" @click="submit">保存</n-button>
<n-button :disabled="saving" @click="handleReset">{{ t('common.actions.reset') }}</n-button>
<n-button :disabled="saving" @click="close">{{ t('common.actions.cancel') }}</n-button>
<n-button type="primary" :loading="saving" :disabled="saving" @click="submit">{{ t('common.actions.save') }}</n-button>
</n-space>
</div>
</n-form>
@@ -300,10 +307,10 @@ import {
NSwitch,
NTabPane,
NTabs,
NTooltip,
useMessage
NTooltip
} from 'naive-ui'
import { CloseOutline, ColorWandOutline, HelpCircleOutline, SearchOutline } from '@vicons/ionicons5'
import { t } from '@/locales'
import { api } from '@/composables/api'
import { useSettingsQuery } from '@/composables/settings-query'
import ReminderRulesPreview from '@/components/ReminderRulesPreview.vue'
@@ -317,6 +324,7 @@ import {
type SubscriptionFormErrors,
validateSubscriptionForm
} from '@/utils/subscription-form'
import { useLocalizedMessage } from '@/utils/localized-message'
import type { AiRecognitionResult, LogoSearchResult, Subscription, Tag } from '@/types/api'
const LOGO_TAB_WEB = 'web'
@@ -339,7 +347,7 @@ const emit = defineEmits<{
const { width } = useWindowSize()
const { data: settings } = useSettingsQuery()
const message = useMessage()
const message = useLocalizedMessage()
const helpCircleOutline = HelpCircleOutline
const subscriptionReminderPreviewRef = ref<InstanceType<typeof ReminderRulesPreview> | null>(null)
const subscriptionReminderPreviewVisible = ref(false)
@@ -358,13 +366,13 @@ const layoutCols = computed(() => (width.value < 700 ? 1 : 2))
const moneyCols = computed(() => (width.value < 900 ? 2 : 4))
const dateCols = computed(() => (width.value < 900 ? 1 : 2))
const intervalOptions = [
{ label: '天', value: 'day' },
{ label: '周', value: 'week' },
{ label: '月', value: 'month' },
{ label: '季', value: 'quarter' },
{ label: '年', value: 'year' }
]
const intervalOptions = computed(() => [
{ label: t('common.units.day'), value: 'day' },
{ label: t('common.units.week'), value: 'week' },
{ label: t('common.units.month'), value: 'month' },
{ label: t('common.units.quarter'), value: 'quarter' },
{ label: t('common.units.year'), value: 'year' }
])
const frequencyOptions = Array.from({ length: 12 }, (_, index) => ({
label: `${index + 1}`,
@@ -528,12 +536,12 @@ function hydrateFromModel(model: Subscription) {
function handleReset() {
if (props.model) {
hydrateFromModel(props.model)
message.success('已重置为当前订阅内容')
message.success(t('subscriptions.messages.resetToCurrent'))
return
}
resetForm()
message.success('已重置表单')
message.success(t('subscriptions.messages.resetForm'))
}
async function openLogoPanel() {
@@ -542,7 +550,7 @@ async function openLogoPanel() {
if (!form.name.trim() && !form.websiteUrl.trim()) {
logoPanelTab.value = LOGO_TAB_LIBRARY
message.info('未填写名称或官网时,先为你展示本地已保存 Logo。')
message.info(t('subscriptions.messages.localLogoFirst'))
return
}
@@ -565,11 +573,11 @@ async function searchLogos() {
})
if (!logoCandidates.value.length) {
message.warning('没有找到可用 Logo')
message.warning(t('subscriptions.messages.logoSearchEmpty'))
}
} catch (error) {
logoCandidates.value = []
message.error(error instanceof Error ? error.message : 'Logo 搜索失败')
message.error(error instanceof Error ? error.message : t('subscriptions.messages.logoSearchFailed'))
} finally {
searchingLogoCandidates.value = false
}
@@ -583,7 +591,7 @@ async function loadLocalLogoLibrary(force = false) {
try {
localLogoLibrary.value = await api.getSubscriptionLogoLibrary()
} catch (error) {
message.error(error instanceof Error ? error.message : '读取本地 Logo 失败')
message.error(error instanceof Error ? error.message : t('subscriptions.messages.localLogoLoadFailed'))
} finally {
loadingLocalLogoLibrary.value = false
}
@@ -608,9 +616,9 @@ async function applyRemoteLogoCandidate(item: LogoSearchResult) {
showLogoPanel.value = false
await loadLocalLogoLibrary(true)
message.success('已保存到本地并应用')
message.success(t('subscriptions.messages.logoSavedAndApplied'))
} catch (error) {
message.error(error instanceof Error ? error.message : 'Logo 导入失败')
message.error(error instanceof Error ? error.message : t('subscriptions.messages.logoImportFailed'))
}
}
@@ -618,7 +626,7 @@ function applyLocalLogoCandidate(item: LogoSearchResult) {
form.logoUrl = item.logoUrl
form.logoSource = item.source
showLogoPanel.value = false
message.success('已从本地库复用')
message.success(t('subscriptions.messages.logoReused'))
}
async function deleteLocalLogo(item: LogoSearchResult) {
@@ -631,9 +639,9 @@ async function deleteLocalLogo(item: LogoSearchResult) {
form.logoUrl = ''
form.logoSource = ''
}
message.success('本地 Logo 已删除')
message.success(t('subscriptions.messages.localLogoDeleted'))
} catch (error) {
message.error(error instanceof Error ? error.message : '删除 Logo 失败')
message.error(error instanceof Error ? error.message : t('subscriptions.messages.logoDeleteFailed'))
}
}
@@ -652,9 +660,9 @@ async function handleLogoFileChange(event: Event) {
form.logoUrl = uploaded.logoUrl
form.logoSource = uploaded.logoSource
await loadLocalLogoLibrary(true)
message.success('Logo 上传成功')
message.success(t('subscriptions.messages.logoUploadSuccess'))
} catch (error) {
message.error(error instanceof Error ? error.message : 'Logo 上传失败')
message.error(error instanceof Error ? error.message : t('subscriptions.messages.logoUploadFailed'))
} finally {
if (logoFileInputRef.value) {
logoFileInputRef.value.value = ''
@@ -775,7 +783,7 @@ function submit() {
form.websiteUrl = validation.normalizedWebsiteUrl ?? ''
if (form.startDateTs === null || form.nextRenewalDateTs === null) {
message.warning('请选择开始日期和下次续订日期')
message.warning(t('subscriptions.messages.chooseRequiredDates'))
return
}
@@ -833,10 +841,10 @@ function readFileAsBase64(file: File) {
function formatLogoSource(source: string) {
const map: Record<string, string> = {
upload: '本地上传',
remote: '远程导入',
upload: t('subscriptions.form.logo.source.upload'),
remote: t('subscriptions.form.logo.source.remote'),
'wallos-zip': 'Wallos ZIP',
local: '本地库'
local: t('subscriptions.form.logo.source.local')
}
return map[source] ?? source
}

View File

@@ -1,7 +1,7 @@
<template>
<n-drawer :show="show" :width="drawerWidth" @mask-click="$emit('close')" @update:show="handleShowUpdate">
<n-drawer-content title="续订记录" closable>
<n-empty v-if="records.length === 0" description="暂无续订记录" />
<n-drawer-content :title="t('subscriptions.paymentRecords.title')" closable>
<n-empty v-if="records.length === 0" :description="t('subscriptions.paymentRecords.noData')" />
<n-data-table v-else :columns="columns" :data="records" :pagination="{ pageSize: 8 }" />
</n-drawer-content>
</n-drawer>
@@ -11,6 +11,7 @@
import { computed } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { NDataTable, NDrawer, NDrawerContent, NEmpty } from 'naive-ui'
import { t } from '@/locales'
import { useSettingsQuery } from '@/composables/settings-query'
import type { PaymentRecord } from '@/types/api'
import { formatDateInTimezone, formatDateTimeInTimezone } from '@/utils/timezone'
@@ -26,33 +27,33 @@ const { width } = useWindowSize()
const { data: settings } = useSettingsQuery()
const drawerWidth = computed(() => (width.value < 760 ? '100%' : 760))
const columns = [
const columns = computed(() => [
{
title: '续订时间',
title: t('subscriptions.paymentRecords.renewedAt'),
key: 'paidAt',
render: (row: PaymentRecord) => formatDateTimeInTimezone(row.paidAt, settings.value?.timezone)
},
{
title: '金额',
title: t('common.labels.amount'),
key: 'amount',
render: (row: PaymentRecord) => `${row.currency} ${row.amount.toFixed(2)}`
},
{
title: '折算金额',
title: t('subscriptions.paymentRecords.convertedAmount'),
key: 'convertedAmount',
render: (row: PaymentRecord) => `${row.baseCurrency} ${row.convertedAmount.toFixed(2)}`
},
{
title: '周期开始',
title: t('subscriptions.paymentRecords.periodStart'),
key: 'periodStart',
render: (row: PaymentRecord) => formatDateInTimezone(row.periodStart, settings.value?.timezone)
},
{
title: '周期结束',
title: t('subscriptions.paymentRecords.periodEnd'),
key: 'periodEnd',
render: (row: PaymentRecord) => formatDateInTimezone(row.periodEnd, settings.value?.timezone)
}
]
])
function handleShowUpdate(value: boolean) {
if (!value) {

View File

@@ -1,8 +1,8 @@
<template>
<n-modal :show="show" preset="card" title="恢复备份" style="width: min(960px, calc(100vw - 24px))" @update:show="handleShowUpdate">
<n-modal :show="show" preset="card" :title="t('subscriptions.backupModal.title')" style="width: min(960px, calc(100vw - 24px))" @update:show="handleShowUpdate">
<n-space vertical :size="16" style="width: 100%">
<n-alert type="info" :show-icon="false">
ZIP 会恢复订阅标签支付记录排序系统设置与本地 Logo不会恢复登录凭据会话密钥Webhook 历史和汇率快照
{{ t('subscriptions.backupModal.description') }}
</n-alert>
<n-space align="center" wrap>
@@ -13,82 +13,84 @@
class="hidden-input"
@change="handleFileChange"
/>
<n-button @click="pickFile">选择 ZIP 文件</n-button>
<span class="file-name">{{ selectedFileName || '未选择文件' }}</span>
<n-button type="primary" :disabled="!selectedFile" :loading="inspecting" @click="inspectFile">预览备份</n-button>
<n-button @click="pickFile">{{ t('subscriptions.backupModal.pickZip') }}</n-button>
<span class="file-name">{{ selectedFileName || t('subscriptions.backupModal.noFileSelected') }}</span>
<n-button type="primary" :disabled="!selectedFile" :loading="inspecting" @click="inspectFile">
{{ t('subscriptions.backupModal.previewBackup') }}
</n-button>
</n-space>
<template v-if="preview">
<n-grid :cols="summaryCols" :x-gap="12" :y-gap="12">
<n-grid-item>
<n-card size="small">
<div class="summary-label">订阅</div>
<div class="summary-label">{{ t('subscriptions.backupModal.subscriptions') }}</div>
<div class="summary-value">{{ preview.summary.subscriptionsTotal }}</div>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<div class="summary-label">标签</div>
<div class="summary-label">{{ t('subscriptions.backupModal.tags') }}</div>
<div class="summary-value">{{ preview.summary.tagsTotal }}</div>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<div class="summary-label">支付记录</div>
<div class="summary-label">{{ t('subscriptions.backupModal.paymentRecords') }}</div>
<div class="summary-value">{{ preview.summary.paymentRecordsTotal }}</div>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<div class="summary-label">本地 Logo</div>
<div class="summary-label">{{ t('subscriptions.backupModal.localLogos') }}</div>
<div class="summary-value">{{ preview.summary.logosTotal }}</div>
</n-card>
</n-grid-item>
</n-grid>
<n-card title="恢复模式" size="small">
<n-card :title="t('subscriptions.backupModal.restoreMode')" size="small">
<n-space vertical>
<n-radio-group v-model:value="restoreMode">
<n-space vertical>
<n-radio value="replace">清空现有数据后恢复</n-radio>
<n-radio value="append">保留现有数据并追加恢复</n-radio>
<n-radio value="replace">{{ t('subscriptions.backupModal.replaceMode') }}</n-radio>
<n-radio value="append">{{ t('subscriptions.backupModal.appendMode') }}</n-radio>
</n-space>
</n-radio-group>
<n-alert v-if="restoreMode === 'replace'" type="warning" :show-icon="false">
将删除当前实例中的订阅标签支付记录排序系统设置和本地 Logo然后再按文件内容重新恢复
{{ t('subscriptions.backupModal.replaceWarning') }}
</n-alert>
<template v-else>
<n-alert type="info" :show-icon="false">
追加恢复时同名标签会复用现有标签订阅与支付记录按备份中的唯一标识CUID幂等跳过系统设置是否覆盖由你单独选择
{{ t('subscriptions.backupModal.appendHelp') }}
</n-alert>
<div class="switch-row">
<n-switch v-model:value="restoreSettings" />
<span class="switch-inline-label">同时覆盖当前系统设置</span>
<span class="switch-inline-label">{{ t('subscriptions.backupModal.restoreSettingsLabel') }}</span>
</div>
</template>
</n-space>
</n-card>
<n-card title="恢复预览" size="small">
<n-card :title="t('subscriptions.backupModal.restorePreview')" size="small">
<n-space vertical :size="8">
<div class="conflict-row">
<span>现有同名标签</span>
<span>{{ t('subscriptions.backupModal.existingSameNameTags') }}</span>
<strong>{{ preview.conflicts.existingTagNameCount }}</strong>
</div>
<div class="conflict-row">
<span>现有同唯一标识CUID订阅</span>
<span>{{ t('subscriptions.backupModal.existingSubscriptions') }}</span>
<strong>{{ preview.conflicts.existingSubscriptionIdCount }}</strong>
</div>
<div class="conflict-row">
<span>现有同唯一标识CUID支付记录</span>
<span>{{ t('subscriptions.backupModal.existingPaymentRecords') }}</span>
<strong>{{ preview.conflicts.existingPaymentRecordIdCount }}</strong>
</div>
</n-space>
</n-card>
<n-card title="警告信息" size="small">
<n-card :title="t('subscriptions.backupModal.warnings')" size="small">
<ul class="warning-list">
<li v-for="item in preview.warnings" :key="item">{{ item }}</li>
</ul>
@@ -96,8 +98,10 @@
</template>
<n-space justify="end">
<n-button @click="close">取消</n-button>
<n-button type="primary" :disabled="!preview" :loading="committing" @click="commitImport">确认恢复</n-button>
<n-button @click="close">{{ t('common.actions.cancel') }}</n-button>
<n-button type="primary" :disabled="!preview" :loading="committing" @click="commitImport">
{{ t('subscriptions.backupModal.confirmRestore') }}
</n-button>
</n-space>
</n-space>
</n-modal>
@@ -106,9 +110,11 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { NAlert, NButton, NCard, NGrid, NGridItem, NModal, NRadio, NRadioGroup, NSpace, NSwitch, useMessage } from 'naive-ui'
import { NAlert, NButton, NCard, NGrid, NGridItem, NModal, NRadio, NRadioGroup, NSpace, NSwitch } from 'naive-ui'
import { t } from '@/locales'
import { api } from '@/composables/api'
import type { SubtrackerBackupInspectResult } from '@/types/api'
import { useLocalizedMessage } from '@/utils/localized-message'
const props = defineProps<{
show: boolean
@@ -120,7 +126,7 @@ const emit = defineEmits<{
}>()
const { width } = useWindowSize()
const message = useMessage()
const message = useLocalizedMessage()
const fileInputRef = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const selectedFileName = ref('')
@@ -135,11 +141,11 @@ const summaryCols = computed(() => (width.value < 700 ? 2 : 4))
function normalizePreviewErrorMessage(error: unknown) {
if (error instanceof Error) {
if (/invalid zip data/i.test(error.message)) {
return '备份 ZIP 无法解析'
return t('subscriptions.backupModal.invalidZip')
}
return error.message
}
return '备份预览失败'
return t('subscriptions.backupModal.previewFailed')
}
function buildRestoreSuccessMessage(result: {
@@ -153,10 +159,15 @@ function buildRestoreSuccessMessage(result: {
result.importedSubscriptions + result.importedTags + result.importedPaymentRecords + result.importedLogos
if (result.mode === 'append' && importedTotal === 0) {
return '未导入任何新数据,重复项已自动跳过'
return t('subscriptions.backupModal.nothingImported')
}
return `恢复完成:${result.importedSubscriptions} 条订阅,${result.importedTags} 个新标签,${result.importedPaymentRecords} 条支付记录,${result.importedLogos} 个 Logo`
return t('subscriptions.backupModal.restoreCompleted', {
subscriptions: result.importedSubscriptions,
tags: result.importedTags,
payments: result.importedPaymentRecords,
logos: result.importedLogos
})
}
function pickFile() {
@@ -183,7 +194,7 @@ async function inspectFile() {
contentType: selectedFile.value.type || 'application/zip',
base64
})
message.success('已生成备份预览')
message.success(t('subscriptions.backupModal.previewGenerated'))
} catch (error) {
preview.value = null
message.error(normalizePreviewErrorMessage(error))
@@ -209,7 +220,7 @@ async function commitImport() {
})
close()
} catch (error) {
message.error(error instanceof Error ? error.message : '恢复失败')
message.error(error instanceof Error ? error.message : t('subscriptions.backupModal.restoreFailed'))
} finally {
committing.value = false
}

View File

@@ -1,10 +1,10 @@
<template>
<n-modal :show="show" preset="card" title="设置标签月预算" :style="modalStyle" @update:show="handleShowChange">
<n-modal :show="show" preset="card" :title="t('tags.budget.title')" :style="modalStyle" @update:show="handleShowChange">
<div class="modal-intro">
为需要单独控制支出的标签设置月预算未设置的标签不会参与标签预算分析
{{ t('tags.budget.description') }}
</div>
<n-input v-model:value="keyword" placeholder="搜索标签" clearable style="margin-bottom: 12px" />
<n-input v-model:value="keyword" :placeholder="t('tags.budget.searchPlaceholder')" clearable style="margin-bottom: 12px" />
<div class="budget-list">
<div v-for="tag in filteredTags" :key="tag.id" class="budget-item">
@@ -16,7 +16,7 @@
v-model:value="draftBudgets[tag.id]"
:min="0"
:precision="2"
:placeholder="`未设置(${baseCurrency}`"
:placeholder="t('tags.budget.budgetPlaceholder', { currency: baseCurrency })"
style="width: 180px"
/>
</div>
@@ -24,9 +24,9 @@
<template #footer>
<n-space justify="end">
<n-button @click="emit('close')">取消</n-button>
<n-button @click="resetDraft">重置</n-button>
<n-button type="primary" @click="handleSave">保存</n-button>
<n-button @click="emit('close')">{{ t('common.actions.cancel') }}</n-button>
<n-button @click="resetDraft">{{ t('common.actions.reset') }}</n-button>
<n-button type="primary" @click="handleSave">{{ t('common.actions.save') }}</n-button>
</n-space>
</template>
</n-modal>
@@ -35,6 +35,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { NButton, NInput, NInputNumber, NModal, NSpace } from 'naive-ui'
import { t } from '@/locales'
import type { Tag } from '@/types/api'
const props = defineProps<{

View File

@@ -2,44 +2,45 @@
<n-modal
:show="show"
preset="card"
:title="model ? '编辑标签' : '新增标签'"
:title="model ? t('tags.form.editTitle') : t('tags.form.createTitle')"
style="width: min(560px, calc(100vw - 24px))"
@mask-click="close"
@update:show="handleUpdateShow"
>
<n-form :model="form" label-placement="top">
<n-form-item label="标签名称">
<n-input v-model:value="form.name" placeholder="例如:云服务" />
<n-form-item :label="t('tags.form.nameLabel')">
<n-input v-model:value="form.name" :placeholder="t('tags.form.namePlaceholder')" />
</n-form-item>
<n-grid :cols="2" :x-gap="16">
<n-grid-item>
<n-form-item label="颜色">
<n-form-item :label="t('tags.form.colorLabel')">
<div class="color-field">
<n-input v-model:value="form.color" placeholder="#3b82f6 或 rgb(59,130,246)" />
<n-input v-model:value="form.color" :placeholder="t('tags.form.colorPlaceholder')" />
<n-color-picker v-model:value="form.color" :modes="['hex', 'rgb']" :show-alpha="false" class="color-field__picker" />
</div>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="排序">
<n-form-item :label="t('tags.form.sortOrderLabel')">
<n-input-number v-model:value="form.sortOrder" :min="0" style="width: 100%" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-space justify="end">
<n-button @click="close">取消</n-button>
<n-button type="primary" @click="submit">保存</n-button>
<n-button @click="close">{{ t('common.actions.cancel') }}</n-button>
<n-button type="primary" @click="submit">{{ t('common.actions.save') }}</n-button>
</n-space>
</n-form>
</n-modal>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { computed, reactive, watch } from 'vue'
import { NButton, NColorPicker, NForm, NFormItem, NGrid, NGridItem, NInput, NInputNumber, NModal, NSpace } from 'naive-ui'
import { t } from '@/locales'
import type { Tag } from '@/types/api'
type TagFormPayload = {

View File

@@ -2,15 +2,15 @@
<n-modal
:show="show"
preset="card"
title="标签管理"
:title="t('tags.manage.title')"
style="width: min(760px, calc(100vw - 24px))"
@mask-click="$emit('close')"
@update:show="handleUpdateShow"
>
<n-space vertical :size="16">
<n-space justify="space-between" align="center">
<n-text depth="3">在这里统一新增编辑和删除标签</n-text>
<n-button type="primary" @click="openCreate">新增标签</n-button>
<n-text depth="3">{{ t('tags.manage.description') }}</n-text>
<n-button type="primary" @click="openCreate">{{ t('tags.manage.create') }}</n-button>
</n-space>
<n-data-table :columns="columns" :data="tags" :pagination="{ pageSize: 8 }" :bordered="false" />
@@ -23,6 +23,7 @@
<script setup lang="ts">
import { computed, h, ref } from 'vue'
import { NButton, NDataTable, NModal, NPopconfirm, NSpace, NText } from 'naive-ui'
import { t } from '@/locales'
import TagFormModal from '@/components/TagFormModal.vue'
import type { Tag } from '@/types/api'
@@ -51,7 +52,7 @@ const editing = ref<Tag | null>(null)
const columns = computed(() => [
{
title: '标签',
title: t('tags.manage.tag'),
key: 'name',
render: (row: Tag) =>
h(
@@ -87,18 +88,18 @@ const columns = computed(() => [
)
},
{
title: '排序',
title: t('tags.manage.sortOrder'),
key: 'sortOrder',
width: 90
},
{
title: '订阅数',
title: t('tags.manage.subscriptionCount'),
key: 'subscriptionCount',
width: 100,
render: (row: Tag) => props.subscriptionCounts?.[row.id] ?? 0
},
{
title: '操作',
title: t('tags.manage.actions'),
key: 'actions',
width: 180,
render: (row: Tag) =>
@@ -110,13 +111,13 @@ const columns = computed(() => [
size: 'small',
onClick: () => openEdit(row)
},
{ default: () => '编辑' }
{ default: () => t('common.actions.edit') }
),
h(
NPopconfirm,
{
positiveText: '删除',
negativeText: '取消',
positiveText: t('common.actions.delete'),
negativeText: t('common.actions.cancel'),
onPositiveClick: () => emit('delete', row)
},
{
@@ -128,10 +129,12 @@ const columns = computed(() => [
type: 'error',
ghost: true
},
{ default: () => '删除' }
{ default: () => t('common.actions.delete') }
),
default: () =>
(props.subscriptionCounts?.[row.id] ?? 0) > 0 ? '删除后,该标签会从订阅上移除,确认继续?' : '确认删除该标签?'
(props.subscriptionCounts?.[row.id] ?? 0) > 0
? t('tags.manage.deleteInUseConfirm')
: t('tags.manage.deleteConfirm')
}
)
]

View File

@@ -1,77 +1,77 @@
<template>
<n-modal :show="show" preset="card" title="导入 Wallos 数据" style="width: min(1080px, calc(100vw - 24px))" @update:show="handleShowUpdate">
<n-modal :show="show" preset="card" :title="t('imports.wallos.title')" style="width: min(1080px, calc(100vw - 24px))" @update:show="handleShowUpdate">
<n-space vertical :size="16" style="width: 100%">
<n-alert type="info" :show-icon="false">
支持上传 Wallos JSONSQLite 数据库或 ZIP 当前只导入实际被订阅使用到的标签
{{ t('imports.wallos.description') }}
</n-alert>
<n-space align="center" wrap>
<input ref="fileInputRef" type="file" accept=".json,.db,.sqlite,.sqlite3,.zip,application/octet-stream,application/json,application/zip" class="hidden-input" @change="handleFileChange" />
<n-button @click="pickFile">选择文件</n-button>
<span class="file-name">{{ selectedFileName || '未选择文件' }}</span>
<n-button type="primary" :disabled="!selectedFile" :loading="inspecting" @click="inspectFile">生成预览</n-button>
<n-button @click="pickFile">{{ t('imports.wallos.pickFile') }}</n-button>
<span class="file-name">{{ selectedFileName || t('common.placeholders.noFileSelected') }}</span>
<n-button type="primary" :disabled="!selectedFile" :loading="inspecting" @click="inspectFile">{{ t('imports.wallos.preview') }}</n-button>
</n-space>
<n-alert v-if="showJsonImportWarning" type="warning" :show-icon="false">
{{ JSON_IMPORT_WARNING_MESSAGE }}
{{ jsonImportWarningMessage }}
</n-alert>
<n-space vertical :size="8" style="width: 100%">
<span class="advanced-label">Wallos 源时区高级</span>
<span class="advanced-label">{{ t('imports.wallos.sourceTimezoneLabel') }}</span>
<n-select
v-model:value="wallosSourceTimezone"
:options="timeZoneOptions"
filterable
style="max-width: 360px"
placeholder="默认使用当前业务时区"
:placeholder="t('imports.wallos.sourceTimezonePlaceholder')"
/>
<span class="advanced-hint">仅在导出的 Wallos 实例使用了不同的 TZ 时需要调整否则保持默认即可</span>
<span class="advanced-hint">{{ t('imports.wallos.sourceTimezoneHint') }}</span>
</n-space>
<template v-if="preview">
<n-grid :cols="summaryCols" :x-gap="12" :y-gap="12">
<n-grid-item>
<n-card size="small">
<div class="summary-label">导入类型</div>
<div class="summary-label">{{ t('imports.wallos.importTypeLabel') }}</div>
<div class="summary-value">{{ fileTypeText(preview.summary.fileType) }}</div>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<div class="summary-label">可导入订阅</div>
<div class="summary-label">{{ t('imports.wallos.importableSubscriptionsLabel') }}</div>
<div class="summary-value">{{ preview.summary.supportedSubscriptions }}</div>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<div class="summary-label">实际导入标签</div>
<div class="summary-label">{{ t('imports.wallos.importedTagsLabel') }}</div>
<div class="summary-value">{{ preview.summary.usedTagsTotal }}</div>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<div class="summary-label">ZIP Logo 匹配</div>
<div class="summary-label">{{ t('imports.wallos.zipLogoLabel') }}</div>
<div class="summary-value">{{ preview.summary.zipLogoMatched }}/{{ preview.summary.zipLogoMatched + preview.summary.zipLogoMissing }}</div>
</n-card>
</n-grid-item>
</n-grid>
<n-card title="标签预览" size="small">
<n-empty v-if="preview.usedTags.length === 0" description="没有可导入的标签" />
<n-card :title="t('imports.wallos.tagPreviewTitle')" size="small">
<n-empty v-if="preview.usedTags.length === 0" :description="t('imports.wallos.noImportableTags')" />
<n-data-table v-else :columns="tagColumns" :data="preview.usedTags" :pagination="{ pageSize: 6 }" />
</n-card>
<n-card title="订阅预览" size="small">
<n-card :title="t('imports.wallos.subscriptionPreviewTitle')" size="small">
<n-data-table :columns="subscriptionColumns" :data="preview.subscriptionsPreview" :pagination="{ pageSize: 8 }" />
</n-card>
<n-card title="警告信息" size="small">
<n-empty v-if="previewWarnings.length === 0" description="没有额外警告" />
<n-card :title="t('imports.wallos.warningTitle')" size="small">
<n-empty v-if="previewWarnings.length === 0" :description="t('imports.wallos.noWarnings')" />
<template v-else>
<div class="warning-header">
<span> {{ previewWarnings.length }} 条警告</span>
<span>{{ t('imports.wallos.warningCount', { count: previewWarnings.length }) }}</span>
<n-button text type="primary" @click="warningsExpanded = !warningsExpanded">
{{ warningsExpanded ? '收起' : '展开查看' }}
{{ warningsExpanded ? t('common.actions.collapse') : t('common.actions.expand') }}
</n-button>
</div>
<ul v-if="warningsExpanded" class="warning-list">
@@ -82,8 +82,10 @@
</template>
<n-space justify="end">
<n-button @click="close">取消</n-button>
<n-button type="primary" :disabled="!preview" :loading="committing" @click="commitImport">确认导入</n-button>
<n-button @click="close">{{ t('common.actions.cancel') }}</n-button>
<n-button type="primary" :disabled="!preview" :loading="committing" @click="commitImport">
{{ t('imports.wallos.confirmImport') }}
</n-button>
</n-space>
</n-space>
</n-modal>
@@ -92,12 +94,14 @@
<script setup lang="ts">
import { computed, h, ref } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { NAlert, NButton, NCard, NDataTable, NEmpty, NGrid, NGridItem, NModal, NSelect, NSpace, NTag, useMessage } from 'naive-ui'
import { NAlert, NButton, NCard, NDataTable, NEmpty, NGrid, NGridItem, NModal, NSelect, NSpace, NTag } from 'naive-ui'
import { t } from '@/locales'
import { api } from '@/composables/api'
import type { WallosImportInspectResult, WallosImportSubscriptionPreview } from '@/types/api'
import { getSubscriptionStatusTagType, getSubscriptionStatusText } from '@/utils/subscription-status'
import { JSON_IMPORT_WARNING_MESSAGE, shouldRecommendDbImport } from '@/utils/wallos-import'
import { getJsonImportWarningMessage, shouldRecommendDbImport } from '@/utils/wallos-import'
import { buildTimeZoneOptions, formatDateInTimezone, normalizeAppTimezone } from '@/utils/timezone'
import { useLocalizedMessage } from '@/utils/localized-message'
const props = defineProps<{
show: boolean
@@ -112,7 +116,7 @@ const emit = defineEmits<{
}>()
const { width } = useWindowSize()
const message = useMessage()
const message = useLocalizedMessage()
const fileInputRef = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const selectedFileName = ref('')
@@ -121,6 +125,7 @@ const inspecting = ref(false)
const committing = ref(false)
const warningsExpanded = ref(false)
const wallosSourceTimezone = ref(normalizeAppTimezone(props.appTimezone))
const jsonImportWarningMessage = computed(() => getJsonImportWarningMessage())
const summaryCols = computed(() => (width.value < 700 ? 2 : 4))
const timeZoneOptions = computed(() => buildTimeZoneOptions())
@@ -132,56 +137,60 @@ const previewWarnings = computed(() => {
return Array.from(new Set([...preview.value.warnings, ...preview.value.subscriptionsPreview.flatMap((item) => item.warnings)]))
})
const tagColumns = [
{ title: '来源 ID', key: 'sourceId' },
{ title: '标签名', key: 'name' },
{ title: '排序', key: 'sortOrder' }
]
const tagColumns = computed(() => [
{ title: t('imports.wallos.sourceId'), key: 'sourceId' },
{ title: t('imports.wallos.tagName'), key: 'name' },
{ title: t('imports.wallos.order'), key: 'sortOrder' }
])
const subscriptionColumns = [
{ title: '名称', key: 'name' },
const subscriptionColumns = computed(() => [
{ title: t('imports.wallos.name'), key: 'name' },
{
title: '金额',
title: t('imports.wallos.amount'),
key: 'amount',
render: (row: WallosImportSubscriptionPreview) => `${row.currency} ${row.amount.toFixed(2)}`
},
{
title: '频率',
title: t('imports.wallos.frequency'),
key: 'billingInterval',
render: (row: WallosImportSubscriptionPreview) => `${row.billingIntervalCount} ${unitText(row.billingIntervalUnit)}`
render: (row: WallosImportSubscriptionPreview) =>
t('subscriptions.values.interval', {
count: row.billingIntervalCount,
unit: unitText(row.billingIntervalUnit)
})
},
{
title: '下次续订',
title: t('imports.wallos.nextRenewal'),
key: 'nextRenewalDate',
render: (row: WallosImportSubscriptionPreview) => formatDateInTimezone(row.nextRenewalDate, props.appTimezone)
},
{
title: '标签',
title: t('imports.wallos.tags'),
key: 'tagNames',
render: (row: WallosImportSubscriptionPreview) => row.tagNames.join(' / ') || '未打标签'
render: (row: WallosImportSubscriptionPreview) => row.tagNames.join(' / ') || t('imports.wallos.noTags')
},
{
title: '自动续订',
title: t('imports.wallos.autoRenew'),
key: 'autoRenew',
render: (row: WallosImportSubscriptionPreview) => (row.autoRenew ? '是' : '否')
render: (row: WallosImportSubscriptionPreview) => (row.autoRenew ? t('imports.wallos.yes') : t('imports.wallos.no'))
},
{
title: '状态',
title: t('imports.wallos.status'),
key: 'status',
render: (row: WallosImportSubscriptionPreview) =>
h(NTag, { type: getSubscriptionStatusTagType(row.status) }, { default: () => getSubscriptionStatusText(row.status) })
},
{
title: 'Logo',
title: t('imports.wallos.logo'),
key: 'logoImportStatus',
render: (row: WallosImportSubscriptionPreview) =>
({
none: '无',
'pending-file-match': '待匹配',
'ready-from-zip': 'ZIP 可导入'
none: t('imports.wallos.logoNone'),
'pending-file-match': t('imports.wallos.logoPending'),
'ready-from-zip': t('imports.wallos.logoReady')
})[row.logoImportStatus]
}
]
])
function pickFile() {
fileInputRef.value?.click()
@@ -208,10 +217,10 @@ async function inspectFile() {
sourceTimezone: wallosSourceTimezone.value
})
warningsExpanded.value = false
message.success('已生成导入预览')
message.success(t('imports.wallos.previewGenerated'))
} catch (error) {
preview.value = null
message.error(error instanceof Error ? error.message : '预览生成失败')
message.error(error instanceof Error ? error.message : t('imports.wallos.previewFailed'))
} finally {
inspecting.value = false
}
@@ -223,11 +232,17 @@ async function commitImport() {
committing.value = true
try {
const result = await api.commitWallosImport(preview.value.importToken)
message.success(`导入完成:${result.importedSubscriptions} 条订阅,${result.importedTags} 个标签,${result.importedLogos} 个 Logo`)
message.success(
t('imports.wallos.importCompleted', {
subscriptions: result.importedSubscriptions,
tags: result.importedTags,
logos: result.importedLogos
})
)
emit('imported')
close()
} catch (error) {
message.error(error instanceof Error ? error.message : '导入失败')
message.error(error instanceof Error ? error.message : t('imports.wallos.importFailed'))
} finally {
committing.value = false
}
@@ -260,19 +275,19 @@ function readFileAsBase64(file: File) {
function unitText(unit: WallosImportSubscriptionPreview['billingIntervalUnit']) {
return {
day: '天',
week: '周',
month: '月',
quarter: '季',
year: '年'
day: t('common.units.day'),
week: t('common.units.week'),
month: t('common.units.month'),
quarter: t('common.units.quarter'),
year: t('common.units.year')
}[unit]
}
function fileTypeText(type: WallosImportInspectResult['summary']['fileType']) {
return {
json: 'JSON',
db: 'SQLite',
zip: 'ZIP'
json: t('imports.wallos.fileTypes.json'),
db: t('imports.wallos.fileTypes.db'),
zip: t('imports.wallos.fileTypes.zip')
}[type]
}
</script>

View File

@@ -36,6 +36,8 @@ import type {
} from '@/types/api'
import { clearAuthSession, getStoredToken } from '@/utils/auth-storage'
import { getApiBaseUrl } from '@/utils/api-base'
import { getAppLocale } from '@/locales'
import { getMessage } from '@subtracker/shared'
const client = axios.create({
baseURL: getApiBaseUrl(import.meta.env.VITE_API_BASE_URL),
@@ -49,6 +51,7 @@ client.interceptors.request.use((request) => {
if (token) {
request.headers.Authorization = `Bearer ${token}`
}
request.headers['X-SubTracker-Locale'] = getAppLocale()
return request
})
@@ -68,7 +71,8 @@ client.interceptors.response.use(
? Object.values(fieldErrors).flatMap((messages) => messages ?? []).find(Boolean)
: null
const message = firstFieldError || errorPayload?.message
return Promise.reject(new Error(message || error.message || '请求失败'))
const fallback = getMessage(getAppLocale(), 'common.errors.requestFailed')
return Promise.reject(new Error(message || error.message || fallback))
}
)

View File

@@ -0,0 +1,79 @@
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { dateEnUS, dateZhCN, enUS, zhCN } from 'naive-ui'
import {
DEFAULT_APP_LOCALE,
LOCALE_PREFERENCE_STORAGE_KEY,
getDefaultAiDashboardSummaryPrompt,
getDefaultAiSubscriptionPrompt,
normalizeAppLocale,
sharedMessages,
type AppLocale
} from '@subtracker/shared'
const messages = sharedMessages
function readStoredLocalePreference(): AppLocale {
if (typeof window === 'undefined') return DEFAULT_APP_LOCALE
const stored = window.localStorage.getItem(LOCALE_PREFERENCE_STORAGE_KEY)
if (stored) {
return normalizeAppLocale(stored, DEFAULT_APP_LOCALE)
}
return normalizeAppLocale(window.navigator.language, DEFAULT_APP_LOCALE)
}
const localeRef = ref<AppLocale>(readStoredLocalePreference())
export const i18n = createI18n({
legacy: false,
locale: localeRef.value,
fallbackLocale: DEFAULT_APP_LOCALE,
messages
})
function syncLocale(locale: AppLocale) {
localeRef.value = locale
i18n.global.locale.value = locale
}
export function setAppLocale(locale: AppLocale) {
const normalized = normalizeAppLocale(locale, DEFAULT_APP_LOCALE)
syncLocale(normalized)
if (typeof window !== 'undefined') {
window.localStorage.setItem(LOCALE_PREFERENCE_STORAGE_KEY, normalized)
}
}
export function useAppLocale() {
const naiveLocale = computed(() => (localeRef.value === 'en-US' ? enUS : zhCN))
const naiveDateLocale = computed(() => (localeRef.value === 'en-US' ? dateEnUS : dateZhCN))
return {
locale: computed(() => localeRef.value),
naiveLocale,
naiveDateLocale,
setLocale: setAppLocale
}
}
export function getAppLocale() {
return localeRef.value
}
export function t(key: string, params?: Record<string, unknown>) {
// Ensure callers that use this shared wrapper inside template/computed/render
// establish a reactive dependency on the current locale.
localeRef.value
return params ? i18n.global.t(key, params) : i18n.global.t(key)
}
export function getDefaultAiPromptByLocale() {
return getDefaultAiSubscriptionPrompt(localeRef.value)
}
export function getDefaultAiSummaryPromptByLocale() {
return getDefaultAiDashboardSummaryPrompt(localeRef.value)
}

View File

@@ -4,6 +4,7 @@ import { createPinia } from 'pinia'
import { create, NConfigProvider, NMessageProvider } from 'naive-ui'
import App from './App.vue'
import { router } from './router'
import { i18n } from './locales'
import './style.css'
const naive = create({
@@ -16,5 +17,6 @@ const queryClient = new QueryClient()
app.use(createPinia())
app.use(router)
app.use(naive)
app.use(i18n)
app.use(VueQueryPlugin, { queryClient })
app.mount('#app')

View File

@@ -1,15 +1,15 @@
<template>
<div>
<page-header
title="预算统计"
subtitle="查看总预算使用情况与标签月预算分析"
:title="t('budgets.page.title')"
:subtitle="t('budgets.page.subtitle')"
:icon="walletOutline"
icon-background="linear-gradient(135deg, #f59e0b 0%, #ea580c 100%)"
/>
<n-grid :cols="gridCols" :x-gap="12" :y-gap="12">
<n-grid-item>
<n-card title="月预算使用">
<n-card :title="t('budgets.sections.monthlyBudgetUsage')">
<template v-if="budgetStats?.budgetSummary.monthly.budget">
<div class="budget-progress-row">
<n-progress
@@ -23,19 +23,19 @@
</span>
</div>
<div class="budget-meta">
已使用
{{ t('budgets.labels.usedPrefix') }}
<span :class="usedValueClass(budgetStats.budgetSummary.monthly.status)">
{{ formatMoney(budgetStats.budgetSummary.monthly.spent, baseCurrency) }}
</span>
/ 预算 {{ formatMoney(budgetStats.budgetSummary.monthly.budget ?? 0, baseCurrency) }}
{{ t('budgets.labels.budgetPrefix') }} {{ formatMoney(budgetStats.budgetSummary.monthly.budget ?? 0, baseCurrency) }}
</div>
</template>
<n-empty v-else description="未设置月预算" />
<n-empty v-else :description="t('budgets.empty.noMonthlyBudget')" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="年预算使用">
<n-card :title="t('budgets.sections.yearlyBudgetUsage')">
<template v-if="budgetStats?.budgetSummary.yearly.budget">
<div class="budget-progress-row">
<n-progress
@@ -49,14 +49,14 @@
</span>
</div>
<div class="budget-meta">
已使用
{{ t('budgets.labels.usedPrefix') }}
<span :class="usedValueClass(budgetStats.budgetSummary.yearly.status)">
{{ formatMoney(budgetStats.budgetSummary.yearly.spent, baseCurrency) }}
</span>
/ 预算 {{ formatMoney(budgetStats.budgetSummary.yearly.budget ?? 0, baseCurrency) }}
{{ t('budgets.labels.budgetPrefix') }} {{ formatMoney(budgetStats.budgetSummary.yearly.budget ?? 0, baseCurrency) }}
</div>
</template>
<n-empty v-else description="未设置年预算" />
<n-empty v-else :description="t('budgets.empty.noYearlyBudget')" />
</n-card>
</n-grid-item>
</n-grid>
@@ -64,9 +64,9 @@
<template v-if="budgetStats?.enabledTagBudgets">
<n-space justify="space-between" align="center" style="margin-top: 12px; flex-wrap: wrap; gap: 12px">
<div class="section-hint">
标签月预算与总预算相互独立仅对已配置预算的标签生效
{{ t('budgets.hints.section') }}
</div>
<n-button type="primary" @click="tagBudgetModalVisible = true">设置标签月预算</n-button>
<n-button type="primary" @click="tagBudgetModalVisible = true">{{ t('budgets.buttons.setTagBudgets') }}</n-button>
</n-space>
<n-grid :cols="summaryCols" :x-gap="12" :y-gap="12" style="margin-top: 12px">
@@ -83,25 +83,25 @@
<template v-if="hasConfiguredTagBudgets">
<n-grid :cols="gridCols" :x-gap="12" :y-gap="12" style="margin-top: 12px">
<n-grid-item>
<n-card title="标签预算使用率">
<n-card :title="t('budgets.sections.tagBudgetUsageRate')">
<chart-view v-if="tagBudgetOption" :option="tagBudgetOption" />
<n-empty v-else description="暂无标签预算数据" />
<n-empty v-else :description="t('budgets.empty.noTagBudgetData')" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="预算摘要">
<n-card :title="t('budgets.sections.budgetSummary')">
<div class="summary-list">
<div class="summary-list__item">
<span>已配置标签预算</span>
<span>{{ t('budgets.labels.configuredTagBudgets') }}</span>
<strong>{{ budgetStats.tagBudgetSummary?.configuredCount ?? 0 }}</strong>
</div>
<div class="summary-list__item">
<span>接近预算</span>
<span>{{ t('budgets.labels.nearingBudget') }}</span>
<strong class="text-warning">{{ budgetStats.tagBudgetSummary?.warningCount ?? 0 }}</strong>
</div>
<div class="summary-list__item">
<span>超标</span>
<span>{{ t('budgets.labels.overBudget') }}</span>
<strong class="text-danger">{{ budgetStats.tagBudgetSummary?.overBudgetCount ?? 0 }}</strong>
</div>
</div>
@@ -109,7 +109,7 @@
<n-divider />
<div class="top-tags">
<div class="top-tags__title">使用率最高 Top 3</div>
<div class="top-tags__title">{{ t('budgets.sections.topUsageRate') }}</div>
<div v-if="budgetStats.tagBudgetSummary?.topTags.length" class="top-tags__list">
<div v-for="tag in budgetStats.tagBudgetSummary.topTags" :key="tag.tagId" class="top-tags__item">
<div class="top-tags__name">{{ tag.name }}</div>
@@ -119,19 +119,19 @@
</div>
</div>
</div>
<n-empty v-else description="暂无标签预算数据" />
<n-empty v-else :description="t('budgets.empty.noTagBudgetData')" />
</div>
</n-card>
</n-grid-item>
</n-grid>
<n-card title="标签预算使用表" style="margin-top: 12px">
<n-card :title="t('budgets.sections.tagBudgetUsageTable')" style="margin-top: 12px">
<n-data-table :columns="columns" :data="budgetStats.tagBudgetUsage" :pagination="false" />
</n-card>
</template>
<n-card v-else title="标签预算" style="margin-top: 12px">
<n-empty description="尚未配置标签预算" />
<n-card v-else :title="t('budgets.sections.tagBudget')" style="margin-top: 12px">
<n-empty :description="t('budgets.empty.noConfiguredTagBudgets')" />
</n-card>
</template>
@@ -151,8 +151,9 @@ import { computed, h, ref, watch } from 'vue'
import { useQueryClient } from '@tanstack/vue-query'
import { useWindowSize } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { NButton, NCard, NDataTable, NDivider, NEmpty, NGrid, NGridItem, NProgress, NSpace, NTag, useMessage, useThemeVars } from 'naive-ui'
import { NButton, NCard, NDataTable, NDivider, NEmpty, NGrid, NGridItem, NProgress, NSpace, NTag, useThemeVars } from 'naive-ui'
import { WalletOutline } from '@vicons/ionicons5'
import { t } from '@/locales'
import { api } from '@/composables/api'
import { BUDGET_STATISTICS_QUERY_KEY, useBudgetStatisticsQuery } from '@/composables/budget-statistics-query'
import { SETTINGS_QUERY_KEY, useSettingsQuery } from '@/composables/settings-query'
@@ -161,11 +162,12 @@ import ChartView from '@/components/ChartView.vue'
import PageHeader from '@/components/PageHeader.vue'
import TagBudgetSettingsModal from '@/components/TagBudgetSettingsModal.vue'
import type { BudgetStatistics, TagBudgetUsage } from '@/types/api'
import { useLocalizedMessage } from '@/utils/localized-message'
const walletOutline = WalletOutline
const { width } = useWindowSize()
const router = useRouter()
const message = useMessage()
const message = useLocalizedMessage()
const queryClient = useQueryClient()
const tagBudgetModalVisible = ref(false)
const themeVars = useThemeVars()
@@ -195,9 +197,9 @@ const hasConfiguredTagBudgets = computed(() => (budgetStats.value?.tagBudgetSumm
const summaryCards = computed(() => {
const summary = budgetStats.value?.tagBudgetSummary
return [
{ label: '已配置标签预算', value: summary?.configuredCount ?? 0, className: '' },
{ label: '接近预算', value: summary?.warningCount ?? 0, className: 'text-warning' },
{ label: '超标', value: summary?.overBudgetCount ?? 0, className: 'text-danger' }
{ label: t('budgets.labels.configuredTagBudgets'), value: summary?.configuredCount ?? 0, className: '' },
{ label: t('budgets.labels.nearingBudget'), value: summary?.warningCount ?? 0, className: 'text-warning' },
{ label: t('budgets.labels.overBudget'), value: summary?.overBudgetCount ?? 0, className: 'text-danger' }
]
})
@@ -217,9 +219,9 @@ const tagBudgetOption = computed(() => {
if (!row) return ''
return [
`${row.name}`,
`使用率${formatPercentage(row.ratio)}%`,
`已使用${formatMoney(row.spent, baseCurrency.value)}`,
`预算${formatMoney(row.budget, baseCurrency.value)}`
`${t('budgets.labels.usageRate')}${formatPercentage(row.ratio)}%`,
`${t('budgets.labels.spent')}${formatMoney(row.spent, baseCurrency.value)}`,
`${t('budgets.labels.budget')}${formatMoney(row.budget, baseCurrency.value)}`
].join('<br/>')
}
},
@@ -251,27 +253,27 @@ const tagBudgetOption = computed(() => {
})
const columns = [
{ title: '标签', key: 'name' },
{ title: t('budgets.labels.tag'), key: 'name' },
{
title: '已使用',
title: t('budgets.labels.spent'),
key: 'spent',
render: (row: TagBudgetUsage) => formatMoney(row.spent, baseCurrency.value)
},
{
title: '预算',
title: t('budgets.labels.budget'),
key: 'budget',
render: (row: TagBudgetUsage) => formatMoney(row.budget, baseCurrency.value)
},
{
title: '剩余 / 超出',
title: t('budgets.labels.remainingOrOver'),
key: 'remaining',
render: (row: TagBudgetUsage) =>
row.overBudget > 0
? `超出 ${formatMoney(row.overBudget, baseCurrency.value)}`
: `剩余 ${formatMoney(row.remaining, baseCurrency.value)}`
? `${t('budgets.labels.overPrefix')} ${formatMoney(row.overBudget, baseCurrency.value)}`
: `${t('budgets.labels.remainingPrefix')} ${formatMoney(row.remaining, baseCurrency.value)}`
},
{
title: '使用率',
title: t('budgets.labels.usageRate'),
key: 'ratio',
render: (row: TagBudgetUsage) =>
h(
@@ -283,13 +285,20 @@ const columns = [
)
},
{
title: '状态',
title: t('budgets.labels.status'),
key: 'status',
render: (row: TagBudgetUsage) =>
h(
NTag,
{ type: row.status === 'over' ? 'error' : row.status === 'warning' ? 'warning' : 'success' },
{ default: () => (row.status === 'over' ? '超标' : row.status === 'warning' ? '接近预算' : '正常') }
{
default: () =>
row.status === 'over'
? t('budgets.status.over')
: row.status === 'warning'
? t('budgets.status.warning')
: t('budgets.status.normal')
}
)
}
]
@@ -297,7 +306,7 @@ const columns = [
async function saveTagBudgets(tagBudgets: Record<string, number>) {
await api.updateSettings({ tagBudgets })
tagBudgetModalVisible.value = false
message.success('标签月预算已保存')
message.success(t('budgets.messages.saved'))
await Promise.all([
queryClient.invalidateQueries({ queryKey: BUDGET_STATISTICS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ['statistics-overview'] }),

View File

@@ -1,30 +1,30 @@
<template>
<div>
<page-header
title="订阅日历"
subtitle="查看订阅日期分布,支持月视图和列表视图"
:title="t('calendar.page.title')"
:subtitle="t('calendar.page.subtitle')"
:icon="calendarOutline"
icon-background="linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)"
/>
<n-grid :cols="summaryCols" :x-gap="12" :y-gap="12" style="margin-bottom: 12px">
<n-grid-item>
<stat-card label="当前月份" :value="panelMonthLabel" suffix="当前正在查看的月份" :icon="calendarClearOutline" />
<stat-card :label="t('calendar.cards.currentMonth')" :value="panelMonthLabel" :suffix="t('calendar.cards.currentMonthSuffix')" :icon="calendarClearOutline" />
</n-grid-item>
<n-grid-item>
<stat-card label="本月应续订数量" :value="monthEventCount" suffix="当前月份内的订阅数" :icon="notificationsOutline" />
<stat-card :label="t('calendar.cards.monthlyRenewalCount')" :value="monthEventCount" :suffix="t('calendar.cards.monthlyRenewalCountSuffix')" :icon="notificationsOutline" />
</n-grid-item>
<n-grid-item>
<stat-card
label="本月预计需支出"
:label="t('calendar.cards.monthlySpend')"
:value="`${baseCurrency} ${monthConvertedAmount.toFixed(2)}`"
suffix="已按汇率折算"
:suffix="t('calendar.cards.convertedSuffix')"
:icon="walletOutline"
/>
</n-grid-item>
<n-grid-item>
<stat-card
label="选中日期订阅数"
:label="t('calendar.cards.selectedDateRenewals')"
:value="selectedDateEvents.length"
:suffix="`${selectedDateLabel} · ${baseCurrency} ${selectedDateConvertedAmount.toFixed(2)}`"
:icon="todayOutline"
@@ -34,14 +34,14 @@
<n-card class="calendar-panel-card">
<n-tabs v-model:value="tab">
<n-tab-pane name="month" tab="月视图">
<n-tab-pane name="month" :tab="t('calendar.tabs.month')">
<n-grid :cols="calendarCols" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="calendar-wrapper">
<n-calendar v-model:value="selectedDateTs" @panel-change="handlePanelChange">
<template #default="{ year, month, date }">
<div v-if="getDaySummary(year, month, date)" class="calendar-cell-summary">
<div class="calendar-cell-summary__count">{{ getDaySummary(year, month, date)?.count }} </div>
<div class="calendar-cell-summary__count">{{ getDaySummary(year, month, date)?.count }} {{ t('calendar.detail.itemsSuffix') }}</div>
<div class="calendar-cell-summary__amount">
{{ baseCurrency }} {{ getDaySummary(year, month, date)?.convertedAmount.toFixed(0) }}
</div>
@@ -52,14 +52,14 @@
</n-grid-item>
<n-grid-item>
<n-card :title="`当天续订(${selectedDateLabel}`" size="small" class="day-detail-card">
<n-card :title="t('calendar.detail.dayRenewalsTitle', { date: selectedDateLabel })" size="small" class="day-detail-card">
<template #header-extra>
<span class="day-summary-inline">
{{ selectedDateEvents.length }} · {{ baseCurrency }} {{ selectedDateConvertedAmount.toFixed(2) }}
{{ t('calendar.detail.dayRenewalsSummary', { count: selectedDateEvents.length, currency: baseCurrency, amount: selectedDateConvertedAmount.toFixed(2) }) }}
</span>
</template>
<n-empty v-if="selectedDateEvents.length === 0" description="当天无续订" />
<n-empty v-if="selectedDateEvents.length === 0" :description="t('calendar.detail.noRenewalOnDay')" />
<n-space v-else vertical :size="10">
<div v-for="item in selectedDateEvents" :key="item.id" class="day-event-item">
@@ -71,7 +71,7 @@
</div>
<div class="day-event-item__meta">
{{ item.currency }} {{ item.amount.toFixed(2) }} / 折算 {{ baseCurrency }}
{{ item.currency }} {{ item.amount.toFixed(2) }} / {{ t('calendar.detail.converted') }} {{ baseCurrency }}
{{ item.convertedAmount.toFixed(2) }}
</div>
</div>
@@ -81,7 +81,7 @@
</n-grid>
</n-tab-pane>
<n-tab-pane name="list" tab="列表视图">
<n-tab-pane name="list" :tab="t('calendar.tabs.list')">
<n-data-table :columns="columns" :data="events" :pagination="{ pageSize: 12 }" />
</n-tab-pane>
</n-tabs>
@@ -100,6 +100,7 @@ import {
TodayOutline,
WalletOutline
} from '@vicons/ionicons5'
import { t } from '@/locales'
import { useCalendarEventsQuery } from '@/composables/calendar-events-query'
import { useSettingsQuery } from '@/composables/settings-query'
import PageHeader from '@/components/PageHeader.vue'
@@ -204,24 +205,24 @@ const monthEventCount = computed(() => events.value.length)
const monthConvertedAmount = computed(() => events.value.reduce((sum, item) => sum + item.convertedAmount, 0))
const columns = [
{ title: '订阅', key: 'title' },
{ title: t('calendar.table.subscription'), key: 'title' },
{
title: '日期',
title: t('calendar.table.date'),
key: 'date',
render: (row: CalendarEvent) => formatDateInTimezone(row.date, settings.value?.timezone)
},
{
title: '原始金额',
title: t('calendar.table.amount'),
key: 'amount',
render: (row: CalendarEvent) => `${row.currency} ${row.amount.toFixed(2)}`
},
{
title: '折算金额',
title: t('calendar.table.convertedAmount'),
key: 'convertedAmount',
render: (row: CalendarEvent) => `${baseCurrency.value} ${row.convertedAmount.toFixed(2)}`
},
{
title: '状态',
title: t('calendar.table.status'),
key: 'status',
render: (row: CalendarEvent) => getSubscriptionStatusText(row.status)
}

View File

@@ -1,6 +1,6 @@
<template>
<div>
<page-header title="仪表盘" subtitle="总览订阅规模、预算使用、待续订与支出分布" :icon="gridOutline" />
<page-header :title="t('dashboard.page.title')" :subtitle="t('dashboard.page.subtitle')" :icon="gridOutline" />
<n-grid :cols="24" :x-gap="12" :y-gap="12">
<n-grid-item v-for="item in summaryCards" :key="item.label" :span="summarySpan">
@@ -8,7 +8,7 @@
</n-grid-item>
<n-grid-item :span="halfSpan">
<n-card title="月预算使用">
<n-card :title="t('dashboard.sections.monthlyBudgetUsage')">
<template v-if="overview?.budgetSummary.monthly.budget">
<div class="budget-progress-row">
<n-progress
@@ -22,19 +22,19 @@
</span>
</div>
<div class="budget-meta">
已使用
{{ t('dashboard.labels.usedPrefix') }}
<span :class="usedValueClass(overview.budgetSummary.monthly.status)">
{{ formatMoney(overview.budgetSummary.monthly.spent, baseCurrency) }}
</span>
/ 预算 {{ formatMoney(overview.budgetSummary.monthly.budget ?? 0, baseCurrency) }}
{{ t('dashboard.labels.budgetPrefix') }} {{ formatMoney(overview.budgetSummary.monthly.budget ?? 0, baseCurrency) }}
</div>
</template>
<n-empty v-else description="未设置月预算" />
<n-empty v-else :description="t('dashboard.empty.noMonthlyBudget')" />
</n-card>
</n-grid-item>
<n-grid-item :span="halfSpan">
<n-card title="年预算使用">
<n-card :title="t('dashboard.sections.yearlyBudgetUsage')">
<template v-if="overview?.budgetSummary.yearly.budget">
<div class="budget-progress-row">
<n-progress
@@ -48,37 +48,37 @@
</span>
</div>
<div class="budget-meta">
已使用
{{ t('dashboard.labels.usedPrefix') }}
<span :class="usedValueClass(overview.budgetSummary.yearly.status)">
{{ formatMoney(overview.budgetSummary.yearly.spent, baseCurrency) }}
</span>
/ 预算 {{ formatMoney(overview.budgetSummary.yearly.budget ?? 0, baseCurrency) }}
{{ t('dashboard.labels.budgetPrefix') }} {{ formatMoney(overview.budgetSummary.yearly.budget ?? 0, baseCurrency) }}
</div>
</template>
<n-empty v-else description="未设置年预算" />
<n-empty v-else :description="t('dashboard.empty.noYearlyBudget')" />
</n-card>
</n-grid-item>
<n-grid-item v-if="showTagBudgetSummary" :span="24">
<n-card title="标签预算概况">
<n-card :title="t('dashboard.sections.tagBudgetOverview')">
<template v-if="overview?.tagBudgetSummary?.configuredCount">
<div class="tag-budget-summary">
<div class="tag-budget-summary__stats">
<div class="tag-budget-summary__stat">
<span class="tag-budget-summary__label">已配置标签预算</span>
<span class="tag-budget-summary__label">{{ t('dashboard.labels.configuredTagBudgets') }}</span>
<strong>{{ overview.tagBudgetSummary.configuredCount }}</strong>
</div>
<div class="tag-budget-summary__stat">
<span class="tag-budget-summary__label">接近预算</span>
<span class="tag-budget-summary__label">{{ t('dashboard.labels.nearingBudget') }}</span>
<strong class="text-warning">{{ overview.tagBudgetSummary.warningCount }}</strong>
</div>
<div class="tag-budget-summary__stat">
<span class="tag-budget-summary__label">超标</span>
<span class="tag-budget-summary__label">{{ t('dashboard.labels.overBudget') }}</span>
<strong class="text-danger">{{ overview.tagBudgetSummary.overBudgetCount }}</strong>
</div>
</div>
<div class="tag-budget-summary__top">
<div class="tag-budget-summary__title">使用率最高</div>
<div class="tag-budget-summary__title">{{ t('dashboard.labels.topUsageRate') }}</div>
<div class="tag-budget-summary__items">
<div v-for="tag in overview.tagBudgetSummary.topTags" :key="tag.tagId" class="tag-budget-summary__item">
<span class="tag-budget-summary__name">{{ tag.name }}</span>
@@ -88,27 +88,27 @@
</div>
</div>
</template>
<n-empty v-else description="尚未配置标签预算" />
<n-empty v-else :description="t('dashboard.empty.noTagBudgetConfigured')" />
</n-card>
</n-grid-item>
</n-grid>
<n-grid :cols="chartCols" :x-gap="12" :y-gap="12" style="margin-top: 12px">
<n-grid-item>
<n-card title="标签月度支出">
<n-card :title="t('dashboard.sections.tagMonthlySpend')">
<chart-view v-if="tagSpendOption" :option="tagSpendOption" />
<n-empty v-else description="暂无数据" />
<n-empty v-else :description="t('dashboard.empty.noData')" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="月支付趋势未来12个月">
<n-card :title="t('dashboard.sections.monthlyTrend')">
<chart-view v-if="trendOption" :option="trendOption" />
<n-empty v-else description="暂无数据" />
<n-empty v-else :description="t('dashboard.empty.noData')" />
</n-card>
</n-grid-item>
</n-grid>
<n-card title="即将续订30天" style="margin-top: 12px">
<n-card :title="t('dashboard.sections.upcoming30')" style="margin-top: 12px">
<n-data-table :columns="columns" :data="overview?.upcomingRenewals ?? []" :pagination="false" />
</n-card>
</div>
@@ -119,6 +119,7 @@ import { computed, h } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { NCard, NDataTable, NEmpty, NGrid, NGridItem, NProgress, NTag, useThemeVars } from 'naive-ui'
import { CashOutline, GridOutline, LayersOutline, NotificationsOutline, WalletOutline } from '@vicons/ionicons5'
import { t } from '@/locales'
import { useSettingsQuery } from '@/composables/settings-query'
import { useStatisticsOverviewQuery } from '@/composables/statistics-overview-query'
import ChartView from '@/components/ChartView.vue'
@@ -147,15 +148,15 @@ const halfSpan = computed(() => (width.value < 1100 ? 24 : 12))
const chartCols = computed(() => (width.value < 1100 ? 1 : 2))
const summaryCards = computed(() => [
{ label: '活跃订阅', value: overview.value?.activeSubscriptions ?? 0, icon: LayersOutline },
{ label: '7 天内续订', value: overview.value?.upcoming7Days ?? 0, icon: NotificationsOutline },
{ label: t('dashboard.cards.activeSubscriptions'), value: overview.value?.activeSubscriptions ?? 0, icon: LayersOutline },
{ label: t('dashboard.cards.renewalsIn7Days'), value: overview.value?.upcoming7Days ?? 0, icon: NotificationsOutline },
{
label: '本月预计支出',
label: t('dashboard.cards.estimatedMonthlySpend'),
value: overview.value ? formatMoney(overview.value.monthlyEstimatedBase, baseCurrency.value) : '--',
icon: WalletOutline
},
{
label: '年度预计支出',
label: t('dashboard.cards.estimatedYearlySpend'),
value: overview.value ? formatMoney(overview.value.yearlyEstimatedBase, baseCurrency.value) : '--',
icon: CashOutline
}
@@ -212,30 +213,30 @@ const trendOption = computed(() => {
}
})
const columns = [
{ title: '订阅', key: 'name' },
const columns = computed(() => [
{ title: t('dashboard.table.subscription'), key: 'name' },
{
title: '下次续订',
title: t('dashboard.table.nextRenewal'),
key: 'nextRenewalDate',
render: (row: StatisticsOverview['upcomingRenewals'][number]) => formatDateInTimezone(row.nextRenewalDate, settings.value?.timezone)
},
{
title: '原始金额',
title: t('dashboard.table.originalAmount'),
key: 'amount',
render: (row: StatisticsOverview['upcomingRenewals'][number]) => formatMoney(row.amount, row.currency)
},
{
title: '折算金额',
title: t('dashboard.table.convertedAmount'),
key: 'convertedAmount',
render: (row: StatisticsOverview['upcomingRenewals'][number]) => formatMoney(row.convertedAmount, baseCurrency.value)
},
{
title: '状态',
title: t('dashboard.table.status'),
key: 'status',
render: (row: StatisticsOverview['upcomingRenewals'][number]) =>
h(NTag, { type: getSubscriptionStatusTagType(row.status) }, { default: () => getSubscriptionStatusText(row.status) })
}
]
])
function formatMoney(amount: number, currency: string) {
return `${currency} ${amount.toFixed(2)}`

View File

@@ -6,63 +6,68 @@
<img :src="brandLogoUrl" alt="SubTracker logo" class="login-header__logo" />
</div>
<div>
<h1 class="login-title">登录 SubTracker</h1>
<p class="login-subtitle">请输入您的用户名和密码</p>
<h1 class="login-title">{{ t('login.title') }}</h1>
<p class="login-subtitle">{{ t('login.subtitle') }}</p>
</div>
</div>
<n-form :model="form" label-placement="top" @submit.prevent="submit">
<n-form-item label="用户名">
<n-form-item :label="t('common.labels.username')">
<n-input
v-model:value="form.username"
placeholder="请输入用户名"
:placeholder="t('login.usernamePlaceholder')"
@keydown.enter.prevent="submit"
/>
</n-form-item>
<n-form-item label="密码">
<n-form-item :label="t('common.labels.password')">
<n-input
v-model:value="form.password"
type="password"
show-password-on="click"
placeholder="请输入密码"
:placeholder="t('login.passwordPlaceholder')"
@keydown.enter.prevent="submit"
/>
</n-form-item>
<div class="login-options">
<n-checkbox v-model:checked="form.rememberMe">
记住我
<span class="login-options__hint">{{ rememberSessionDays }} </span>
{{ t('login.rememberMe') }}
<span class="login-options__hint">{{ t('login.rememberMeDays', { days: rememberSessionDays }) }}</span>
</n-checkbox>
<n-button v-if="forgotPasswordEnabled" text type="primary" @click="forgotPasswordVisible = !forgotPasswordVisible">
{{ forgotPasswordVisible ? '收起找回密码' : '忘记密码' }}
{{ forgotPasswordVisible ? t('login.collapseForgotPassword') : t('login.forgotPassword') }}
</n-button>
</div>
<n-button type="primary" block attr-type="submit" :loading="submitting" @click="submit">登录</n-button>
<n-button type="primary" block attr-type="submit" :loading="submitting" @click="submit">{{ t('app.auth.login') }}</n-button>
</n-form>
<n-collapse-transition :show="forgotPasswordVisible">
<div class="forgot-password-panel">
<n-form label-placement="top">
<n-form-item label="用户名">
<n-input v-model:value="forgotPasswordForm.username" placeholder="请输入用户名" />
<n-form-item :label="t('common.labels.username')">
<n-input v-model:value="forgotPasswordForm.username" :placeholder="t('login.usernamePlaceholder')" />
</n-form-item>
<n-button block secondary :loading="sendingCode" @click="sendForgotPasswordCode">发送验证码</n-button>
<n-form-item label="验证码" style="margin-top: 12px">
<n-input v-model:value="forgotPasswordForm.code" placeholder="请输入 6 位验证码" />
<n-button block secondary :loading="sendingCode" @click="sendForgotPasswordCode">{{ t('login.sendCode') }}</n-button>
<n-form-item :label="t('common.labels.code')" style="margin-top: 12px">
<n-input v-model:value="forgotPasswordForm.code" :placeholder="t('login.codePlaceholder')" />
</n-form-item>
<n-form-item label="新密码">
<n-input v-model:value="forgotPasswordForm.newPassword" type="password" show-password-on="click" placeholder="请输入新密码" />
<n-form-item :label="t('app.newPassword')">
<n-input
v-model:value="forgotPasswordForm.newPassword"
type="password"
show-password-on="click"
:placeholder="t('login.newPasswordPlaceholder')"
/>
</n-form-item>
<n-form-item label="确认新密码">
<n-form-item :label="t('app.confirmNewPassword')">
<n-input
v-model:value="forgotPasswordForm.confirmPassword"
type="password"
show-password-on="click"
placeholder="请再次输入新密码"
:placeholder="t('login.confirmNewPasswordPlaceholder')"
/>
</n-form-item>
<n-button block type="primary" ghost :loading="resettingPassword" @click="resetForgotPassword">
验证并重置密码
{{ t('login.verifyAndResetPassword') }}
</n-button>
</n-form>
</div>
@@ -74,15 +79,17 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NCard, NCheckbox, NCollapseTransition, NForm, NFormItem, NIcon, NInput, useMessage } from 'naive-ui'
import { NButton, NCard, NCheckbox, NCollapseTransition, NForm, NFormItem, NIcon, NInput } from 'naive-ui'
import brandLogoUrl from '@/assets/brand-logo.png'
import { api } from '@/composables/api'
import { t } from '@/locales'
import { useAuthStore } from '@/stores/auth'
import { validateLoginForm } from '@/utils/login-validation'
import { useLocalizedMessage } from '@/utils/localized-message'
const route = useRoute()
const router = useRouter()
const message = useMessage()
const message = useLocalizedMessage()
const authStore = useAuthStore()
const rememberSessionDays = ref(7)
const forgotPasswordEnabled = ref(false)
@@ -132,11 +139,11 @@ async function submit() {
form.rememberMe,
form.rememberMe ? rememberSessionDays.value : undefined
)
message.success('登录成功')
message.success(t('auth.success.login'))
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
await router.replace(redirect)
} catch (error) {
message.error(error instanceof Error ? error.message : '登录失败')
message.error(error instanceof Error ? error.message : t('auth.error.login'))
} finally {
submitting.value = false
}
@@ -145,7 +152,7 @@ async function submit() {
async function sendForgotPasswordCode() {
if (sendingCode.value) return
if (!forgotPasswordForm.username.trim()) {
message.error('请输入用户名')
message.error(t('auth.validation.usernameRequired'))
return
}
@@ -154,9 +161,9 @@ async function sendForgotPasswordCode() {
await api.requestForgotPasswordCode({
username: forgotPasswordForm.username.trim()
})
message.success('如果用户名有效且通知已启用,验证码已发送')
message.success(t('auth.success.forgotPasswordCodeSent'))
} catch (error) {
message.error(error instanceof Error ? error.message : '验证码发送失败')
message.error(error instanceof Error ? error.message : t('auth.error.forgotPasswordCodeSend'))
} finally {
sendingCode.value = false
}
@@ -165,25 +172,25 @@ async function sendForgotPasswordCode() {
async function resetForgotPassword() {
if (resettingPassword.value) return
if (!forgotPasswordForm.username.trim()) {
message.error('请输入用户名')
message.error(t('auth.validation.usernameRequired'))
return
}
if (!/^\d{6}$/.test(forgotPasswordForm.code.trim())) {
message.error('请输入 6 位验证码')
message.error(t('auth.validation.codeRequired'))
return
}
const newPassword = forgotPasswordForm.newPassword.trim()
const confirmPassword = forgotPasswordForm.confirmPassword.trim()
if (!newPassword) {
message.error('请输入新密码')
message.error(t('auth.validation.newPasswordRequired'))
return
}
if (newPassword.length < 4) {
message.error('新密码至少 4 位')
message.error(t('auth.validation.newPasswordMin'))
return
}
if (newPassword !== confirmPassword) {
message.error('两次输入的新密码不一致')
message.error(t('auth.validation.passwordMismatch'))
return
}
@@ -195,11 +202,11 @@ async function resetForgotPassword() {
newPassword
})
authStore.setSession(result.token, result.user.username, false, result.user.mustChangePassword)
message.success('密码已重置并自动登录')
message.success(t('auth.success.passwordResetAndLoggedIn'))
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
await router.replace(redirect)
} catch (error) {
message.error(error instanceof Error ? error.message : '密码重置失败')
message.error(error instanceof Error ? error.message : t('auth.error.passwordReset'))
} finally {
resettingPassword.value = false
}

View File

@@ -1,19 +1,19 @@
<template>
<div>
<page-header
title="费用统计"
subtitle="从趋势、结构和风险三个维度分析订阅支出"
:title="t('statistics.page.title')"
:subtitle="t('statistics.page.subtitle')"
:icon="barChartOutline"
icon-background="linear-gradient(135deg, #0ea5e9 0%, #2563eb 100%)"
/>
<n-grid v-if="showAiSummaryCard" :cols="1" :x-gap="12" :y-gap="12">
<n-grid-item>
<n-card title="AI 总结">
<n-card :title="t('statistics.ai.title')">
<template #header-extra>
<div class="ai-summary-header-actions">
<span v-if="dashboardAiSummary?.generatedAt" class="card-muted ai-summary-generated-at">
最近生成{{ summaryGeneratedAtText(dashboardAiSummary.generatedAt) }}
{{ t('statistics.ai.generatedAtPrefix') }}{{ summaryGeneratedAtText(dashboardAiSummary.generatedAt) }}
</span>
<n-button
quaternary
@@ -21,20 +21,20 @@
class="ai-summary-toggle"
@click="summaryExpanded = !summaryExpanded"
>
{{ summaryExpanded ? '收起详情' : '查看详情' }}
{{ summaryExpanded ? t('statistics.ai.collapseDetails') : t('statistics.ai.viewDetails') }}
</n-button>
<n-button size="small" :loading="generatingSummary" :disabled="generatingSummary" @click="regenerateSummary">
重新生成总结
{{ t('statistics.ai.regenerate') }}
</n-button>
</div>
</template>
<n-space vertical :size="12" style="width: 100%">
<div v-if="summaryExpanded" class="card-muted">基于当前统计自动生成不会修改订阅数据</div>
<div v-if="summaryExpanded" class="card-muted">{{ t('statistics.ai.expandedHint') }}</div>
<div v-if="summaryLoadingVisible" class="ai-summary-loading">
<n-spin size="small" />
<div class="card-muted">正在基于当前统计生成 AI 总结请稍候</div>
<div class="card-muted">{{ t('statistics.ai.generatingHint') }}</div>
</div>
<template v-else-if="dashboardAiSummary">
@@ -43,7 +43,7 @@
type="warning"
:show-icon="false"
>
请先前往系统设置启用 AI 能力与 AI 总结之后统计页面会自动生成总结
{{ t('statistics.ai.unconfiguredHint') }}
</n-alert>
<n-alert
@@ -51,17 +51,17 @@
type="error"
:show-icon="false"
>
{{ dashboardAiSummary.errorMessage || 'AI 总结生成失败,请稍后重试。' }}
{{ dashboardAiSummary.errorMessage || t('statistics.ai.failedFallback') }}
</n-alert>
<n-empty
v-else-if="!dashboardAiSummary.content"
description="暂无 AI 总结"
:description="t('statistics.ai.noSummary')"
/>
<template v-else>
<div v-if="!summaryExpanded" class="ai-summary-preview">
<div class="ai-summary-preview__label">摘要</div>
<div class="ai-summary-preview__label">{{ t('statistics.ai.previewLabel') }}</div>
<div class="ai-summary-preview__text">{{ dashboardSummaryPreviewText }}</div>
</div>
<n-collapse-transition :show="summaryExpanded">
@@ -76,7 +76,7 @@
<n-empty
v-else-if="!dashboardAiSummaryQuery.isLoading.value"
description="暂无 AI 总结"
:description="t('statistics.ai.noSummary')"
/>
</n-space>
</n-card>
@@ -85,54 +85,54 @@
<n-grid :cols="gridCols" :x-gap="12" :y-gap="12" style="margin-top: 12px">
<n-grid-item>
<n-card title="月支付趋势未来12个月">
<n-card :title="t('statistics.sections.monthlyTrend')">
<chart-view v-if="trendOption" :option="trendOption" />
<n-empty v-else description="暂无数据" />
<n-empty v-else :description="t('statistics.empty.noData')" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="标签月度支出占比">
<n-card :title="t('statistics.sections.tagSpend')">
<chart-view v-if="tagSpendOption" :option="tagSpendOption" />
<n-empty v-else description="暂无数据" />
<n-empty v-else :description="t('statistics.empty.noData')" />
</n-card>
</n-grid-item>
</n-grid>
<n-grid :cols="gridCols" :x-gap="12" :y-gap="12" style="margin-top: 12px">
<n-grid-item>
<n-card title="状态分布">
<n-card :title="t('statistics.sections.statusDistribution')">
<chart-view v-if="statusOption" :option="statusOption" />
<n-empty v-else description="暂无数据" />
<n-empty v-else :description="t('statistics.empty.noData')" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="自动续订占比">
<n-card :title="t('statistics.sections.autoRenewShare')">
<chart-view v-if="renewalModeOption" :option="renewalModeOption" />
<n-empty v-else description="暂无数据" />
<n-empty v-else :description="t('statistics.empty.noData')" />
</n-card>
</n-grid-item>
</n-grid>
<n-grid :cols="gridCols" :x-gap="12" :y-gap="12" style="margin-top: 12px">
<n-grid-item>
<n-card title="订阅币种分布">
<n-card :title="t('statistics.sections.currencyDistribution')">
<chart-view v-if="currencyOption" :option="currencyOption" />
<n-empty v-else description="暂无数据" />
<n-empty v-else :description="t('statistics.empty.noData')" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="未来30天续订分布">
<n-card :title="t('statistics.sections.upcoming30')">
<chart-view v-if="upcoming30Option" :option="upcoming30Option" />
<n-empty v-else description="未来30天暂无续订" />
<n-empty v-else :description="t('statistics.empty.noUpcoming30')" />
</n-card>
</n-grid-item>
</n-grid>
<n-grid :cols="1" :x-gap="12" :y-gap="12" style="margin-top: 12px">
<n-grid-item>
<n-card title="月订阅支出 TOP10">
<n-card :title="t('statistics.sections.top10')">
<chart-view v-if="topSubscriptionsOption" :option="topSubscriptionsOption" />
<n-empty v-else description="暂无数据" />
<n-empty v-else :description="t('statistics.empty.noData')" />
</n-card>
</n-grid-item>
</n-grid>
@@ -144,8 +144,9 @@
import { computed, ref, watch } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { useQueryClient } from '@tanstack/vue-query'
import { NAlert, NButton, NCard, NCollapseTransition, NEmpty, NGrid, NGridItem, NSpace, NSpin, useMessage, useThemeVars } from 'naive-ui'
import { NAlert, NButton, NCard, NCollapseTransition, NEmpty, NGrid, NGridItem, NSpace, NSpin, useThemeVars } from 'naive-ui'
import { BarChartOutline } from '@vicons/ionicons5'
import { t } from '@/locales'
import { api } from '@/composables/api'
import { DASHBOARD_AI_SUMMARY_QUERY_KEY, useDashboardAiSummaryQuery } from '@/composables/dashboard-ai-summary-query'
import { useSettingsQuery } from '@/composables/settings-query'
@@ -158,12 +159,13 @@ import { renderMarkdownToHtml } from '@/utils/simple-markdown'
import { formatDateInTimezone } from '@/utils/timezone'
import { formatDateTimeInTimezone } from '@/utils/timezone'
import { buildTopSubscriptionsOption } from '@/utils/statistics-top-subscriptions'
import { useLocalizedMessage } from '@/utils/localized-message'
const { width } = useWindowSize()
const barChartOutline = BarChartOutline
const themeVars = useThemeVars()
const queryClient = useQueryClient()
const message = useMessage()
const message = useLocalizedMessage()
const { data: overview } = useStatisticsOverviewQuery()
@@ -193,10 +195,10 @@ const dashboardSummaryPreviewText = computed(() =>
)
const statusLabelMap: Record<SubscriptionStatus, string> = {
active: '正常',
paused: '暂停',
cancelled: '停用',
expired: '过期'
active: t('statistics.status.active'),
paused: t('statistics.status.paused'),
cancelled: t('statistics.status.cancelled'),
expired: t('statistics.status.expired')
}
const statusColorMap: Record<SubscriptionStatus, string> = {
@@ -216,7 +218,7 @@ const trendOption = computed(() => {
borderColor: themeVars.value.borderColor,
textStyle: { color: themeVars.value.textColor2 }
},
legend: { data: ['预测金额'], textStyle: { color: themeVars.value.textColor2 } },
legend: { data: [t('statistics.series.trend')], textStyle: { color: themeVars.value.textColor2 } },
xAxis: {
type: 'category',
data: overview.value.monthlyTrend.map((item) => item.month),
@@ -230,7 +232,7 @@ const trendOption = computed(() => {
},
series: [
{
name: '预测金额',
name: t('statistics.series.trend'),
type: 'line',
smooth: true,
areaStyle: {},
@@ -306,7 +308,7 @@ const renewalModeOption = computed(() => {
tooltip: {
trigger: 'item',
formatter: (params: { data: { count: number; amount: number; name: string } }) =>
`${params.data.name}<br/>订阅数:${params.data.count}<br/>月度金额${formatMoney(params.data.amount, baseCurrency.value)}`
`${params.data.name}<br/>${t('statistics.labels.renewalCountTooltip')}${params.data.count}<br/>${t('statistics.labels.amountTooltip')}${formatMoney(params.data.amount, baseCurrency.value)}`
},
legend: { bottom: 0, textStyle: { color: themeVars.value.textColor2 } },
series: [
@@ -314,7 +316,7 @@ const renewalModeOption = computed(() => {
type: 'pie',
radius: ['42%', '68%'],
data: data.map((item) => ({
name: item.autoRenew ? '自动续订' : '手动续订',
name: item.autoRenew ? t('statistics.labels.autoRenew') : t('statistics.labels.manualRenew'),
value: item.count,
count: item.count,
amount: item.amount,
@@ -373,7 +375,7 @@ const upcoming30Option = computed(() => {
borderColor: themeVars.value.borderColor,
textStyle: { color: themeVars.value.textColor2 }
},
legend: { data: ['续订数', '金额'], textStyle: { color: themeVars.value.textColor2 } },
legend: { data: [t('statistics.series.renewalCount'), t('statistics.series.amount')], textStyle: { color: themeVars.value.textColor2 } },
xAxis: {
type: 'category',
data: source.map((item) => formatDateInTimezone(item.date, settings.value?.timezone).slice(5)),
@@ -383,28 +385,28 @@ const upcoming30Option = computed(() => {
yAxis: [
{
type: 'value',
name: '续订数',
name: t('statistics.labels.renewalsCountAxis'),
nameTextStyle: { color: themeVars.value.textColor3 },
axisLabel: { color: themeVars.value.textColor3 },
splitLine: { lineStyle: { color: themeVars.value.dividerColor } }
},
{
type: 'value',
name: `金额(${baseCurrency.value})`,
name: `${t('statistics.series.amount')}(${baseCurrency.value})`,
nameTextStyle: { color: themeVars.value.textColor3 },
axisLabel: { color: themeVars.value.textColor3 }
}
],
series: [
{
name: '续订数',
name: t('statistics.series.renewalCount'),
type: 'bar',
data: source.map((item) => item.count),
itemStyle: { color: '#8b5cf6' },
barMaxWidth: 18
},
{
name: '金额',
name: t('statistics.series.amount'),
type: 'line',
smooth: true,
yAxisIndex: 1,
@@ -436,9 +438,9 @@ async function regenerateSummary() {
try {
await api.generateDashboardAiSummary()
await queryClient.invalidateQueries({ queryKey: DASHBOARD_AI_SUMMARY_QUERY_KEY })
message.success('AI 总结已更新')
message.success(t('statistics.ai.updated'))
} catch (error) {
message.error(error instanceof Error ? error.message : 'AI 总结生成失败')
message.error(error instanceof Error ? error.message : t('statistics.ai.failed'))
} finally {
generatingSummary.value = false
}

View File

@@ -1,22 +1,22 @@
<template>
<div>
<n-space justify="space-between" align="start" class="page-top">
<page-header title="订阅管理" subtitle="管理不同周期、不同币种的订阅" :icon="layersOutline" />
<page-header :title="t('subscriptions.page.title')" :subtitle="t('subscriptions.page.subtitle')" :icon="layersOutline" />
<n-space>
<n-button :type="batchMode ? 'primary' : 'default'" ghost @click="toggleBatchMode">
{{ batchMode ? '退出批量管理' : '批量管理' }}
{{ batchMode ? t('subscriptions.page.exitBatchMode') : t('subscriptions.page.batchMode') }}
</n-button>
<n-button @click="showTagManageModal = true">
<template #icon>
<n-icon><pricetags-outline /></n-icon>
</template>
标签管理
{{ t('subscriptions.actions.tagManagement') }}
</n-button>
<n-button type="primary" @click="openCreate">
<template #icon>
<n-icon><add-circle-outline /></n-icon>
</template>
新建订阅
{{ t('subscriptions.actions.create') }}
</n-button>
</n-space>
</n-space>
@@ -24,17 +24,27 @@
<n-card style="margin-bottom: 12px">
<n-space vertical :size="12" style="width: 100%">
<div class="filters-grid">
<n-input v-model:value="filters.q" placeholder="搜索名称/描述" clearable @keyup.enter="loadSubscriptions" />
<n-select v-model:value="filters.status" clearable placeholder="状态" :options="statusOptions" />
<n-select v-model:value="sortMode" placeholder="排序方式" :options="sortOptions" />
<n-input
v-model:value="filters.q"
:placeholder="t('subscriptions.page.searchPlaceholder')"
clearable
@keyup.enter="loadSubscriptions"
/>
<n-select
v-model:value="filters.status"
clearable
:placeholder="t('subscriptions.page.statusPlaceholder')"
:options="statusOptions"
/>
<n-select v-model:value="sortMode" :placeholder="t('subscriptions.page.sortPlaceholder')" :options="sortOptions" />
<n-button @click="loadSubscriptions">
<template #icon>
<n-icon><search-outline /></n-icon>
</template>
查询
{{ t('common.actions.search') }}
</n-button>
<n-button quaternary @click="showTagFilter = !showTagFilter">
{{ showTagFilter ? '收起标签筛选' : '展开标签筛选' }}
{{ showTagFilter ? t('subscriptions.page.collapseTagFilter') : t('subscriptions.page.expandTagFilter') }}
</n-button>
</div>
@@ -71,7 +81,7 @@
</n-card>
<n-card>
<template #header>订阅列表</template>
<template #header>{{ t('subscriptions.page.listTitle') }}</template>
<template #header-extra>
<n-button
v-if="sortMode === 'custom' && !batchMode"
@@ -80,29 +90,41 @@
ghost
@click="toggleDragHandles"
>
{{ showDragHandles ? '完成调整' : '调整顺序' }}
{{ showDragHandles ? t('subscriptions.actions.finishReorder') : t('subscriptions.actions.reorder') }}
</n-button>
</template>
<n-space v-if="batchMode" justify="space-between" align="center" style="margin-bottom: 12px" wrap>
<n-space align="center" wrap>
<n-tag type="info">已选 {{ selectedCount }} </n-tag>
<n-tag type="info">{{ t('subscriptions.page.selectedItems', { count: selectedCount }) }}</n-tag>
<n-button size="small" :disabled="visibleSelectionIds.length === 0 || allVisibleSubscriptionsSelected" @click="selectVisibleSubscriptions">
全选当前页
{{ t('subscriptions.page.selectCurrentPage') }}
</n-button>
<n-button size="small" :disabled="selectedCount === 0" @click="clearSelectedSubscriptions">
{{ t('subscriptions.page.clearSelection') }}
</n-button>
<n-button size="small" :disabled="selectedCount === 0" @click="clearSelectedSubscriptions">清空选择</n-button>
</n-space>
<n-space wrap>
<n-button size="small" type="primary" ghost :disabled="selectedCount === 0" @click="runBatchRenew">批量续订</n-button>
<n-button size="small" :disabled="selectedCount === 0" @click="runBatchSetStatus('active')">设为正常</n-button>
<n-button size="small" :disabled="selectedCount === 0" @click="runBatchSetStatus('paused')">设为暂停</n-button>
<n-button size="small" type="warning" ghost :disabled="selectedCount === 0" @click="runBatchSetStatus('cancelled')">设为停用</n-button>
<n-button size="small" type="error" ghost :disabled="!canBatchDelete" @click="runBatchDelete">批量删除</n-button>
<n-button size="small" type="primary" ghost :disabled="selectedCount === 0" @click="runBatchRenew">
{{ t('subscriptions.actions.batchRenew') }}
</n-button>
<n-button size="small" :disabled="selectedCount === 0" @click="runBatchSetStatus('active')">
{{ t('subscriptions.actions.setActive') }}
</n-button>
<n-button size="small" :disabled="selectedCount === 0" @click="runBatchSetStatus('paused')">
{{ t('subscriptions.actions.setPaused') }}
</n-button>
<n-button size="small" type="warning" ghost :disabled="selectedCount === 0" @click="runBatchSetStatus('cancelled')">
{{ t('subscriptions.actions.setCancelled') }}
</n-button>
<n-button size="small" type="error" ghost :disabled="!canBatchDelete" @click="runBatchDelete">
{{ t('subscriptions.actions.batchDelete') }}
</n-button>
</n-space>
</n-space>
<div v-if="isMobile" class="mobile-list">
<n-empty v-if="orderedSubscriptions.length === 0" description="暂无订阅" />
<n-empty v-if="orderedSubscriptions.length === 0" :description="t('subscriptions.page.noSubscriptions')" />
<n-card v-for="item in orderedSubscriptions" :key="item.id" size="small" class="mobile-subscription-card">
<div class="mobile-subscription-card__header">
@@ -120,7 +142,8 @@
<div class="mobile-subscription-card__title-group">
<div class="mobile-subscription-card__title">{{ item.name }}</div>
<div class="mobile-subscription-card__meta">
{{ item.currency }} {{ Number(item.amount).toFixed(2) }} · {{ item.billingIntervalCount }} {{ unitLabel(item.billingIntervalUnit) }}
{{ item.currency }} {{ Number(item.amount).toFixed(2) }} ·
{{ formatInterval(item.billingIntervalCount, unitLabel(item.billingIntervalUnit)) }}
</div>
</div>
</div>
@@ -144,30 +167,30 @@
</template>
<span>{{ formatTagOverflowTooltip(getTagDisplay(item.tags).overflow) }}</span>
</n-tooltip>
<span v-if="!(item.tags?.length)" class="muted-text">未打标签</span>
<span v-if="!(item.tags?.length)" class="muted-text">{{ t('common.empty.noTags') }}</span>
</n-space>
<div class="mobile-subscription-card__rows">
<div class="mobile-subscription-card__row">
<span>下次续订</span>
<span>{{ t('common.labels.nextRenewal') }}</span>
<span>{{ formatDate(item.nextRenewalDate) }}</span>
</div>
<div class="mobile-subscription-card__row">
<span>自动续订</span>
<span>{{ item.autoRenew ? '已启用' : '未启用' }}</span>
<span>{{ t('common.labels.autoRenew') }}</span>
<span>{{ item.autoRenew ? t('common.status.enabled') : t('common.status.disabled') }}</span>
</div>
</div>
<div v-if="item.notes?.trim()" class="note-strip">
<n-icon :size="14"><document-text-outline /></n-icon>
<span class="note-strip__label">备注</span>
<span class="note-strip__label">{{ t('subscriptions.labels.note') }}</span>
<span class="note-strip__content">{{ item.notes.trim() }}</span>
</div>
<n-space v-if="!batchMode" wrap style="margin-top: 12px">
<n-button size="small" @click="openDetail(item.id)">详情</n-button>
<n-button size="small" @click="openRecords(item.id)">记录</n-button>
<n-button size="small" @click="openEdit(item)">编辑</n-button>
<n-button size="small" @click="openDetail(item.id)">{{ t('subscriptions.actions.detail') }}</n-button>
<n-button size="small" @click="openRecords(item.id)">{{ t('subscriptions.actions.records') }}</n-button>
<n-button size="small" @click="openEdit(item)">{{ t('subscriptions.actions.edit') }}</n-button>
<n-button
v-if="item.status === 'active' || item.status === 'expired'"
size="small"
@@ -175,43 +198,43 @@
ghost
@click="quickRenew(item)"
>
续订
{{ t('subscriptions.actions.renew') }}
</n-button>
<template v-if="item.status === 'active'">
<n-popconfirm positive-text="确认" negative-text="取消" @positive-click="pause(item.id)">
<n-popconfirm :positive-text="t('common.actions.confirm')" :negative-text="t('subscriptions.actions.cancel')" @positive-click="pause(item.id)">
<template #trigger>
<n-button size="small">暂停</n-button>
<n-button size="small">{{ t('subscriptions.actions.pause') }}</n-button>
</template>
确认暂停该订阅
{{ t('subscriptions.confirm.pause') }}
</n-popconfirm>
<n-popconfirm positive-text="确认" negative-text="取消" @positive-click="cancel(item.id)">
<n-popconfirm :positive-text="t('common.actions.confirm')" :negative-text="t('subscriptions.actions.cancel')" @positive-click="cancel(item.id)">
<template #trigger>
<n-button size="small" type="error" ghost>取消</n-button>
<n-button size="small" type="error" ghost>{{ t('subscriptions.actions.cancel') }}</n-button>
</template>
确认取消该订阅
{{ t('subscriptions.confirm.cancel') }}
</n-popconfirm>
</template>
<n-popconfirm
v-else-if="item.status === 'paused'"
positive-text="恢复"
negative-text="取消"
:positive-text="t('subscriptions.actions.resume')"
:negative-text="t('subscriptions.actions.cancel')"
@positive-click="resume(item.id)"
>
<template #trigger>
<n-button size="small" type="primary" ghost>恢复</n-button>
<n-button size="small" type="primary" ghost>{{ t('subscriptions.actions.resume') }}</n-button>
</template>
确认恢复该订阅为正常状态
{{ t('subscriptions.confirm.resume') }}
</n-popconfirm>
<n-popconfirm
v-if="item.status === 'paused' || item.status === 'cancelled' || item.status === 'expired'"
positive-text="删除"
negative-text="保留"
:positive-text="t('common.actions.delete')"
:negative-text="t('common.actions.keep')"
@positive-click="removeSubscription(item.id, item.name)"
>
<template #trigger>
<n-button size="small" type="error" ghost>删除</n-button>
<n-button size="small" type="error" ghost>{{ t('common.actions.delete') }}</n-button>
</template>
将删除{{ item.name }}及其续订记录与相关历史此操作不可恢复确认继续
{{ t('subscriptions.confirm.delete', { name: item.name }) }}
</n-popconfirm>
</n-space>
</n-card>
@@ -290,8 +313,7 @@ import {
NSelect,
NSpace,
NTag,
NTooltip,
useMessage
NTooltip
} from 'naive-ui'
import {
AddCircleOutline,
@@ -301,6 +323,7 @@ import {
ReorderThreeOutline,
SearchOutline
} from '@vicons/ionicons5'
import { t } from '@/locales'
import { api } from '@/composables/api'
import { useExchangeRateSnapshotQuery } from '@/composables/exchange-rate-query'
import { useSettingsQuery } from '@/composables/settings-query'
@@ -330,10 +353,11 @@ import {
setStoredSubscriptionPageSize
} from '@/utils/subscription-pagination'
import { buildSubscriptionTableRows, paginateSubscriptions, type SubscriptionTableRow } from '@/utils/subscription-table'
import { useLocalizedMessage } from '@/utils/localized-message'
type SortMode = 'custom' | 'renewal' | 'amount-desc' | 'name'
const message = useMessage()
const message = useLocalizedMessage()
const { width } = useWindowSize()
const queryClient = useQueryClient()
const layersOutline = LayersOutline
@@ -379,19 +403,19 @@ const desktopPageSize = ref<number>(DEFAULT_SUBSCRIPTION_PAGE_SIZE)
const batchMode = ref(false)
const selectedSubscriptionIds = ref<string[]>([])
const statusOptions = [
{ label: '正常', value: 'active' },
{ label: '暂停', value: 'paused' },
{ label: '停用', value: 'cancelled' },
{ label: '过期', value: 'expired' }
]
const statusOptions = computed(() => [
{ label: t('subscriptions.status.active'), value: 'active' },
{ label: t('subscriptions.status.paused'), value: 'paused' },
{ label: t('subscriptions.status.cancelled'), value: 'cancelled' },
{ label: t('subscriptions.status.expired'), value: 'expired' }
])
const sortOptions = [
{ label: '自定义顺序', value: 'custom' },
{ label: '按下次续订', value: 'renewal' },
{ label: '按金额从高到低', value: 'amount-desc' },
{ label: '按名称', value: 'name' }
]
const sortOptions = computed(() => [
{ label: t('subscriptions.sort.custom'), value: 'custom' },
{ label: t('subscriptions.sort.renewal'), value: 'renewal' },
{ label: t('subscriptions.sort.amountDesc'), value: 'amount-desc' },
{ label: t('subscriptions.sort.name'), value: 'name' }
])
const tagSubscriptionCounts = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
@@ -479,7 +503,7 @@ const dragColumn = {
'div',
{
class: ['drag-handle-cell', 'drag-handle-cell--enabled', canDragReorder.value ? 'drag-handle-cell--active' : ''].join(' ').trim(),
title: canDragReorder.value ? '拖拽调整顺序' : '当前排序不可拖拽',
title: canDragReorder.value ? t('subscriptions.page.dragHandleEnabledTitle') : t('subscriptions.page.dragHandleDisabledTitle'),
onMousedown: () => armDrag(row.id),
onMouseup: resetArmedDrag
},
@@ -570,6 +594,7 @@ const tagListStyle = {
gap: '6px'
}
<<<<<<< HEAD
function getTagDisplay(tags?: Tag[] | null) {
return splitSubscriptionTagsForDisplay(tags)
}
@@ -579,8 +604,11 @@ function formatTagOverflowTooltip(tags: Tag[]) {
}
const mainColumns = [
=======
const mainColumns = computed(() => [
>>>>>>> 48ca025 (feat: add web i18n with shared messages)
{
title: '名称',
title: t('common.labels.name'),
key: 'name',
colSpan: (row: SubscriptionTableRow) =>
row.__rowType === 'note' ? (batchMode.value ? 8 : dragHandleVisible.value ? 8 : 7) : 1,
@@ -588,7 +616,7 @@ const mainColumns = [
if (row.__rowType === 'note') {
return h('div', { style: noteContainerStyle }, [
h(NIcon, { size: 14 }, { default: () => h(DocumentTextOutline) }),
h('span', { style: noteLabelStyle }, '备注:'),
h('span', { style: noteLabelStyle }, t('subscriptions.labels.note')),
h('span', { style: noteContentStyle }, row.note)
])
}
@@ -606,12 +634,12 @@ const mainColumns = [
}
},
{
title: '标签',
title: t('common.labels.tags'),
key: 'tags',
colSpan: (row: SubscriptionTableRow) => (row.__rowType === 'note' ? 0 : 1),
render: (row: SubscriptionTableRow) => {
if (row.__rowType === 'note') return null
if (!(row.tags?.length)) return '未打标签'
if (!(row.tags?.length)) return t('common.empty.noTags')
const { visible, overflow, overflowCount } = splitSubscriptionTagsForDisplay(row.tags)
@@ -653,33 +681,32 @@ const mainColumns = [
}
},
{
title: '金额',
title: t('common.labels.amount'),
key: 'amount',
colSpan: (row: SubscriptionTableRow) => (row.__rowType === 'note' ? 0 : 1),
render: (row: SubscriptionTableRow) => (row.__rowType === 'note' ? null : `${row.currency} ${Number(row.amount).toFixed(2)}`)
},
{
title: '频率',
title: t('subscriptions.labels.interval'),
key: 'interval',
colSpan: (row: SubscriptionTableRow) => (row.__rowType === 'note' ? 0 : 1),
render: (row: SubscriptionTableRow) =>
row.__rowType === 'note' ? null : `${row.billingIntervalCount} ${unitLabel(row.billingIntervalUnit)}`
render: (row: SubscriptionTableRow) => (row.__rowType === 'note' ? null : formatInterval(row.billingIntervalCount, unitLabel(row.billingIntervalUnit)))
},
{
title: '下次续订',
title: t('common.labels.nextRenewal'),
key: 'nextRenewalDate',
colSpan: (row: SubscriptionTableRow) => (row.__rowType === 'note' ? 0 : 1),
render: (row: SubscriptionTableRow) => (row.__rowType === 'note' ? null : formatDate(row.nextRenewalDate))
},
{
title: '状态',
title: t('common.labels.status'),
key: 'status',
colSpan: (row: SubscriptionTableRow) => (row.__rowType === 'note' ? 0 : 1),
render: (row: SubscriptionTableRow) =>
row.__rowType === 'note' ? null : h(NTag, { type: statusTagType(row.status) }, { default: () => statusText(row.status) })
},
{
title: '操作',
title: t('common.labels.actions'),
key: 'actions',
render: (row: SubscriptionTableRow) => {
if (row.__rowType === 'note') return null
@@ -689,18 +716,18 @@ const mainColumns = [
? [
h(
NPopconfirm,
{ positiveText: '确认', negativeText: '取消', onPositiveClick: () => void pause(row.id) },
{ positiveText: t('common.actions.confirm'), negativeText: t('subscriptions.actions.cancel'), onPositiveClick: () => void pause(row.id) },
{
trigger: () => h(NButton, { size: 'small' }, { default: () => '暂停' }),
default: () => '确认暂停该订阅?'
trigger: () => h(NButton, { size: 'small' }, { default: () => t('subscriptions.actions.pause') }),
default: () => t('subscriptions.confirm.pause')
}
),
h(
NPopconfirm,
{ positiveText: '确认', negativeText: '取消', onPositiveClick: () => void cancel(row.id) },
{ positiveText: t('common.actions.confirm'), negativeText: t('subscriptions.actions.cancel'), onPositiveClick: () => void cancel(row.id) },
{
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, { default: () => '取消' }),
default: () => '确认取消该订阅?'
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, { default: () => t('subscriptions.actions.cancel') }),
default: () => t('subscriptions.confirm.cancel')
}
)
]
@@ -708,49 +735,49 @@ const mainColumns = [
? [
h(
NPopconfirm,
{ positiveText: '恢复', negativeText: '取消', onPositiveClick: () => void resume(row.id) },
{ positiveText: t('subscriptions.actions.resume'), negativeText: t('subscriptions.actions.cancel'), onPositiveClick: () => void resume(row.id) },
{
trigger: () => h(NButton, { size: 'small', type: 'primary', ghost: true }, { default: () => '恢复' }),
default: () => '确认恢复该订阅为正常状态?'
trigger: () => h(NButton, { size: 'small', type: 'primary', ghost: true }, { default: () => t('subscriptions.actions.resume') }),
default: () => t('subscriptions.confirm.resume')
}
),
h(
NPopconfirm,
{ positiveText: '删除', negativeText: '保留', onPositiveClick: () => void removeSubscription(row.id, row.name) },
{ positiveText: t('common.actions.delete'), negativeText: t('common.actions.keep'), onPositiveClick: () => void removeSubscription(row.id, row.name) },
{
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, { default: () => '删除' }),
default: () => `将删除“${row.name}”及其续订记录与相关历史,此操作不可恢复,确认继续?`
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, { default: () => t('common.actions.delete') }),
default: () => t('subscriptions.confirm.delete', { name: row.name })
}
)
]
: [
h(
NPopconfirm,
{ positiveText: '删除', negativeText: '保留', onPositiveClick: () => void removeSubscription(row.id, row.name) },
{ positiveText: t('common.actions.delete'), negativeText: t('common.actions.keep'), onPositiveClick: () => void removeSubscription(row.id, row.name) },
{
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, { default: () => '删除' }),
default: () => `将删除“${row.name}”及其续订记录与相关历史,此操作不可恢复,确认继续?`
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, { default: () => t('common.actions.delete') }),
default: () => t('subscriptions.confirm.delete', { name: row.name })
}
)
]
return h(NSpace, { size: 6, wrapItem: false }, {
default: () => [
h(NButton, { size: 'small', onClick: () => void openDetail(row.id) }, { default: () => '详情' }),
h(NButton, { size: 'small', onClick: () => void openRecords(row.id) }, { default: () => '记录' }),
h(NButton, { size: 'small', onClick: () => openEdit(row) }, { default: () => '编辑' }),
h(NButton, { size: 'small', onClick: () => void openDetail(row.id) }, { default: () => t('subscriptions.actions.detail') }),
h(NButton, { size: 'small', onClick: () => void openRecords(row.id) }, { default: () => t('subscriptions.actions.records') }),
h(NButton, { size: 'small', onClick: () => openEdit(row) }, { default: () => t('subscriptions.actions.edit') }),
...(row.status === 'active' || row.status === 'expired'
? [h(NButton, { size: 'small', type: 'primary', ghost: true, onClick: () => void quickRenew(row) }, { default: () => '续订' })]
? [h(NButton, { size: 'small', type: 'primary', ghost: true, onClick: () => void quickRenew(row) }, { default: () => t('subscriptions.actions.renew') })]
: []),
...statusActions
]
})
}
}
]
])
const columns = computed(() => {
const result = [...mainColumns]
const result = [...mainColumns.value]
if (batchMode.value) {
return [selectionColumn, ...result]
}
@@ -901,16 +928,20 @@ const submitSubscriptionTask = createSingleFlight(async (payload: Record<string,
try {
if (editingId) {
await api.updateSubscription(editingId, payload)
message.success('订阅已更新')
message.success(t('subscriptions.messages.subscriptionUpdated'))
} else {
await api.createSubscription(payload)
message.success('订阅已创建')
message.success(t('subscriptions.messages.subscriptionCreated'))
}
closeModal()
await refetchCurrentSubscriptions()
} catch (error) {
message.error(`保存失败:${error instanceof Error ? error.message : 'Unknown'}`)
message.error(
t('subscriptions.messages.subscriptionSaveFailed', {
message: error instanceof Error ? error.message : t('common.errors.requestFailed')
})
)
} finally {
savingSubscription.value = false
}
@@ -923,31 +954,43 @@ function submitSubscription(payload: Record<string, unknown>, editingId?: string
async function createTag(payload: { name: string; color: string; sortOrder: number }) {
try {
await api.createTag(payload)
message.success('标签已创建')
message.success(t('subscriptions.messages.tagCreated'))
await Promise.all([queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY }), refetchCurrentSubscriptions()])
} catch (error) {
message.error(`标签创建失败:${error instanceof Error ? error.message : 'Unknown'}`)
message.error(
t('subscriptions.messages.tagCreateFailed', {
message: error instanceof Error ? error.message : t('common.errors.requestFailed')
})
)
}
}
async function updateTag(payload: { name: string; color: string; sortOrder: number }, id: string) {
try {
await api.updateTag(id, payload)
message.success('标签已更新')
message.success(t('subscriptions.messages.tagUpdated'))
await Promise.all([queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY }), refetchCurrentSubscriptions()])
} catch (error) {
message.error(`标签更新失败:${error instanceof Error ? error.message : 'Unknown'}`)
message.error(
t('subscriptions.messages.tagUpdateFailed', {
message: error instanceof Error ? error.message : t('common.errors.requestFailed')
})
)
}
}
async function deleteTag(tag: Tag) {
try {
await api.deleteTag(tag.id)
message.success(`已删除标签:${tag.name}`)
message.success(t('subscriptions.messages.tagDeleted', { name: tag.name }))
filters.tagIds = filters.tagIds.filter((item) => item !== tag.id)
await Promise.all([queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY }), refetchCurrentSubscriptions()])
} catch (error) {
message.error(`标签删除失败:${error instanceof Error ? error.message : 'Unknown'}`)
message.error(
t('subscriptions.messages.tagDeleteFailed', {
message: error instanceof Error ? error.message : t('common.errors.requestFailed')
})
)
}
}
@@ -979,7 +1022,7 @@ function selectVisibleSubscriptions() {
function ensureBatchSelection() {
if (!selectedCount.value) {
message.warning('请先选择订阅')
message.warning(t('subscriptions.batch.selectFirst'))
return false
}
return true
@@ -987,11 +1030,11 @@ function ensureBatchSelection() {
function summarizeBatchResult(label: string, result: { successCount: number; failureCount: number }) {
if (result.failureCount === 0) {
message.success(`${label}成功,共 ${result.successCount}`)
message.success(t('subscriptions.messages.batchActionSuccess', { label, count: result.successCount }))
return
}
message.warning(`${label}完成:成功 ${result.successCount} 项,失败 ${result.failureCount}`)
message.warning(t('subscriptions.messages.batchActionPartial', { label, success: result.successCount, failure: result.failureCount }))
}
async function refreshOpenDetailIfNeeded(ids: string[]) {
@@ -1004,7 +1047,7 @@ async function runBatchRenew() {
if (!ensureBatchSelection()) return
const ids = [...selectedSubscriptionIds.value]
const result = await api.batchRenewSubscriptions(ids)
summarizeBatchResult('批量续订', result)
summarizeBatchResult(t('subscriptions.actions.batchRenew'), result)
await refetchCurrentSubscriptions()
await refreshOpenDetailIfNeeded(ids)
}
@@ -1012,10 +1055,16 @@ async function runBatchRenew() {
async function runBatchSetStatus(status: BatchSettableStatus) {
if (!ensureBatchSelection()) return
const statusLabel = getBatchStatusText(status)
if (!window.confirm(`确认将已选的 ${selectedCount.value} 项订阅设为${statusLabel}吗?`)) return
if (!window.confirm(t('subscriptions.batch.statusConfirm', { count: selectedCount.value, status: statusLabel }))) return
const ids = [...selectedSubscriptionIds.value]
const result = await api.batchUpdateSubscriptionStatus(ids, status)
summarizeBatchResult(`批量设为${statusLabel}`, result)
const actionLabel =
status === 'active'
? t('subscriptions.actions.setActive')
: status === 'paused'
? t('subscriptions.actions.setPaused')
: t('subscriptions.actions.setCancelled')
summarizeBatchResult(actionLabel, result)
await refetchCurrentSubscriptions()
await refreshOpenDetailIfNeeded(ids)
}
@@ -1025,17 +1074,23 @@ async function runBatchDelete() {
const { deletableCount, blockedCount } = batchDeleteSummary.value
const confirmMessage =
blockedCount > 0
? `确认批量删除吗?将删除 ${deletableCount} 项,并跳过 ${blockedCount} 项正常订阅。此操作不可恢复。`
: `确认批量删除已选的 ${deletableCount} 项订阅吗?此操作不可恢复。`
? t('subscriptions.batch.deleteConfirmPartial', { deletable: deletableCount, blocked: blockedCount })
: t('subscriptions.batch.deleteConfirmAll', { count: deletableCount })
if (!window.confirm(confirmMessage)) return
const ids = [...selectedSubscriptionIds.value]
const result = await api.batchDeleteSubscriptions(ids)
const skippedActiveCount = result.failures.filter((item) => item.message.includes('Active subscriptions cannot be deleted directly')).length
const skippedActiveCount = result.failures.filter((item) => item.message === 'api.errors.subscriptions.activeDeleteBlocked').length
const otherFailureCount = result.failureCount - skippedActiveCount
if (result.failureCount === 0) {
message.success(`批量删除成功,共 ${result.successCount}`)
message.success(t('subscriptions.messages.batchDeleteSuccess', { count: result.successCount }))
} else {
message.warning(`批量删除完成:已删除 ${result.successCount} 项,跳过 ${skippedActiveCount} 项正常订阅,失败 ${otherFailureCount}`)
message.warning(
t('subscriptions.messages.batchDeletePartial', {
success: result.successCount,
skipped: skippedActiveCount,
failure: otherFailureCount
})
)
}
const deletedIds = ids.filter((id) => !result.failures.some((failure) => failure.id === id))
if (detail.value && deletedIds.includes(detail.value.id)) {
@@ -1048,7 +1103,7 @@ async function runBatchDelete() {
async function quickRenew(row: Subscription) {
await api.renewSubscription(row.id)
message.success(`已续订:${row.name}`)
message.success(t('subscriptions.messages.renewed', { name: row.name }))
await refetchCurrentSubscriptions()
if (detail.value?.id === row.id) {
detail.value = await api.getSubscription(row.id)
@@ -1057,7 +1112,7 @@ async function quickRenew(row: Subscription) {
async function pause(id: string) {
await api.pauseSubscription(id)
message.success('已暂停')
message.success(t('subscriptions.messages.paused'))
await refetchCurrentSubscriptions()
if (detail.value?.id === id) {
detail.value = await api.getSubscription(id)
@@ -1066,7 +1121,7 @@ async function pause(id: string) {
async function resume(id: string) {
await api.updateSubscription(id, { status: 'active' })
message.success('已恢复')
message.success(t('subscriptions.messages.resumed'))
await refetchCurrentSubscriptions()
if (detail.value?.id === id) {
detail.value = await api.getSubscription(id)
@@ -1075,7 +1130,7 @@ async function resume(id: string) {
async function cancel(id: string) {
await api.cancelSubscription(id)
message.success('已停用')
message.success(t('subscriptions.messages.cancelled'))
await refetchCurrentSubscriptions()
if (detail.value?.id === id) {
detail.value = await api.getSubscription(id)
@@ -1084,7 +1139,7 @@ async function cancel(id: string) {
async function removeSubscription(id: string, name: string) {
await api.deleteSubscription(id)
message.success(`已删除:${name}`)
message.success(t('subscriptions.messages.deleted', { name }))
await refetchCurrentSubscriptions()
if (detail.value?.id === id) {
detail.value = null
@@ -1154,9 +1209,9 @@ async function handleDrop(event: DragEvent, targetId: string) {
savingOrder.value = true
await api.reorderSubscriptions(nextIds)
await refetchCurrentSubscriptions()
message.success('顺序已更新')
message.success(t('subscriptions.messages.orderUpdated'))
} catch (error) {
message.error(error instanceof Error ? error.message : '排序更新失败')
message.error(error instanceof Error ? error.message : t('subscriptions.messages.orderUpdateFailed'))
} finally {
savingOrder.value = false
resetDragState()
@@ -1183,16 +1238,16 @@ function toggleDragHandles() {
resetDragState()
if (showDragHandles.value) {
message.info('已开启拖拽排序,仅拖拽手柄可调整顺序')
message.info(t('subscriptions.messages.dragSortEnabled'))
}
}
function statusText(status: Subscription['status']) {
return {
active: '正常',
paused: '暂停',
cancelled: '停用',
expired: '过期'
active: t('subscriptions.status.active'),
paused: t('subscriptions.status.paused'),
cancelled: t('subscriptions.status.cancelled'),
expired: t('subscriptions.status.expired')
}[status]
}
@@ -1209,13 +1264,17 @@ function formatDate(value: string) {
return formatDateInTimezone(value, settings.value?.timezone)
}
function formatInterval(count: number, unit: string) {
return t('subscriptions.values.interval', { count, unit })
}
function unitLabel(unit: string) {
return {
day: '天',
week: '周',
month: '月',
quarter: '季',
year: '年'
day: t('common.units.day'),
week: t('common.units.week'),
month: t('common.units.month'),
quarter: t('common.units.quarter'),
year: t('common.units.year')
}[unit] ?? unit
}
</script>

View File

@@ -7,43 +7,43 @@ export const routes = [
path: '/login',
name: 'login',
component: () => import('@/pages/LoginPage.vue'),
meta: { public: true, label: '登录' }
meta: { public: true, labelKey: 'app.auth.login' }
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('@/pages/DashboardPage.vue'),
meta: { label: '仪表盘' }
meta: { labelKey: 'app.menu.dashboard' }
},
{
path: '/subscriptions',
name: 'subscriptions',
component: () => import('@/pages/SubscriptionsPage.vue'),
meta: { label: '订阅管理' }
meta: { labelKey: 'app.menu.subscriptions' }
},
{
path: '/calendar',
name: 'calendar',
component: () => import('@/pages/CalendarPage.vue'),
meta: { label: '订阅日历' }
meta: { labelKey: 'app.menu.calendar' }
},
{
path: '/statistics',
name: 'statistics',
component: () => import('@/pages/StatisticsPage.vue'),
meta: { label: '费用统计' }
meta: { labelKey: 'app.menu.statistics' }
},
{
path: '/budgets',
name: 'budgets',
component: () => import('@/pages/BudgetPage.vue'),
meta: { label: '预算统计' }
meta: { labelKey: 'app.menu.budgets' }
},
{
path: '/settings',
name: 'settings',
component: () => import('@/pages/SettingsPage.vue'),
meta: { label: '系统设置' }
meta: { labelKey: 'app.menu.settings' }
}
]

View File

@@ -1,76 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { DEFAULT_ADVANCE_REMINDER_RULES, DEFAULT_AI_CONFIG, DEFAULT_OVERDUE_REMINDER_RULES, DEFAULT_RESEND_API_URL } from '@subtracker/shared'
import { api } from '@/composables/api'
import type { Settings } from '@/types/api'
export const useAppStore = defineStore('app', () => {
const settings = ref<Settings>({
baseCurrency: 'CNY',
timezone: 'Asia/Shanghai',
defaultNotifyDays: 3,
defaultAdvanceReminderRules: DEFAULT_ADVANCE_REMINDER_RULES,
rememberSessionDays: 7,
forgotPasswordEnabled: false,
notifyOnDueDay: true,
mergeMultiSubscriptionNotifications: true,
monthlyBudgetBase: null,
yearlyBudgetBase: null,
enableTagBudgets: false,
overdueReminderDays: [1, 2, 3],
defaultOverdueReminderRules: DEFAULT_OVERDUE_REMINDER_RULES,
tagBudgets: {},
emailNotificationsEnabled: false,
emailProvider: 'smtp',
pushplusNotificationsEnabled: false,
telegramNotificationsEnabled: false,
serverchanNotificationsEnabled: false,
gotifyNotificationsEnabled: false,
smtpConfig: {
host: '',
port: 587,
secure: false,
username: '',
password: '',
from: '',
to: ''
},
resendConfig: {
apiBaseUrl: DEFAULT_RESEND_API_URL,
apiKey: '',
from: '',
to: ''
},
pushplusConfig: {
token: '',
topic: ''
},
telegramConfig: {
botToken: '',
chatId: ''
},
serverchanConfig: {
sendkey: ''
},
gotifyConfig: {
url: '',
token: '',
ignoreSsl: false
},
aiConfig: {
...DEFAULT_AI_CONFIG,
capabilities: {
...DEFAULT_AI_CONFIG.capabilities
}
}
})
async function refreshSettings() {
settings.value = await api.getSettings()
}
return {
settings,
refreshSettings
}
})

View File

@@ -1,3 +1,5 @@
export type AppLocale = 'zh-CN' | 'en-US'
export type SubscriptionStatus = 'active' | 'paused' | 'cancelled' | 'expired'
export interface AuthUser {
@@ -260,6 +262,7 @@ export interface AiDashboardSummary {
}
export interface Settings {
systemDefaultLocale: AppLocale
baseCurrency: string
timezone: string
defaultNotifyDays: number

View File

@@ -1,9 +1,13 @@
import { t } from '@/locales'
export function getAiRecognitionStatusText(input: { hasImage: boolean; elapsedMs: number }) {
if (!input.hasImage) {
return input.elapsedMs >= 6_000 ? '仍在识别中,模型响应可能稍慢,请继续稍候。' : '识别中,通常几秒内完成,请勿重复点击。'
return input.elapsedMs >= 6_000
? t('ai.status.textSlow')
: t('ai.status.textFast')
}
return input.elapsedMs >= 12_000
? '图片识别仍在进行中,外部模型响应较慢,请再稍候片刻。'
: '正在识别图片与文本,通常需要 5-10 秒,请勿关闭窗口。'
? t('ai.status.imageSlow')
: t('ai.status.imageFast')
}

View File

@@ -1,7 +1,14 @@
import currencyNameMap from '@/data/currency-names.zh-CN.json'
import { getAppLocale } from '@/locales'
export function getCurrencyLabel(code: string) {
const upper = code.toUpperCase()
if (getAppLocale() === 'en-US' && typeof Intl !== 'undefined' && typeof Intl.DisplayNames === 'function') {
const displayNames = new Intl.DisplayNames(['en-US'], { type: 'currency' })
const englishName = displayNames.of(upper)
return englishName ? `${englishName} (${upper})` : upper
}
const name = (currencyNameMap as Record<string, string>)[upper]
return name ? `${name} (${upper})` : upper
}

View File

@@ -0,0 +1,46 @@
import { useMessage, createDiscreteApi } from 'naive-ui'
type MaybeContent = string | (() => string)
function normalizeContent(content: MaybeContent) {
return typeof content === 'function' ? content() : content
}
export function useLocalizedMessage() {
const message = useMessage()
return {
...message,
success(content: MaybeContent, ...args: unknown[]) {
return message.success(normalizeContent(content), ...(args as []))
},
error(content: MaybeContent, ...args: unknown[]) {
return message.error(normalizeContent(content), ...(args as []))
},
warning(content: MaybeContent, ...args: unknown[]) {
return message.warning(normalizeContent(content), ...(args as []))
},
info(content: MaybeContent, ...args: unknown[]) {
return message.info(normalizeContent(content), ...(args as []))
}
}
}
export function createLocalizedDiscreteMessage() {
const { message } = createDiscreteApi(['message'])
return {
success(content: string) {
return message.success(content)
},
error(content: string) {
return message.error(content)
},
warning(content: string) {
return message.warning(content)
},
info(content: string) {
return message.info(content)
}
}
}

View File

@@ -1,6 +1,8 @@
import { t } from '@/locales'
export function validateLoginForm(username: string, password: string) {
if (!username.trim() && !password.trim()) return '请输入用户名和密码'
if (!username.trim()) return '请输入用户名'
if (!password.trim()) return '请输入密码'
if (!username.trim() && !password.trim()) return t('auth.validation.usernameAndPasswordRequired')
if (!username.trim()) return t('auth.validation.usernameRequired')
if (!password.trim()) return t('auth.validation.passwordRequired')
return null
}

View File

@@ -14,6 +14,32 @@ export type ReminderRulesEvaluation = {
usingFallback: boolean
}
type ReminderRulesI18n = {
fallback: string
emptyTitle: string
resultTitle: string
invalidTitle: string
defaultRulesLabel: string
defaultAdvanceRulesLabel: string
defaultOverdueRulesLabel: string
fallbackPreviewTitle: string
fallbackInvalidTitle: string
noAdvance: string
noOverdue: string
parseFailed: string
invalidSegmentFormat: string
invalidDaysInteger: string
invalidOverdueDays: string
invalidAdvanceDays: string
invalidTime: string
inlineAdvanceSameDay: string
inlineAdvanceBefore: string
inlineOverdue: string
evalAdvanceSameDay: string
evalAdvanceBefore: string
evalOverdue: string
}
type ParsedReminderRule = {
days: number
time: string
@@ -21,32 +47,64 @@ type ParsedReminderRule = {
const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/
function parseRuleSegment(segment: string, kind: ReminderRulesKind): ParsedReminderRule {
function formatI18n(template: string, params: Record<string, string | number>) {
return template.replace(/\{(\w+)\}/g, (_match, key) => String(params[key] ?? `{${key}}`))
}
function getDefaultI18n(): ReminderRulesI18n {
return {
fallback: '沿用系统默认',
emptyTitle: '请先输入规则后再演算',
resultTitle: '演算结果',
invalidTitle: '规则格式有误',
defaultRulesLabel: '系统默认规则',
defaultAdvanceRulesLabel: '系统默认到期前规则',
defaultOverdueRulesLabel: '系统默认过期规则',
fallbackPreviewTitle: '当前未填写,以下按{label}演算',
fallbackInvalidTitle: '{label}格式有误',
noAdvance: '暂无到期前提醒规则',
noOverdue: '暂无过期提醒规则',
parseFailed: '规则解析失败',
invalidSegmentFormat: '规则 "{segment}" 格式无效,应为 天数&HH:mm',
invalidDaysInteger: '规则 "{segment}" 中的天数必须为整数',
invalidOverdueDays: '规则 "{segment}" 中的天数必须大于等于 1',
invalidAdvanceDays: '规则 "{segment}" 中的天数不能小于 0',
invalidTime: '规则 "{segment}" 中的时间必须为 HH:mm',
inlineAdvanceSameDay: '当天 {time}',
inlineAdvanceBefore: '提前 {days} 天 {time}',
inlineOverdue: '过期 {days} 天 {time}',
evalAdvanceSameDay: '到期当天 {time} 提醒',
evalAdvanceBefore: '提前 {days} 天 {time} 提醒',
evalOverdue: '过期 {days} 天 {time} 提醒'
}
}
function parseRuleSegment(segment: string, kind: ReminderRulesKind, copy: ReminderRulesI18n): ParsedReminderRule {
const parts = segment
.split('&')
.map((item) => item.trim())
.filter(Boolean)
if (parts.length !== 2) {
throw new Error(`规则 "${segment}" 格式无效,应为 天数&HH:mm`)
throw new Error(formatI18n(copy.invalidSegmentFormat, { segment }))
}
const [rawDays, rawTime] = parts
if (!/^\d+$/.test(rawDays)) {
throw new Error(`规则 "${segment}" 中的天数必须为整数`)
throw new Error(formatI18n(copy.invalidDaysInteger, { segment }))
}
const days = Number(rawDays)
if (kind === 'overdue' && days < 1) {
throw new Error(`规则 "${segment}" 中的天数必须大于等于 1`)
throw new Error(formatI18n(copy.invalidOverdueDays, { segment }))
}
if (kind === 'advance' && days < 0) {
throw new Error(`规则 "${segment}" 中的天数不能小于 0`)
throw new Error(formatI18n(copy.invalidAdvanceDays, { segment }))
}
if (!TIME_PATTERN.test(rawTime)) {
throw new Error(`规则 "${segment}" 中的时间必须为 HH:mm`)
throw new Error(formatI18n(copy.invalidTime, { segment }))
}
return {
@@ -77,7 +135,7 @@ function dedupeRules(rules: ParsedReminderRule[]) {
return result
}
function parseReminderRulesStrict(value: string, kind: ReminderRulesKind) {
function parseReminderRulesStrict(value: string, kind: ReminderRulesKind, copy: ReminderRulesI18n) {
const compact = value.replace(/\s+/g, '')
if (!compact) return []
@@ -86,38 +144,46 @@ function parseReminderRulesStrict(value: string, kind: ReminderRulesKind) {
.map((item) => item.trim())
.filter(Boolean)
const parsed = dedupeRules(segments.map((segment) => parseRuleSegment(segment, kind)))
const parsed = dedupeRules(segments.map((segment) => parseRuleSegment(segment, kind, copy)))
parsed.sort((a, b) => compareRules(a, b, kind))
return parsed
}
function toInlineDescription(rule: ParsedReminderRule, kind: ReminderRulesKind) {
function toInlineDescription(rule: ParsedReminderRule, kind: ReminderRulesKind, copy: ReminderRulesI18n) {
if (kind === 'advance') {
return rule.days === 0 ? `当天 ${rule.time}` : `提前 ${rule.days}${rule.time}`
return rule.days === 0
? formatI18n(copy.inlineAdvanceSameDay, { time: rule.time })
: formatI18n(copy.inlineAdvanceBefore, { days: rule.days, time: rule.time })
}
return `过期 ${rule.days}${rule.time}`
return formatI18n(copy.inlineOverdue, { days: rule.days, time: rule.time })
}
function toEvaluationDescription(rule: ParsedReminderRule, kind: ReminderRulesKind) {
function toEvaluationDescription(rule: ParsedReminderRule, kind: ReminderRulesKind, copy: ReminderRulesI18n) {
if (kind === 'advance') {
return rule.days === 0 ? `到期当天 ${rule.time} 提醒` : `提前 ${rule.days}${rule.time} 提醒`
return rule.days === 0
? formatI18n(copy.evalAdvanceSameDay, { time: rule.time })
: formatI18n(copy.evalAdvanceBefore, { days: rule.days, time: rule.time })
}
return `过期 ${rule.days}${rule.time} 提醒`
return formatI18n(copy.evalOverdue, { days: rule.days, time: rule.time })
}
export function formatReminderRulesText(
value: string | null | undefined,
kind: ReminderRulesKind,
fallback = '沿用系统默认'
fallback = getDefaultI18n().fallback,
options?: {
i18n?: Partial<ReminderRulesI18n>
}
) {
const copy = { ...getDefaultI18n(), ...options?.i18n }
if (!value?.trim()) return fallback
try {
const parts = parseReminderRulesStrict(value, kind)
const parts = parseReminderRulesStrict(value, kind, copy)
if (!parts.length) return fallback
return parts.map((item) => toInlineDescription(item, kind)).join('')
return parts.map((item) => toInlineDescription(item, kind, copy)).join('')
} catch {
return fallback
}
@@ -126,17 +192,21 @@ export function formatReminderRulesText(
export function listReminderRuleDescriptions(
value: string | null | undefined,
kind: ReminderRulesKind,
fallback?: string | null | undefined
fallback?: string | null | undefined,
options?: {
i18n?: Partial<ReminderRulesI18n>
}
) {
const copy = { ...getDefaultI18n(), ...options?.i18n }
const currentValue = value?.trim() ?? ''
try {
const source = currentValue || fallback?.trim() || ''
if (!source) return []
const parts = parseReminderRulesStrict(source, kind)
const parts = parseReminderRulesStrict(source, kind, copy)
return parts.map((item) => ({
key: `${item.days}&${item.time}`,
description: toInlineDescription(item, kind)
description: toInlineDescription(item, kind, copy)
}))
} catch {
return []
@@ -150,10 +220,14 @@ export function evaluateReminderRules(
fallbackValue?: string | null | undefined
fallbackLabel?: string
emptyTitle?: string
i18n?: Partial<ReminderRulesI18n>
}
): ReminderRulesEvaluation {
const fallbackLabel = options?.fallbackLabel ?? '系统默认规则'
const emptyTitle = options?.emptyTitle ?? '请先输入规则后再演算'
const copy = { ...getDefaultI18n(), ...options?.i18n }
const fallbackLabel =
options?.fallbackLabel ??
(kind === 'advance' ? copy.defaultAdvanceRulesLabel : kind === 'overdue' ? copy.defaultOverdueRulesLabel : copy.defaultRulesLabel)
const emptyTitle = options?.emptyTitle ?? (kind === 'advance' ? copy.noAdvance : copy.noOverdue)
const currentValue = value?.trim() ?? ''
if (!currentValue) {
@@ -168,46 +242,46 @@ export function evaluateReminderRules(
}
try {
const parsed = parseReminderRulesStrict(fallbackValue, kind)
const parsed = parseReminderRulesStrict(fallbackValue, kind, copy)
return {
title: `当前未填写,以下按${fallbackLabel}演算`,
title: formatI18n(copy.fallbackPreviewTitle, { label: fallbackLabel }),
entries: parsed.map((rule) => ({
key: `${rule.days}&${rule.time}`,
days: rule.days,
time: rule.time,
description: toEvaluationDescription(rule, kind)
description: toEvaluationDescription(rule, kind, copy)
})),
error: null,
usingFallback: true
}
} catch (error) {
return {
title: `${fallbackLabel}格式有误`,
title: formatI18n(copy.fallbackInvalidTitle, { label: fallbackLabel }),
entries: [],
error: error instanceof Error ? error.message : '规则解析失败',
error: error instanceof Error ? error.message : copy.parseFailed,
usingFallback: true
}
}
}
try {
const parsed = parseReminderRulesStrict(currentValue, kind)
const parsed = parseReminderRulesStrict(currentValue, kind, copy)
return {
title: '演算结果',
title: copy.resultTitle,
entries: parsed.map((rule) => ({
key: `${rule.days}&${rule.time}`,
days: rule.days,
time: rule.time,
description: toEvaluationDescription(rule, kind)
description: toEvaluationDescription(rule, kind, copy)
})),
error: null,
usingFallback: false
}
} catch (error) {
return {
title: '规则格式有误',
title: copy.invalidTitle,
entries: [],
error: error instanceof Error ? error.message : '规则解析失败',
error: error instanceof Error ? error.message : copy.parseFailed,
usingFallback: false
}
}

View File

@@ -1,4 +1,5 @@
import type { Subscription, SubscriptionStatus } from '@/types/api'
import { t } from '@/locales'
export type BatchSettableStatus = Extract<SubscriptionStatus, 'active' | 'paused' | 'cancelled'>
@@ -26,9 +27,9 @@ export function areAllVisibleSubscriptionsSelected(visibleIds: string[], selecte
export function getBatchStatusText(status: BatchSettableStatus) {
return {
active: '正常',
paused: '暂停',
cancelled: '停用'
active: t('subscriptions.status.active'),
paused: t('subscriptions.status.paused'),
cancelled: t('subscriptions.status.cancelled')
}[status]
}

View File

@@ -1,5 +1,6 @@
import dayjs from 'dayjs'
import type { BillingIntervalUnit } from '@subtracker/shared'
import { getMessage, type BillingIntervalUnit } from '@subtracker/shared'
import { getAppLocale } from '@/locales'
import { normalizeWebsiteUrlInput } from './website-url'
export interface SubscriptionFormValidationInput {
@@ -54,40 +55,41 @@ export function validateSubscriptionForm(input: SubscriptionFormValidationInput)
errors: SubscriptionFormErrors
normalizedWebsiteUrl: string | null
} {
const locale = getAppLocale()
const errors: SubscriptionFormErrors = {}
if (!input.name.trim()) {
errors.name = '请填写名称'
errors.name = getMessage(locale, 'validation.subscriptionForm.nameRequired')
} else if (input.name.trim().length > 150) {
errors.name = '名称不能超过 150 个字符'
errors.name = getMessage(locale, 'validation.subscriptionForm.nameTooLong')
}
if (input.description.length > 500) {
errors.description = '描述不能超过 500 个字符'
errors.description = getMessage(locale, 'validation.subscriptionForm.descriptionTooLong')
}
if (input.amount === null || input.amount === undefined || Number.isNaN(Number(input.amount)) || Number(input.amount) < 0) {
errors.amount = '请填写有效金额'
errors.amount = getMessage(locale, 'validation.subscriptionForm.amountInvalid')
}
if (!/^[A-Z]{3}$/.test(String(input.currency || '').trim().toUpperCase())) {
errors.currency = '请选择合法货币'
errors.currency = getMessage(locale, 'validation.subscriptionForm.currencyInvalid')
}
if (!Number.isInteger(Number(input.billingIntervalCount)) || Number(input.billingIntervalCount) <= 0) {
errors.billingIntervalCount = '频率必须为正整数'
errors.billingIntervalCount = getMessage(locale, 'validation.subscriptionForm.billingIntervalCountInvalid')
}
if (!input.billingIntervalUnit) {
errors.billingIntervalUnit = '请选择频率单位'
errors.billingIntervalUnit = getMessage(locale, 'validation.subscriptionForm.billingIntervalUnitRequired')
}
if (input.startDateTs === null || !Number.isFinite(input.startDateTs)) {
errors.startDateTs = '请选择开始日期'
errors.startDateTs = getMessage(locale, 'validation.subscriptionForm.startDateRequired')
}
if (input.nextRenewalDateTs === null || !Number.isFinite(input.nextRenewalDateTs)) {
errors.nextRenewalDateTs = '请选择下次续订日期'
errors.nextRenewalDateTs = getMessage(locale, 'validation.subscriptionForm.nextRenewalDateRequired')
}
if (
@@ -97,7 +99,7 @@ export function validateSubscriptionForm(input: SubscriptionFormValidationInput)
Number.isFinite(input.nextRenewalDateTs) &&
dayjs(input.nextRenewalDateTs).isBefore(dayjs(input.startDateTs), 'day')
) {
errors.nextRenewalDateTs = '下次续订日期不能早于开始日期'
errors.nextRenewalDateTs = getMessage(locale, 'validation.subscriptionForm.nextRenewalDateEarlierThanStartDate')
}
const normalizedWebsite = normalizeWebsiteUrlInput(input.websiteUrl)
@@ -106,7 +108,7 @@ export function validateSubscriptionForm(input: SubscriptionFormValidationInput)
}
if (input.notes.length > 1000) {
errors.notes = '备注不能超过 1000 个字符'
errors.notes = getMessage(locale, 'validation.subscriptionForm.notesTooLong')
}
return {

View File

@@ -1,15 +1,16 @@
import type { SubscriptionStatus } from '@/types/api'
import { t } from '@/locales'
export function getSubscriptionStatusText(status: SubscriptionStatus | string) {
switch (status) {
case 'active':
return '正常'
return t('subscriptions.status.active')
case 'paused':
return '暂停'
return t('subscriptions.status.paused')
case 'cancelled':
return '停用'
return t('subscriptions.status.cancelled')
case 'expired':
return '过期'
return t('subscriptions.status.expired')
default:
return status
}

View File

@@ -2,6 +2,7 @@ import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc.js'
import timezone from 'dayjs/plugin/timezone.js'
import customParseFormat from 'dayjs/plugin/customParseFormat.js'
import { getAppLocale } from '@/locales'
dayjs.extend(utc)
dayjs.extend(timezone)
@@ -151,7 +152,9 @@ export function addIntervalToPickerTs(
}
export function formatMonthLabelInTimezone(value: Date | string | number, timezoneValue = DEFAULT_APP_TIMEZONE) {
return toTimezonedDayjs(typeof value === 'number' ? new Date(value) : value, timezoneValue).format('YYYY 年 M 月')
const locale = getAppLocale()
const date = toTimezonedDayjs(typeof value === 'number' ? new Date(value) : value, timezoneValue)
return locale === 'en-US' ? date.format('MMMM YYYY') : date.format('YYYY 年 M 月')
}
export function addIntervalToBusinessDateString(

View File

@@ -1,8 +1,11 @@
import { t } from '@/locales'
export function shouldRecommendDbImport(fileName?: string | null, previewFileType?: 'json' | 'db' | 'zip') {
if (previewFileType === 'json') return true
if (!fileName) return false
return fileName.toLowerCase().endsWith('.json')
}
export const JSON_IMPORT_WARNING_MESSAGE =
'检测到 Wallos JSON 导入。推荐优先使用 Wallos DB 导入DB 包含 start_date、完整币种代码等更完整信息JSON 虽可导入,但可能出现开始日期代填、币种推断等字段降级。'
export function getJsonImportWarningMessage() {
return t('imports.wallos.jsonWarning')
}

View File

@@ -1,4 +1,5 @@
const WEBSITE_URL_ERROR_MESSAGE = '请输入合法网址,例如 https://example.com'
import { t } from '@/locales'
const FQDN_LABEL_RE = /^[a-z_\u00a1-\uffff0-9-]+$/i
const FQDN_TLD_RE = /^([a-z\u00A1-\u00A8\u00AA-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{2,}|xn[a-z0-9-]{2,})$/i
const FULL_WIDTH_RE = /[\uff01-\uff5e]/
@@ -33,7 +34,7 @@ export function normalizeWebsiteUrlInput(input: string | null | undefined): {
const normalized = normalizeWebsiteUrlString(trimmed)
if (!normalized) {
return { value: null, error: WEBSITE_URL_ERROR_MESSAGE }
return { value: null, error: t('validation.websiteUrlInvalid') }
}
return { value: normalized, error: null }

View File

@@ -8,10 +8,10 @@ describe('login page forgot password', () => {
expect(source).toContain("import brandLogoUrl from '@/assets/brand-logo.png'")
expect(source).toContain('class="login-header__logo"')
expect(source).toContain('v-if="forgotPasswordEnabled"')
expect(source).toContain('忘记密码')
expect(source).toContain('发送验证码')
expect(source).toContain('验证码')
expect(source).toContain('验证并重置密码')
expect(source).toContain("t('login.forgotPassword')")
expect(source).toContain("t('login.sendCode')")
expect(source).toContain("t('common.labels.code')")
expect(source).toContain("t('login.verifyAndResetPassword')")
expect(source).toContain('api.requestForgotPasswordCode')
expect(source).toContain('api.resetForgotPassword')
})

View File

@@ -5,14 +5,15 @@ describe('subscription detail drawer remaining value', () => {
it('renders remaining value fields in detail drawer', () => {
const source = readFileSync('src/components/SubscriptionDetailDrawer.vue', 'utf8')
expect(source).toContain('label="当前周期"')
expect(source).toContain('label="剩余价值"')
expect(source).toContain("t('subscriptions.labels.currentCycle')")
expect(source).toContain("t('subscriptions.labels.remainingValue')")
expect(source).toContain('detail.currentCycleStartDate')
expect(source).toContain('detail.currentCycleEndDate')
expect(source).toContain('detail.remainingValue')
expect(source).toContain('detail.remainingDays')
expect(source).toContain('detail.remainingRatio')
expect(source).toContain('listReminderRuleDescriptions')
expect(source).toContain('reminderRulesI18n')
expect(source).toContain('detail-descriptions')
expect(source).toContain('white-space: nowrap;')
expect(source).toContain('detail-value-block')

View File

@@ -11,8 +11,8 @@ describe('app layout version update brand indicator', () => {
expect(source).toContain('brandLogoUrl')
expect(source).toContain('logo__image')
expect(source).toContain('openVersionUpdatePanel')
expect(source).toContain('title="版本更新"')
expect(source).toContain(":title=\"t('app.versionUpdates')\"")
expect(source).toContain('renderReleaseBody')
expect(source).toContain('查看 Release')
expect(source).toContain("{{ t('app.viewRelease') }}")
})
})

View File

@@ -1,13 +1,16 @@
import { describe, expect, it } from 'vitest'
import { setAppLocale } from '@/locales'
import { getAiRecognitionStatusText } from '@/utils/ai-recognition-status'
describe('getAiRecognitionStatusText', () => {
it('returns fast guidance for text-only recognition', () => {
setAppLocale('zh-CN')
expect(getAiRecognitionStatusText({ hasImage: false, elapsedMs: 0 })).toBe('识别中,通常几秒内完成,请勿重复点击。')
expect(getAiRecognitionStatusText({ hasImage: false, elapsedMs: 6_000 })).toBe('仍在识别中,模型响应可能稍慢,请继续稍候。')
})
it('returns longer guidance for image recognition', () => {
setAppLocale('zh-CN')
expect(getAiRecognitionStatusText({ hasImage: true, elapsedMs: 0 })).toBe('正在识别图片与文本,通常需要 5-10 秒,请勿关闭窗口。')
expect(getAiRecognitionStatusText({ hasImage: true, elapsedMs: 12_000 })).toBe('图片识别仍在进行中,外部模型响应较慢,请再稍候片刻。')
})

View File

@@ -1,20 +1,25 @@
import { describe, expect, it } from 'vitest'
import { setAppLocale } from '../../../src/locales'
import { validateLoginForm } from '../../../src/utils/login-validation'
describe('validateLoginForm', () => {
it('requires both username and password', () => {
setAppLocale('zh-CN')
expect(validateLoginForm('', '')).toBe('请输入用户名和密码')
})
it('requires username', () => {
setAppLocale('zh-CN')
expect(validateLoginForm('', 'password')).toBe('请输入用户名')
})
it('requires password', () => {
setAppLocale('zh-CN')
expect(validateLoginForm('admin', '')).toBe('请输入密码')
})
it('passes when both fields are provided', () => {
setAppLocale('zh-CN')
expect(validateLoginForm('admin', 'password')).toBeNull()
})
})

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { JSON_IMPORT_WARNING_MESSAGE, shouldRecommendDbImport } from '../../../src/utils/wallos-import'
import { setAppLocale } from '@/locales'
import { getJsonImportWarningMessage, shouldRecommendDbImport } from '../../../src/utils/wallos-import'
describe('wallos import ui helpers', () => {
it('recommends db import for json file selection or json preview type', () => {
@@ -10,7 +11,9 @@ describe('wallos import ui helpers', () => {
})
it('provides a stable warning message', () => {
expect(JSON_IMPORT_WARNING_MESSAGE).toContain('推荐优先使用 Wallos DB 导入')
expect(JSON_IMPORT_WARNING_MESSAGE).toContain('JSON')
setAppLocale('zh-CN')
const message = getJsonImportWarningMessage()
expect(message).toContain('推荐优先使用 Wallos DB 导入')
expect(message).toContain('JSON')
})
})

83
package-lock.json generated
View File

@@ -61,6 +61,7 @@
"pinia": "^3.0.2",
"vue": "^3.5.13",
"vue-echarts": "^7.0.3",
"vue-i18n": "^11.4.2",
"vue-router": "^4.5.0"
},
"devDependencies": {
@@ -857,6 +858,67 @@
"toad-cache": "^3.7.0"
}
},
"node_modules/@intlify/core-base": {
"version": "11.4.2",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.2.tgz",
"integrity": "sha512-7fpuCcVmeLv2T9qHsARqGvh8xt+sV2fH+Q+gMHFwB/rPXzo85DpbJFKn7dBH1L5p0c2cSh2DW+2h/64EKrISmA==",
"license": "MIT",
"dependencies": {
"@intlify/devtools-types": "11.4.2",
"@intlify/message-compiler": "11.4.2",
"@intlify/shared": "11.4.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/devtools-types": {
"version": "11.4.2",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.2.tgz",
"integrity": "sha512-3u8EN1kB6EMSi96KXs5k7a8y2X2g4+h3X6iwVZU47cP4n+mTuq//WMjG588BzSp/2XQ/dTXo2BLUXX+XS+PNfA==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.2",
"@intlify/shared": "11.4.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.4.2",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.2.tgz",
"integrity": "sha512-a6CDSGSMTGrg0BjD97x8TBYPf7qQMDlZipJ6UDfv/pd4OIym8TMlHu3MsH0bTNnRdAG2D6EFEykIgiQPqvtTkA==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.4.2",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "11.4.2",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.2.tgz",
"integrity": "sha512-NzpHbguRCsOHDwxmlBa9qu/imc+/QWgsYUaK6FZeNC0wK8QfAbhqrktEp/haVzxU1aikH8IX4ytD+mfFEMi/9A==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -6062,6 +6124,27 @@
}
}
},
"node_modules/vue-i18n": {
"version": "11.4.2",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.2.tgz",
"integrity": "sha512-sADDeKXqAGsPX6tK3t3y2ZiMpbVWN12tG+MhTiJ06rVoh58eGtM4wFyw3uWGbVkXByVp9Ne/AP+nSSzI+J9OAQ==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.2",
"@intlify/devtools-types": "11.4.2",
"@intlify/shared": "11.4.2",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",