Files
EmailBill/Web/src/views/SettingView.vue
孙诚 ef4ed9fd57
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 6s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
feat: Implement scheduled tasks management and budget archiving functionality
- Added BudgetArchiveJob for monthly budget archiving.
- Created BudgetArchive entity and BudgetArchiveRepository for managing archived budgets.
- Introduced JobController for handling job execution, pausing, and resuming.
- Developed ScheduledTasksView for displaying and managing scheduled tasks in the frontend.
- Updated PeriodicBillJob to improve scope handling.
- Enhanced OpenAiService with increased HTTP timeout.
- Added archiveBudgets API endpoint for archiving budgets by year and month.
- Refactored BudgetController to utilize new repository patterns and improved error handling.
- Introduced rich-content styles for better rendering of HTML content in Vue components.
- Updated various Vue components to support rich HTML content display.
2026-01-09 14:03:01 +08:00

342 lines
9.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-container-flex">
<van-nav-bar title="设置" placeholder/>
<div class="scroll-content">
<div class="detail-header" style="padding-bottom: 5px;">
<p>账单</p>
</div>
<van-cell-group inset>
<van-cell title="从支付宝导入" is-link @click="handleImportClick('Alipay')" />
<van-cell title="从微信导入" is-link @click="handleImportClick('WeChat')" />
<van-cell title="周期记录" is-link @click="handlePeriodicRecord" />
</van-cell-group>
<!-- 隐藏的文件选择器 -->
<input ref="fileInputRef" type="file" accept=".csv,.xlsx,.xls" style="display: none" @change="handleFileChange" />
<div class="detail-header" style="padding-bottom: 5px;">
<p>分类</p>
</div>
<van-cell-group inset>
<van-cell title="编辑分类" is-link @click="handleEditClassification" />
<van-cell title="批量分类" is-link @click="handleBatchClassification" />
<van-cell title="智能分类" is-link @click="handleSmartClassification" />
<!-- <van-cell title="自然语言分类" is-link @click="handleNaturalLanguageClassification" /> -->
</van-cell-group>
<div class="detail-header" style="padding-bottom: 5px;">
<p>通知</p>
</div>
<van-cell-group inset>
<van-cell title="开启消息通知">
<template #right-icon>
<van-switch v-model="notificationEnabled" size="24" :loading="notificationLoading" @change="handleNotificationToggle" />
</template>
</van-cell>
<van-cell v-if="notificationEnabled" title="测试通知" is-link @click="handleTestNotification" />
</van-cell-group>
<div class="detail-header" style="padding-bottom: 5px;">
<p>开发者</p>
</div>
<van-cell-group inset>
<van-cell title="查看日志" is-link @click="handleLogView" />
<van-cell title="清除缓存" is-link @click="handleReloadFromNetwork" />
<van-cell title="定时任务" is-link @click="handleScheduledTasks" />
</van-cell-group>
<div class="detail-header" style="padding-bottom: 5px;">
<p>账户</p>
</div>
<van-cell-group inset>
<van-cell title="退出登录" is-link @click="handleLogout" />
</van-cell-group>
<!-- 底部安全距离 -->
<div style="height: calc(80px + env(safe-area-inset-bottom, 0px))"></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showLoadingToast, showSuccessToast, showToast, closeToast, showConfirmDialog } from 'vant'
import { uploadBillFile } from '@/api/billImport'
import { useAuthStore } from '@/stores/auth'
import { getVapidPublicKey, subscribe, testNotification } from '@/api/notification'
import { updateServiceWorker } from '@/registerServiceWorker'
const router = useRouter()
const authStore = useAuthStore()
const fileInputRef = ref(null)
const currentType = ref('')
const notificationEnabled = ref(false)
const notificationLoading = ref(false)
onMounted(async () => {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
notificationEnabled.value = !!subscription
}
})
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
const handleNotificationToggle = async (checked) => {
if (!('serviceWorker' in navigator)) {
showToast('您的浏览器不支持推送通知')
notificationEnabled.value = false
return
}
notificationLoading.value = true
try {
const registration = await navigator.serviceWorker.ready
if (checked) {
// 开启通知
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
showToast('请允许通知权限')
notificationEnabled.value = false
return
}
let { success, data, message } = await getVapidPublicKey()
if (!success) {
throw new Error(message || '获取 VAPID 公钥失败')
}
const convertedVapidKey = urlBase64ToUint8Array(data)
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey
})
const subJson = subscription.toJSON()
await subscribe({
endpoint: subJson.endpoint,
p256DH: subJson.keys.p256dh,
auth: subJson.keys.auth
})
showSuccessToast('开启成功')
} else {
// 关闭通知
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
}
showSuccessToast('已关闭')
}
} catch (error) {
console.error(error)
showToast('操作失败: ' + (error.message || '未知错误'))
notificationEnabled.value = !checked // 回滚状态
} finally {
notificationLoading.value = false
}
}
const handleTestNotification = async () => {
try {
await testNotification('这是一条测试消息')
showSuccessToast('发送成功,请查看通知栏')
} catch (error) {
console.error(error)
showToast('发送失败')
}
}
/**
* 处理导入按钮点击
*/
const handleImportClick = (type) => {
currentType.value = type
// 触发文件选择
fileInputRef.value?.click()
}
const handlePeriodicRecord = () => {
router.push({ name: 'periodic-record' })
}
/**
* 处理文件选择
*/
const handleFileChange = async (event) => {
const file = event.target.files?.[0]
if (!file) {
return
}
// 验证文件类型
const validTypes = ['text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']
if (!validTypes.includes(file.type)) {
showToast('请选择 CSV 或 Excel 文件')
return
}
// 验证文件大小(限制为 10MB
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
showToast('文件大小不能超过 10MB')
return
}
try {
// 显示加载提示
showLoadingToast({
message: '上传中...',
forbidClick: true,
duration: 0
})
// 上传文件
const typeName = currentType.value === 'Alipay' ? '支付宝' : '微信'
const { success, message } = await uploadBillFile(file, currentType.value)
if (!success) {
showToast(message || `${typeName}账单导入失败`)
return
}
showSuccessToast(message || `${typeName}账单导入成功`)
} catch (error) {
console.error('上传失败:', error)
showToast('上传失败: ' + (error.message || '未知错误'))
}
finally {
closeToast()
// 清空文件输入,允许重复选择同一文件
event.target.value = ''
}
}
const handleEditClassification = () => {
router.push({ name: 'classification-edit' })
}
const handleBatchClassification = () => {
router.push({ name: 'classification-batch' })
}
const handleSmartClassification = () => {
router.push({ name: 'smart-classification' })
}
// const handleNaturalLanguageClassification = () => {
// router.push({ name: 'classification-nlp' })
// }
/**
* 处理退出登录
*/
const handleLogout = async () => {
try {
await showConfirmDialog({
title: '提示',
message: '确定要退出登录吗?',
})
authStore.logout()
showSuccessToast('已退出登录')
router.push({ name: 'login' })
} catch (error) {
console.error('取消退出登录:', error)
showToast('已取消退出登录')
}
}
/**
* 处理查看日志
*/
const handleLogView = () => {
router.push({ name: 'log' })
}
const handleReloadFromNetwork = async () => {
try {
await showConfirmDialog({
title: '提示',
message: '确定要刷新网络吗?此操作不可撤销。',
})
// PWA程序强制页面更新到最新版本
if ('serviceWorker' in navigator) {
await updateServiceWorker()
showSuccessToast('正在更新,请稍候...')
// 延迟刷新页面以加载新版本
setTimeout(() => {
window.location.reload()
}, 1500)
} else {
showToast('当前环境不支持此操作')
return
}
} catch (error) {
console.error('取消刷新网络:', error)
showToast('已取消刷新网络')
}
}
const handleScheduledTasks = () => {
router.push({ name: 'scheduled-tasks' })
}
</script>
<style scoped>
/* 页面背景色 */
:deep(body) {
background-color: #f5f5f5;
}
@media (prefers-color-scheme: dark) {
:deep(body) {
background-color: #1a1a1a;
}
}
/* 增加卡片对比度 */
:deep(.van-cell-group--inset) {
background-color: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
@media (prefers-color-scheme: dark) {
:deep(.van-cell-group--inset) {
background-color: #2c2c2c;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
}
.detail-header {
padding: 16px 16px 5px 16px;
margin-bottom: 5px;
}
.detail-header p {
margin: 0;
font-size: 14px;
color: #969799;
font-weight: normal;
}
/* 设置页面容器背景色 */
:deep(.van-nav-bar) {
background: transparent !important;
}
</style>