Enhance category wheel interactions

This commit is contained in:
openclaw 2026-07-24 00:13:23 +08:00
parent 5cc918bcd9
commit 0150a29e88
4 changed files with 163 additions and 38 deletions

View File

@ -25,7 +25,7 @@ const emit = defineEmits<{
}>();
const FOCUS_ANGLE = 270;
const wheelEntryTypes = [...entryTypes, ...entryTypes];
const wheelEntryTypes = [...entryTypes, ...entryTypes, ...entryTypes];
const selectorLevel = ref<SelectorLevel>("type");
const wheelStage = ref<HTMLElement | null>(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<HTMLElement>(".app-shell");
if (shell) shell.scrollLeft = 0;
@ -253,8 +280,9 @@ function resetShellScroll() {
@pointerup="endWheelDrag"
@pointercancel="endWheelDrag"
@click.capture="cancelOptionClick"
@click="chooseWheelSegment"
>
<div class="category-wheel-rotor" :style="rotorStyle">
<div :key="selectorLevel" class="category-wheel-rotor" :style="rotorStyle">
<div class="category-wheel-disc"></div>
<button
v-for="(option, index) in selectorOptions"
@ -264,10 +292,12 @@ function resetShellScroll() {
:class="{ selected: isOptionSelected(option) }"
:style="optionStyle(index)"
:aria-label="option.label"
@click="chooseOption(option)"
@click.stop="chooseOption(option)"
>
<span>{{ option.label }}</span>
<component :is="option.icon" :size="21" />
<span class="wheel-option-content">
<span class="wheel-option-label">{{ option.label }}</span>
<component :is="option.icon" :size="21" />
</span>
</button>
</div>
</div>

View File

@ -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<EntryType, Category[]> = {
{ 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<EntryType, Category[]> = {
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<EntryType, Category[]> = {
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<EntryType, Category[]> = {
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<EntryType, Category[]> = {
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<EntryType, Category[]> = {
{ 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 },
],
};

View File

@ -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;

View File

@ -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 @@
- 一级分类决定账目类型,即支出或收入。
- 二级分类是账目所属的大类。
- 三级分类是部分二级分类下的细分类。
- 环形扇区、文字和图标均可点击选择。
- 任意层级均可点击确认完成,不要求选择到三级分类。
- 返回上一级时清除更深层级的选择。