feat: 记账支持手动新建分类标签渠道并完善日历偏好与统计口径

前端在收支记录新增/编辑弹窗内提供直接新建分类、标签、渠道的入口,日历页面记住最近浏览年月并把统计口径贯通到当日明细与收支记录跳转,同时收敛小屏布局避免横向溢出。

变更内容:
- 记账弹窗新增「新建分类 / 新建标签 / 新建渠道」按钮与对应弹窗,创建后刷新选项并自动回填所选值(TransactionList.vue)
- 记账弹窗的分类、渠道、标签等表单项改用可换行的 form-inline 布局,避免小屏横向溢出(TransactionList.vue)
- 日历新增最近浏览年月偏好键并在进入时恢复上次年月,年视图切换年份时同步记录(Calendar.vue、storageKeys.ts)
- 日历统计口径同步作用于当日明细查询与跳转收支记录页的「计入统计」筛选(Calendar.vue)
- 日历月视图小屏下将当日收入与支出分两行展示(Calendar.vue)
- 补充新建分类、标签、渠道的三语文案(messages.ts)
This commit is contained in:
2026-10-01 14:03:57 +08:00
parent 338c72f7a5
commit 0aaceef235
4 changed files with 232 additions and 14 deletions
+3
View File
@@ -526,6 +526,9 @@ export default {
remarkPlaceholder: { zh: '可选备注说明', en: 'Optional remark', ja: '備考(任意)' }, remarkPlaceholder: { zh: '可选备注说明', en: 'Optional remark', ja: '備考(任意)' },
remarkHistoryClear: { zh: '清空历史', en: 'Clear history', ja: '履歴をクリア' }, remarkHistoryClear: { zh: '清空历史', en: 'Clear history', ja: '履歴をクリア' },
tagPlaceholder: { zh: '选择标签', en: 'Select tags', ja: 'タグを選択' }, tagPlaceholder: { zh: '选择标签', en: 'Select tags', ja: 'タグを選択' },
newCategoryButton: { zh: '新建分类', en: 'New Category', ja: 'カテゴリを新規作成' },
newTagButton: { zh: '新建标签', en: 'New Tag', ja: 'タグを新規作成' },
newChannelButton: { zh: '新建渠道', en: 'New Channel', ja: 'チャネルを新規作成' },
noRawData: { zh: '该记录没有原始账单数据', en: 'This record has no raw bill data', ja: 'この記録には元の明細データがありません' }, noRawData: { zh: '该记录没有原始账单数据', en: 'This record has no raw bill data', ja: 'この記録には元の明細データがありません' },
remarkHiddenSuccess: { zh: '已隐藏备注', en: 'Remark hidden', ja: '備考を非表示にしました' }, remarkHiddenSuccess: { zh: '已隐藏备注', en: 'Remark hidden', ja: '備考を非表示にしました' },
remarkShownSuccess: { zh: '已取消隐藏', en: 'Remark unhidden', ja: '備考の非表示を解除しました' }, remarkShownSuccess: { zh: '已取消隐藏', en: 'Remark unhidden', ja: '備考の非表示を解除しました' },
+4
View File
@@ -15,6 +15,10 @@ export const STORAGE_KEYS = {
dashboardPrefs: 'ledgerly.dashboard.prefs', dashboardPrefs: 'ledgerly.dashboard.prefs',
// 日历视图模式(month-月视图 / year-年视图) // 日历视图模式(month-月视图 / year-年视图)
calendarView: 'ledgerly.calendar.view', calendarView: 'ledgerly.calendar.view',
// 日历最近浏览的年份(月视图与年视图共用,字符串形式的四位年份)
calendarYear: 'ledgerly.calendar.year',
// 日历月视图最近浏览的月份(字符串形式的 1-12)
calendarMonth: 'ledgerly.calendar.month',
// 日历统计口径(INCLUDED-仅计入 / ALL-全部 / EXCLUDED-仅不计入) // 日历统计口径(INCLUDED-仅计入 / ALL-全部 / EXCLUDED-仅不计入)
calendarStatsScope: 'ledgerly.calendar.statsScope', calendarStatsScope: 'ledgerly.calendar.statsScope',
// 统计面板 AI 查询历史 // 统计面板 AI 查询历史
+46 -7
View File
@@ -69,6 +69,20 @@ const statsScope = useStringPreference(STORAGE_KEYS.calendarStatsScope, 'INCLUDE
/** 传给统计接口的口径参数(限定为接口联合类型) */ /** 传给统计接口的口径参数(限定为接口联合类型) */
const scopeParam = computed(() => statsScope.value as 'INCLUDED' | 'ALL' | 'EXCLUDED') const scopeParam = computed(() => statsScope.value as 'INCLUDED' | 'ALL' | 'EXCLUDED')
/** 当前统计口径对应的明细过滤参数:INCLUDED-仅计入、EXCLUDED-仅不计入、ALL-全部不传 */
function statsScopeFilter(): boolean | undefined {
if (statsScope.value === 'INCLUDED') return true
if (statsScope.value === 'EXCLUDED') return false
return undefined
}
/** 当前统计口径对应收支记录页的筛选值:'true'-计入、'false'-不计入、null-全部 */
function statsScopePref(): string | null {
if (statsScope.value === 'INCLUDED') return 'true'
if (statsScope.value === 'EXCLUDED') return 'false'
return null
}
/** 统计口径选项 */ /** 统计口径选项 */
const statsScopeOptions = computed(() => [ const statsScopeOptions = computed(() => [
{ label: t('dashboard.statsScope.included'), value: 'INCLUDED' }, { label: t('dashboard.statsScope.included'), value: 'INCLUDED' },
@@ -76,11 +90,20 @@ const statsScopeOptions = computed(() => [
{ label: t('dashboard.statsScope.excluded'), value: 'EXCLUDED' }, { label: t('dashboard.statsScope.excluded'), value: 'EXCLUDED' },
]) ])
// 月视图面板年月(月份为 1-based) // 最近浏览的年月偏好:字符串存取,非法值回退当前年月(URL 参数优先,见下方初始化)
const panelYear = ref(new Date().getFullYear()) const initialDate = new Date()
const panelMonth = ref(new Date().getMonth() + 1) const lastYearPref = useStringPreference(STORAGE_KEYS.calendarYear, String(initialDate.getFullYear()), {
validate: (v) => /^\d{4}$/.test(v) && Number(v) > 0,
})
const lastMonthPref = useStringPreference(STORAGE_KEYS.calendarMonth, String(initialDate.getMonth() + 1), {
validate: (v) => /^\d{1,2}$/.test(v) && Number(v) >= 1 && Number(v) <= 12,
})
// 月视图面板年月(月份为 1-based):默认恢复上次浏览,URL 参数会在下方覆盖
const panelYear = ref(Number(lastYearPref.value))
const panelMonth = ref(Number(lastMonthPref.value))
// 年视图当前年份 // 年视图当前年份
const yearViewYear = ref(new Date().getFullYear()) const yearViewYear = ref(panelYear.value)
// 选中日期的时间戳(默认今天) // 选中日期的时间戳(默认今天)
const selectedTs = ref<number>(new Date().setHours(0, 0, 0, 0)) const selectedTs = ref<number>(new Date().setHours(0, 0, 0, 0))
// NCalendar 依赖 defaultValue 决定初始面板,程序化切月时通过自增该 key 重建组件 // NCalendar 依赖 defaultValue 决定初始面板,程序化切月时通过自增该 key 重建组件
@@ -365,6 +388,7 @@ async function openDetail(year: number, month: number, date: number) {
size: DETAIL_PAGE_SIZE, size: DETAIL_PAGE_SIZE,
startTime: `${key}T00:00:00`, startTime: `${key}T00:00:00`,
endTime: `${key}T23:59:59`, endTime: `${key}T23:59:59`,
includeInStats: statsScopeFilter(),
sortField: 'transactionTime', sortField: 'transactionTime',
sortOrder: 'asc', sortOrder: 'asc',
}) })
@@ -386,6 +410,7 @@ function goToTransactions() {
new Date(y, m - 1, d, 0, 0, 0).getTime(), new Date(y, m - 1, d, 0, 0, 0).getTime(),
new Date(y, m - 1, d, 23, 59, 59).getTime(), new Date(y, m - 1, d, 23, 59, 59).getTime(),
], ],
filterIncludeInStats: statsScopePref(),
}) })
detailShow.value = false detailShow.value = false
router.push('/transactions') router.push('/transactions')
@@ -419,6 +444,19 @@ watch([() => panelYear.value, () => panelMonth.value, () => yearViewYear.value,
immediate: true, immediate: true,
}) })
// 浏览年月变化时写入偏好,供下次进入日历恢复上次年月
watch([panelYear, panelMonth], ([y, m]) => {
lastYearPref.value = String(y)
lastMonthPref.value = String(m)
})
// 年视图切换年份时写入年份偏好,使下次进入年视图也恢复上次年份
watch(yearViewYear, (y) => {
if (viewMode.value === 'year') {
lastYearPref.value = String(y)
}
})
// 切换统计口径时按当前视图重新加载数据 // 切换统计口径时按当前视图重新加载数据
watch(statsScope, () => { watch(statsScope, () => {
if (viewMode.value === 'year') { if (viewMode.value === 'year') {
@@ -562,9 +600,10 @@ onMounted(() => {
<div v-if="year === panelYear && month === panelMonth" class="day-cell"> <div v-if="year === panelYear && month === panelMonth" class="day-cell">
<template v-for="d in [daySummary(dateKey(year, month, date))]" :key="`cell-${date}`"> <template v-for="d in [daySummary(dateKey(year, month, date))]" :key="`cell-${date}`">
<template v-if="d"> <template v-if="d">
<div v-if="isMobile" class="day-cell__row" :style="{ color: d.expense > 0 ? '#d03050' : '#18a058' }"> <template v-if="isMobile">
{{ d.expense > 0 ? `-${moneyCompact(d.expense)}` : `+${moneyCompact(d.income)}` }} <div v-if="d.income > 0" class="day-cell__row day-cell__row--income">+{{ moneyCompact(d.income) }}</div>
</div> <div v-if="d.expense > 0" class="day-cell__row day-cell__row--expense">-{{ moneyCompact(d.expense) }}</div>
</template>
<template v-else> <template v-else>
<div v-if="d.income > 0" class="day-cell__row day-cell__row--income">+{{ money(d.income) }}</div> <div v-if="d.income > 0" class="day-cell__row day-cell__row--income">+{{ money(d.income) }}</div>
<div v-if="d.expense > 0" class="day-cell__row day-cell__row--expense">-{{ money(d.expense) }}</div> <div v-if="d.expense > 0" class="day-cell__row day-cell__row--expense">-{{ money(d.expense) }}</div>
+179 -7
View File
@@ -3,7 +3,7 @@
import { ref, onMounted, onBeforeUnmount, computed, h, watch } from 'vue' import { ref, onMounted, onBeforeUnmount, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NUpload, NUploadDragger, NTabs, NTabPane } from 'naive-ui' import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NUpload, NUploadDragger, NTabs, NTabPane, NColorPicker } from 'naive-ui'
import { EyeOutline, EyeOffOutline, SparklesOutline, RefreshOutline, CloudUploadOutline, CheckmarkCircleOutline, StatsChartOutline } from '@vicons/ionicons5' import { EyeOutline, EyeOffOutline, SparklesOutline, RefreshOutline, CloudUploadOutline, CheckmarkCircleOutline, StatsChartOutline } from '@vicons/ionicons5'
import type { DataTableColumns, UploadFileInfo } from 'naive-ui' import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions, batchCreateTransactions } from '../api/transaction' import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions, batchCreateTransactions } from '../api/transaction'
@@ -23,6 +23,7 @@ import { readTablePageSize, writeTablePageSize } from '../utils/tablePageSizePre
import { openSse } from '../utils/sse' import { openSse } from '../utils/sse'
import { STORAGE_KEYS } from '../utils/storageKeys' import { STORAGE_KEYS } from '../utils/storageKeys'
import { loadTransactionFilterPrefs, saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs' import { loadTransactionFilterPrefs, saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs'
import EmojiPicker from '../components/EmojiPicker.vue'
import BillImportModal from './BillImportModal.vue' import BillImportModal from './BillImportModal.vue'
import TransactionDetailModal from '../components/TransactionDetailModal.vue' import TransactionDetailModal from '../components/TransactionDetailModal.vue'
import TransactionRelationEditorModal from '../components/TransactionRelationEditorModal.vue' import TransactionRelationEditorModal from '../components/TransactionRelationEditorModal.vue'
@@ -177,6 +178,23 @@ const aiTagNewLoading = ref(false) // 编辑弹窗内「生成新标签」请求
const aiTagSaving = ref(false) // 新标签保存状态 const aiTagSaving = ref(false) // 新标签保存状态
// AI 生成但尚未保存的新标签,需用户确认后再落库 // AI 生成但尚未保存的新标签,需用户确认后再落库
const pendingTag = ref<{ name: string; color: string } | null>(null) const pendingTag = ref<{ name: string; color: string } | null>(null)
// 手动新建分类弹窗:与 AI 新建相互独立,不受 AI 启用状态控制
const showNewCategoryModal = ref(false) // 新建分类弹窗显示状态
const newCategorySaving = ref(false) // 新建分类保存状态
const newCategoryForm = ref<{ name: string; type: string; icon: string }>({ name: '', type: 'EXPENSE', icon: '' })
// 手动新建标签弹窗
const showNewTagModal = ref(false) // 新建标签弹窗显示状态
const newTagSaving = ref(false) // 新建标签保存状态
const newTagForm = ref<{ name: string; color: string }>({ name: '', color: '#18a058' })
// 手动新建渠道弹窗
const showNewChannelModal = ref(false) // 新建渠道弹窗显示状态
const newChannelSaving = ref(false) // 新建渠道保存状态
const newChannelForm = ref<{ name: string }>({ name: '' })
// 新建标签可选预设色板(与标签管理页保持一致)
const newTagColorSwatches = [
'#18a058', '#2080f0', '#f0a020', '#d03050', '#7c3aed', '#13c2c2',
'#eb2f96', '#fa8c16', '#2f54eb', '#52c41a', '#8c8c8c', '#000000',
]
const aiApplying = ref(false) // AI 建议应用状态 const aiApplying = ref(false) // AI 建议应用状态
const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表 const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代表记录ID const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代表记录ID
@@ -1238,6 +1256,105 @@ function addTagToForm(tagId: number) {
} }
} }
/** 打开手动新建分类弹窗,类型默认跟随当前账单类型 */
function openNewCategory() {
newCategoryForm.value = { name: '', type: form.value.type, icon: '' }
showNewCategoryModal.value = true
}
/** 手动新建分类并选用:创建后刷新分类列表,回填分类ID并同步收支类型 */
async function handleCreateCategory() {
const name = newCategoryForm.value.name.trim()
if (!name) {
message.warning(t('category.nameRequired'))
return
}
newCategorySaving.value = true
try {
await createCategory({
name,
type: newCategoryForm.value.type,
icon: newCategoryForm.value.icon || undefined,
sortOrder: 0,
})
await fetchCategories()
// 排序值相同,取同名同类型中的最后一个(最新创建)
const created = [...categories.value].reverse().find((c) => c.label === name && c.type === newCategoryForm.value.type)
if (created) {
form.value.categoryId = created.value
// 同步收支类型,避免新建分类与当前账单类型不一致导致选中项不可见
form.value.type = created.type
}
message.success(t('common.createSuccess'))
showNewCategoryModal.value = false
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
newCategorySaving.value = false
}
}
/** 打开手动新建标签弹窗,使用默认颜色 */
function openNewTag() {
newTagForm.value = { name: '', color: '#18a058' }
showNewTagModal.value = true
}
/** 手动新建标签并选用:创建后刷新标签列表并追加到已选标签 */
async function handleCreateTag() {
const name = newTagForm.value.name.trim()
if (!name) {
message.warning(t('tag.nameRequired'))
return
}
newTagSaving.value = true
try {
await createTag({ name, color: newTagForm.value.color, sortOrder: 0 })
await fetchTags()
// 排序值相同,取同名中的最后一个(最新创建)
const created = [...allTags.value].reverse().find((tag) => tag.name === name)
if (created) {
addTagToForm(created.id)
}
message.success(t('common.createSuccess'))
showNewTagModal.value = false
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
newTagSaving.value = false
}
}
/** 打开手动新建渠道弹窗 */
function openNewChannel() {
newChannelForm.value = { name: '' }
showNewChannelModal.value = true
}
/** 手动新建渠道并选用:创建后刷新渠道列表并回填渠道编码 */
async function handleCreateChannel() {
const name = newChannelForm.value.name.trim()
if (!name) {
message.warning(t('channel.nameRequired'))
return
}
newChannelSaving.value = true
try {
const res: any = await createChannel(name)
await loadChannels()
const created = res?.data
if (created?.code) {
form.value.channel = created.code
}
message.success(t('common.createSuccess'))
showNewChannelModal.value = false
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
newChannelSaving.value = false
}
}
/** 一键应用全部 AI 建议:覆盖分类并追加标签 */ /** 一键应用全部 AI 建议:覆盖分类并追加标签 */
async function handleAiApply() { async function handleAiApply() {
const selected = new Set(checkedAiRowKeys.value) const selected = new Set(checkedAiRowKeys.value)
@@ -2264,10 +2381,11 @@ onBeforeUnmount(() => {
<NSelect v-model:value="form.type" :options="typeOptions" /> <NSelect v-model:value="form.type" :options="typeOptions" />
</NFormItem> </NFormItem>
<NFormItem :label="t('transaction.column.category')"> <NFormItem :label="t('transaction.column.category')">
<div style="display: flex; align-items: center; gap: 8px; width: 100%;"> <div class="form-inline">
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" :render-label="renderCategoryLabel" :placeholder="t('transaction.selectCategoryPlaceholder')" filterable style="flex: 1;" /> <NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" :render-label="renderCategoryLabel" :placeholder="t('transaction.selectCategoryPlaceholder')" filterable style="flex: 1; min-width: 160px;" />
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiFormLoading" @click="handleAiSuggestOne">{{ t('ai.pickButton') }}</NButton> <NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiFormLoading" @click="handleAiSuggestOne">{{ t('ai.pickButton') }}</NButton>
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiNewLoading" @click="handleAiNewCategory">{{ t('ai.newCategory.button') }}</NButton> <NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiNewLoading" @click="handleAiNewCategory">{{ t('ai.newCategory.button') }}</NButton>
<NButton style="flex-shrink: 0;" @click="openNewCategory">{{ t('transaction.newCategoryButton') }}</NButton>
</div> </div>
</NFormItem> </NFormItem>
<!-- AI 生成的新分类确认条:用户确认后才保存 --> <!-- AI 生成的新分类确认条:用户确认后才保存 -->
@@ -2281,10 +2399,11 @@ onBeforeUnmount(() => {
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" :placeholder="t('transaction.amountPlaceholder')" /> <NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" :placeholder="t('transaction.amountPlaceholder')" />
</NFormItem> </NFormItem>
<NFormItem :label="t('transaction.column.channel')"> <NFormItem :label="t('transaction.column.channel')">
<div style="display: flex; align-items: center; gap: 8px; width: 100%;"> <div class="form-inline">
<NSelect v-model:value="form.channel" :options="channelOptions" clearable :placeholder="t('transaction.channelPlaceholder')" style="flex: 1;" /> <NSelect v-model:value="form.channel" :options="channelOptions" clearable :placeholder="t('transaction.channelPlaceholder')" style="flex: 1; min-width: 160px;" />
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiChannelLoading" @click="handleAiChannelSuggest">{{ t('ai.pickButton') }}</NButton> <NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiChannelLoading" @click="handleAiChannelSuggest">{{ t('ai.pickButton') }}</NButton>
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiChannelNewLoading" @click="handleAiNewChannel">{{ t('ai.newChannel.button') }}</NButton> <NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiChannelNewLoading" @click="handleAiNewChannel">{{ t('ai.newChannel.button') }}</NButton>
<NButton style="flex-shrink: 0;" @click="openNewChannel">{{ t('transaction.newChannelButton') }}</NButton>
</div> </div>
</NFormItem> </NFormItem>
<!-- AI 生成的新渠道确认条:用户确认后才保存 --> <!-- AI 生成的新渠道确认条:用户确认后才保存 -->
@@ -2301,7 +2420,7 @@ onBeforeUnmount(() => {
<NInput v-model:value="form.remark" type="textarea" :placeholder="t('transaction.remarkPlaceholder')" /> <NInput v-model:value="form.remark" type="textarea" :placeholder="t('transaction.remarkPlaceholder')" />
</NFormItem> </NFormItem>
<NFormItem :label="t('transaction.column.tags')"> <NFormItem :label="t('transaction.column.tags')">
<div style="display: flex; align-items: center; gap: 8px; width: 100%;"> <div class="form-inline">
<NSelect <NSelect
v-model:value="form.tagIds" v-model:value="form.tagIds"
:options="tagFilterOptions" :options="tagFilterOptions"
@@ -2309,10 +2428,11 @@ onBeforeUnmount(() => {
clearable clearable
filterable filterable
:placeholder="t('transaction.tagPlaceholder')" :placeholder="t('transaction.tagPlaceholder')"
style="flex: 1;" style="flex: 1; min-width: 160px;"
/> />
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiTagSuggestLoading" @click="handleAiTagSuggest">{{ t('ai.pickButton') }}</NButton> <NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiTagSuggestLoading" @click="handleAiTagSuggest">{{ t('ai.pickButton') }}</NButton>
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiTagNewLoading" @click="handleAiNewTag">{{ t('ai.newTag.button') }}</NButton> <NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiTagNewLoading" @click="handleAiNewTag">{{ t('ai.newTag.button') }}</NButton>
<NButton style="flex-shrink: 0;" @click="openNewTag">{{ t('transaction.newTagButton') }}</NButton>
</div> </div>
</NFormItem> </NFormItem>
<!-- AI 生成的新标签确认条:用户确认后才保存 --> <!-- AI 生成的新标签确认条:用户确认后才保存 -->
@@ -2330,6 +2450,50 @@ onBeforeUnmount(() => {
</NForm> </NForm>
</NModal> </NModal>
<!-- 手动新建分类弹窗 -->
<NModal v-model:show="showNewCategoryModal" preset="card" :title="t('category.addTitle')" style="width: 450px; max-width: 92vw;">
<NForm>
<NFormItem :label="t('category.name')">
<NInput v-model:value="newCategoryForm.name" :placeholder="t('category.namePlaceholder')" @keyup.enter="handleCreateCategory" />
</NFormItem>
<NFormItem :label="t('category.type')">
<NSelect v-model:value="newCategoryForm.type" :options="typeOptions" />
</NFormItem>
<NFormItem :label="t('category.icon')">
<EmojiPicker
v-model="newCategoryForm.icon"
:placeholder="t('category.iconPlaceholder')"
:input-placeholder="t('category.emojiInputPlaceholder')"
:clear-text="t('category.clearIcon')"
/>
</NFormItem>
<NButton type="primary" block :loading="newCategorySaving" @click="handleCreateCategory">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
<!-- 手动新建标签弹窗 -->
<NModal v-model:show="showNewTagModal" preset="card" :title="t('tag.addTitle')" style="width: 450px; max-width: 92vw;">
<NForm>
<NFormItem :label="t('tag.name')">
<NInput v-model:value="newTagForm.name" :placeholder="t('tag.namePlaceholder')" @keyup.enter="handleCreateTag" />
</NFormItem>
<NFormItem :label="t('tag.color')">
<NColorPicker v-model:value="newTagForm.color" :swatches="newTagColorSwatches" :show-alpha="false" />
</NFormItem>
<NButton type="primary" block :loading="newTagSaving" @click="handleCreateTag">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
<!-- 手动新建渠道弹窗 -->
<NModal v-model:show="showNewChannelModal" preset="card" :title="t('channel.addTitle')" style="width: 450px; max-width: 92vw;">
<NForm>
<NFormItem :label="t('channel.name')">
<NInput v-model:value="newChannelForm.name" :placeholder="t('channel.namePlaceholder')" @keyup.enter="handleCreateChannel" />
</NFormItem>
<NButton type="primary" block :loading="newChannelSaving" @click="handleCreateChannel">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
<!-- 账单导入弹窗 --> <!-- 账单导入弹窗 -->
<BillImportModal v-model:show="showImportModal" @imported="fetchData" /> <BillImportModal v-model:show="showImportModal" @imported="fetchData" />
@@ -2562,6 +2726,14 @@ onBeforeUnmount(() => {
color: #999; color: #999;
font-size: 12px; font-size: 12px;
} }
/* 表单行:选择框与按钮组,小屏自动换行避免横向溢出 */
.form-inline {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
flex-wrap: wrap;
}
.active-filters { .active-filters {
margin-top: 12px; margin-top: 12px;
padding-top: 12px; padding-top: 12px;