feat: 前端适配移动端与小屏并优化触屏图表交互

新增断点基础设施与响应式布局,使手机(<768px)与平板(768–1023px)下可正常浏览和操作,桌面端(≥1024px)布局保持原有形态不变;引入此前未被入口引用的全局样式文件,使全局小屏兜底规则生效,并修复小屏分页控件被裁切、触屏端图表点击误触直接跳转的问题。

变更内容:
- 断点基础设施:新增 `frontend/src/composables/useBreakpoints.ts`(isMobile / isTablet / isDesktop / viewportWidth / isTouch),作为全站统一断点与触屏判定来源
- 全局样式:`frontend/src/main.ts` 引入 `./style.css`(此前从未被引入,导致其中规则一直无效),使弹层 max-width 92vw、body 防横向溢出等兜底规则实际生效
- 主布局 `views/Layout.vue`:小屏侧栏收起为 NDrawer、Header 增加菜单按钮,切回桌面自动收起;内容区高度改用 dvh,内边距随断点收窄;新增三语文案 `layout.menu`
- 表格:为列宽可能超出窄屏的 NDataTable 统一补 `:scroll-x`(收支记录、邮箱账单、规则/分类/标签/渠道、账单预览、AI 使用、关系编辑等)
- 弹窗:为固定宽度弹窗补 `max-width: 92vw`(分类/标签/渠道/规则、收支新增编辑与原始数据与一键设置、通知渠道、登录与注册卡片)
- 栅格与图表:`Dashboard.vue` / `SqlQuery.vue` 栅格改用断点列(responsive="screen"),图表高度随断点降低
- 浮窗:`FloatingPanel.vue` 按视口约束宽高、最小尺寸随视口下调;`AiQueryWindow.vue` / `EmojiPicker.vue` 小屏自适应
- 表单与列表:左标签表单小屏改为顶部标签(AI 配置、数据库配置、通知配置、邮箱账单弹窗);未换行 NSpace 与关系编辑器成员行小屏可换行
- 分页修复:≤1023px 下 `.n-pagination` 允许换行、居中、高度自适应(naive-ui 运行期注入样式优先级更高,用 !important 覆盖),表格内置分页容器同步居中,避免页大小选择与跳页输入被裁到视口外
- 触屏图表交互:`Dashboard.vue` 触屏端点击趋势柱或饼图扇区时按点击坐标弹出确认气泡(「查看明细 / 取消」),确认后才跳转;`handleTrendBlankClick` 在触屏端直接返回、禁用「绘图区整列可点」;桌面端保持点击即跳转;新增三语文案 `dashboard.chartActionHint` / `dashboard.chartActionView`

说明:
- 桌面端布局与跳转逻辑不变,点击柱子直接跳转、不显示气泡
- 分页控件在窄屏换行显示,页大小可切换、跳页输入可见可用
- 触屏端滑动/缩放不触发跳转(仅图形本体点击且需二次确认)
- 采用「基础可用适配」范围,未做移动端卡片式重排;断点统一走 `useBreakpoints`,后续新增页面按工作区 `AGENTS.md`《前端响应式适配(移动端 / 小屏)》约定实现

