mirror of
https://github.com/Smile-QWQ/SubTracker.git
synced 2026-06-09 07:22:52 +08:00
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:
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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 的 JSON、SQLite 数据库或 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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
79
apps/web/src/locales/index.ts
Normal file
79
apps/web/src/locales/index.ts
Normal 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)
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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'] }),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
46
apps/web/src/utils/localized-message.ts
Normal file
46
apps/web/src/utils/localized-message.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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') }}")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('图片识别仍在进行中,外部模型响应较慢,请再稍候片刻。')
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
83
package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user