|
|
|
@@ -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;
|
|
|
|
|