Files
EmailBill/Web/src/views/SettingView.vue
SunCheng 6e95568906
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 18s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 0s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
fix
2026-02-20 13:56:29 +08:00

432 lines
10 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">
<!-- 自定义头部 -->
<header class="setting-header">
<h1 class="header-title">
设置
</h1>
</header>
<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="handleUnconfirmedClassification"
/>
<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(95px + env(safe-area-inset-bottom, 0px))" />
</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
}
const { 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 handleUnconfirmedClassification = () => {
router.push({ name: 'unconfirmed-classification' })
}
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 lang="scss">
@import '@/assets/theme.css';
/* ========== 自定义头部 ========== */
.setting-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 24px;
background: transparent;
position: relative;
z-index: 1;
min-height: 60px; /* 与其他 header 保持一致,防止切换抖动 */
}
.header-title {
font-family: var(--font-primary);
font-size: var(--font-2xl);
font-weight: var(--font-medium);
color: var(--text-primary);
margin: 0;
}
/* ========== 页面内容 ========== */
/* 增加卡片对比度 */
:deep(.van-cell-group--inset) {
background-color: var(--van-background-2);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.detail-header {
padding: 16px 16px 5px 16px;
margin-bottom: 5px;
}
.detail-header p {
margin: 0;
font-size: 14px;
color: var(--van-text-color-2);
font-weight: normal;
}
</style>