From 0150a29e88f45a9c35ccb827794e9ad7c4f6f390 Mon Sep 17 00:00:00 2001 From: openclaw Date: Fri, 24 Jul 2026 00:13:23 +0800 Subject: [PATCH] Enhance category wheel interactions --- .../components/CategoryCoordinateSelector.vue | 56 ++++++++--- apps/web/src/data/categories.ts | 24 +++-- apps/web/src/styles.css | 95 +++++++++++++++++-- docs/分类体系.md | 26 ++--- 4 files changed, 163 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/CategoryCoordinateSelector.vue b/apps/web/src/components/CategoryCoordinateSelector.vue index f20fd4f..b2390f4 100644 --- a/apps/web/src/components/CategoryCoordinateSelector.vue +++ b/apps/web/src/components/CategoryCoordinateSelector.vue @@ -25,7 +25,7 @@ const emit = defineEmits<{ }>(); const FOCUS_ANGLE = 270; -const wheelEntryTypes = [...entryTypes, ...entryTypes]; +const wheelEntryTypes = [...entryTypes, ...entryTypes, ...entryTypes]; const selectorLevel = ref("type"); const wheelStage = ref(null); const wheelRotation = ref(0); @@ -95,6 +95,7 @@ onBeforeUnmount(stopInertia); function optionStyle(index: number) { return { "--option-angle": `${index * segmentAngle.value}deg`, + "--option-index": index, }; } @@ -129,20 +130,35 @@ function chooseOption(option: SelectorOption) { emit("confirm"); } +function chooseWheelSegment(event: MouseEvent) { + const bounds = wheelStage.value?.getBoundingClientRect(); + if (!bounds || !selectorOptions.value.length) return; + const offsetX = event.clientX - (bounds.left + bounds.width / 2); + const offsetY = event.clientY - (bounds.top + bounds.height / 2); + if (Math.hypot(offsetX, offsetY) > bounds.width / 2) return; + + const screenAngle = (Math.atan2(offsetY, offsetX) * 180) / Math.PI; + const conicAngle = normalizeCircleAngle(screenAngle + 90 - wheelRotation.value); + const index = + Math.round(conicAngle / segmentAngle.value) % selectorOptions.value.length; + const option = selectorOptions.value[index]; + if (option) chooseOption(option); +} + function goBack() { if (selectorLevel.value === "subcategory") { + if (selectedParent.value) emit("update:categoryPath", [selectedParent.value]); selectorLevel.value = "category"; return; } - if (selectorLevel.value === "category") selectorLevel.value = "type"; + if (selectorLevel.value === "category") { + emit("update:categoryPath", []); + selectorLevel.value = "type"; + } } function confirmSelection() { - if (selectorLevel.value === "type") { - selectorLevel.value = "category"; - return; - } - if (selectedParent.value) emit("confirm"); + emit("confirm"); } function alignSelectedOption() { @@ -195,8 +211,15 @@ function endWheelDrag(event: PointerEvent) { } const releaseDelay = Math.max(0, performance.now() - pointerLastTime - 16); pointerVelocity *= Math.exp(-releaseDelay / 90); - if (pointerTravel > 5 && Math.abs(pointerVelocity) > 0.008) startInertia(); - else pointerVelocity = 0; + if ( + pointerTravel > 5 && + Math.abs(pointerVelocity) > 0.008 && + !window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + startInertia(); + } else { + pointerVelocity = 0; + } } function cancelOptionClick(event: MouseEvent) { @@ -233,6 +256,10 @@ function normalizeAngle(value: number) { return ((value + 180) % 360 + 360) % 360 - 180; } +function normalizeCircleAngle(value: number) { + return ((value % 360) + 360) % 360; +} + function resetShellScroll() { const shell = wheelStage.value?.closest(".app-shell"); if (shell) shell.scrollLeft = 0; @@ -253,8 +280,9 @@ function resetShellScroll() { @pointerup="endWheelDrag" @pointercancel="endWheelDrag" @click.capture="cancelOptionClick" + @click="chooseWheelSegment" > -
+
diff --git a/apps/web/src/data/categories.ts b/apps/web/src/data/categories.ts index d336423..d9800ff 100644 --- a/apps/web/src/data/categories.ts +++ b/apps/web/src/data/categories.ts @@ -12,6 +12,7 @@ import { CarTaxiFront, ChartNoAxesCombined, Clapperboard, + ClipboardCheck, Coffee, CookingPot, Cookie, @@ -27,15 +28,16 @@ import { Gift, GraduationCap, HandCoins, + HandHeart, Handshake, HeartPulse, Hospital, Hotel, House, - Music2, ParkingCircle, + Pencil, Pill, - Plane, + Puzzle, ReceiptText, RotateCcw, School, @@ -51,8 +53,8 @@ import { Ticket, TrainFront, Utensils, + UsersRound, WalletCards, - Wifi, Zap, type LucideIcon, } from "@lucide/vue"; @@ -125,6 +127,7 @@ export const categories: Record = { { id: "groceries", label: "杂货", color: "#1a9b62", tint: "#eaf9f1", icon: ShoppingBasket }, { id: "digital", label: "数码", color: "#52606f", tint: "#eff2f5", icon: Smartphone }, { id: "home", label: "家居", color: "#2b8a74", tint: "#e8f7f2", icon: House }, + { id: "toys", label: "玩具", color: "#7559d9", tint: "#f2efff", icon: Puzzle }, ], }, { @@ -136,7 +139,6 @@ export const categories: Record = { children: [ { id: "games", label: "游戏", color: "#7958b3", tint: "#f3effb", icon: Gamepad2 }, { id: "movies", label: "电影", color: "#a74d65", tint: "#faeef2", icon: Clapperboard }, - { id: "music", label: "音乐", color: "#4f70aa", tint: "#edf2fa", icon: Music2 }, { id: "massage", label: "按摩", color: "#9b678f", tint: "#f8eef5", icon: Sparkles }, { id: "sports", label: "运动", color: "#2f8768", tint: "#eaf7f1", icon: Dumbbell }, { id: "tickets", label: "门票", color: "#b57a0e", tint: "#fff7dd", icon: Ticket }, @@ -152,6 +154,10 @@ export const categories: Record = { children: [ { id: "red-packet", label: "红包", color: "#d94f45", tint: "#fff0ed", icon: WalletCards }, { id: "gift", label: "礼物", color: "#b57a0e", tint: "#fff7dd", icon: Gift }, + { id: "treating", label: "请客", color: "#d76a35", tint: "#fff1e9", icon: Utensils }, + { id: "gathering", label: "聚会", color: "#5368bd", tint: "#eef1fb", icon: UsersRound }, + { id: "donation", label: "捐赠", color: "#2f8768", tint: "#eaf7f1", icon: HandHeart }, + { id: "social-obligation", label: "人情", color: "#9b6a48", tint: "#f8eee6", icon: HandCoins }, ], }, { @@ -177,6 +183,9 @@ export const categories: Record = { children: [ { id: "books", label: "书籍", color: "#6b5c4c", tint: "#f5f0ea", icon: BookOpen }, { id: "courses", label: "课程", color: "#4869b1", tint: "#edf2fb", icon: School }, + { id: "tuition", label: "学费", color: "#b57a0e", tint: "#fff7dd", icon: GraduationCap }, + { id: "stationery", label: "文具", color: "#d84486", tint: "#fff0f7", icon: Pencil }, + { id: "exams", label: "考试", color: "#526a78", tint: "#edf2f4", icon: ClipboardCheck }, ], }, { @@ -187,17 +196,15 @@ export const categories: Record = { icon: FileText, children: [ { id: "mobile", label: "话费", color: "#526a78", tint: "#edf2f4", icon: Smartphone }, - { id: "internet", label: "宽带网络", color: "#3478a5", tint: "#edf5f9", icon: Wifi }, { id: "water", label: "水费", color: "#2d8f9e", tint: "#eaf8fa", icon: Droplets }, { id: "electricity", label: "电费", color: "#b27a22", tint: "#fff7e7", icon: Zap }, { id: "gas", label: "燃气", color: "#d76a35", tint: "#fff1e9", icon: Flame }, { id: "property-fee", label: "物业", color: "#387b70", tint: "#eaf6f3", icon: Building2 }, - { id: "parking-fee", label: "车位费", color: "#4f7190", tint: "#edf3f7", icon: ParkingCircle }, - { id: "membership", label: "会员费", color: "#8559cf", tint: "#f4efff", icon: Crown }, + { id: "parking-fee", label: "车位", color: "#4f7190", tint: "#edf3f7", icon: ParkingCircle }, + { id: "membership", label: "会员", color: "#8559cf", tint: "#f4efff", icon: Crown }, { id: "insurance", label: "保险", color: "#477866", tint: "#edf6f2", icon: ShieldCheck }, ], }, - { id: "travel", label: "旅行", color: "#7559d9", tint: "#f2efff", icon: Plane }, ], income: [ { id: "salary", label: "工资", color: "#15956d", tint: "#e8faf3", icon: BriefcaseBusiness }, @@ -206,6 +213,7 @@ export const categories: Record = { { id: "investment", label: "投资", color: "#28805d", tint: "#eaf7f1", icon: ChartNoAxesCombined }, { id: "refund", label: "报销", color: "#3c7d9b", tint: "#edf6fa", icon: RotateCcw }, { id: "other-income", label: "其他", color: "#8559cf", tint: "#f4efff", icon: Banknote }, + { id: "received-red-packet", label: "红包", color: "#d94f45", tint: "#fff0ed", icon: WalletCards }, ], }; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index c875337..d281f97 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1015,6 +1015,7 @@ input:focus-visible { inset: 0; transform: rotate(var(--wheel-rotation)); transform-origin: center; + animation: wheel-level-reveal 220ms cubic-bezier(0.22, 0.8, 0.28, 1); will-change: transform; } @@ -1042,11 +1043,8 @@ input:focus-visible { left: 50%; width: 48px; height: 54px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 3px; + display: grid; + place-items: center; transform: translate(-50%, -50%) rotate(var(--option-angle)) @@ -1063,7 +1061,22 @@ input:focus-visible { pointer-events: auto; } -.wheel-option span { +.wheel-option-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + animation: wheel-option-reveal 240ms cubic-bezier(0.22, 0.8, 0.28, 1) both; + animation-delay: calc(var(--option-index) * 16ms); + transition: transform 100ms ease; +} + +.wheel-option:active .wheel-option-content { + transform: scale(0.88); +} + +.wheel-option-label { max-width: 48px; overflow: hidden; text-overflow: ellipsis; @@ -1079,6 +1092,7 @@ input:focus-visible { border-radius: 50%; background: #ffffff; box-shadow: 0 1px 3px rgba(19, 35, 31, 0.25); + animation: wheel-selection-pop 320ms cubic-bezier(0.22, 0.8, 0.28, 1); content: ""; } @@ -1095,6 +1109,7 @@ input:focus-visible { overflow: hidden; border-radius: 50%; box-shadow: 0 8px 24px rgba(29, 55, 48, 0.22); + animation: wheel-controls-reveal 180ms ease-out; pointer-events: auto; } @@ -1107,6 +1122,12 @@ input:focus-visible { padding: 0; font-size: 12px; font-weight: 740; + transition: filter 120ms ease, transform 120ms ease; +} + +.wheel-center-controls button:not(:disabled):active { + filter: brightness(0.94); + transform: scale(0.97); } .wheel-back { @@ -1125,6 +1146,68 @@ input:focus-visible { color: #ffffff; } +@keyframes wheel-level-reveal { + from { + opacity: 0.35; + scale: 0.96; + } + + to { + opacity: 1; + scale: 1; + } +} + +@keyframes wheel-option-reveal { + from { + opacity: 0; + transform: translateY(-5px) scale(0.9); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes wheel-selection-pop { + from { + opacity: 0; + scale: 0.4; + } + + to { + opacity: 1; + scale: 1; + } +} + +@keyframes wheel-controls-reveal { + from { + opacity: 0; + scale: 0.94; + } + + to { + opacity: 1; + scale: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .category-wheel-rotor, + .wheel-option-content, + .wheel-option.selected::after, + .wheel-center-controls { + animation: none; + } + + .wheel-option-content, + .wheel-center-controls button { + transition: none; + } +} + .share-sheet-layer { position: absolute; inset: 0; diff --git a/docs/分类体系.md b/docs/分类体系.md index 71ad66c..751dce6 100644 --- a/docs/分类体系.md +++ b/docs/分类体系.md @@ -1,6 +1,8 @@ # 分类体系 -本文记录快速记账当前使用的一级、二级和三级分类。二级、三级分类的排列顺序与分类轮盘一致。 +本文记录快速记账拟采用的一级、二级和三级分类。二级、三级分类的排列顺序与分类轮盘一致。 + +> 状态:已同步到代码。 ## 一级分类 @@ -9,13 +11,13 @@ | 1 | 支出 | `expense` | | 2 | 收入 | `income` | -一级逻辑分类仍然只有两个。为了缩小单个扇区,轮盘按“支出、收入、支出、收入”重复显示为四个象限。 +一级逻辑分类仍然只有两个。为了缩小单个扇区,轮盘按“支出、收入、支出、收入、支出、收入”交替显示为六个扇区。 ## 二级分类 ### 支出 -共 9 个。 +共 8 个。 | 顺序 | 分类 | ID | | --- | --- | --- | @@ -27,11 +29,10 @@ | 6 | 医疗 | `health` | | 7 | 教育 | `education` | | 8 | 缴费 | `bills` | -| 9 | 旅行 | `travel` | ### 收入 -共 6 个。 +共 7 个。 | 顺序 | 分类 | ID | | --- | --- | --- | @@ -41,6 +42,7 @@ | 4 | 投资 | `investment` | | 5 | 报销 | `refund` | | 6 | 其他 | `other-income` | +| 7 | 红包 | `received-red-packet` | ## 三级分类 @@ -52,13 +54,12 @@ | --- | --- | | 餐饮 | 外卖 `takeout`、堂食 `restaurant`、咖啡 `coffee`、零食 `snacks`、饮品 `drinks`、水果 `fruit` | | 交通 | 打车 `taxi`、公交 `public-transit`、地铁 `subway`、火车 `train`、骑行 `cycling`、加油 `fuel`、充电 `charging`、停车 `parking`、高速 `highway` | -| 购物 | 服饰 `clothing`、杂货 `groceries`、数码 `digital`、家居 `home` | -| 娱乐 | 游戏 `games`、电影 `movies`、音乐 `music`、按摩 `massage`、运动 `sports`、门票 `tickets`、酒店 `hotel` | -| 社交 | 红包 `red-packet`、礼物 `gift` | +| 购物 | 服饰 `clothing`、杂货 `groceries`、数码 `digital`、家居 `home`、玩具 `toys` | +| 娱乐 | 游戏 `games`、电影 `movies`、按摩 `massage`、运动 `sports`、门票 `tickets`、酒店 `hotel` | +| 社交 | 红包 `red-packet`、礼物 `gift`、请客 `treating`、聚会 `gathering`、捐赠 `donation`、人情 `social-obligation` | | 医疗 | 药店 `pharmacy`、门诊 `outpatient`、急诊 `emergency`、疫苗 `vaccine`、住院 `hospitalization` | -| 教育 | 书籍 `books`、课程 `courses` | -| 缴费 | 话费 `mobile`、宽带网络 `internet`、水费 `water`、电费 `electricity`、燃气 `gas`、物业 `property-fee`、车位费 `parking-fee`、会员费 `membership`、保险 `insurance` | -| 旅行 | 无 | +| 教育 | 书籍 `books`、课程 `courses`、学费 `tuition`、文具 `stationery`、考试 `exams` | +| 缴费 | 话费 `mobile`、水费 `water`、电费 `electricity`、燃气 `gas`、物业 `property-fee`、车位 `parking-fee`、会员 `membership`、保险 `insurance` | ### 收入 @@ -69,3 +70,6 @@ - 一级分类决定账目类型,即支出或收入。 - 二级分类是账目所属的大类。 - 三级分类是部分二级分类下的细分类。 +- 环形扇区、文字和图标均可点击选择。 +- 任意层级均可点击确认完成,不要求选择到三级分类。 +- 返回上一级时清除更深层级的选择。