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
+179 -7
View File
@@ -3,7 +3,7 @@
import { ref, onMounted, onBeforeUnmount, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
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 type { DataTableColumns, UploadFileInfo } from 'naive-ui'
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 { STORAGE_KEYS } from '../utils/storageKeys'
import { loadTransactionFilterPrefs, saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs'
import EmojiPicker from '../components/EmojiPicker.vue'
import BillImportModal from './BillImportModal.vue'
import TransactionDetailModal from '../components/TransactionDetailModal.vue'
import TransactionRelationEditorModal from '../components/TransactionRelationEditorModal.vue'
@@ -177,6 +178,23 @@ const aiTagNewLoading = ref(false) // 编辑弹窗内「生成新标签」请求
const aiTagSaving = ref(false) // 新标签保存状态
// AI 生成但尚未保存的新标签,需用户确认后再落库
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 aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
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 建议:覆盖分类并追加标签 */
async function handleAiApply() {
const selected = new Set(checkedAiRowKeys.value)
@@ -2264,10 +2381,11 @@ onBeforeUnmount(() => {
<NSelect v-model:value="form.type" :options="typeOptions" />
</NFormItem>
<NFormItem :label="t('transaction.column.category')">
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
<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;" />
<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; 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="aiNewLoading" @click="handleAiNewCategory">{{ t('ai.newCategory.button') }}</NButton>
<NButton style="flex-shrink: 0;" @click="openNewCategory">{{ t('transaction.newCategoryButton') }}</NButton>
</div>
</NFormItem>
<!-- AI 生成的新分类确认条:用户确认后才保存 -->
@@ -2281,10 +2399,11 @@ onBeforeUnmount(() => {
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" :placeholder="t('transaction.amountPlaceholder')" />
</NFormItem>
<NFormItem :label="t('transaction.column.channel')">
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
<NSelect v-model:value="form.channel" :options="channelOptions" clearable :placeholder="t('transaction.channelPlaceholder')" style="flex: 1;" />
<div class="form-inline">
<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="aiChannelNewLoading" @click="handleAiNewChannel">{{ t('ai.newChannel.button') }}</NButton>
<NButton style="flex-shrink: 0;" @click="openNewChannel">{{ t('transaction.newChannelButton') }}</NButton>
</div>
</NFormItem>
<!-- AI 生成的新渠道确认条:用户确认后才保存 -->
@@ -2301,7 +2420,7 @@ onBeforeUnmount(() => {
<NInput v-model:value="form.remark" type="textarea" :placeholder="t('transaction.remarkPlaceholder')" />
</NFormItem>
<NFormItem :label="t('transaction.column.tags')">
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
<div class="form-inline">
<NSelect
v-model:value="form.tagIds"
:options="tagFilterOptions"
@@ -2309,10 +2428,11 @@ onBeforeUnmount(() => {
clearable
filterable
: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="aiTagNewLoading" @click="handleAiNewTag">{{ t('ai.newTag.button') }}</NButton>
<NButton style="flex-shrink: 0;" @click="openNewTag">{{ t('transaction.newTagButton') }}</NButton>
</div>
</NFormItem>
<!-- AI 生成的新标签确认条:用户确认后才保存 -->
@@ -2330,6 +2450,50 @@ onBeforeUnmount(() => {
</NForm>
</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" />
@@ -2562,6 +2726,14 @@ onBeforeUnmount(() => {
color: #999;
font-size: 12px;
}
/* 表单行:选择框与按钮组,小屏自动换行避免横向溢出 */
.form-inline {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
flex-wrap: wrap;
}
.active-filters {
margin-top: 12px;
padding-top: 12px;