feat: 新增定时账单导入提醒与通知直达导入页
- 新增通知配置、渠道、发送日志三张表(MySQL/SQLite 双建表脚本)及实体、Mapper、Service、Controller - 支持邮箱(SMTP)与 Server 酱两种推送渠道,抽象 NotificationSender 工厂统一发送 - 新增 NotificationScheduler 定时任务,按每天/每周/每月周期检查并推送账单导入提醒 - 新增全局通知链接地址配置(/api/app/notification-url),随提醒一并下发 - 前端系统设置页新增「定时通知」面板,支持周期配置、渠道增删改测、立即发送与发送日志 - 新增 /bill-import 直达页,供提醒链接一键打开账单上传弹窗 - 登录支持 redirect 回跳,仅允许站内路径以防开放重定向
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
// 定时通知相关 API 接口:提醒周期配置、推送渠道管理与发送日志
|
||||
import request from './request'
|
||||
|
||||
/** 通知周期配置 */
|
||||
export interface NotificationConfig {
|
||||
enabled: boolean
|
||||
frequency: string
|
||||
dayOfMonth: number
|
||||
dayOfWeek: number
|
||||
sendTime: string
|
||||
lastSentAt: string | null
|
||||
}
|
||||
|
||||
/** 通知渠道 */
|
||||
export interface NotificationChannel {
|
||||
id: number
|
||||
type: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
config: Record<string, any>
|
||||
hasPassword: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** 通知发送日志 */
|
||||
export interface NotificationLog {
|
||||
id: number
|
||||
channelId: number | null
|
||||
channelType: string | null
|
||||
success: boolean
|
||||
message: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 获取当前用户的通知配置 */
|
||||
export function getNotificationConfig() {
|
||||
return request.get('/notification/config')
|
||||
}
|
||||
|
||||
/** 保存当前用户的通知配置 */
|
||||
export function saveNotificationConfig(data: {
|
||||
enabled: boolean
|
||||
frequency: string
|
||||
dayOfMonth: number
|
||||
dayOfWeek: number
|
||||
sendTime: string
|
||||
}) {
|
||||
return request.put('/notification/config', data)
|
||||
}
|
||||
|
||||
/** 查询通知渠道列表 */
|
||||
export function listNotificationChannels() {
|
||||
return request.get('/notification/channels')
|
||||
}
|
||||
|
||||
/** 新增通知渠道 */
|
||||
export function createNotificationChannel(data: {
|
||||
type: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
config: Record<string, any>
|
||||
}) {
|
||||
return request.post('/notification/channels', data)
|
||||
}
|
||||
|
||||
/** 修改通知渠道,邮箱授权码留空表示不修改 */
|
||||
export function updateNotificationChannel(id: number, data: {
|
||||
type: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
config: Record<string, any>
|
||||
}) {
|
||||
return request.put(`/notification/channels/${id}`, data)
|
||||
}
|
||||
|
||||
/** 删除通知渠道 */
|
||||
export function deleteNotificationChannel(id: number) {
|
||||
return request.delete(`/notification/channels/${id}`)
|
||||
}
|
||||
|
||||
/** 向指定渠道发送测试消息(耗时取决于渠道,放宽超时) */
|
||||
export function testNotificationChannel(id: number) {
|
||||
return request.post(`/notification/channels/${id}/test`, {}, { timeout: 60000 })
|
||||
}
|
||||
|
||||
/** 立即向所有已启用渠道发送账单导入提醒 */
|
||||
export function sendNotificationNow() {
|
||||
return request.post('/notification/send-now', {}, { timeout: 60000 })
|
||||
}
|
||||
|
||||
/** 查询最近的发送日志 */
|
||||
export function listNotificationLogs(limit = 20) {
|
||||
return request.get('/notification/logs', { params: { limit } })
|
||||
}
|
||||
|
||||
/** 获取通知链接地址(全局应用配置) */
|
||||
export function getNotificationUrl() {
|
||||
return request.get('/app/notification-url')
|
||||
}
|
||||
|
||||
/** 保存通知链接地址(全局应用配置) */
|
||||
export function updateNotificationUrl(url: string) {
|
||||
return request.put('/app/notification-url', { url })
|
||||
}
|
||||
@@ -146,7 +146,7 @@ onMounted(loadConfig)
|
||||
</NInput>
|
||||
</NFormItem>
|
||||
<NFormItem>
|
||||
<NSpace>
|
||||
<NSpace justify="end" style="width: 100%;">
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">
|
||||
<template #icon>
|
||||
<NIcon :component="SaveOutline" />
|
||||
|
||||
@@ -208,7 +208,7 @@ onMounted(loadStatus)
|
||||
</template>
|
||||
|
||||
<NFormItem>
|
||||
<NSpace>
|
||||
<NSpace justify="end" style="width: 100%;">
|
||||
<NButton :loading="testing" @click="handleTest">
|
||||
<template #icon>
|
||||
<NIcon :component="FlashOutline" />
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
<script setup lang="ts">
|
||||
// 定时通知配置面板:设置账单导入提醒周期,管理邮箱/Server 酱推送渠道,并查看发送日志。
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
useMessage, useDialog, NCard, NForm, NFormItem, NInput, NInputNumber, NSwitch, NButton,
|
||||
NSpace, NAlert, NText, NTag, NIcon, NSelect, NModal, NDivider,
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
NotificationsOutline, SaveOutline, AddOutline, TrashOutline, CreateOutline, SendOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import {
|
||||
getNotificationConfig, saveNotificationConfig, listNotificationChannels,
|
||||
createNotificationChannel, updateNotificationChannel, deleteNotificationChannel,
|
||||
testNotificationChannel, sendNotificationNow, listNotificationLogs,
|
||||
getNotificationUrl, updateNotificationUrl,
|
||||
} from '../api/notification'
|
||||
import type { NotificationChannel, NotificationLog } from '../api/notification'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 提醒周期表单
|
||||
const config = ref({ enabled: false, frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1, sendTime: '09:00' })
|
||||
const saving = ref(false)
|
||||
const sendingNow = ref(false)
|
||||
|
||||
// 通知链接地址(全局应用配置,用于通知正文中的跳转链接)
|
||||
const linkUrl = ref('')
|
||||
const savingLink = ref(false)
|
||||
|
||||
// 渠道与日志
|
||||
const channels = ref<NotificationChannel[]>([])
|
||||
const logs = ref<NotificationLog[]>([])
|
||||
const testingId = ref<number | null>(null)
|
||||
|
||||
// 渠道编辑弹窗
|
||||
const showChannelModal = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const editingHasPassword = ref(false)
|
||||
const savingChannel = ref(false)
|
||||
const channelForm = ref(emptyChannelForm())
|
||||
|
||||
/** 渠道表单默认值 */
|
||||
function emptyChannelForm() {
|
||||
return {
|
||||
type: 'SERVERCHAN',
|
||||
name: '',
|
||||
enabled: true,
|
||||
sendKey: '',
|
||||
smtpHost: 'smtp.qq.com',
|
||||
smtpPort: 465,
|
||||
username: '',
|
||||
password: '',
|
||||
to: '',
|
||||
ssl: true,
|
||||
}
|
||||
}
|
||||
|
||||
const frequencyOptions = computed(() => [
|
||||
{ label: t('notification.freqDaily'), value: 'DAILY' },
|
||||
{ label: t('notification.freqWeekly'), value: 'WEEKLY' },
|
||||
{ label: t('notification.freqMonthly'), value: 'MONTHLY' },
|
||||
])
|
||||
|
||||
const weekOptions = computed(() => [
|
||||
{ label: t('notification.weekday1'), value: 1 },
|
||||
{ label: t('notification.weekday2'), value: 2 },
|
||||
{ label: t('notification.weekday3'), value: 3 },
|
||||
{ label: t('notification.weekday4'), value: 4 },
|
||||
{ label: t('notification.weekday5'), value: 5 },
|
||||
{ label: t('notification.weekday6'), value: 6 },
|
||||
{ label: t('notification.weekday7'), value: 7 },
|
||||
])
|
||||
|
||||
// 每月提醒日限制在 1-28,避免不同月份天数差异
|
||||
const monthDayOptions = computed(() => Array.from({ length: 28 }, (_, i) => ({ label: String(i + 1), value: i + 1 })))
|
||||
|
||||
// 前端开放 Server 酱与邮箱两种推送渠道
|
||||
const channelTypeOptions = computed(() => [
|
||||
{ label: t('notification.typeServerChan'), value: 'SERVERCHAN' },
|
||||
{ label: t('notification.typeEmail'), value: 'EMAIL' },
|
||||
])
|
||||
|
||||
/** 渠道类型显示名 */
|
||||
function channelTypeLabel(type: string | null) {
|
||||
if (type === 'SERVERCHAN') return t('notification.typeServerChan')
|
||||
if (type === 'EMAIL') return t('notification.typeEmail')
|
||||
return type || ''
|
||||
}
|
||||
|
||||
/** 渠道类型标签颜色 */
|
||||
function channelTagType(type: string) {
|
||||
if (type === 'SERVERCHAN') return 'success'
|
||||
if (type === 'EMAIL') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
/** 格式化后端时间字符串用于展示 */
|
||||
function formatTime(value: string | null) {
|
||||
return value ? value.replace('T', ' ').slice(0, 19) : ''
|
||||
}
|
||||
|
||||
/** 加载通知配置、渠道与日志 */
|
||||
async function loadAll() {
|
||||
try {
|
||||
const [configRes, channelRes, logRes, linkRes]: any = await Promise.all([
|
||||
getNotificationConfig(), listNotificationChannels(), listNotificationLogs(20), getNotificationUrl(),
|
||||
])
|
||||
linkUrl.value = linkRes.data || ''
|
||||
const data = configRes.data
|
||||
config.value = {
|
||||
enabled: !!data.enabled,
|
||||
frequency: data.frequency || 'MONTHLY',
|
||||
dayOfMonth: data.dayOfMonth || 1,
|
||||
dayOfWeek: data.dayOfWeek || 1,
|
||||
sendTime: data.sendTime || '09:00',
|
||||
}
|
||||
channels.value = channelRes.data || []
|
||||
logs.value = logRes.data || []
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('notification.loadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存通知链接地址(全局应用配置) */
|
||||
async function handleSaveLinkUrl() {
|
||||
savingLink.value = true
|
||||
try {
|
||||
const res: any = await updateNotificationUrl(linkUrl.value.trim())
|
||||
linkUrl.value = res.data || ''
|
||||
message.success(t('notification.linkUrlSaveSuccess'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('notification.linkUrlSaveFailed'))
|
||||
} finally {
|
||||
savingLink.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存提醒周期 */
|
||||
async function handleSaveConfig() {
|
||||
if (!/^\d{2}:\d{2}$/.test(config.value.sendTime)) {
|
||||
message.error(t('notification.sendTimeInvalid'))
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await saveNotificationConfig({ ...config.value })
|
||||
message.success(t('notification.saveSuccess'))
|
||||
await loadAll()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.operationFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开渠道弹窗,传入渠道表示编辑 */
|
||||
function openChannelModal(channel: NotificationChannel | null) {
|
||||
editingId.value = channel ? channel.id : null
|
||||
editingHasPassword.value = !!channel?.hasPassword
|
||||
const base = emptyChannelForm()
|
||||
if (channel) {
|
||||
const cfg = channel.config || {}
|
||||
Object.assign(base, {
|
||||
type: channel.type,
|
||||
name: channel.name,
|
||||
enabled: channel.enabled,
|
||||
sendKey: cfg.sendKey || '',
|
||||
smtpHost: cfg.smtpHost || 'smtp.qq.com',
|
||||
smtpPort: cfg.smtpPort || 465,
|
||||
username: cfg.username || '',
|
||||
password: '',
|
||||
to: cfg.to || '',
|
||||
ssl: cfg.ssl !== false,
|
||||
})
|
||||
}
|
||||
channelForm.value = base
|
||||
showChannelModal.value = true
|
||||
}
|
||||
|
||||
/** 根据类型构建渠道配置,邮箱授权码为空时不提交 */
|
||||
function buildChannelConfig() {
|
||||
const form = channelForm.value
|
||||
if (form.type === 'SERVERCHAN') {
|
||||
return { sendKey: form.sendKey.trim() }
|
||||
}
|
||||
const config: Record<string, any> = {
|
||||
smtpHost: form.smtpHost.trim(),
|
||||
smtpPort: form.smtpPort,
|
||||
username: form.username.trim(),
|
||||
to: form.to.trim(),
|
||||
ssl: form.ssl,
|
||||
}
|
||||
if (form.password) {
|
||||
config.password = form.password
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
/** 保存渠道 */
|
||||
async function handleSaveChannel() {
|
||||
const form = channelForm.value
|
||||
if (form.type === 'SERVERCHAN') {
|
||||
if (!form.sendKey.trim()) {
|
||||
message.error(t('notification.sendKeyRequired'))
|
||||
return
|
||||
}
|
||||
} else if (!form.username.trim()) {
|
||||
message.error(t('notification.usernameRequired'))
|
||||
return
|
||||
}
|
||||
savingChannel.value = true
|
||||
try {
|
||||
const payload = { type: form.type, name: form.name, enabled: form.enabled, config: buildChannelConfig() }
|
||||
if (editingId.value) {
|
||||
await updateNotificationChannel(editingId.value, payload)
|
||||
} else {
|
||||
await createNotificationChannel(payload)
|
||||
}
|
||||
message.success(t('notification.saveChannelSuccess'))
|
||||
showChannelModal.value = false
|
||||
await loadAll()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.operationFailed'))
|
||||
} finally {
|
||||
savingChannel.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 测试渠道 */
|
||||
async function handleTestChannel(channel: NotificationChannel) {
|
||||
testingId.value = channel.id
|
||||
try {
|
||||
await testNotificationChannel(channel.id)
|
||||
message.success(t('notification.testSuccess'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('notification.testFailed'))
|
||||
} finally {
|
||||
testingId.value = null
|
||||
await loadAll()
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除渠道 */
|
||||
function handleDeleteChannel(channel: NotificationChannel) {
|
||||
dialog.warning({
|
||||
title: t('common.confirmDelete'),
|
||||
content: t('notification.confirmDeleteChannel', { name: channel.name }),
|
||||
positiveText: t('common.confirm'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteNotificationChannel(channel.id)
|
||||
message.success(t('common.deleteSuccess'))
|
||||
await loadAll()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.deleteFailed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 立即发送提醒 */
|
||||
async function handleSendNow() {
|
||||
sendingNow.value = true
|
||||
try {
|
||||
const res: any = await sendNotificationNow()
|
||||
message.success(t('notification.sendNowSuccess', { count: res.data }))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('notification.sendNowFailed'))
|
||||
} finally {
|
||||
sendingNow.value = false
|
||||
await loadAll()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" class="config-card">
|
||||
<template #header>
|
||||
<NSpace align="center" :size="8">
|
||||
<NIcon :component="NotificationsOutline" />
|
||||
<span>{{ t('settings.notificationSection') }}</span>
|
||||
</NSpace>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<NSpace align="center" :size="8">
|
||||
<NTag :type="config.enabled ? 'success' : 'default'" size="small" :bordered="false">
|
||||
{{ config.enabled ? t('notification.statusOn') : t('notification.statusOff') }}
|
||||
</NTag>
|
||||
<NButton size="small" quaternary :loading="sendingNow" @click="handleSendNow">
|
||||
<template #icon>
|
||||
<NIcon :component="SendOutline" />
|
||||
</template>
|
||||
{{ t('notification.sendNow') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<NAlert type="info" :show-icon="true" style="margin-bottom: 20px;">
|
||||
{{ t('notification.intro') }}
|
||||
</NAlert>
|
||||
|
||||
<NForm label-placement="left" label-width="130">
|
||||
<NFormItem :label="t('notification.linkUrl')">
|
||||
<NSpace align="center" :size="8" style="width: 100%;">
|
||||
<NInput
|
||||
v-model:value="linkUrl"
|
||||
:placeholder="t('notification.linkUrlPlaceholder')"
|
||||
style="max-width: 420px"
|
||||
/>
|
||||
<NButton :loading="savingLink" @click="handleSaveLinkUrl">
|
||||
<template #icon>
|
||||
<NIcon :component="SaveOutline" />
|
||||
</template>
|
||||
{{ t('notification.save') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.enabled')">
|
||||
<div class="switch-row">
|
||||
<NSwitch v-model:value="config.enabled" />
|
||||
<NText depth="3" style="font-size: 12px;">{{ t('notification.enabledHint') }}</NText>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.frequency')">
|
||||
<NSpace align="center" :size="8">
|
||||
<NSelect v-model:value="config.frequency" :options="frequencyOptions" style="width: 120px" />
|
||||
<NSelect
|
||||
v-if="config.frequency === 'WEEKLY'"
|
||||
v-model:value="config.dayOfWeek"
|
||||
:options="weekOptions"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<template v-else-if="config.frequency === 'MONTHLY'">
|
||||
<NSelect v-model:value="config.dayOfMonth" :options="monthDayOptions" style="width: 90px" />
|
||||
<NText depth="3">{{ t('notification.dayOfMonthSuffix') }}</NText>
|
||||
</template>
|
||||
</NSpace>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.sendTime')">
|
||||
<NInput
|
||||
v-model:value="config.sendTime"
|
||||
:placeholder="t('notification.sendTimePlaceholder')"
|
||||
style="width: 160px"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem>
|
||||
<NSpace justify="end" style="width: 100%;">
|
||||
<NButton type="primary" :loading="saving" @click="handleSaveConfig">
|
||||
<template #icon>
|
||||
<NIcon :component="SaveOutline" />
|
||||
</template>
|
||||
{{ t('notification.save') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<NDivider />
|
||||
|
||||
<div class="section-header">
|
||||
<span class="section-title">{{ t('notification.channels') }}</span>
|
||||
<NButton size="small" @click="openChannelModal(null)">
|
||||
<template #icon>
|
||||
<NIcon :component="AddOutline" />
|
||||
</template>
|
||||
{{ t('notification.addChannel') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div v-if="channels.length" class="channel-list">
|
||||
<div v-for="channel in channels" :key="channel.id" class="channel-item">
|
||||
<div class="channel-main">
|
||||
<NTag size="small" :bordered="false" :type="channelTagType(channel.type)">
|
||||
{{ channelTypeLabel(channel.type) }}
|
||||
</NTag>
|
||||
<span class="channel-name">{{ channel.name }}</span>
|
||||
<NTag v-if="channel.enabled" size="small" type="success" :bordered="false">
|
||||
{{ t('notification.enabledLabel') }}
|
||||
</NTag>
|
||||
<NTag v-else size="small" :bordered="false">{{ t('notification.disabledLabel') }}</NTag>
|
||||
</div>
|
||||
<NSpace :size="4" align="center">
|
||||
<NButton size="tiny" quaternary :loading="testingId === channel.id" @click="handleTestChannel(channel)">
|
||||
{{ t('notification.test') }}
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary @click="openChannelModal(channel)">
|
||||
<template #icon>
|
||||
<NIcon :component="CreateOutline" />
|
||||
</template>
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary type="error" @click="handleDeleteChannel(channel)">
|
||||
<template #icon>
|
||||
<NIcon :component="TrashOutline" />
|
||||
</template>
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</div>
|
||||
<NText v-else depth="3">{{ t('notification.noChannels') }}</NText>
|
||||
|
||||
<NDivider />
|
||||
|
||||
<div class="section-header">
|
||||
<span class="section-title">{{ t('notification.logs') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length" class="log-list">
|
||||
<div v-for="log in logs" :key="log.id" class="log-item">
|
||||
<NTag size="tiny" :bordered="false" :type="log.success ? 'success' : 'error'">
|
||||
{{ log.success ? t('notification.success') : t('notification.failed') }}
|
||||
</NTag>
|
||||
<span class="log-time">{{ formatTime(log.createdAt) }}</span>
|
||||
<span class="log-channel">{{ channelTypeLabel(log.channelType) }}</span>
|
||||
<span class="log-message">{{ log.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<NText v-else depth="3">{{ t('notification.noLogs') }}</NText>
|
||||
|
||||
<NModal
|
||||
v-model:show="showChannelModal"
|
||||
preset="card"
|
||||
:title="editingId ? t('notification.editChannel') : t('notification.addChannel')"
|
||||
style="width: 560px"
|
||||
>
|
||||
<NForm label-placement="left" label-width="120">
|
||||
<NFormItem :label="t('notification.channelType')">
|
||||
<NSelect v-model:value="channelForm.type" :options="channelTypeOptions" :disabled="!!editingId" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.channelName')">
|
||||
<NInput v-model:value="channelForm.name" :placeholder="t('notification.channelNamePlaceholder')" />
|
||||
</NFormItem>
|
||||
|
||||
<template v-if="channelForm.type === 'SERVERCHAN'">
|
||||
<NFormItem :label="t('notification.sendKey')">
|
||||
<NInput v-model:value="channelForm.sendKey" :placeholder="t('notification.sendKeyPlaceholder')" />
|
||||
</NFormItem>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<NFormItem :label="t('notification.smtpHost')">
|
||||
<NInput v-model:value="channelForm.smtpHost" placeholder="smtp.qq.com" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.smtpPort')">
|
||||
<NInputNumber v-model:value="channelForm.smtpPort" :min="1" :max="65535" style="width: 160px" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.username')">
|
||||
<NInput v-model:value="channelForm.username" placeholder="example@qq.com" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.password')">
|
||||
<NInput
|
||||
v-model:value="channelForm.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="editingHasPassword ? t('notification.passwordKeep') : t('notification.passwordPlaceholder')"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.to')">
|
||||
<NInput v-model:value="channelForm.to" :placeholder="t('notification.toPlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.ssl')">
|
||||
<NSwitch v-model:value="channelForm.ssl" />
|
||||
</NFormItem>
|
||||
</template>
|
||||
|
||||
<NFormItem :label="t('notification.enabledLabel')">
|
||||
<NSwitch v-model:value="channelForm.enabled" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="showChannelModal = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="savingChannel" @click="handleSaveChannel">
|
||||
{{ t('common.confirm') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.channel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.channel-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #efefef;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.channel-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.channel-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
.log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.log-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.log-time {
|
||||
color: #999;
|
||||
}
|
||||
.log-channel {
|
||||
color: #666;
|
||||
min-width: 100px;
|
||||
}
|
||||
.log-message {
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -73,7 +73,7 @@ onMounted(loadStatus)
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
<NSpace style="margin-top: 16px;">
|
||||
<NSpace justify="end" style="margin-top: 16px; width: 100%;">
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">
|
||||
<template #icon>
|
||||
<NIcon :component="SaveOutline" />
|
||||
|
||||
@@ -128,6 +128,7 @@ export default {
|
||||
aiSection: { zh: 'AI 模型配置', en: 'AI Model', ja: 'AI モデル' },
|
||||
viewUsage: { zh: '使用情况', en: 'Usage', ja: '利用状況' },
|
||||
registrationSection: { zh: '注册设置', en: 'Registration', ja: '登録設定' },
|
||||
notificationSection: { zh: '定时通知', en: 'Scheduled Notifications', ja: '定期通知' },
|
||||
},
|
||||
|
||||
// 注册设置
|
||||
@@ -142,6 +143,73 @@ export default {
|
||||
loadFailed: { zh: '加载注册状态失败', en: 'Failed to load registration status', ja: '登録状態の読み込みに失敗しました' },
|
||||
},
|
||||
|
||||
// 定时通知
|
||||
notification: {
|
||||
statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' },
|
||||
statusOff: { zh: '已关闭', en: 'Disabled', ja: '無効' },
|
||||
intro: { zh: '按自定义周期,通过邮箱或 Server 酱提醒你及时导入新的账单。', en: 'Get reminded via email or ServerChan to import new bills on your own schedule.', ja: 'カスタム周期でメール・ServerChan を通じて、新しい明細のインポートをお知らせします。' },
|
||||
linkUrl: { zh: '通知链接地址', en: 'Notification link URL', ja: '通知リンク URL' },
|
||||
linkUrlPlaceholder: { zh: '如 https://finance.rosercode.cn/bill-import', en: 'e.g. https://finance.rosercode.cn/bill-import', ja: '例:https://finance.rosercode.cn/bill-import' },
|
||||
linkUrlSaveSuccess: { zh: '通知链接地址已保存', en: 'Notification link URL saved', ja: '通知リンク URL を保存しました' },
|
||||
linkUrlSaveFailed: { zh: '保存通知链接地址失败', en: 'Failed to save notification link URL', ja: '通知リンク URL の保存に失敗しました' },
|
||||
enabled: { zh: '启用定时提醒', en: 'Enable reminders', ja: '定期リマインダーを有効化' },
|
||||
enabledHint: { zh: '开启后按下方周期自动发送提醒', en: 'Sends reminders automatically based on the schedule below', ja: '有効にすると以下の周期で自動送信します' },
|
||||
frequency: { zh: '提醒周期', en: 'Frequency', ja: '通知周期' },
|
||||
freqDaily: { zh: '每天', en: 'Daily', ja: '毎日' },
|
||||
freqWeekly: { zh: '每周', en: 'Weekly', ja: '毎週' },
|
||||
freqMonthly: { zh: '每月', en: 'Monthly', ja: '毎月' },
|
||||
weekday1: { zh: '周一', en: 'Monday', ja: '月曜日' },
|
||||
weekday2: { zh: '周二', en: 'Tuesday', ja: '火曜日' },
|
||||
weekday3: { zh: '周三', en: 'Wednesday', ja: '水曜日' },
|
||||
weekday4: { zh: '周四', en: 'Thursday', ja: '木曜日' },
|
||||
weekday5: { zh: '周五', en: 'Friday', ja: '金曜日' },
|
||||
weekday6: { zh: '周六', en: 'Saturday', ja: '土曜日' },
|
||||
weekday7: { zh: '周日', en: 'Sunday', ja: '日曜日' },
|
||||
dayOfMonthSuffix: { zh: '日', en: 'day', ja: '日' },
|
||||
sendTime: { zh: '提醒时间', en: 'Time', ja: '通知時刻' },
|
||||
sendTimePlaceholder: { zh: '例如 09:00', en: 'e.g. 09:00', ja: '例:09:00' },
|
||||
sendTimeInvalid: { zh: '提醒时间格式应为 HH:mm', en: 'Time must be in HH:mm format', ja: '時刻は HH:mm 形式で入力してください' },
|
||||
save: { zh: '保存', en: 'Save', ja: '保存' },
|
||||
saveSuccess: { zh: '通知设置已保存', en: 'Notification settings saved', ja: '通知設定を保存しました' },
|
||||
sendNow: { zh: '立即发送', en: 'Send now', ja: '今すぐ送信' },
|
||||
sendNowSuccess: { zh: '已发送 {count} 个渠道', en: 'Sent to {count} channel(s)', ja: '{count} 件のチャネルに送信しました' },
|
||||
sendNowFailed: { zh: '发送提醒失败', en: 'Failed to send reminder', ja: 'リマインダーの送信に失敗しました' },
|
||||
channels: { zh: '推送渠道', en: 'Channels', ja: '通知チャネル' },
|
||||
addChannel: { zh: '添加渠道', en: 'Add channel', ja: 'チャネルを追加' },
|
||||
editChannel: { zh: '编辑渠道', en: 'Edit channel', ja: 'チャネルを編集' },
|
||||
channelType: { zh: '渠道类型', en: 'Type', ja: 'チャネル種別' },
|
||||
channelName: { zh: '渠道名称', en: 'Name', ja: 'チャネル名' },
|
||||
channelNamePlaceholder: { zh: '便于识别的名称', en: 'A name to identify it', ja: '識別しやすい名前' },
|
||||
enabledLabel: { zh: '已启用', en: 'Enabled', ja: '有効' },
|
||||
disabledLabel: { zh: '已停用', en: 'Disabled', ja: '無効' },
|
||||
typeEmail: { zh: '邮箱', en: 'Email', ja: 'メール' },
|
||||
typeServerChan: { zh: 'Server 酱', en: 'ServerChan', ja: 'ServerChan' },
|
||||
smtpHost: { zh: 'SMTP 服务器', en: 'SMTP host', ja: 'SMTP サーバー' },
|
||||
smtpPort: { zh: 'SMTP 端口', en: 'SMTP port', ja: 'SMTP ポート' },
|
||||
username: { zh: '邮箱账号', en: 'Email account', ja: 'メールアカウント' },
|
||||
usernameRequired: { zh: '请填写邮箱账号', en: 'Please enter the email account', ja: 'メールアカウントを入力してください' },
|
||||
sendKey: { zh: 'SendKey', en: 'SendKey', ja: 'SendKey' },
|
||||
sendKeyPlaceholder: { zh: '在 sct.ftqq.com 获取的 SendKey', en: 'SendKey obtained from sct.ftqq.com', ja: 'sct.ftqq.com で取得した SendKey' },
|
||||
sendKeyRequired: { zh: '请填写 Server 酱 SendKey', en: 'Please enter the ServerChan SendKey', ja: 'ServerChan の SendKey を入力してください' },
|
||||
password: { zh: '授权码', en: 'Auth code', ja: '認証コード' },
|
||||
passwordPlaceholder: { zh: '邮箱 SMTP 授权码', en: 'SMTP authorization code', ja: 'SMTP 認証コード' },
|
||||
passwordKeep: { zh: '已配置,留空表示不修改', en: 'Configured; leave blank to keep', ja: '設定済み。空欄なら変更しません' },
|
||||
to: { zh: '收件人', en: 'Recipient', ja: '宛先' },
|
||||
toPlaceholder: { zh: '默认与账号相同', en: 'Defaults to the account', ja: '既定ではアカウントと同じ' },
|
||||
ssl: { zh: '使用 SSL', en: 'Use SSL', ja: 'SSL を使用' },
|
||||
test: { zh: '测试', en: 'Test', ja: 'テスト' },
|
||||
testSuccess: { zh: '测试消息已发送', en: 'Test message sent', ja: 'テストメッセージを送信しました' },
|
||||
testFailed: { zh: '测试发送失败', en: 'Test failed', ja: 'テスト送信に失敗しました' },
|
||||
confirmDeleteChannel: { zh: '确认删除渠道「{name}」?', en: 'Delete channel "{name}"?', ja: 'チャネル「{name}」を削除しますか?' },
|
||||
saveChannelSuccess: { zh: '渠道已保存', en: 'Channel saved', ja: 'チャネルを保存しました' },
|
||||
noChannels: { zh: '暂无渠道,请先添加', en: 'No channels yet, please add one', ja: 'チャネルがありません。追加してください' },
|
||||
logs: { zh: '最近发送记录', en: 'Recent deliveries', ja: '最近の送信履歴' },
|
||||
success: { zh: '成功', en: 'Success', ja: '成功' },
|
||||
failed: { zh: '失败', en: 'Failed', ja: '失敗' },
|
||||
noLogs: { zh: '暂无发送记录', en: 'No delivery records yet', ja: '送信履歴はまだありません' },
|
||||
loadFailed: { zh: '加载通知设置失败', en: 'Failed to load notification settings', ja: '通知設定の読み込みに失敗しました' },
|
||||
},
|
||||
|
||||
// 模型使用情况
|
||||
aiUsage: {
|
||||
title: { zh: '模型使用情况', en: 'Model Usage', ja: 'モデル利用状況' },
|
||||
|
||||
@@ -23,6 +23,11 @@ const router = createRouter({
|
||||
name: 'Register',
|
||||
component: () => import('../views/Register.vue'), // 注册页
|
||||
},
|
||||
{
|
||||
path: '/bill-import',
|
||||
name: 'BillImport',
|
||||
component: () => import('../views/BillImport.vue'), // 账单导入(独立页,无侧边栏)
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../views/Layout.vue'), // 主布局(带侧边栏)
|
||||
@@ -101,9 +106,9 @@ router.beforeEach(async (to, _from, next) => {
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
// 如果目标页面不是登录/注册页,且没有 token,则重定向到登录页
|
||||
// 如果目标页面不是登录/注册页,且没有 token,则重定向到登录页并记录来源,登录后可回跳
|
||||
if (to.path !== '/login' && to.path !== '/register' && !userStore.token) {
|
||||
next('/login')
|
||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
// 账单导入页:以独立页面形式承载账单导入面板(非弹窗),可由通知链接直达
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NCard } from 'naive-ui'
|
||||
import BillImportPanel from './BillImportPanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
/** 面板关闭/完成后返回收支记录页 */
|
||||
function handleClose() {
|
||||
router.push('/transactions')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bill-import-page">
|
||||
<NCard :title="t('import.title')" :bordered="true" class="bill-import-card">
|
||||
<BillImportPanel @close="handleClose" />
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bill-import-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
background: #f5f7fa;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.bill-import-card {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 账单导入弹窗:先解析、后勾选导入(默认全选),当前支持支付宝与微信账单
|
||||
import { ref, computed, h, watch } from 'vue'
|
||||
// 账单导入弹窗:以弹窗形式承载 BillImportPanel,供收支记录页调用
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
useMessage, NModal, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable,
|
||||
NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber,
|
||||
} from 'naive-ui'
|
||||
import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
|
||||
import { CloudUploadOutline } from '@vicons/ionicons5'
|
||||
import { parseBill, confirmImport } from '../api/transaction'
|
||||
import type { BillType, ParsedBillRow, BillImportItem } from '../api/transaction'
|
||||
import { getCategoryList } from '../api/category'
|
||||
import { getTagList } from '../api/tag'
|
||||
import { NModal } from 'naive-ui'
|
||||
import BillImportPanel from './BillImportPanel.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
@@ -19,368 +10,12 @@ const emit = defineEmits<{
|
||||
(e: 'imported'): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
// 账单类型偏好存储键
|
||||
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
|
||||
|
||||
// 账单类型选项,当前支持支付宝与微信
|
||||
const billTypeOptions = computed(() => [
|
||||
{ label: t('import.billTypeALIPAY'), value: 'ALIPAY' },
|
||||
{ label: t('import.billTypeWECHAT'), value: 'WECHAT' },
|
||||
])
|
||||
|
||||
/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */
|
||||
function loadBillTypePref(): BillType {
|
||||
const saved = localStorage.getItem(BILL_TYPE_PREFS_KEY)
|
||||
return saved === 'ALIPAY' || saved === 'WECHAT' ? saved : 'ALIPAY'
|
||||
}
|
||||
|
||||
// 当前账单类型,初始值取上次选择(个人偏好)
|
||||
const billType = ref<BillType>(loadBillTypePref())
|
||||
|
||||
// 各账单类型允许上传的文件扩展名
|
||||
const allowedExtensions: Record<BillType, string[]> = {
|
||||
ALIPAY: ['.csv'],
|
||||
WECHAT: ['.xlsx'],
|
||||
}
|
||||
|
||||
// 当前账单类型允许的扩展名,用于上传组件 accept 属性
|
||||
const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).join(','))
|
||||
|
||||
// 当前账单类型的文件格式提示
|
||||
const fileHint = computed(() => {
|
||||
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
|
||||
const exts = (allowedExtensions[billType.value] || []).join(t('import.extSeparator'))
|
||||
return t('import.fileHint', { label, exts })
|
||||
})
|
||||
|
||||
// 当前账单类型的导出路径提示
|
||||
const exportHint = computed(() =>
|
||||
billType.value === 'WECHAT' ? t('import.exportHintWECHAT') : t('import.exportHintALIPAY'),
|
||||
)
|
||||
|
||||
// 弹窗阶段:选择文件 -> 预览勾选 -> 导入结果
|
||||
const stage = ref<'select' | 'preview' | 'result'>('select')
|
||||
|
||||
const file = ref<File | null>(null)
|
||||
const uploadFileList = ref<UploadFileInfo[]>([])
|
||||
const parsing = ref(false)
|
||||
const importing = ref(false)
|
||||
|
||||
// 解析结果与勾选状态
|
||||
const rows = ref<ParsedBillRow[]>([])
|
||||
const checkedRowNos = ref<number[]>([])
|
||||
|
||||
// 预览筛选条件
|
||||
const filterKeyword = ref('')
|
||||
const filterType = ref<string | null>(null)
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const filterMinAmount = ref<number | null>(null)
|
||||
const filterMaxAmount = ref<number | null>(null)
|
||||
|
||||
// 类型筛选选项
|
||||
const typeFilterOptions = computed(() => [
|
||||
{ label: t('import.typeFilter.income'), value: 'INCOME' },
|
||||
{ label: t('import.typeFilter.expense'), value: 'EXPENSE' },
|
||||
{ label: t('import.typeFilter.neutral'), value: 'NEUTRAL' },
|
||||
])
|
||||
|
||||
// 状态筛选选项
|
||||
const statusFilterOptions = computed(() => [
|
||||
{ label: t('import.statusFilter.importable'), value: 'importable' },
|
||||
{ label: t('import.statusFilter.duplicate'), value: 'duplicate' },
|
||||
{ label: t('import.statusFilter.error'), value: 'error' },
|
||||
])
|
||||
|
||||
/** 重置预览筛选条件 */
|
||||
function resetFilters() {
|
||||
filterKeyword.value = ''
|
||||
filterType.value = null
|
||||
filterStatus.value = null
|
||||
filterMinAmount.value = null
|
||||
filterMaxAmount.value = null
|
||||
}
|
||||
|
||||
// 默认分类
|
||||
const expenseCategories = ref<{ label: string; value: number; icon: string }[]>([])
|
||||
const incomeCategories = ref<{ label: string; value: number; icon: string }[]>([])
|
||||
const expenseCategoryId = ref<number | null>(null)
|
||||
const incomeCategoryId = ref<number | null>(null)
|
||||
|
||||
// 默认标签
|
||||
const allTags = ref<{ label: string; value: number }[]>([])
|
||||
const importTagIds = ref<number[]>([])
|
||||
|
||||
// 导入结果
|
||||
const result = ref<{ total: number; imported: number; skipped: number; failed: number; messages: string[] } | null>(null)
|
||||
|
||||
/** 按筛选条件过滤后的账单行 */
|
||||
const filteredRows = computed(() => {
|
||||
const kw = filterKeyword.value.trim().toLowerCase()
|
||||
return rows.value.filter((r) => {
|
||||
if (kw) {
|
||||
const text = `${r.counterparty || ''} ${r.product || ''} ${r.externalNo || ''}`.toLowerCase()
|
||||
if (!text.includes(kw)) return false
|
||||
}
|
||||
if (filterType.value === 'NEUTRAL') {
|
||||
if (r.includeInStats) return false
|
||||
} else if (filterType.value && r.type !== filterType.value) {
|
||||
return false
|
||||
}
|
||||
if (filterStatus.value === 'importable' && !r.importable) return false
|
||||
if (filterStatus.value === 'duplicate' && !r.duplicate) return false
|
||||
if (filterStatus.value === 'error' && !r.error) return false
|
||||
if (filterMinAmount.value != null && Number(r.amount) < filterMinAmount.value) return false
|
||||
if (filterMaxAmount.value != null && Number(r.amount) > filterMaxAmount.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
/** 可导入的行(受筛选影响) */
|
||||
const selectableRows = computed(() => filteredRows.value.filter((r) => r.importable))
|
||||
/** 已勾选的行 */
|
||||
const checkedRows = computed(() => rows.value.filter((r) => checkedRowNos.value.includes(r.rowNo)))
|
||||
/** 是否全选(基于当前筛选出的可导入行) */
|
||||
const allChecked = computed(
|
||||
() => selectableRows.value.length > 0 && selectableRows.value.every((r) => checkedRowNos.value.includes(r.rowNo)),
|
||||
)
|
||||
/** 是否存在部分勾选(基于当前筛选出的可导入行) */
|
||||
const someChecked = computed(() => {
|
||||
const checkedVisible = selectableRows.value.filter((r) => checkedRowNos.value.includes(r.rowNo))
|
||||
return checkedVisible.length > 0 && !allChecked.value
|
||||
})
|
||||
|
||||
/** 表格列定义(随语言切换响应式更新) */
|
||||
const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
|
||||
{
|
||||
title: () =>
|
||||
h(NCheckbox, {
|
||||
checked: allChecked.value,
|
||||
indeterminate: someChecked.value,
|
||||
disabled: selectableRows.value.length === 0,
|
||||
onUpdateChecked: (v: boolean) => toggleAll(v),
|
||||
}),
|
||||
key: 'checked',
|
||||
width: 50,
|
||||
render: (row) =>
|
||||
h(NCheckbox, {
|
||||
checked: checkedRowNos.value.includes(row.rowNo),
|
||||
disabled: !row.importable,
|
||||
onUpdateChecked: (v: boolean) => toggleRow(row, v),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('import.column.transactionTime'),
|
||||
key: 'transactionTime',
|
||||
width: 150,
|
||||
render: (row) => (row.transactionTime ? row.transactionTime.replace('T', ' ').substring(0, 16) : '-'),
|
||||
},
|
||||
{
|
||||
title: t('import.column.type'),
|
||||
key: 'type',
|
||||
width: 90,
|
||||
render: (row) => {
|
||||
if (!row.importable) {
|
||||
return h(NTag, { type: 'error', size: 'small' }, { default: () => t('import.notImportable') })
|
||||
}
|
||||
if (!row.includeInStats) {
|
||||
return h(NTag, { type: 'default', size: 'small' }, { default: () => t('import.notIncluded') })
|
||||
}
|
||||
return h(
|
||||
NTag,
|
||||
{ type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' },
|
||||
{ default: () => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')) },
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('import.column.amount'),
|
||||
key: 'amount',
|
||||
width: 110,
|
||||
render: (row) => {
|
||||
const color = row.type === 'INCOME' ? '#18a058' : '#d03050'
|
||||
return h('span', { style: { color, fontWeight: 'bold' } }, `${row.type === 'INCOME' ? '+' : '-'}${Number(row.amount).toFixed(2)}`)
|
||||
},
|
||||
},
|
||||
{ title: t('import.column.counterparty'), key: 'counterparty', width: 160, ellipsis: { tooltip: true } },
|
||||
{ title: t('import.column.product'), key: 'product', ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: t('import.column.status'),
|
||||
key: 'tradeStatus',
|
||||
width: 150,
|
||||
render: (row) => {
|
||||
if (row.error) {
|
||||
return h(NTag, { type: 'error', size: 'small' }, { default: () => row.error })
|
||||
}
|
||||
if (row.duplicate) {
|
||||
return h(NTag, { type: 'warning', size: 'small' }, { default: () => t('import.statusFilter.duplicate') })
|
||||
}
|
||||
return h('span', null, row.tradeStatus || '-')
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
/** 加载分类选项 */
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const [expenseRes, incomeRes]: any = await Promise.all([getCategoryList('EXPENSE'), getCategoryList('INCOME')])
|
||||
expenseCategories.value = (expenseRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
|
||||
incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('import.loadCategoriesFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 分类下拉项渲染:图标 + 名称 */
|
||||
function renderCategoryLabel(option: any) {
|
||||
return h('span', null, [
|
||||
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
|
||||
option.label,
|
||||
])
|
||||
}
|
||||
|
||||
/** 加载标签选项 */
|
||||
async function loadTags() {
|
||||
try {
|
||||
const res: any = await getTagList()
|
||||
allTags.value = (res.data || []).map((tag: any) => ({ label: tag.name, value: tag.id }))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('import.loadTagsFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断文件名是否符合当前账单类型允许的扩展名 */
|
||||
function isAllowedFile(name: string) {
|
||||
const exts = allowedExtensions[billType.value] || []
|
||||
const lower = name.toLowerCase()
|
||||
return exts.length === 0 || exts.some((ext) => lower.endsWith(ext))
|
||||
}
|
||||
|
||||
/** 文件选择变化(支持点击或拖拽),按账单类型校验扩展名,非法文件直接拒绝 */
|
||||
function handleFileChange(options: { fileList: UploadFileInfo[] }) {
|
||||
const last = options.fileList[options.fileList.length - 1]
|
||||
if (last?.file && !isAllowedFile(last.name)) {
|
||||
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
|
||||
const exts = allowedExtensions[billType.value].join(t('import.extSeparator'))
|
||||
message.error(t('import.errorFileType', { label, exts }))
|
||||
uploadFileList.value = []
|
||||
file.value = null
|
||||
return
|
||||
}
|
||||
uploadFileList.value = options.fileList
|
||||
file.value = last?.file ?? null
|
||||
}
|
||||
|
||||
/** 解析账单 */
|
||||
async function handleParse() {
|
||||
if (!file.value) {
|
||||
message.warning(t('import.selectFileFirst'))
|
||||
return
|
||||
}
|
||||
parsing.value = true
|
||||
try {
|
||||
const res: any = await parseBill(file.value, billType.value)
|
||||
rows.value = res.data.rows || []
|
||||
// 默认勾选解析器建议勾选的行
|
||||
checkedRowNos.value = rows.value.filter((r) => r.selected).map((r) => r.rowNo)
|
||||
resetFilters()
|
||||
stage.value = 'preview'
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('import.parseFailed'))
|
||||
} finally {
|
||||
parsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换单行勾选 */
|
||||
function toggleRow(row: ParsedBillRow, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!checkedRowNos.value.includes(row.rowNo)) {
|
||||
checkedRowNos.value = [...checkedRowNos.value, row.rowNo]
|
||||
}
|
||||
} else {
|
||||
checkedRowNos.value = checkedRowNos.value.filter((n) => n !== row.rowNo)
|
||||
}
|
||||
}
|
||||
|
||||
/** 全选/取消全选 */
|
||||
function toggleAll(checked: boolean) {
|
||||
checkedRowNos.value = checked ? selectableRows.value.map((r) => r.rowNo) : []
|
||||
}
|
||||
|
||||
/** 确认导入 */
|
||||
async function handleConfirm() {
|
||||
if (checkedRows.value.length === 0) {
|
||||
message.warning(t('import.selectAtLeastOne'))
|
||||
return
|
||||
}
|
||||
const items: BillImportItem[] = checkedRows.value.map((r) => ({
|
||||
externalNo: r.externalNo,
|
||||
transactionTime: r.transactionTime,
|
||||
type: r.type,
|
||||
amount: r.amount,
|
||||
counterparty: r.counterparty,
|
||||
product: r.product,
|
||||
includeInStats: r.includeInStats,
|
||||
rawData: r.rawData,
|
||||
}))
|
||||
importing.value = true
|
||||
try {
|
||||
const res: any = await confirmImport({
|
||||
billType: billType.value,
|
||||
expenseCategoryId: expenseCategoryId.value,
|
||||
incomeCategoryId: incomeCategoryId.value,
|
||||
tagIds: importTagIds.value.length ? importTagIds.value : undefined,
|
||||
items,
|
||||
})
|
||||
result.value = res.data
|
||||
stage.value = 'result'
|
||||
emit('imported')
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('import.importFailed'))
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
function reset() {
|
||||
stage.value = 'select'
|
||||
file.value = null
|
||||
uploadFileList.value = []
|
||||
rows.value = []
|
||||
checkedRowNos.value = []
|
||||
result.value = null
|
||||
expenseCategoryId.value = null
|
||||
incomeCategoryId.value = null
|
||||
importTagIds.value = []
|
||||
resetFilters()
|
||||
}
|
||||
const { t } = useI18n()
|
||||
|
||||
/** 关闭弹窗 */
|
||||
function handleClose() {
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
// 切换账单类型时清空已选文件、记录偏好,避免文件格式与类型不匹配
|
||||
watch(billType, (value) => {
|
||||
uploadFileList.value = []
|
||||
file.value = null
|
||||
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
|
||||
})
|
||||
|
||||
// 打开弹窗时重置并加载分类
|
||||
watch(
|
||||
() => props.show,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
reset()
|
||||
loadCategories()
|
||||
loadTags()
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -392,139 +27,6 @@ watch(
|
||||
:mask-closable="false"
|
||||
@update:show="handleClose"
|
||||
>
|
||||
<!-- 第一步:选择账单类型与文件 -->
|
||||
<template v-if="stage === 'select'">
|
||||
<NSpace vertical :size="16">
|
||||
<NSpace align="center">
|
||||
<NText>{{ t('import.billType') }}</NText>
|
||||
<NSelect v-model:value="billType" :options="billTypeOptions" style="width: 160px;" />
|
||||
</NSpace>
|
||||
|
||||
<div>
|
||||
<NText>{{ t('import.billFile') }}</NText>
|
||||
<NUpload
|
||||
:file-list="uploadFileList"
|
||||
:default-upload="false"
|
||||
:max="1"
|
||||
:accept="acceptTypes"
|
||||
style="margin-top: 8px;"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<NUploadDragger>
|
||||
<div style="padding: 24px 0; text-align: center;">
|
||||
<NIcon size="48" :depth="3">
|
||||
<CloudUploadOutline />
|
||||
</NIcon>
|
||||
<div style="font-size: 15px; margin-top: 8px;">{{ t('import.dropText') }}</div>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">
|
||||
{{ fileHint }}
|
||||
</div>
|
||||
</div>
|
||||
</NUploadDragger>
|
||||
</NUpload>
|
||||
</div>
|
||||
|
||||
<NAlert type="info" :show-icon="true">
|
||||
{{ exportHint }}
|
||||
</NAlert>
|
||||
|
||||
<NSpace justify="end">
|
||||
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse">{{ t('import.parse') }}</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<!-- 第二步:预览并勾选 -->
|
||||
<template v-else-if="stage === 'preview'">
|
||||
<NSpace vertical :size="12">
|
||||
<NSpace align="center" :wrap="true" :size="[12, 12]">
|
||||
<NText>{{ t('import.expenseDefaultCategory') }}</NText>
|
||||
<NSelect
|
||||
v-model:value="expenseCategoryId"
|
||||
:options="expenseCategories"
|
||||
:render-label="renderCategoryLabel"
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="t('import.notSetCategory')"
|
||||
style="width: 180px;"
|
||||
/>
|
||||
<NText>{{ t('import.incomeDefaultCategory') }}</NText>
|
||||
<NSelect
|
||||
v-model:value="incomeCategoryId"
|
||||
:options="incomeCategories"
|
||||
:render-label="renderCategoryLabel"
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="t('import.notSetCategory')"
|
||||
style="width: 180px;"
|
||||
/>
|
||||
<NText>{{ t('import.defaultTags') }}</NText>
|
||||
<NSelect
|
||||
v-model:value="importTagIds"
|
||||
:options="allTags"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:max-tag-count="2"
|
||||
:placeholder="t('import.notSetTag')"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NCheckbox
|
||||
:checked="allChecked"
|
||||
:indeterminate="someChecked"
|
||||
:disabled="selectableRows.length === 0"
|
||||
@update:checked="toggleAll"
|
||||
>
|
||||
{{ t('import.selectAll') }}
|
||||
</NCheckbox>
|
||||
<NText depth="3">{{ t('import.previewSummary', { total: rows.length, filtered: filteredRows.length, checked: checkedRows.length }) }}</NText>
|
||||
</NSpace>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<NSpace align="center" :wrap="true" :size="[12, 12]">
|
||||
<NInput v-model:value="filterKeyword" clearable :placeholder="t('import.searchPlaceholder')" style="width: 220px;" />
|
||||
<NSelect v-model:value="filterType" :options="typeFilterOptions" clearable :placeholder="t('import.typePlaceholder')" style="width: 120px;" />
|
||||
<NSelect v-model:value="filterStatus" :options="statusFilterOptions" clearable :placeholder="t('import.statusPlaceholder')" style="width: 130px;" />
|
||||
<NInputNumber v-model:value="filterMinAmount" :min="0" :precision="2" clearable :placeholder="t('common.min')" style="width: 120px;" />
|
||||
<span style="color: #999;">~</span>
|
||||
<NInputNumber v-model:value="filterMaxAmount" :min="0" :precision="2" clearable :placeholder="t('common.max')" style="width: 120px;" />
|
||||
<NButton size="small" @click="resetFilters">{{ t('common.reset') }}</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="filteredRows"
|
||||
:max-height="420"
|
||||
:row-key="(row) => row.rowNo"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
/>
|
||||
|
||||
<NSpace justify="end">
|
||||
<NButton @click="stage = 'select'">{{ t('import.prevStep') }}</NButton>
|
||||
<NButton type="primary" :loading="importing" @click="handleConfirm">
|
||||
{{ t('import.confirmImport', { count: checkedRows.length }) }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<!-- 第三步:导入结果 -->
|
||||
<template v-else>
|
||||
<NSpace vertical :size="12">
|
||||
<NAlert type="success" :show-icon="true">
|
||||
{{ t('import.resultSummary', { imported: result?.imported ?? 0, skipped: result?.skipped ?? 0, failed: result?.failed ?? 0 }) }}
|
||||
</NAlert>
|
||||
<div v-if="result?.messages?.length" style="max-height: 320px; overflow: auto;">
|
||||
<NText v-for="(msg, i) in result.messages" :key="i" depth="3" style="display: block; font-size: 12px;">
|
||||
{{ msg }}
|
||||
</NText>
|
||||
</div>
|
||||
<NSpace justify="end">
|
||||
<NButton type="primary" @click="handleClose">{{ t('import.done') }}</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</template>
|
||||
<BillImportPanel @close="handleClose" @imported="emit('imported')" />
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
<script setup lang="ts">
|
||||
// 账单导入面板:先解析、后勾选导入(默认全选),当前支持支付宝与微信账单
|
||||
// 以纯内容形式提供,可由弹窗(BillImportModal)或独立页面(BillImport)承载
|
||||
import { ref, computed, h, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
useMessage, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable,
|
||||
NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber,
|
||||
} from 'naive-ui'
|
||||
import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
|
||||
import { CloudUploadOutline } from '@vicons/ionicons5'
|
||||
import { parseBill, confirmImport } from '../api/transaction'
|
||||
import type { BillType, ParsedBillRow, BillImportItem } from '../api/transaction'
|
||||
import { getCategoryList } from '../api/category'
|
||||
import { getTagList } from '../api/tag'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'imported'): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
// 账单类型偏好存储键
|
||||
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
|
||||
|
||||
// 账单类型选项,当前支持支付宝与微信
|
||||
const billTypeOptions = computed(() => [
|
||||
{ label: t('import.billTypeALIPAY'), value: 'ALIPAY' },
|
||||
{ label: t('import.billTypeWECHAT'), value: 'WECHAT' },
|
||||
])
|
||||
|
||||
/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */
|
||||
function loadBillTypePref(): BillType {
|
||||
const saved = localStorage.getItem(BILL_TYPE_PREFS_KEY)
|
||||
return saved === 'ALIPAY' || saved === 'WECHAT' ? saved : 'ALIPAY'
|
||||
}
|
||||
|
||||
// 当前账单类型,初始值取上次选择(个人偏好)
|
||||
const billType = ref<BillType>(loadBillTypePref())
|
||||
|
||||
// 各账单类型允许上传的文件扩展名
|
||||
const allowedExtensions: Record<BillType, string[]> = {
|
||||
ALIPAY: ['.csv'],
|
||||
WECHAT: ['.xlsx'],
|
||||
}
|
||||
|
||||
// 当前账单类型允许的扩展名,用于上传组件 accept 属性
|
||||
const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).join(','))
|
||||
|
||||
// 当前账单类型的文件格式提示
|
||||
const fileHint = computed(() => {
|
||||
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
|
||||
const exts = (allowedExtensions[billType.value] || []).join(t('import.extSeparator'))
|
||||
return t('import.fileHint', { label, exts })
|
||||
})
|
||||
|
||||
// 当前账单类型的导出路径提示
|
||||
const exportHint = computed(() =>
|
||||
billType.value === 'WECHAT' ? t('import.exportHintWECHAT') : t('import.exportHintALIPAY'),
|
||||
)
|
||||
|
||||
// 面板阶段:选择文件 -> 预览勾选 -> 导入结果
|
||||
const stage = ref<'select' | 'preview' | 'result'>('select')
|
||||
|
||||
const file = ref<File | null>(null)
|
||||
const uploadFileList = ref<UploadFileInfo[]>([])
|
||||
const parsing = ref(false)
|
||||
const importing = ref(false)
|
||||
|
||||
// 解析结果与勾选状态
|
||||
const rows = ref<ParsedBillRow[]>([])
|
||||
const checkedRowNos = ref<number[]>([])
|
||||
|
||||
// 预览筛选条件
|
||||
const filterKeyword = ref('')
|
||||
const filterType = ref<string | null>(null)
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const filterMinAmount = ref<number | null>(null)
|
||||
const filterMaxAmount = ref<number | null>(null)
|
||||
|
||||
// 类型筛选选项
|
||||
const typeFilterOptions = computed(() => [
|
||||
{ label: t('import.typeFilter.income'), value: 'INCOME' },
|
||||
{ label: t('import.typeFilter.expense'), value: 'EXPENSE' },
|
||||
{ label: t('import.typeFilter.neutral'), value: 'NEUTRAL' },
|
||||
])
|
||||
|
||||
// 状态筛选选项
|
||||
const statusFilterOptions = computed(() => [
|
||||
{ label: t('import.statusFilter.importable'), value: 'importable' },
|
||||
{ label: t('import.statusFilter.duplicate'), value: 'duplicate' },
|
||||
{ label: t('import.statusFilter.error'), value: 'error' },
|
||||
])
|
||||
|
||||
/** 重置预览筛选条件 */
|
||||
function resetFilters() {
|
||||
filterKeyword.value = ''
|
||||
filterType.value = null
|
||||
filterStatus.value = null
|
||||
filterMinAmount.value = null
|
||||
filterMaxAmount.value = null
|
||||
}
|
||||
|
||||
// 默认分类
|
||||
const expenseCategories = ref<{ label: string; value: number; icon: string }[]>([])
|
||||
const incomeCategories = ref<{ label: string; value: number; icon: string }[]>([])
|
||||
const expenseCategoryId = ref<number | null>(null)
|
||||
const incomeCategoryId = ref<number | null>(null)
|
||||
|
||||
// 默认标签
|
||||
const allTags = ref<{ label: string; value: number }[]>([])
|
||||
const importTagIds = ref<number[]>([])
|
||||
|
||||
// 导入结果
|
||||
const result = ref<{ total: number; imported: number; skipped: number; failed: number; messages: string[] } | null>(null)
|
||||
|
||||
/** 按筛选条件过滤后的账单行 */
|
||||
const filteredRows = computed(() => {
|
||||
const kw = filterKeyword.value.trim().toLowerCase()
|
||||
return rows.value.filter((r) => {
|
||||
if (kw) {
|
||||
const text = `${r.counterparty || ''} ${r.product || ''} ${r.externalNo || ''}`.toLowerCase()
|
||||
if (!text.includes(kw)) return false
|
||||
}
|
||||
if (filterType.value === 'NEUTRAL') {
|
||||
if (r.includeInStats) return false
|
||||
} else if (filterType.value && r.type !== filterType.value) {
|
||||
return false
|
||||
}
|
||||
if (filterStatus.value === 'importable' && !r.importable) return false
|
||||
if (filterStatus.value === 'duplicate' && !r.duplicate) return false
|
||||
if (filterStatus.value === 'error' && !r.error) return false
|
||||
if (filterMinAmount.value != null && Number(r.amount) < filterMinAmount.value) return false
|
||||
if (filterMaxAmount.value != null && Number(r.amount) > filterMaxAmount.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
/** 可导入的行(受筛选影响) */
|
||||
const selectableRows = computed(() => filteredRows.value.filter((r) => r.importable))
|
||||
/** 已勾选的行 */
|
||||
const checkedRows = computed(() => rows.value.filter((r) => checkedRowNos.value.includes(r.rowNo)))
|
||||
/** 是否全选(基于当前筛选出的可导入行) */
|
||||
const allChecked = computed(
|
||||
() => selectableRows.value.length > 0 && selectableRows.value.every((r) => checkedRowNos.value.includes(r.rowNo)),
|
||||
)
|
||||
/** 是否存在部分勾选(基于当前筛选出的可导入行) */
|
||||
const someChecked = computed(() => {
|
||||
const checkedVisible = selectableRows.value.filter((r) => checkedRowNos.value.includes(r.rowNo))
|
||||
return checkedVisible.length > 0 && !allChecked.value
|
||||
})
|
||||
|
||||
/** 表格列定义(随语言切换响应式更新) */
|
||||
const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
|
||||
{
|
||||
title: () =>
|
||||
h(NCheckbox, {
|
||||
checked: allChecked.value,
|
||||
indeterminate: someChecked.value,
|
||||
disabled: selectableRows.value.length === 0,
|
||||
onUpdateChecked: (v: boolean) => toggleAll(v),
|
||||
}),
|
||||
key: 'checked',
|
||||
width: 50,
|
||||
render: (row) =>
|
||||
h(NCheckbox, {
|
||||
checked: checkedRowNos.value.includes(row.rowNo),
|
||||
disabled: !row.importable,
|
||||
onUpdateChecked: (v: boolean) => toggleRow(row, v),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('import.column.transactionTime'),
|
||||
key: 'transactionTime',
|
||||
width: 150,
|
||||
render: (row) => (row.transactionTime ? row.transactionTime.replace('T', ' ').substring(0, 16) : '-'),
|
||||
},
|
||||
{
|
||||
title: t('import.column.type'),
|
||||
key: 'type',
|
||||
width: 90,
|
||||
render: (row) => {
|
||||
if (!row.importable) {
|
||||
return h(NTag, { type: 'error', size: 'small' }, { default: () => t('import.notImportable') })
|
||||
}
|
||||
if (!row.includeInStats) {
|
||||
return h(NTag, { type: 'default', size: 'small' }, { default: () => t('import.notIncluded') })
|
||||
}
|
||||
return h(
|
||||
NTag,
|
||||
{ type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' },
|
||||
{ default: () => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')) },
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('import.column.amount'),
|
||||
key: 'amount',
|
||||
width: 110,
|
||||
render: (row) => {
|
||||
const color = row.type === 'INCOME' ? '#18a058' : '#d03050'
|
||||
return h('span', { style: { color, fontWeight: 'bold' } }, `${row.type === 'INCOME' ? '+' : '-'}${Number(row.amount).toFixed(2)}`)
|
||||
},
|
||||
},
|
||||
{ title: t('import.column.counterparty'), key: 'counterparty', width: 160, ellipsis: { tooltip: true } },
|
||||
{ title: t('import.column.product'), key: 'product', ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: t('import.column.status'),
|
||||
key: 'tradeStatus',
|
||||
width: 150,
|
||||
render: (row) => {
|
||||
if (row.error) {
|
||||
return h(NTag, { type: 'error', size: 'small' }, { default: () => row.error })
|
||||
}
|
||||
if (row.duplicate) {
|
||||
return h(NTag, { type: 'warning', size: 'small' }, { default: () => t('import.statusFilter.duplicate') })
|
||||
}
|
||||
return h('span', null, row.tradeStatus || '-')
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
/** 加载分类选项 */
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const [expenseRes, incomeRes]: any = await Promise.all([getCategoryList('EXPENSE'), getCategoryList('INCOME')])
|
||||
expenseCategories.value = (expenseRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
|
||||
incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('import.loadCategoriesFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 分类下拉项渲染:图标 + 名称 */
|
||||
function renderCategoryLabel(option: any) {
|
||||
return h('span', null, [
|
||||
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
|
||||
option.label,
|
||||
])
|
||||
}
|
||||
|
||||
/** 加载标签选项 */
|
||||
async function loadTags() {
|
||||
try {
|
||||
const res: any = await getTagList()
|
||||
allTags.value = (res.data || []).map((tag: any) => ({ label: tag.name, value: tag.id }))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('import.loadTagsFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断文件名是否符合当前账单类型允许的扩展名 */
|
||||
function isAllowedFile(name: string) {
|
||||
const exts = allowedExtensions[billType.value] || []
|
||||
const lower = name.toLowerCase()
|
||||
return exts.length === 0 || exts.some((ext) => lower.endsWith(ext))
|
||||
}
|
||||
|
||||
/** 文件选择变化(支持点击或拖拽),按账单类型校验扩展名,非法文件直接拒绝 */
|
||||
function handleFileChange(options: { fileList: UploadFileInfo[] }) {
|
||||
const last = options.fileList[options.fileList.length - 1]
|
||||
if (last?.file && !isAllowedFile(last.name)) {
|
||||
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
|
||||
const exts = allowedExtensions[billType.value].join(t('import.extSeparator'))
|
||||
message.error(t('import.errorFileType', { label, exts }))
|
||||
uploadFileList.value = []
|
||||
file.value = null
|
||||
return
|
||||
}
|
||||
uploadFileList.value = options.fileList
|
||||
file.value = last?.file ?? null
|
||||
}
|
||||
|
||||
/** 解析账单 */
|
||||
async function handleParse() {
|
||||
if (!file.value) {
|
||||
message.warning(t('import.selectFileFirst'))
|
||||
return
|
||||
}
|
||||
parsing.value = true
|
||||
try {
|
||||
const res: any = await parseBill(file.value, billType.value)
|
||||
rows.value = res.data.rows || []
|
||||
// 默认勾选解析器建议勾选的行
|
||||
checkedRowNos.value = rows.value.filter((r) => r.selected).map((r) => r.rowNo)
|
||||
resetFilters()
|
||||
stage.value = 'preview'
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('import.parseFailed'))
|
||||
} finally {
|
||||
parsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换单行勾选 */
|
||||
function toggleRow(row: ParsedBillRow, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!checkedRowNos.value.includes(row.rowNo)) {
|
||||
checkedRowNos.value = [...checkedRowNos.value, row.rowNo]
|
||||
}
|
||||
} else {
|
||||
checkedRowNos.value = checkedRowNos.value.filter((n) => n !== row.rowNo)
|
||||
}
|
||||
}
|
||||
|
||||
/** 全选/取消全选 */
|
||||
function toggleAll(checked: boolean) {
|
||||
checkedRowNos.value = checked ? selectableRows.value.map((r) => r.rowNo) : []
|
||||
}
|
||||
|
||||
/** 确认导入 */
|
||||
async function handleConfirm() {
|
||||
if (checkedRows.value.length === 0) {
|
||||
message.warning(t('import.selectAtLeastOne'))
|
||||
return
|
||||
}
|
||||
const items: BillImportItem[] = checkedRows.value.map((r) => ({
|
||||
externalNo: r.externalNo,
|
||||
transactionTime: r.transactionTime,
|
||||
type: r.type,
|
||||
amount: r.amount,
|
||||
counterparty: r.counterparty,
|
||||
product: r.product,
|
||||
includeInStats: r.includeInStats,
|
||||
rawData: r.rawData,
|
||||
}))
|
||||
importing.value = true
|
||||
try {
|
||||
const res: any = await confirmImport({
|
||||
billType: billType.value,
|
||||
expenseCategoryId: expenseCategoryId.value,
|
||||
incomeCategoryId: incomeCategoryId.value,
|
||||
tagIds: importTagIds.value.length ? importTagIds.value : undefined,
|
||||
items,
|
||||
})
|
||||
result.value = res.data
|
||||
stage.value = 'result'
|
||||
emit('imported')
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('import.importFailed'))
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置面板状态 */
|
||||
function reset() {
|
||||
stage.value = 'select'
|
||||
file.value = null
|
||||
uploadFileList.value = []
|
||||
rows.value = []
|
||||
checkedRowNos.value = []
|
||||
result.value = null
|
||||
expenseCategoryId.value = null
|
||||
incomeCategoryId.value = null
|
||||
importTagIds.value = []
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
/** 关闭面板(取消或完成) */
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 切换账单类型时清空已选文件、记录偏好,避免文件格式与类型不匹配
|
||||
watch(billType, (value) => {
|
||||
uploadFileList.value = []
|
||||
file.value = null
|
||||
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
|
||||
})
|
||||
|
||||
// 挂载时重置并加载分类/标签(页面进入或弹窗打开时均会触发)
|
||||
onMounted(() => {
|
||||
reset()
|
||||
loadCategories()
|
||||
loadTags()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 第一步:选择账单类型与文件 -->
|
||||
<NSpace v-if="stage === 'select'" vertical :size="16">
|
||||
<NSpace align="center">
|
||||
<NText>{{ t('import.billType') }}</NText>
|
||||
<NSelect v-model:value="billType" :options="billTypeOptions" style="width: 160px;" />
|
||||
</NSpace>
|
||||
|
||||
<div>
|
||||
<NText>{{ t('import.billFile') }}</NText>
|
||||
<NUpload
|
||||
:file-list="uploadFileList"
|
||||
:default-upload="false"
|
||||
:max="1"
|
||||
:accept="acceptTypes"
|
||||
style="margin-top: 8px;"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<NUploadDragger>
|
||||
<div style="padding: 24px 0; text-align: center;">
|
||||
<NIcon size="48" :depth="3">
|
||||
<CloudUploadOutline />
|
||||
</NIcon>
|
||||
<div style="font-size: 15px; margin-top: 8px;">{{ t('import.dropText') }}</div>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">
|
||||
{{ fileHint }}
|
||||
</div>
|
||||
</div>
|
||||
</NUploadDragger>
|
||||
</NUpload>
|
||||
</div>
|
||||
|
||||
<NAlert type="info" :show-icon="true">
|
||||
{{ exportHint }}
|
||||
</NAlert>
|
||||
|
||||
<NSpace justify="end">
|
||||
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse">{{ t('import.parse') }}</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<!-- 第二步:预览并勾选 -->
|
||||
<NSpace v-else-if="stage === 'preview'" vertical :size="12">
|
||||
<NSpace align="center" :wrap="true" :size="[12, 12]">
|
||||
<NText>{{ t('import.expenseDefaultCategory') }}</NText>
|
||||
<NSelect
|
||||
v-model:value="expenseCategoryId"
|
||||
:options="expenseCategories"
|
||||
:render-label="renderCategoryLabel"
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="t('import.notSetCategory')"
|
||||
style="width: 180px;"
|
||||
/>
|
||||
<NText>{{ t('import.incomeDefaultCategory') }}</NText>
|
||||
<NSelect
|
||||
v-model:value="incomeCategoryId"
|
||||
:options="incomeCategories"
|
||||
:render-label="renderCategoryLabel"
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="t('import.notSetCategory')"
|
||||
style="width: 180px;"
|
||||
/>
|
||||
<NText>{{ t('import.defaultTags') }}</NText>
|
||||
<NSelect
|
||||
v-model:value="importTagIds"
|
||||
:options="allTags"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:max-tag-count="2"
|
||||
:placeholder="t('import.notSetTag')"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NCheckbox
|
||||
:checked="allChecked"
|
||||
:indeterminate="someChecked"
|
||||
:disabled="selectableRows.length === 0"
|
||||
@update:checked="toggleAll"
|
||||
>
|
||||
{{ t('import.selectAll') }}
|
||||
</NCheckbox>
|
||||
<NText depth="3">{{ t('import.previewSummary', { total: rows.length, filtered: filteredRows.length, checked: checkedRows.length }) }}</NText>
|
||||
</NSpace>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<NSpace align="center" :wrap="true" :size="[12, 12]">
|
||||
<NInput v-model:value="filterKeyword" clearable :placeholder="t('import.searchPlaceholder')" style="width: 220px;" />
|
||||
<NSelect v-model:value="filterType" :options="typeFilterOptions" clearable :placeholder="t('import.typePlaceholder')" style="width: 120px;" />
|
||||
<NSelect v-model:value="filterStatus" :options="statusFilterOptions" clearable :placeholder="t('import.statusPlaceholder')" style="width: 130px;" />
|
||||
<NInputNumber v-model:value="filterMinAmount" :min="0" :precision="2" clearable :placeholder="t('common.min')" style="width: 120px;" />
|
||||
<span style="color: #999;">~</span>
|
||||
<NInputNumber v-model:value="filterMaxAmount" :min="0" :precision="2" clearable :placeholder="t('common.max')" style="width: 120px;" />
|
||||
<NButton size="small" @click="resetFilters">{{ t('common.reset') }}</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="filteredRows"
|
||||
:max-height="420"
|
||||
:row-key="(row) => row.rowNo"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
/>
|
||||
|
||||
<NSpace justify="end">
|
||||
<NButton @click="stage = 'select'">{{ t('import.prevStep') }}</NButton>
|
||||
<NButton type="primary" :loading="importing" @click="handleConfirm">
|
||||
{{ t('import.confirmImport', { count: checkedRows.length }) }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<!-- 第三步:导入结果 -->
|
||||
<NSpace v-else vertical :size="12">
|
||||
<NAlert type="success" :show-icon="true">
|
||||
{{ t('import.resultSummary', { imported: result?.imported ?? 0, skipped: result?.skipped ?? 0, failed: result?.failed ?? 0 }) }}
|
||||
</NAlert>
|
||||
<div v-if="result?.messages?.length" style="max-height: 320px; overflow: auto;">
|
||||
<NText v-for="(msg, i) in result.messages" :key="i" depth="3" style="display: block; font-size: 12px;">
|
||||
{{ msg }}
|
||||
</NText>
|
||||
</div>
|
||||
<NSpace justify="end">
|
||||
<NButton type="primary" @click="handleClose">{{ t('import.done') }}</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 登录页面组件:提供用户名/密码表单,验证后跳转至首页
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace } from 'naive-ui'
|
||||
import { login } from '../api/user'
|
||||
@@ -10,6 +10,7 @@ import { useRegistrationStore } from '../store/registration'
|
||||
import LocaleSwitch from '../components/LocaleSwitch.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const message = useMessage() // 消息提示工具
|
||||
const userStore = useUserStore() // 用户状态管理
|
||||
const registrationStore = useRegistrationStore() // 注册状态管理
|
||||
@@ -31,6 +32,16 @@ const form = ref({
|
||||
})
|
||||
const loading = ref(false) // 登录按钮加载状态
|
||||
|
||||
/** 解析登录后的回跳地址:仅允许站内路径,避免开放重定向 */
|
||||
function resolveRedirect(): string {
|
||||
const redirect = route.query.redirect
|
||||
const target = Array.isArray(redirect) ? redirect[0] : redirect
|
||||
if (typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')) {
|
||||
return target
|
||||
}
|
||||
return '/dashboard'
|
||||
}
|
||||
|
||||
/** 处理登录操作:校验表单 -> 调用登录接口 -> 保存 token -> 跳转首页 */
|
||||
async function handleLogin() {
|
||||
if (!form.value.username || !form.value.password) {
|
||||
@@ -43,7 +54,7 @@ async function handleLogin() {
|
||||
userStore.setToken(res.data.token) // 保存 token
|
||||
userStore.setUserInfo(res.data.user.nickname, res.data.user.id) // 保存用户信息
|
||||
message.success(t('login.success'))
|
||||
router.push('/dashboard') // 跳转到统计面板
|
||||
router.push(resolveRedirect()) // 跳转到来源页或统计面板
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('login.failed'))
|
||||
} finally {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 系统设置页:上下分区展示数据库配置与 AI 模型配置
|
||||
// 系统设置页:上下分区展示数据库配置、注册设置、AI 模型配置与定时通知
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
|
||||
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
|
||||
import AiConfigPanel from '../components/AiConfigPanel.vue'
|
||||
import NotificationConfigPanel from '../components/NotificationConfigPanel.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -14,17 +15,23 @@ const { t } = useI18n()
|
||||
<DatabaseConfigPanel />
|
||||
<RegistrationConfigPanel />
|
||||
<AiConfigPanel />
|
||||
<NotificationConfigPanel />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
max-width: 860px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.page-title {
|
||||
margin: 0 0 16px;
|
||||
margin: 0;
|
||||
}
|
||||
.settings-page > :deep(.n-card) {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #efeff5;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user