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:
@@ -139,7 +139,7 @@ async function handleSubmit() {
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('account.newPassword')">
|
||||
<NSpace :size="8" style="width: 100%;">
|
||||
<NSpace :size="8" :wrap="true" style="width: 100%;">
|
||||
<NInput
|
||||
v-model:value="form.newPassword"
|
||||
:type="revealed ? 'text' : 'password'"
|
||||
|
||||
@@ -11,10 +11,13 @@ import {
|
||||
} from '@vicons/ionicons5'
|
||||
import { getAiConfig, saveAiConfig, testAiConfig } from '../api/ai'
|
||||
import { refreshAiEnabled } from '../composables/useAiEnabled'
|
||||
import { useBreakpoints } from '../composables/useBreakpoints'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
// 小屏时标签置于顶部,避免左侧固定标签挤压输入框
|
||||
const { isMobile } = useBreakpoints()
|
||||
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
@@ -117,7 +120,7 @@ onMounted(loadConfig)
|
||||
{{ t('ai.intro') }}
|
||||
</NAlert>
|
||||
|
||||
<NForm label-placement="left" label-width="130">
|
||||
<NForm :label-placement="isMobile ? 'top' : 'left'" :label-width="isMobile ? undefined : 130">
|
||||
<NFormItem :label="t('ai.enabled')">
|
||||
<div class="switch-row">
|
||||
<NSwitch v-model:value="form.enabled" />
|
||||
|
||||
@@ -160,6 +160,7 @@ onMounted(loadUsage)
|
||||
:loading="usageLoading"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
:scroll-x="900"
|
||||
:striped="true"
|
||||
class="usage-table"
|
||||
>
|
||||
|
||||
@@ -392,6 +392,7 @@ onMounted(() => {
|
||||
:columns="columns"
|
||||
:data="filteredRows"
|
||||
:max-height="420"
|
||||
:scroll-x="1020"
|
||||
:row-key="(row) => row.rowNo"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getDatabaseStatus } from '../api/database'
|
||||
import type { DatabaseConfigPayload, DatabaseType } from '../api/database'
|
||||
import { useDatabaseStore } from '../store/database'
|
||||
import { openSse } from '../utils/sse'
|
||||
import { useBreakpoints } from '../composables/useBreakpoints'
|
||||
|
||||
const props = defineProps<{ setup?: boolean }>()
|
||||
|
||||
@@ -22,6 +23,8 @@ const router = useRouter()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
const databaseStore = useDatabaseStore()
|
||||
// 小屏时表单标签置顶,避免左侧固定标签挤压输入区
|
||||
const { isMobile } = useBreakpoints()
|
||||
|
||||
const testing = ref(false)
|
||||
const saving = ref(false)
|
||||
@@ -259,7 +262,7 @@ onBeforeUnmount(() => {
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
|
||||
<NForm label-placement="left" label-width="120">
|
||||
<NForm :label-placement="isMobile ? 'top' : 'left'" :label-width="isMobile ? undefined : 120">
|
||||
<NFormItem :label="t('database.type')">
|
||||
<NRadioGroup v-model:value="form.type">
|
||||
<NRadioButton value="SQLITE">SQLite</NRadioButton>
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
// Emoji 选择器组件:提供常用 Emoji 分组快速点选与自定义文本输入,供分类图标等场景复用
|
||||
import { computed, ref, watch } from 'vue'
|
||||
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<{
|
||||
@@ -68,7 +73,7 @@ function handleClear() {
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<div class="emoji-trigger" :class="{ 'is-empty': !props.modelValue }">
|
||||
<span class="emoji-current">{{ displayText }}</span>
|
||||
@@ -175,6 +180,17 @@ function handleClear() {
|
||||
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 {
|
||||
margin: 8px 0 4px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -45,10 +45,20 @@ const emit = defineEmits<{
|
||||
const MARGIN = 8
|
||||
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 {
|
||||
const width = props.defaultWidth
|
||||
const height = props.defaultHeight
|
||||
const width = Math.min(props.defaultWidth, viewportMaxWidth())
|
||||
const height = Math.min(props.defaultHeight, viewportMaxHeight())
|
||||
return {
|
||||
x: Math.max(MARGIN, (window.innerWidth - width) / 2),
|
||||
y: Math.max(MARGIN, (window.innerHeight - height) / 3),
|
||||
@@ -57,8 +67,10 @@ function defaultRect(): PanelRect {
|
||||
}
|
||||
}
|
||||
|
||||
/** 将浮窗限制在视口内 */
|
||||
/** 将浮窗限制在视口内:先按视口收缩宽高,再钳制位置,保证小屏不溢出 */
|
||||
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 maxY = Math.max(MARGIN, window.innerHeight - rect.value.height - MARGIN)
|
||||
rect.value.x = Math.min(Math.max(MARGIN, rect.value.x), maxX)
|
||||
@@ -150,13 +162,15 @@ function handleResizeStart(e: PointerEvent) {
|
||||
window.addEventListener('pointerup', handleResizeEnd)
|
||||
}
|
||||
|
||||
/** 缩放移动:限制最小尺寸与视口边界 */
|
||||
/** 缩放移动:限制最小尺寸与视口边界(小屏时最小尺寸随视口下调) */
|
||||
function handleResizeMove(e: PointerEvent) {
|
||||
if (!resizing) return
|
||||
const maxW = Math.max(props.minWidth, window.innerWidth - rect.value.x - MARGIN)
|
||||
const maxH = Math.max(props.minHeight, window.innerHeight - rect.value.y - MARGIN)
|
||||
rect.value.width = Math.min(Math.max(props.minWidth, resizeOriginW + (e.clientX - resizeStartX)), maxW)
|
||||
rect.value.height = Math.min(Math.max(props.minHeight, resizeOriginH + (e.clientY - resizeStartY)), maxH)
|
||||
const effMinW = Math.min(props.minWidth, viewportMaxWidth())
|
||||
const effMinH = Math.min(props.minHeight, viewportMaxHeight())
|
||||
const maxW = Math.max(effMinW, window.innerWidth - rect.value.x - MARGIN)
|
||||
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;
|
||||
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>
|
||||
|
||||
@@ -16,10 +16,13 @@ import {
|
||||
getNotificationUrl, updateNotificationUrl,
|
||||
} from '../api/notification'
|
||||
import type { NotificationChannel, NotificationLog } from '../api/notification'
|
||||
import { useBreakpoints } from '../composables/useBreakpoints'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { t } = useI18n()
|
||||
// 小屏时表单标签置顶,避免左侧固定标签挤压输入区
|
||||
const { isMobile } = useBreakpoints()
|
||||
|
||||
// 提醒周期表单
|
||||
const config = ref({ enabled: false, frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1, sendTime: '09:00' })
|
||||
@@ -305,7 +308,7 @@ onMounted(loadAll)
|
||||
{{ t('notification.intro') }}
|
||||
</NAlert>
|
||||
|
||||
<NForm label-placement="left" label-width="130">
|
||||
<NForm :label-placement="isMobile ? 'top' : 'left'" :label-width="isMobile ? undefined : 130">
|
||||
<NFormItem :label="t('notification.linkUrl')">
|
||||
<NSpace align="center" :size="8" style="width: 100%;">
|
||||
<NInput
|
||||
@@ -426,9 +429,9 @@ onMounted(loadAll)
|
||||
v-model:show="showChannelModal"
|
||||
preset="card"
|
||||
: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')">
|
||||
<NSelect v-model:value="channelForm.type" :options="channelTypeOptions" :disabled="!!editingId" />
|
||||
</NFormItem>
|
||||
|
||||
@@ -373,7 +373,7 @@ const pickerColumns = computed<DataTableColumns<RelationMemberDraft>>(() => [
|
||||
<div
|
||||
v-for="member in editorMembers"
|
||||
: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: 100px;">{{ member.categoryName || t('transaction.uncategorized') }}</span>
|
||||
@@ -428,6 +428,7 @@ const pickerColumns = computed<DataTableColumns<RelationMemberDraft>>(() => [
|
||||
:loading="pickerLoading"
|
||||
:row-key="(row: RelationMemberDraft) => row.transactionId"
|
||||
:max-height="420"
|
||||
:scroll-x="620"
|
||||
size="small"
|
||||
: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 }
|
||||
}
|
||||
@@ -98,6 +98,7 @@ export default {
|
||||
settings: { zh: '设置', en: 'Settings', ja: '設定' },
|
||||
user: { zh: '用户', en: 'User', 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: '収入の取引チャネル割合' },
|
||||
uncategorized: { zh: '未分类', en: 'Uncategorized', ja: '未分類' },
|
||||
viewTransactions: { zh: '查看收支记录', en: 'View Transactions', ja: '収支記録を表示' },
|
||||
chartActionHint: { zh: '查看对应账单明细?', en: 'View matching records?', ja: '該当する明細を表示しますか?' },
|
||||
chartActionView: { zh: '查看明细', en: 'View Details', ja: '明細を表示' },
|
||||
},
|
||||
|
||||
// 收支记录
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import i18n from './locales'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
// 创建 Vue 应用实例
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -3,3 +3,43 @@
|
||||
padding: 0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +309,7 @@ onMounted(() => {
|
||||
:data="expenseList"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:scroll-x="520"
|
||||
:pagination="expensePagination"
|
||||
@update:page="handleExpensePageChange"
|
||||
@update:page-size="handleExpensePageSizeChange"
|
||||
@@ -322,13 +323,14 @@ onMounted(() => {
|
||||
:data="incomeList"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:scroll-x="520"
|
||||
:pagination="incomePagination"
|
||||
@update:page="handleIncomePageChange"
|
||||
@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>
|
||||
<NFormItem v-if="aiEnabled" :label="t('ai.category.label')">
|
||||
<div style="display: flex; gap: 8px; width: 100%;">
|
||||
|
||||
@@ -186,13 +186,14 @@ onMounted(fetchData)
|
||||
:data="channels"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:scroll-x="520"
|
||||
:pagination="pagination"
|
||||
@update:page="handlePageChange"
|
||||
@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>
|
||||
<NFormItem :label="t('channel.name')">
|
||||
<NInput v-model:value="form.name" :placeholder="t('channel.namePlaceholder')" />
|
||||
|
||||
@@ -17,6 +17,11 @@ import { readJson, writeJson } from '../utils/storage'
|
||||
import { STORAGE_KEYS } from '../utils/storageKeys'
|
||||
import { loadTransactionFilterPrefs, saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs'
|
||||
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 在后台按需下载并注册,避免阻塞目标页首屏
|
||||
@@ -473,6 +478,53 @@ function applyPanelNavigation() {
|
||||
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跳转,渠道按渠道编码跳转,无渠道编码的扇区不响应
|
||||
*
|
||||
@@ -485,11 +537,13 @@ function handlePieClick(params: any) {
|
||||
// 渠道饼图数据项带 channelCode 字段,为空表示未指定渠道,不响应
|
||||
if ('channelCode' in d) {
|
||||
if (!d.channelCode) return
|
||||
applyChannelNavigation(d.navType, d.channelCode)
|
||||
const channelCode = d.channelCode
|
||||
triggerChartNavigation(params, () => applyChannelNavigation(d.navType, channelCode))
|
||||
return
|
||||
}
|
||||
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]
|
||||
if (!key) return
|
||||
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)
|
||||
*/
|
||||
function handleTrendBlankClick(e: any) {
|
||||
// 触屏端禁用「整列可点」,仅允许点击柱子本体,避免滑动/误触跳转
|
||||
if (isTouch.value) return
|
||||
// 命中柱子等图形时交给 handleTrendClick 处理,避免重复跳转
|
||||
if (e?.target) return
|
||||
const chart = unref(trendChartRef.value?.chart)
|
||||
@@ -902,7 +958,7 @@ onMounted(async () => {
|
||||
</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>
|
||||
<NCard>
|
||||
<NStatistic :label="t('dashboard.totalIncome')" :value="Number(summary.totalIncome).toFixed(2)" style="color: #18a058;">
|
||||
@@ -930,7 +986,7 @@ onMounted(async () => {
|
||||
</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>
|
||||
<NCard>
|
||||
<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" />
|
||||
</NSpace>
|
||||
</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>
|
||||
|
||||
<!-- 饼图区域:支出/收入分类占比 + 支出/收入交易渠道占比 -->
|
||||
<NGrid :cols="2" :x-gap="16" :y-gap="16">
|
||||
<NGrid responsive="screen" cols="1 m:2" :x-gap="16" :y-gap="16">
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="pieOption" style="height: 350px;" autoresize @click="handlePieClick" />
|
||||
<VChart :option="pieOption" :style="{ height: chartHeight }" autoresize @click="handlePieClick" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="incomePieOption" style="height: 350px;" autoresize @click="handlePieClick" />
|
||||
<VChart :option="incomePieOption" :style="{ height: chartHeight }" autoresize @click="handlePieClick" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="channelOption" style="height: 350px;" autoresize @click="handlePieClick" />
|
||||
<VChart :option="channelOption" :style="{ height: chartHeight }" autoresize @click="handlePieClick" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="incomeChannelOption" style="height: 350px;" autoresize @click="handlePieClick" />
|
||||
<VChart :option="incomeChannelOption" :style="{ height: chartHeight }" autoresize @click="handlePieClick" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
@@ -1008,5 +1064,44 @@ onMounted(async () => {
|
||||
:storage-key="AI_QUERY_WINDOW_KEY"
|
||||
@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>
|
||||
</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>
|
||||
|
||||
@@ -33,11 +33,14 @@ import { migrateKey, writeString } from '../utils/storage'
|
||||
import { LEGACY_STORAGE_KEYS, STORAGE_KEYS } from '../utils/storageKeys'
|
||||
import { usePageSizeOptions } from '../composables/usePageSizeOptions'
|
||||
import { useTablePageSize } from '../composables/useTablePageSize'
|
||||
import { useBreakpoints } from '../composables/useBreakpoints'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
// 小屏时表单标签置顶,避免左侧固定标签挤压输入区
|
||||
const { isMobile } = useBreakpoints()
|
||||
const router = useRouter()
|
||||
|
||||
// 所有表格共用的每页条数候选列表
|
||||
@@ -769,6 +772,7 @@ onUnmounted(() => {
|
||||
:row-key="(r: EmailBillConfig) => r.id"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
:scroll-x="1140"
|
||||
:pagination="configPagination"
|
||||
@update:page="handleConfigPageChange"
|
||||
@update:page-size="handleConfigPageSizeChange"
|
||||
@@ -803,6 +807,7 @@ onUnmounted(() => {
|
||||
:row-key="(r: EmailBillSenderRule) => r.id"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
:scroll-x="780"
|
||||
:pagination="rulePagination"
|
||||
@update:page="handleRulePageChange"
|
||||
@update:page-size="handleRulePageSizeChange"
|
||||
@@ -836,6 +841,7 @@ onUnmounted(() => {
|
||||
size="small"
|
||||
:bordered="false"
|
||||
remote
|
||||
:scroll-x="960"
|
||||
:pagination="{
|
||||
page: messagePage, pageSize: messageSize, itemCount: messageTotal,
|
||||
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;">
|
||||
<NForm label-placement="left" label-width="130">
|
||||
<NForm :label-placement="isMobile ? 'top' : 'left'" :label-width="isMobile ? undefined : 130">
|
||||
<NFormItem :label="t('emailBill.config.name')">
|
||||
<NInput v-model:value="configForm.name" />
|
||||
</NFormItem>
|
||||
@@ -923,7 +929,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 发件人规则弹窗 -->
|
||||
<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')">
|
||||
<NInput v-model:value="ruleForm.name" />
|
||||
</NFormItem>
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
import { h, computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
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 { DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline, WalletOutline } from '@vicons/ionicons5'
|
||||
import {
|
||||
DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline, WalletOutline, MenuOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import { useUserStore } from '../store/user'
|
||||
import LocaleSwitch from '../components/LocaleSwitch.vue'
|
||||
import { getChannels } from '../api/transaction'
|
||||
@@ -13,11 +18,15 @@ import { readSessionString, writeSessionString } from '../utils/storage'
|
||||
import { scrollKey } from '../utils/storageKeys'
|
||||
import { aiEnabled, refreshAiEnabled } from '../composables/useAiEnabled'
|
||||
import { navEntries, loadNavMenu } from '../composables/useNavMenu'
|
||||
import { useBreakpoints } from '../composables/useBreakpoints'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const { t } = useI18n()
|
||||
// 小屏断点:手机端侧边栏收起为抽屉
|
||||
const { isMobile } = useBreakpoints()
|
||||
const showDrawer = ref(false)
|
||||
|
||||
// 根据当前路由路径计算菜单激活项
|
||||
const activeKey = computed(() => {
|
||||
@@ -54,6 +63,8 @@ const menuOptions = computed<MenuOption[]>(() => {
|
||||
/** 菜单点击事件:根据 key 导航到对应路由 */
|
||||
function handleMenuUpdate(key: string) {
|
||||
router.push(`/${key}`)
|
||||
// 手机端选择菜单后收起抽屉
|
||||
showDrawer.value = false
|
||||
}
|
||||
|
||||
// 顶部用户下拉菜单配置项(随语言切换响应式更新)
|
||||
@@ -80,6 +91,9 @@ function handleLogout() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
// 内容区样式:小屏收窄内边距,高度用 dvh 兜底移动端地址栏伸缩
|
||||
const contentStyle = computed(() => `padding: ${isMobile.value ? 12 : 24}px;`)
|
||||
|
||||
// 内容区滚动位置记忆:按路由分别记录,进入页面时自动恢复
|
||||
const contentRef = ref<InstanceType<typeof NLayoutContent> | null>(null) // 内容区滚动容器
|
||||
let scrollRaf = 0 // scroll 事件的 rAF 节流句柄
|
||||
@@ -161,6 +175,11 @@ function handleBeforeUnload() {
|
||||
saveScroll()
|
||||
}
|
||||
|
||||
// 切回桌面宽度时收起抽屉,避免残留浮层
|
||||
watch(isMobile, (mobile) => {
|
||||
if (!mobile) showDrawer.value = false
|
||||
})
|
||||
|
||||
// 路由切换:先保存离开页的位置,再在 DOM 更新后恢复目标页的位置
|
||||
watch(() => route.path, (to, from) => {
|
||||
saveScroll(from)
|
||||
@@ -199,9 +218,10 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 整体布局:左侧边栏 + 右侧内容区 -->
|
||||
<NLayout has-sider style="height: 100vh">
|
||||
<!-- 整体布局:左侧边栏 + 右侧内容区(手机端侧边栏收起为抽屉) -->
|
||||
<NLayout has-sider style="height: 100dvh">
|
||||
<NLayoutSider
|
||||
v-if="!isMobile"
|
||||
bordered
|
||||
:width="200"
|
||||
content-style="padding: 12px;"
|
||||
@@ -224,8 +244,21 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</NLayoutSider>
|
||||
<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">
|
||||
<LocaleSwitch />
|
||||
<NDropdown :options="userMenuOptions" @select="handleUserMenuSelect">
|
||||
@@ -237,14 +270,50 @@ onBeforeUnmount(() => {
|
||||
</NSpace>
|
||||
</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 />
|
||||
</NLayoutContent>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
|
||||
@@ -66,7 +66,7 @@ async function handleLogin() {
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<LocaleSwitch />
|
||||
</template>
|
||||
|
||||
@@ -59,7 +59,7 @@ async function handleRegister() {
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<LocaleSwitch />
|
||||
</template>
|
||||
|
||||
@@ -815,6 +815,7 @@ onMounted(() => {
|
||||
:data="rules"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:scroll-x="950"
|
||||
:row-key="(row: RuleItem) => row.id"
|
||||
:pagination="pagination"
|
||||
@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>
|
||||
<NFormItem :label="t('common.name')">
|
||||
<NInput v-model:value="form.name" :placeholder="t('rule.namePlaceholder')" />
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useTablePageSize } from '../composables/useTablePageSize'
|
||||
import { readSessionJson, writeSessionJson, removeSessionKey } from '../utils/storage'
|
||||
import { STORAGE_KEYS } from '../utils/storageKeys'
|
||||
import { useUserStore } from '../store/user'
|
||||
import { useBreakpoints } from '../composables/useBreakpoints'
|
||||
|
||||
// 图表组件异步加载:echarts 体积较大,按需加载,避免阻塞首屏
|
||||
const VChart = defineAsyncComponent(async () => {
|
||||
@@ -128,6 +129,14 @@ const canChart = computed(() => !!result.value
|
||||
&& result.value.columns.some((col) => col.type === 'NUMBER'))
|
||||
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(() => {
|
||||
if (!result.value || !isScalar.value) return []
|
||||
@@ -485,7 +494,7 @@ onMounted(async () => {
|
||||
|
||||
<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">
|
||||
<NCard size="small" :bordered="true">
|
||||
<NStatistic :label="item.label" :value="item.value" />
|
||||
@@ -520,7 +529,7 @@ onMounted(async () => {
|
||||
size="small"
|
||||
: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>
|
||||
</div>
|
||||
|
||||
@@ -231,13 +231,14 @@ onMounted(() => {
|
||||
:data="tags"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:scroll-x="560"
|
||||
:pagination="pagination"
|
||||
@update:page="handlePageChange"
|
||||
@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>
|
||||
<NFormItem :label="t('tag.name')">
|
||||
<div style="display: flex; gap: 8px; width: 100%;">
|
||||
|
||||
@@ -2237,6 +2237,7 @@ onBeforeUnmount(() => {
|
||||
:data="transactions"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:scroll-x="1480"
|
||||
:row-key="(row: TransactionItem) => row.id"
|
||||
:row-class-name="(row: TransactionItem) => (!row.includeInStats && excludedDisplayStyle === 'strikethrough') ? 'excluded-row' : ''"
|
||||
v-model:checked-row-keys="checkedTransactionIds"
|
||||
@@ -2257,7 +2258,7 @@ onBeforeUnmount(() => {
|
||||
</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>
|
||||
<NFormItem :label="t('transaction.column.type')">
|
||||
<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 }" />
|
||||
</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>
|
||||
<NFormItem :label="t('transaction.batchScope')">
|
||||
<span style="color: #999; font-size: 12px;">
|
||||
@@ -2419,6 +2420,7 @@ onBeforeUnmount(() => {
|
||||
:columns="aiColumns"
|
||||
:data="aiRows"
|
||||
:max-height="420"
|
||||
:scroll-x="880"
|
||||
:row-key="(row) => row.transactionId"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
@@ -2514,6 +2516,7 @@ onBeforeUnmount(() => {
|
||||
:data="aiRecordRows"
|
||||
:row-key="(row) => row.key"
|
||||
:max-height="420"
|
||||
:scroll-x="900"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user