验证:
- 测试环境(5174)以 375 / 768 / 800 / 1280 宽度遍历统计面板、收支记录、分类管理、设置、邮箱账单、日志、智能查数等页面,均无横向溢出;手机端侧栏抽屉可开合;分页控件均在视口内且页大小可切换
- 触屏(375,hasTouch)点柱子/饼图扇区弹出确认气泡且不跳转,点空白不弹不跳,点「查看明细」跳转 `/transactions` 并写入筛选偏好;桌面(1280)点柱子直接跳转
- `./package.sh` 构建成功(bootJar + bootGuiJar,含前端 build)
- `npx vue-tsc -b --force` 类型检查通过
This commit is contained in:
2026-09-30 17:10:07 +08:00
parent caa0c01952
commit 75e75ad190
24 changed files with 393 additions and 48 deletions
@@ -139,7 +139,7 @@ async function handleSubmit() {
/> />
</NFormItem> </NFormItem>
<NFormItem :label="t('account.newPassword')"> <NFormItem :label="t('account.newPassword')">
<NSpace :size="8" style="width: 100%;"> <NSpace :size="8" :wrap="true" style="width: 100%;">
<NInput <NInput
v-model:value="form.newPassword" v-model:value="form.newPassword"
:type="revealed ? 'text' : 'password'" :type="revealed ? 'text' : 'password'"
+4 -1
View File
@@ -11,10 +11,13 @@ import {
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { getAiConfig, saveAiConfig, testAiConfig } from '../api/ai' import { getAiConfig, saveAiConfig, testAiConfig } from '../api/ai'
import { refreshAiEnabled } from '../composables/useAiEnabled' import { refreshAiEnabled } from '../composables/useAiEnabled'
import { useBreakpoints } from '../composables/useBreakpoints'
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
const { t } = useI18n() const { t } = useI18n()
// 小屏时标签置于顶部,避免左侧固定标签挤压输入框
const { isMobile } = useBreakpoints()
const saving = ref(false) const saving = ref(false)
const testing = ref(false) const testing = ref(false)
@@ -117,7 +120,7 @@ onMounted(loadConfig)
{{ t('ai.intro') }} {{ t('ai.intro') }}
</NAlert> </NAlert>
<NForm label-placement="left" label-width="130"> <NForm :label-placement="isMobile ? 'top' : 'left'" :label-width="isMobile ? undefined : 130">
<NFormItem :label="t('ai.enabled')"> <NFormItem :label="t('ai.enabled')">
<div class="switch-row"> <div class="switch-row">
<NSwitch v-model:value="form.enabled" /> <NSwitch v-model:value="form.enabled" />
+1
View File
@@ -160,6 +160,7 @@ onMounted(loadUsage)
:loading="usageLoading" :loading="usageLoading"
size="small" size="small"
:bordered="false" :bordered="false"
:scroll-x="900"
:striped="true" :striped="true"
class="usage-table" class="usage-table"
> >
@@ -392,6 +392,7 @@ onMounted(() => {
:columns="columns" :columns="columns"
:data="filteredRows" :data="filteredRows"
:max-height="420" :max-height="420"
:scroll-x="1020"
:row-key="(row) => row.rowNo" :row-key="(row) => row.rowNo"
size="small" size="small"
:bordered="false" :bordered="false"
@@ -15,6 +15,7 @@ import { getDatabaseStatus } from '../api/database'
import type { DatabaseConfigPayload, DatabaseType } from '../api/database' import type { DatabaseConfigPayload, DatabaseType } from '../api/database'
import { useDatabaseStore } from '../store/database' import { useDatabaseStore } from '../store/database'
import { openSse } from '../utils/sse' import { openSse } from '../utils/sse'
import { useBreakpoints } from '../composables/useBreakpoints'
const props = defineProps<{ setup?: boolean }>() const props = defineProps<{ setup?: boolean }>()
@@ -22,6 +23,8 @@ const router = useRouter()
const message = useMessage() const message = useMessage()
const { t } = useI18n() const { t } = useI18n()
const databaseStore = useDatabaseStore() const databaseStore = useDatabaseStore()
// 小屏时表单标签置顶,避免左侧固定标签挤压输入区
const { isMobile } = useBreakpoints()
const testing = ref(false) const testing = ref(false)
const saving = ref(false) const saving = ref(false)
@@ -259,7 +262,7 @@ onBeforeUnmount(() => {
</NDescriptionsItem> </NDescriptionsItem>
</NDescriptions> </NDescriptions>
<NForm label-placement="left" label-width="120"> <NForm :label-placement="isMobile ? 'top' : 'left'" :label-width="isMobile ? undefined : 120">
<NFormItem :label="t('database.type')"> <NFormItem :label="t('database.type')">
<NRadioGroup v-model:value="form.type"> <NRadioGroup v-model:value="form.type">
<NRadioButton value="SQLITE">SQLite</NRadioButton> <NRadioButton value="SQLITE">SQLite</NRadioButton>
+17 -1
View File
@@ -2,6 +2,11 @@
// Emoji 选择器组件:提供常用 Emoji 分组快速点选与自定义文本输入,供分类图标等场景复用 // Emoji 选择器组件:提供常用 Emoji 分组快速点选与自定义文本输入,供分类图标等场景复用
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { NPopover, NInput, NButton, NScrollbar } from 'naive-ui' import { NPopover, NInput, NButton, NScrollbar } from 'naive-ui'
import { useBreakpoints } from '../composables/useBreakpoints'
// 弹层宽度随视口收敛,避免小屏右侧溢出
const { viewportWidth } = useBreakpoints()
const popoverWidth = computed(() => Math.min(360, Math.max(240, viewportWidth.value - 24)))
/** 组件属性 */ /** 组件属性 */
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@@ -68,7 +73,7 @@ function handleClear() {
</script> </script>
<template> <template>
<NPopover v-model:show="show" trigger="click" placement="bottom-start" :width="360"> <NPopover v-model:show="show" trigger="click" placement="bottom-start" :width="popoverWidth">
<template #trigger> <template #trigger>
<div class="emoji-trigger" :class="{ 'is-empty': !props.modelValue }"> <div class="emoji-trigger" :class="{ 'is-empty': !props.modelValue }">
<span class="emoji-current">{{ displayText }}</span> <span class="emoji-current">{{ displayText }}</span>
@@ -175,6 +180,17 @@ function handleClear() {
background: #e6f7ec; background: #e6f7ec;
} }
/* 小屏减少列数并放大点击区域 */
@media (max-width: 767px) {
.emoji-grid {
grid-template-columns: repeat(6, 1fr);
}
.emoji-item {
padding: 6px 0;
font-size: 22px;
}
}
.emoji-group-label { .emoji-group-label {
margin: 8px 0 4px; margin: 8px 0 4px;
font-size: 12px; font-size: 12px;
+34 -9
View File
@@ -45,10 +45,20 @@ const emit = defineEmits<{
const MARGIN = 8 const MARGIN = 8
const rect = ref<PanelRect>({ x: 0, y: 0, width: props.defaultWidth, height: props.defaultHeight }) const rect = ref<PanelRect>({ x: 0, y: 0, width: props.defaultWidth, height: props.defaultHeight })
/** 计算居中显示的默认位置 */ /** 视口内允许的最大宽度(扣除两侧边距) */
function viewportMaxWidth(): number {
return Math.max(200, window.innerWidth - 2 * MARGIN)
}
/** 视口内允许的最大高度(扣除上下边距) */
function viewportMaxHeight(): number {
return Math.max(160, window.innerHeight - 2 * MARGIN)
}
/** 计算居中显示的默认位置(小屏时自动收缩到视口内) */
function defaultRect(): PanelRect { function defaultRect(): PanelRect {
const width = props.defaultWidth const width = Math.min(props.defaultWidth, viewportMaxWidth())
const height = props.defaultHeight const height = Math.min(props.defaultHeight, viewportMaxHeight())
return { return {
x: Math.max(MARGIN, (window.innerWidth - width) / 2), x: Math.max(MARGIN, (window.innerWidth - width) / 2),
y: Math.max(MARGIN, (window.innerHeight - height) / 3), y: Math.max(MARGIN, (window.innerHeight - height) / 3),
@@ -57,8 +67,10 @@ function defaultRect(): PanelRect {
} }
} }
/** 将浮窗限制在视口内 */ /** 将浮窗限制在视口内:先按视口收缩宽高,再钳制位置,保证小屏不溢出 */
function clampRect() { function clampRect() {
rect.value.width = Math.min(rect.value.width, viewportMaxWidth())
rect.value.height = Math.min(rect.value.height, viewportMaxHeight())
const maxX = Math.max(MARGIN, window.innerWidth - rect.value.width - MARGIN) const maxX = Math.max(MARGIN, window.innerWidth - rect.value.width - MARGIN)
const maxY = Math.max(MARGIN, window.innerHeight - rect.value.height - MARGIN) const maxY = Math.max(MARGIN, window.innerHeight - rect.value.height - MARGIN)
rect.value.x = Math.min(Math.max(MARGIN, rect.value.x), maxX) rect.value.x = Math.min(Math.max(MARGIN, rect.value.x), maxX)
@@ -150,13 +162,15 @@ function handleResizeStart(e: PointerEvent) {
window.addEventListener('pointerup', handleResizeEnd) window.addEventListener('pointerup', handleResizeEnd)
} }
/** 缩放移动:限制最小尺寸与视口边界 */ /** 缩放移动:限制最小尺寸与视口边界(小屏时最小尺寸随视口下调) */
function handleResizeMove(e: PointerEvent) { function handleResizeMove(e: PointerEvent) {
if (!resizing) return if (!resizing) return
const maxW = Math.max(props.minWidth, window.innerWidth - rect.value.x - MARGIN) const effMinW = Math.min(props.minWidth, viewportMaxWidth())
const maxH = Math.max(props.minHeight, window.innerHeight - rect.value.y - MARGIN) const effMinH = Math.min(props.minHeight, viewportMaxHeight())
rect.value.width = Math.min(Math.max(props.minWidth, resizeOriginW + (e.clientX - resizeStartX)), maxW) const maxW = Math.max(effMinW, window.innerWidth - rect.value.x - MARGIN)
rect.value.height = Math.min(Math.max(props.minHeight, resizeOriginH + (e.clientY - resizeStartY)), maxH) const maxH = Math.max(effMinH, window.innerHeight - rect.value.y - MARGIN)
rect.value.width = Math.min(Math.max(effMinW, resizeOriginW + (e.clientX - resizeStartX)), maxW)
rect.value.height = Math.min(Math.max(effMinH, resizeOriginH + (e.clientY - resizeStartY)), maxH)
} }
/** 缩放结束并持久化 */ /** 缩放结束并持久化 */
@@ -289,4 +303,15 @@ onBeforeUnmount(() => {
touch-action: none; touch-action: none;
background: linear-gradient(135deg, transparent 50%, rgba(0, 0, 0, 0.18) 50%); background: linear-gradient(135deg, transparent 50%, rgba(0, 0, 0, 0.18) 50%);
} }
/* 小屏放大缩放手柄触控区域并收窄内边距 */
@media (max-width: 767px) {
.floating-panel__resize {
width: 26px;
height: 26px;
}
.floating-panel__body {
padding: 12px;
}
}
</style> </style>
@@ -16,10 +16,13 @@ import {
getNotificationUrl, updateNotificationUrl, getNotificationUrl, updateNotificationUrl,
} from '../api/notification' } from '../api/notification'
import type { NotificationChannel, NotificationLog } from '../api/notification' import type { NotificationChannel, NotificationLog } from '../api/notification'
import { useBreakpoints } from '../composables/useBreakpoints'
const message = useMessage() const message = useMessage()
const dialog = useDialog() const dialog = useDialog()
const { t } = useI18n() const { t } = useI18n()
// 小屏时表单标签置顶,避免左侧固定标签挤压输入区
const { isMobile } = useBreakpoints()
// 提醒周期表单 // 提醒周期表单
const config = ref({ enabled: false, frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1, sendTime: '09:00' }) const config = ref({ enabled: false, frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1, sendTime: '09:00' })
@@ -305,7 +308,7 @@ onMounted(loadAll)
{{ t('notification.intro') }} {{ t('notification.intro') }}
</NAlert> </NAlert>
<NForm label-placement="left" label-width="130"> <NForm :label-placement="isMobile ? 'top' : 'left'" :label-width="isMobile ? undefined : 130">
<NFormItem :label="t('notification.linkUrl')"> <NFormItem :label="t('notification.linkUrl')">
<NSpace align="center" :size="8" style="width: 100%;"> <NSpace align="center" :size="8" style="width: 100%;">
<NInput <NInput
@@ -426,9 +429,9 @@ onMounted(loadAll)
v-model:show="showChannelModal" v-model:show="showChannelModal"
preset="card" preset="card"
:title="editingId ? t('notification.editChannel') : t('notification.addChannel')" :title="editingId ? t('notification.editChannel') : t('notification.addChannel')"
style="width: 560px" style="width: 560px; max-width: 92vw"
> >
<NForm label-placement="left" label-width="120"> <NForm :label-placement="isMobile ? 'top' : 'left'" :label-width="isMobile ? undefined : 120">
<NFormItem :label="t('notification.channelType')"> <NFormItem :label="t('notification.channelType')">
<NSelect v-model:value="channelForm.type" :options="channelTypeOptions" :disabled="!!editingId" /> <NSelect v-model:value="channelForm.type" :options="channelTypeOptions" :disabled="!!editingId" />
</NFormItem> </NFormItem>
@@ -373,7 +373,7 @@ const pickerColumns = computed<DataTableColumns<RelationMemberDraft>>(() => [
<div <div
v-for="member in editorMembers" v-for="member in editorMembers"
:key="member.transactionId" :key="member.transactionId"
style="display: flex; align-items: center; gap: 8px; font-size: 13px;" style="display: flex; align-items: center; gap: 8px; font-size: 13px; flex-wrap: wrap;"
> >
<span style="min-width: 130px;">{{ formatTime(member.transactionTime) }}</span> <span style="min-width: 130px;">{{ formatTime(member.transactionTime) }}</span>
<span style="min-width: 100px;">{{ member.categoryName || t('transaction.uncategorized') }}</span> <span style="min-width: 100px;">{{ member.categoryName || t('transaction.uncategorized') }}</span>
@@ -428,6 +428,7 @@ const pickerColumns = computed<DataTableColumns<RelationMemberDraft>>(() => [
:loading="pickerLoading" :loading="pickerLoading"
:row-key="(row: RelationMemberDraft) => row.transactionId" :row-key="(row: RelationMemberDraft) => row.transactionId"
:max-height="420" :max-height="420"
:scroll-x="620"
size="small" size="small"
:bordered="false" :bordered="false"
/> />
@@ -0,0 +1,61 @@
// 响应式断点 composable:全局单例,供页面在手机/平板/桌面间切换布局
import { ref } from 'vue'
import type { Ref } from 'vue'
/** 手机断点上限(视口宽度 < 768px 视为手机) */
export const MOBILE_MAX_WIDTH = 767
/** 平板断点区间 [768, 1023],≥1024 视为桌面 */
export const TABLET_MIN_WIDTH = 768
/** 桌面断点下限 */
export const DESKTOP_MIN_WIDTH = 1024
/** 是否为手机(窄屏) */
export const isMobile: Ref<boolean> = ref(false)
/** 是否为平板 */
export const isTablet: Ref<boolean> = ref(false)
/** 是否为桌面(宽屏) */
export const isDesktop: Ref<boolean> = ref(true)
/** 当前视口宽度(像素) */
export const viewportWidth: Ref<number> = ref(0)
/** 是否为触屏 / 粗指针设备(用于交互降级,降低误触) */
export const isTouch: Ref<boolean> = ref(false)
// 仅注册一次媒体查询监听,避免多处调用重复绑定
let initialized = false
/** 依据当前视口宽度更新各断点状态 */
function updateByWidth(width: number) {
viewportWidth.value = width
isMobile.value = width <= MOBILE_MAX_WIDTH
isTablet.value = width >= TABLET_MIN_WIDTH && width < DESKTOP_MIN_WIDTH
isDesktop.value = width >= DESKTOP_MIN_WIDTH
}
/** 更新是否触屏设备(粗指针或无 hover 能力) */
function updateTouch() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
isTouch.value = window.matchMedia('(pointer: coarse)').matches
|| window.matchMedia('(hover: none)').matches
}
/** 初始化断点监听(幂等),在浏览器环境首次调用时绑定 resize */
export function initBreakpoints() {
if (initialized || typeof window === 'undefined') return
initialized = true
updateByWidth(window.innerWidth)
updateTouch()
window.addEventListener('resize', () => {
updateByWidth(window.innerWidth)
updateTouch()
}, { passive: true })
}
/**
* 获取当前断点状态。
*
* :returns: 包含手机/平板/桌面判断、视口宽度与触屏标识的响应式对象
*/
export function useBreakpoints() {
initBreakpoints()
return { isMobile, isTablet, isDesktop, viewportWidth, isTouch }
}
+3
View File
@@ -98,6 +98,7 @@ export default {
settings: { zh: '设置', en: 'Settings', ja: '設定' }, settings: { zh: '设置', en: 'Settings', ja: '設定' },
user: { zh: '用户', en: 'User', ja: 'ユーザー' }, user: { zh: '用户', en: 'User', ja: 'ユーザー' },
logout: { zh: '退出', en: 'Sign Out', ja: 'ログアウト' }, logout: { zh: '退出', en: 'Sign Out', ja: 'ログアウト' },
menu: { zh: '菜单', en: 'Menu', ja: 'メニュー' },
}, },
// 数据库设置 // 数据库设置
@@ -409,6 +410,8 @@ export default {
incomeChannelPie: { zh: '收入交易渠道占比', en: 'Income by Channel', ja: '収入の取引チャネル割合' }, incomeChannelPie: { zh: '收入交易渠道占比', en: 'Income by Channel', ja: '収入の取引チャネル割合' },
uncategorized: { zh: '未分类', en: 'Uncategorized', ja: '未分類' }, uncategorized: { zh: '未分类', en: 'Uncategorized', ja: '未分類' },
viewTransactions: { zh: '查看收支记录', en: 'View Transactions', ja: '収支記録を表示' }, viewTransactions: { zh: '查看收支记录', en: 'View Transactions', ja: '収支記録を表示' },
chartActionHint: { zh: '查看对应账单明细?', en: 'View matching records?', ja: '該当する明細を表示しますか?' },
chartActionView: { zh: '查看明细', en: 'View Details', ja: '明細を表示' },
}, },
// 收支记录 // 收支记录
+1
View File
@@ -4,6 +4,7 @@ import { createPinia } from 'pinia'
import router from './router' import router from './router'
import i18n from './locales' import i18n from './locales'
import App from './App.vue' import App from './App.vue'
import './style.css'
// 创建 Vue 应用实例 // 创建 Vue 应用实例
const app = createApp(App) const app = createApp(App)
+40
View File
@@ -3,3 +3,43 @@
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
html,
body,
#app {
height: 100%;
}
/* 移动端/小屏基础适配:统一弹层宽度兜底、防止页面级横向溢出 */
@media (max-width: 767px) {
body {
overflow-x: hidden;
}
/* 弹窗(NModal preset card / dialog)宽度不超过视口,避免固定宽度溢出 */
.n-modal {
max-width: 92vw !important;
}
/* 下拉/浮层内容宽度不超过视口 */
.n-popover,
.n-dropdown-menu {
max-width: 92vw;
}
}
/* 手机/平板下分页允许换行,避免页大小选择、跳页输入被裁到视口外无法操作。
naive-ui 运行期注入的样式优先级更高,需 !important 覆盖。 */
@media (max-width: 1023px) {
.n-pagination {
flex-wrap: wrap !important;
justify-content: center !important;
height: auto !important;
row-gap: 8px;
}
/* 表格内置分页容器同步居中 */
.n-data-table__pagination {
justify-content: center !important;
}
}
+3 -1
View File
@@ -309,6 +309,7 @@ onMounted(() => {
:data="expenseList" :data="expenseList"
:loading="loading" :loading="loading"
:bordered="false" :bordered="false"
:scroll-x="520"
:pagination="expensePagination" :pagination="expensePagination"
@update:page="handleExpensePageChange" @update:page="handleExpensePageChange"
@update:page-size="handleExpensePageSizeChange" @update:page-size="handleExpensePageSizeChange"
@@ -322,13 +323,14 @@ onMounted(() => {
:data="incomeList" :data="incomeList"
:loading="loading" :loading="loading"
:bordered="false" :bordered="false"
:scroll-x="520"
:pagination="incomePagination" :pagination="incomePagination"
@update:page="handleIncomePageChange" @update:page="handleIncomePageChange"
@update:page-size="handleIncomePageSizeChange" @update:page-size="handleIncomePageSizeChange"
/> />
<!-- 新增/编辑弹窗 --> <!-- 新增/编辑弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('category.editTitle') : t('category.addTitle')" style="width: 450px;"> <NModal v-model:show="showModal" preset="card" :title="editingId ? t('category.editTitle') : t('category.addTitle')" style="width: 450px; max-width: 92vw;">
<NForm> <NForm>
<NFormItem v-if="aiEnabled" :label="t('ai.category.label')"> <NFormItem v-if="aiEnabled" :label="t('ai.category.label')">
<div style="display: flex; gap: 8px; width: 100%;"> <div style="display: flex; gap: 8px; width: 100%;">
+2 -1
View File
@@ -186,13 +186,14 @@ onMounted(fetchData)
:data="channels" :data="channels"
:loading="loading" :loading="loading"
:bordered="false" :bordered="false"
:scroll-x="520"
:pagination="pagination" :pagination="pagination"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:page-size="handlePageSizeChange" @update:page-size="handlePageSizeChange"
/> />
<!-- 新增/改名弹窗 --> <!-- 新增/改名弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('channel.editTitle') : t('channel.addTitle')" style="width: 450px;"> <NModal v-model:show="showModal" preset="card" :title="editingId ? t('channel.editTitle') : t('channel.addTitle')" style="width: 450px; max-width: 92vw;">
<NForm> <NForm>
<NFormItem :label="t('channel.name')"> <NFormItem :label="t('channel.name')">
<NInput v-model:value="form.name" :placeholder="t('channel.namePlaceholder')" /> <NInput v-model:value="form.name" :placeholder="t('channel.namePlaceholder')" />
+106 -11
View File
@@ -17,6 +17,11 @@ import { readJson, writeJson } from '../utils/storage'
import { STORAGE_KEYS } from '../utils/storageKeys' import { STORAGE_KEYS } from '../utils/storageKeys'
import { loadTransactionFilterPrefs, saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs' import { loadTransactionFilterPrefs, saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs'
import AiQueryWindow from '../components/AiQueryWindow.vue' import AiQueryWindow from '../components/AiQueryWindow.vue'
import { useBreakpoints } from '../composables/useBreakpoints'
// 小屏适配:图表高度随断点降低,避免在窄屏占用过多纵向空间
const { isMobile, isTouch, viewportWidth } = useBreakpoints()
const chartHeight = computed(() => (isMobile.value ? '260px' : '350px'))
// 图表组件异步加载:echarts 体积较大,登录后进入面板时先渲染页面骨架, // 图表组件异步加载:echarts 体积较大,登录后进入面板时先渲染页面骨架,
// echarts 在后台按需下载并注册,避免阻塞目标页首屏 // echarts 在后台按需下载并注册,避免阻塞目标页首屏
@@ -473,6 +478,53 @@ function applyPanelNavigation() {
router.push('/transactions') router.push('/transactions')
} }
// 触屏端点击图表元素后的确认气泡;桌面端不经过该状态,直接跳转
const chartAction = ref<{ x: number; y: number; action: () => void } | null>(null)
/** 确认气泡定位:以点击点为中心,并约束在视口内 */
const chartActionStyle = computed(() => {
const a = chartAction.value
if (!a) return {}
const width = 200
const height = 108
const margin = 8
const vw = viewportWidth.value || window.innerWidth
const vh = window.innerHeight
const left = Math.min(Math.max(margin, a.x - width / 2), Math.max(margin, vw - width - margin))
const top = Math.min(Math.max(margin, a.y), Math.max(margin, vh - height - margin))
return { left: `${left}px`, top: `${top}px` }
})
/**
* 图表元素跳转入口:桌面端直接跳转;触屏端先弹出确认气泡,避免滑动/误触直接跳走
*
* :param params: ECharts 元素点击事件参数(用于取点击坐标定位气泡)
* :param action: 实际执行跳转的回调
*/
function triggerChartNavigation(params: any, action: () => void) {
if (!isTouch.value) {
action()
return
}
const ev = params?.event?.event ?? params?.event
const width = viewportWidth.value || window.innerWidth
const x = typeof ev?.clientX === 'number' ? ev.clientX : width / 2
const y = typeof ev?.clientY === 'number' ? ev.clientY : window.innerHeight / 2
chartAction.value = { x, y, action }
}
/** 确认气泡:执行跳转并关闭 */
function confirmChartAction() {
const pending = chartAction.value
chartAction.value = null
pending?.action()
}
/** 关闭确认气泡(取消跳转) */
function closeChartAction() {
chartAction.value = null
}
/** /**
* 处理分类/渠道饼图扇区点击:分类按分类ID跳转,渠道按渠道编码跳转,无渠道编码的扇区不响应 * 处理分类/渠道饼图扇区点击:分类按分类ID跳转,渠道按渠道编码跳转,无渠道编码的扇区不响应
* *
@@ -485,11 +537,13 @@ function handlePieClick(params: any) {
// 渠道饼图数据项带 channelCode 字段,为空表示未指定渠道,不响应 // 渠道饼图数据项带 channelCode 字段,为空表示未指定渠道,不响应
if ('channelCode' in d) { if ('channelCode' in d) {
if (!d.channelCode) return if (!d.channelCode) return
applyChannelNavigation(d.navType, d.channelCode) const channelCode = d.channelCode
triggerChartNavigation(params, () => applyChannelNavigation(d.navType, channelCode))
return return
} }
if (d.categoryId != null) { if (d.categoryId != null) {
applyCategoryNavigation(d.navType, Number(d.categoryId)) const categoryId = Number(d.categoryId)
triggerChartNavigation(params, () => applyCategoryNavigation(d.navType, categoryId))
} }
} }
@@ -503,7 +557,7 @@ function handleTrendClick(params: any) {
const key = trendKeys.value[params.dataIndex] const key = trendKeys.value[params.dataIndex]
if (!key) return if (!key) return
const type = params.seriesId === 'INCOME' ? 'INCOME' : 'EXPENSE' const type = params.seriesId === 'INCOME' ? 'INCOME' : 'EXPENSE'
applyTrendNavigation(key, type) triggerChartNavigation(params, () => applyTrendNavigation(key, type))
} }
/** /**
@@ -512,6 +566,8 @@ function handleTrendClick(params: any) {
* :param e: zrender 点击事件(含相对画布的 offsetX/offsetY) * :param e: zrender 点击事件(含相对画布的 offsetX/offsetY)
*/ */
function handleTrendBlankClick(e: any) { function handleTrendBlankClick(e: any) {
// 触屏端禁用「整列可点」,仅允许点击柱子本体,避免滑动/误触跳转
if (isTouch.value) return
// 命中柱子等图形时交给 handleTrendClick 处理,避免重复跳转 // 命中柱子等图形时交给 handleTrendClick 处理,避免重复跳转
if (e?.target) return if (e?.target) return
const chart = unref(trendChartRef.value?.chart) const chart = unref(trendChartRef.value?.chart)
@@ -902,7 +958,7 @@ onMounted(async () => {
</NCard> </NCard>
<!-- 汇总卡片 --> <!-- 汇总卡片 -->
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 16px;"> <NGrid responsive="screen" cols="1 s:2 m:4" :x-gap="16" :y-gap="16" style="margin-bottom: 16px;">
<NGridItem> <NGridItem>
<NCard> <NCard>
<NStatistic :label="t('dashboard.totalIncome')" :value="Number(summary.totalIncome).toFixed(2)" style="color: #18a058;"> <NStatistic :label="t('dashboard.totalIncome')" :value="Number(summary.totalIncome).toFixed(2)" style="color: #18a058;">
@@ -930,7 +986,7 @@ onMounted(async () => {
</NGrid> </NGrid>
<!-- 概览指标 --> <!-- 概览指标 -->
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 20px;"> <NGrid responsive="screen" cols="1 s:2 m:4" :x-gap="16" :y-gap="16" style="margin-bottom: 20px;">
<NGridItem> <NGridItem>
<NCard> <NCard>
<NStatistic :label="t('dashboard.avgDailyIncome')" :value="Number(summary.avgDailyIncome).toFixed(2)" /> <NStatistic :label="t('dashboard.avgDailyIncome')" :value="Number(summary.avgDailyIncome).toFixed(2)" />
@@ -968,29 +1024,29 @@ onMounted(async () => {
<NSelect v-model:value="granularity" :options="granularityOptions" style="width: 110px;" size="small" /> <NSelect v-model:value="granularity" :options="granularityOptions" style="width: 110px;" size="small" />
</NSpace> </NSpace>
</template> </template>
<VChart ref="trendChartRef" :option="trendOption" style="height: 350px;" autoresize @click="handleTrendClick" @zr:click="handleTrendBlankClick" /> <VChart ref="trendChartRef" :option="trendOption" :style="{ height: chartHeight }" autoresize @click="handleTrendClick" @zr:click="handleTrendBlankClick" />
</NCard> </NCard>
<!-- 饼图区域:支出/收入分类占比 + 支出/收入交易渠道占比 --> <!-- 饼图区域:支出/收入分类占比 + 支出/收入交易渠道占比 -->
<NGrid :cols="2" :x-gap="16" :y-gap="16"> <NGrid responsive="screen" cols="1 m:2" :x-gap="16" :y-gap="16">
<NGridItem> <NGridItem>
<NCard> <NCard>
<VChart :option="pieOption" style="height: 350px;" autoresize @click="handlePieClick" /> <VChart :option="pieOption" :style="{ height: chartHeight }" autoresize @click="handlePieClick" />
</NCard> </NCard>
</NGridItem> </NGridItem>
<NGridItem> <NGridItem>
<NCard> <NCard>
<VChart :option="incomePieOption" style="height: 350px;" autoresize @click="handlePieClick" /> <VChart :option="incomePieOption" :style="{ height: chartHeight }" autoresize @click="handlePieClick" />
</NCard> </NCard>
</NGridItem> </NGridItem>
<NGridItem> <NGridItem>
<NCard> <NCard>
<VChart :option="channelOption" style="height: 350px;" autoresize @click="handlePieClick" /> <VChart :option="channelOption" :style="{ height: chartHeight }" autoresize @click="handlePieClick" />
</NCard> </NCard>
</NGridItem> </NGridItem>
<NGridItem> <NGridItem>
<NCard> <NCard>
<VChart :option="incomeChannelOption" style="height: 350px;" autoresize @click="handlePieClick" /> <VChart :option="incomeChannelOption" :style="{ height: chartHeight }" autoresize @click="handlePieClick" />
</NCard> </NCard>
</NGridItem> </NGridItem>
</NGrid> </NGrid>
@@ -1008,5 +1064,44 @@ onMounted(async () => {
:storage-key="AI_QUERY_WINDOW_KEY" :storage-key="AI_QUERY_WINDOW_KEY"
@submit="handleAiStatistics" @submit="handleAiStatistics"
/> />
<!-- 触屏端图表点击确认气泡:先确认再跳转,降低误触 -->
<Teleport to="body">
<div v-if="chartAction" class="chart-action-mask" @click="closeChartAction" @contextmenu.prevent>
<div class="chart-action-menu" :style="chartActionStyle" @click.stop>
<div class="chart-action-menu__hint">{{ t('dashboard.chartActionHint') }}</div>
<NSpace vertical :size="8">
<NButton type="primary" size="small" block @click="confirmChartAction">
{{ t('dashboard.chartActionView') }}
</NButton>
<NButton size="small" block quaternary @click="closeChartAction">
{{ t('common.cancel') }}
</NButton>
</NSpace>
</div>
</div>
</Teleport>
</div> </div>
</template> </template>
<style scoped>
/* 触屏端图表点击确认气泡:透明遮罩 + 点击点附近的操作卡片 */
.chart-action-mask {
position: fixed;
inset: 0;
z-index: 2600;
}
.chart-action-menu {
position: fixed;
width: 200px;
padding: 12px;
background: #fff;
border-radius: 10px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.04);
}
.chart-action-menu__hint {
margin-bottom: 8px;
font-size: 13px;
color: #333;
}
</style>
+8 -2
View File
@@ -33,11 +33,14 @@ import { migrateKey, writeString } from '../utils/storage'
import { LEGACY_STORAGE_KEYS, STORAGE_KEYS } from '../utils/storageKeys' import { LEGACY_STORAGE_KEYS, STORAGE_KEYS } from '../utils/storageKeys'
import { usePageSizeOptions } from '../composables/usePageSizeOptions' import { usePageSizeOptions } from '../composables/usePageSizeOptions'
import { useTablePageSize } from '../composables/useTablePageSize' import { useTablePageSize } from '../composables/useTablePageSize'
import { useBreakpoints } from '../composables/useBreakpoints'
const message = useMessage() const message = useMessage()
const dialog = useDialog() const dialog = useDialog()
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
// 小屏时表单标签置顶,避免左侧固定标签挤压输入区
const { isMobile } = useBreakpoints()
const router = useRouter() const router = useRouter()
// 所有表格共用的每页条数候选列表 // 所有表格共用的每页条数候选列表
@@ -769,6 +772,7 @@ onUnmounted(() => {
:row-key="(r: EmailBillConfig) => r.id" :row-key="(r: EmailBillConfig) => r.id"
size="small" size="small"
:bordered="false" :bordered="false"
:scroll-x="1140"
:pagination="configPagination" :pagination="configPagination"
@update:page="handleConfigPageChange" @update:page="handleConfigPageChange"
@update:page-size="handleConfigPageSizeChange" @update:page-size="handleConfigPageSizeChange"
@@ -803,6 +807,7 @@ onUnmounted(() => {
:row-key="(r: EmailBillSenderRule) => r.id" :row-key="(r: EmailBillSenderRule) => r.id"
size="small" size="small"
:bordered="false" :bordered="false"
:scroll-x="780"
:pagination="rulePagination" :pagination="rulePagination"
@update:page="handleRulePageChange" @update:page="handleRulePageChange"
@update:page-size="handleRulePageSizeChange" @update:page-size="handleRulePageSizeChange"
@@ -836,6 +841,7 @@ onUnmounted(() => {
size="small" size="small"
:bordered="false" :bordered="false"
remote remote
:scroll-x="960"
:pagination="{ :pagination="{
page: messagePage, pageSize: messageSize, itemCount: messageTotal, page: messagePage, pageSize: messageSize, itemCount: messageTotal,
pageSizes: pageSizeOptions, showSizePicker: true, pageSizes: pageSizeOptions, showSizePicker: true,
@@ -849,7 +855,7 @@ onUnmounted(() => {
<!-- 邮箱配置弹窗 --> <!-- 邮箱配置弹窗 -->
<NModal v-model:show="configModalVisible" preset="card" :title="t('emailBill.tab.configs')" style="width: 560px; max-width: 92vw;"> <NModal v-model:show="configModalVisible" preset="card" :title="t('emailBill.tab.configs')" style="width: 560px; max-width: 92vw;">
<NForm label-placement="left" label-width="130"> <NForm :label-placement="isMobile ? 'top' : 'left'" :label-width="isMobile ? undefined : 130">
<NFormItem :label="t('emailBill.config.name')"> <NFormItem :label="t('emailBill.config.name')">
<NInput v-model:value="configForm.name" /> <NInput v-model:value="configForm.name" />
</NFormItem> </NFormItem>
@@ -923,7 +929,7 @@ onUnmounted(() => {
<!-- 发件人规则弹窗 --> <!-- 发件人规则弹窗 -->
<NModal v-model:show="ruleModalVisible" preset="card" :title="t('emailBill.tab.senderRules')" style="width: 560px; max-width: 92vw;"> <NModal v-model:show="ruleModalVisible" preset="card" :title="t('emailBill.tab.senderRules')" style="width: 560px; max-width: 92vw;">
<NForm label-placement="left" label-width="130"> <NForm :label-placement="isMobile ? 'top' : 'left'" :label-width="isMobile ? undefined : 130">
<NFormItem :label="t('emailBill.senderRule.name')"> <NFormItem :label="t('emailBill.senderRule.name')">
<NInput v-model:value="ruleForm.name" /> <NInput v-model:value="ruleForm.name" />
</NFormItem> </NFormItem>
+76 -7
View File
@@ -3,9 +3,14 @@
import { h, computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue' import { h, computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui' import {
NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown,
NDrawer, NDrawerContent, NButton,
} from 'naive-ui'
import type { MenuOption } from 'naive-ui' import type { MenuOption } from 'naive-ui'
import { DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline, WalletOutline } from '@vicons/ionicons5' import {
DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline, WalletOutline, MenuOutline,
} from '@vicons/ionicons5'
import { useUserStore } from '../store/user' import { useUserStore } from '../store/user'
import LocaleSwitch from '../components/LocaleSwitch.vue' import LocaleSwitch from '../components/LocaleSwitch.vue'
import { getChannels } from '../api/transaction' import { getChannels } from '../api/transaction'
@@ -13,11 +18,15 @@ import { readSessionString, writeSessionString } from '../utils/storage'
import { scrollKey } from '../utils/storageKeys' import { scrollKey } from '../utils/storageKeys'
import { aiEnabled, refreshAiEnabled } from '../composables/useAiEnabled' import { aiEnabled, refreshAiEnabled } from '../composables/useAiEnabled'
import { navEntries, loadNavMenu } from '../composables/useNavMenu' import { navEntries, loadNavMenu } from '../composables/useNavMenu'
import { useBreakpoints } from '../composables/useBreakpoints'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const userStore = useUserStore() const userStore = useUserStore()
const { t } = useI18n() const { t } = useI18n()
// 小屏断点:手机端侧边栏收起为抽屉
const { isMobile } = useBreakpoints()
const showDrawer = ref(false)
// 根据当前路由路径计算菜单激活项 // 根据当前路由路径计算菜单激活项
const activeKey = computed(() => { const activeKey = computed(() => {
@@ -54,6 +63,8 @@ const menuOptions = computed<MenuOption[]>(() => {
/** 菜单点击事件:根据 key 导航到对应路由 */ /** 菜单点击事件:根据 key 导航到对应路由 */
function handleMenuUpdate(key: string) { function handleMenuUpdate(key: string) {
router.push(`/${key}`) router.push(`/${key}`)
// 手机端选择菜单后收起抽屉
showDrawer.value = false
} }
// 顶部用户下拉菜单配置项(随语言切换响应式更新) // 顶部用户下拉菜单配置项(随语言切换响应式更新)
@@ -80,6 +91,9 @@ function handleLogout() {
router.push('/login') router.push('/login')
} }
// 内容区样式:小屏收窄内边距,高度用 dvh 兜底移动端地址栏伸缩
const contentStyle = computed(() => `padding: ${isMobile.value ? 12 : 24}px;`)
// 内容区滚动位置记忆:按路由分别记录,进入页面时自动恢复 // 内容区滚动位置记忆:按路由分别记录,进入页面时自动恢复
const contentRef = ref<InstanceType<typeof NLayoutContent> | null>(null) // 内容区滚动容器 const contentRef = ref<InstanceType<typeof NLayoutContent> | null>(null) // 内容区滚动容器
let scrollRaf = 0 // scroll 事件的 rAF 节流句柄 let scrollRaf = 0 // scroll 事件的 rAF 节流句柄
@@ -161,6 +175,11 @@ function handleBeforeUnload() {
saveScroll() saveScroll()
} }
// 切回桌面宽度时收起抽屉,避免残留浮层
watch(isMobile, (mobile) => {
if (!mobile) showDrawer.value = false
})
// 路由切换:先保存离开页的位置,再在 DOM 更新后恢复目标页的位置 // 路由切换:先保存离开页的位置,再在 DOM 更新后恢复目标页的位置
watch(() => route.path, (to, from) => { watch(() => route.path, (to, from) => {
saveScroll(from) saveScroll(from)
@@ -199,9 +218,10 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<!-- 整体布局:左侧边栏 + 右侧内容区 --> <!-- 整体布局:左侧边栏 + 右侧内容区(手机端侧边栏收起为抽屉) -->
<NLayout has-sider style="height: 100vh"> <NLayout has-sider style="height: 100dvh">
<NLayoutSider <NLayoutSider
v-if="!isMobile"
bordered bordered
:width="200" :width="200"
content-style="padding: 12px;" content-style="padding: 12px;"
@@ -224,8 +244,21 @@ onBeforeUnmount(() => {
/> />
</NLayoutSider> </NLayoutSider>
<NLayout> <NLayout>
<!-- 顶部栏:用户下拉菜单与语言切换 --> <!-- 顶部栏:左侧菜单按钮(小屏)+ 右侧语言切换与用户下拉菜单 -->
<NLayoutHeader bordered style="padding: 12px 24px; display: flex; justify-content: flex-end; align-items: center;"> <NLayoutHeader bordered class="app-header" :style="{ padding: isMobile ? '8px 12px' : '12px 24px' }">
<div class="app-header__left">
<NButton
v-if="isMobile"
quaternary
circle
:aria-label="t('layout.menu')"
@click="showDrawer = true"
>
<template #icon>
<NIcon :component="MenuOutline" />
</template>
</NButton>
</div>
<NSpace align="center" :size="16"> <NSpace align="center" :size="16">
<LocaleSwitch /> <LocaleSwitch />
<NDropdown :options="userMenuOptions" @select="handleUserMenuSelect"> <NDropdown :options="userMenuOptions" @select="handleUserMenuSelect">
@@ -237,14 +270,50 @@ onBeforeUnmount(() => {
</NSpace> </NSpace>
</NLayoutHeader> </NLayoutHeader>
<!-- 子路由内容渲染区域 --> <!-- 子路由内容渲染区域 -->
<NLayoutContent ref="contentRef" content-style="padding: 24px;" style="height: calc(100vh - 57px); overflow-y: auto;"> <NLayoutContent
ref="contentRef"
:content-style="contentStyle"
style="height: calc(100dvh - 57px); overflow-y: auto;"
>
<router-view /> <router-view />
</NLayoutContent> </NLayoutContent>
</NLayout> </NLayout>
<!-- 手机端导航抽屉 -->
<NDrawer v-model:show="showDrawer" placement="left" :width="220">
<NDrawerContent :native-scrollbar="false" body-content-style="padding: 12px;">
<div class="app-brand">
<div class="app-brand__logo">
<NIcon :component="WalletOutline" size="18" />
</div>
<div class="app-brand__text">
<span class="app-brand__title">{{ t('layout.appTitle') }}</span>
<span class="app-brand__subtitle">{{ t('layout.appSubtitle') }}</span>
</div>
</div>
<NMenu
:value="activeKey"
:options="menuOptions"
@update:value="handleMenuUpdate"
/>
</NDrawerContent>
</NDrawer>
</NLayout> </NLayout>
</template> </template>
<style scoped> <style scoped>
/* 顶部栏:左右两端布局,固定高度与内容区高度计算保持一致 */
.app-header {
height: 57px;
display: flex;
justify-content: space-between;
align-items: center;
}
.app-header__left {
display: flex;
align-items: center;
min-width: 0;
}
/* 左上角品牌区:图标徽标 + 主标题 + 副标题 */ /* 左上角品牌区:图标徽标 + 主标题 + 副标题 */
.app-brand { .app-brand {
display: flex; display: flex;
+1 -1
View File
@@ -66,7 +66,7 @@ async function handleLogin() {
<template> <template>
<div class="login-container"> <div class="login-container">
<NCard :title="t('login.title')" style="width: 400px" :bordered="true"> <NCard :title="t('login.title')" style="width: 400px; max-width: 92vw" :bordered="true">
<template #header-extra> <template #header-extra>
<LocaleSwitch /> <LocaleSwitch />
</template> </template>
+1 -1
View File
@@ -59,7 +59,7 @@ async function handleRegister() {
<template> <template>
<div class="login-container"> <div class="login-container">
<NCard :title="t('register.title')" style="width: 400px" :bordered="true"> <NCard :title="t('register.title')" style="width: 400px; max-width: 92vw" :bordered="true">
<template #header-extra> <template #header-extra>
<LocaleSwitch /> <LocaleSwitch />
</template> </template>
+2 -1
View File
@@ -815,6 +815,7 @@ onMounted(() => {
:data="rules" :data="rules"
:loading="loading" :loading="loading"
:bordered="false" :bordered="false"
:scroll-x="950"
:row-key="(row: RuleItem) => row.id" :row-key="(row: RuleItem) => row.id"
:pagination="pagination" :pagination="pagination"
@update:page="handlePageChange" @update:page="handlePageChange"
@@ -822,7 +823,7 @@ onMounted(() => {
/> />
<!-- 新增/编辑弹窗 --> <!-- 新增/编辑弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('rule.editTitle') : t('rule.addTitle')" style="width: 520px;"> <NModal v-model:show="showModal" preset="card" :title="editingId ? t('rule.editTitle') : t('rule.addTitle')" style="width: 520px; max-width: 92vw;">
<NForm> <NForm>
<NFormItem :label="t('common.name')"> <NFormItem :label="t('common.name')">
<NInput v-model:value="form.name" :placeholder="t('rule.namePlaceholder')" /> <NInput v-model:value="form.name" :placeholder="t('rule.namePlaceholder')" />
+11 -2
View File
@@ -23,6 +23,7 @@ import { useTablePageSize } from '../composables/useTablePageSize'
import { readSessionJson, writeSessionJson, removeSessionKey } from '../utils/storage' import { readSessionJson, writeSessionJson, removeSessionKey } from '../utils/storage'
import { STORAGE_KEYS } from '../utils/storageKeys' import { STORAGE_KEYS } from '../utils/storageKeys'
import { useUserStore } from '../store/user' import { useUserStore } from '../store/user'
import { useBreakpoints } from '../composables/useBreakpoints'
// 图表组件异步加载:echarts 体积较大,按需加载,避免阻塞首屏 // 图表组件异步加载:echarts 体积较大,按需加载,避免阻塞首屏
const VChart = defineAsyncComponent(async () => { const VChart = defineAsyncComponent(async () => {
@@ -128,6 +129,14 @@ const canChart = computed(() => !!result.value
&& result.value.columns.some((col) => col.type === 'NUMBER')) && result.value.columns.some((col) => col.type === 'NUMBER'))
const hasRows = computed(() => resultRows.value.length > 0) const hasRows = computed(() => resultRows.value.length > 0)
// 统计卡片列数随断点收敛:手机单列、平板两列、桌面最多四列;图表高度同步降低
const { isMobile, viewportWidth } = useBreakpoints()
const scalarCols = computed(() => {
const max = isMobile.value ? 1 : viewportWidth.value < 1024 ? 2 : 4
return Math.min(scalarItems.value.length, max)
})
const chartHeight = computed(() => (isMobile.value ? '280px' : '380px'))
// 单行统计卡片项 // 单行统计卡片项
const scalarItems = computed(() => { const scalarItems = computed(() => {
if (!result.value || !isScalar.value) return [] if (!result.value || !isScalar.value) return []
@@ -485,7 +494,7 @@ onMounted(async () => {
<template v-else> <template v-else>
<!-- 单行结果:统计卡片 --> <!-- 单行结果:统计卡片 -->
<NGrid v-if="isScalar" :cols="Math.min(scalarItems.length, 4)" :x-gap="16" :y-gap="16"> <NGrid v-if="isScalar" :cols="scalarCols" :x-gap="16" :y-gap="16">
<NGridItem v-for="item in scalarItems" :key="item.key"> <NGridItem v-for="item in scalarItems" :key="item.key">
<NCard size="small" :bordered="true"> <NCard size="small" :bordered="true">
<NStatistic :label="item.label" :value="item.value" /> <NStatistic :label="item.label" :value="item.value" />
@@ -520,7 +529,7 @@ onMounted(async () => {
size="small" size="small"
:scroll-x="960" :scroll-x="960"
/> />
<VChart v-else :option="chartOption ?? undefined" :update-options="chartUpdateOptions" autoresize style="height: 380px;" /> <VChart v-else :option="chartOption ?? undefined" :update-options="chartUpdateOptions" autoresize :style="{ height: chartHeight }" />
</template> </template>
</template> </template>
</div> </div>
+2 -1
View File
@@ -231,13 +231,14 @@ onMounted(() => {
:data="tags" :data="tags"
:loading="loading" :loading="loading"
:bordered="false" :bordered="false"
:scroll-x="560"
:pagination="pagination" :pagination="pagination"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:page-size="handlePageSizeChange" @update:page-size="handlePageSizeChange"
/> />
<!-- 新增/编辑弹窗 --> <!-- 新增/编辑弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('tag.editTitle') : t('tag.addTitle')" style="width: 450px;"> <NModal v-model:show="showModal" preset="card" :title="editingId ? t('tag.editTitle') : t('tag.addTitle')" style="width: 450px; max-width: 92vw;">
<NForm> <NForm>
<NFormItem :label="t('tag.name')"> <NFormItem :label="t('tag.name')">
<div style="display: flex; gap: 8px; width: 100%;"> <div style="display: flex; gap: 8px; width: 100%;">
+6 -3
View File
@@ -2237,6 +2237,7 @@ onBeforeUnmount(() => {
:data="transactions" :data="transactions"
:loading="loading" :loading="loading"
:bordered="false" :bordered="false"
:scroll-x="1480"
:row-key="(row: TransactionItem) => row.id" :row-key="(row: TransactionItem) => row.id"
:row-class-name="(row: TransactionItem) => (!row.includeInStats && excludedDisplayStyle === 'strikethrough') ? 'excluded-row' : ''" :row-class-name="(row: TransactionItem) => (!row.includeInStats && excludedDisplayStyle === 'strikethrough') ? 'excluded-row' : ''"
v-model:checked-row-keys="checkedTransactionIds" v-model:checked-row-keys="checkedTransactionIds"
@@ -2257,7 +2258,7 @@ onBeforeUnmount(() => {
</NSpace> </NSpace>
<!-- 新增/编辑弹窗 --> <!-- 新增/编辑弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('transaction.editRecord') : t('transaction.addRecordTitle')" style="width: 500px;"> <NModal v-model:show="showModal" preset="card" :title="editingId ? t('transaction.editRecord') : t('transaction.addRecordTitle')" style="width: 500px; max-width: 92vw;">
<NForm> <NForm>
<NFormItem :label="t('transaction.column.type')"> <NFormItem :label="t('transaction.column.type')">
<NSelect v-model:value="form.type" :options="typeOptions" /> <NSelect v-model:value="form.type" :options="typeOptions" />
@@ -2357,12 +2358,12 @@ onBeforeUnmount(() => {
/> />
<!-- 原始账单数据弹窗 --> <!-- 原始账单数据弹窗 -->
<NModal v-model:show="showRawModal" preset="card" :title="t('transaction.rawDataTitle')" style="width: 640px;"> <NModal v-model:show="showRawModal" preset="card" :title="t('transaction.rawDataTitle')" style="width: 640px; max-width: 92vw;">
<NInput :value="rawDataText" type="textarea" readonly :autosize="{ minRows: 8, maxRows: 20 }" /> <NInput :value="rawDataText" type="textarea" readonly :autosize="{ minRows: 8, maxRows: 20 }" />
</NModal> </NModal>
<!-- 一键设置弹窗(作用于当前表格展示的记录) --> <!-- 一键设置弹窗(作用于当前表格展示的记录) -->
<NModal v-model:show="showBatchModal" preset="card" :title="t('transaction.batchTitle')" style="width: 480px;"> <NModal v-model:show="showBatchModal" preset="card" :title="t('transaction.batchTitle')" style="width: 480px; max-width: 92vw;">
<NForm> <NForm>
<NFormItem :label="t('transaction.batchScope')"> <NFormItem :label="t('transaction.batchScope')">
<span style="color: #999; font-size: 12px;"> <span style="color: #999; font-size: 12px;">
@@ -2419,6 +2420,7 @@ onBeforeUnmount(() => {
:columns="aiColumns" :columns="aiColumns"
:data="aiRows" :data="aiRows"
:max-height="420" :max-height="420"
:scroll-x="880"
:row-key="(row) => row.transactionId" :row-key="(row) => row.transactionId"
size="small" size="small"
:bordered="false" :bordered="false"
@@ -2514,6 +2516,7 @@ onBeforeUnmount(() => {
:data="aiRecordRows" :data="aiRecordRows"
:row-key="(row) => row.key" :row-key="(row) => row.key"
:max-height="420" :max-height="420"
:scroll-x="900"
size="small" size="small"
:bordered="false" :bordered="false"
/> />