Refine category wheel and taxonomy

This commit is contained in:
openclaw 2026-07-23 23:47:35 +08:00
parent 00e5e035a9
commit fff1ba9c2e
3 changed files with 94 additions and 33 deletions

View File

@ -25,6 +25,7 @@ const emit = defineEmits<{
}>(); }>();
const FOCUS_ANGLE = 270; const FOCUS_ANGLE = 270;
const wheelEntryTypes = [...entryTypes, ...entryTypes];
const selectorLevel = ref<SelectorLevel>("type"); const selectorLevel = ref<SelectorLevel>("type");
const wheelStage = ref<HTMLElement | null>(null); const wheelStage = ref<HTMLElement | null>(null);
const wheelRotation = ref(0); const wheelRotation = ref(0);
@ -37,7 +38,7 @@ let inertiaFrame: number | null = null;
const selectedParent = computed(() => props.categoryPath[0]); const selectedParent = computed(() => props.categoryPath[0]);
const selectorOptions = computed<SelectorOption[]>(() => { const selectorOptions = computed<SelectorOption[]>(() => {
if (selectorLevel.value === "type") return entryTypes; if (selectorLevel.value === "type") return wheelEntryTypes;
if (selectorLevel.value === "category") return categories[props.entryType]; if (selectorLevel.value === "category") return categories[props.entryType];
return selectedParent.value?.children ?? []; return selectedParent.value?.children ?? [];
}); });
@ -172,12 +173,16 @@ function moveWheelDrag(event: PointerEvent) {
const delta = normalizeAngle(angle - pointerLastAngle); const delta = normalizeAngle(angle - pointerLastAngle);
const now = performance.now(); const now = performance.now();
const elapsed = Math.max(1, now - pointerLastTime); const elapsed = Math.max(1, now - pointerLastTime);
const instantaneousVelocity = delta / elapsed;
wheelRotation.value += delta; wheelRotation.value += delta;
pointerTravel += Math.abs(delta); pointerTravel += Math.abs(delta);
if (pointerTravel > 5 && wheelStage.value && !wheelStage.value.hasPointerCapture(event.pointerId)) { if (pointerTravel > 5 && wheelStage.value && !wheelStage.value.hasPointerCapture(event.pointerId)) {
wheelStage.value.setPointerCapture(event.pointerId); wheelStage.value.setPointerCapture(event.pointerId);
} }
pointerVelocity = pointerVelocity * 0.62 + (delta / elapsed) * 0.38; pointerVelocity = Math.max(
-1.2,
Math.min(1.2, pointerVelocity * 0.62 + instantaneousVelocity * 0.38),
);
pointerLastAngle = angle; pointerLastAngle = angle;
pointerLastTime = now; pointerLastTime = now;
} }
@ -188,8 +193,10 @@ function endWheelDrag(event: PointerEvent) {
if (wheelStage.value?.hasPointerCapture(event.pointerId)) { if (wheelStage.value?.hasPointerCapture(event.pointerId)) {
wheelStage.value.releasePointerCapture(event.pointerId); wheelStage.value.releasePointerCapture(event.pointerId);
} }
if (pointerTravel > 5 && Math.abs(pointerVelocity) > 0.015) startInertia(); const releaseDelay = Math.max(0, performance.now() - pointerLastTime - 16);
else snapWheel(); pointerVelocity *= Math.exp(-releaseDelay / 90);
if (pointerTravel > 5 && Math.abs(pointerVelocity) > 0.008) startInertia();
else pointerVelocity = 0;
} }
function cancelOptionClick(event: MouseEvent) { function cancelOptionClick(event: MouseEvent) {
@ -205,10 +212,10 @@ function startInertia() {
const elapsed = Math.min(32, now - previousTime); const elapsed = Math.min(32, now - previousTime);
previousTime = now; previousTime = now;
wheelRotation.value += pointerVelocity * elapsed; wheelRotation.value += pointerVelocity * elapsed;
pointerVelocity *= Math.pow(0.92, elapsed / 16); pointerVelocity *= Math.exp(-elapsed / 280);
if (Math.abs(pointerVelocity) < 0.012) { if (Math.abs(pointerVelocity) < 0.002) {
inertiaFrame = null; inertiaFrame = null;
snapWheel(); pointerVelocity = 0;
return; return;
} }
inertiaFrame = requestAnimationFrame(step); inertiaFrame = requestAnimationFrame(step);
@ -216,12 +223,6 @@ function startInertia() {
inertiaFrame = requestAnimationFrame(step); inertiaFrame = requestAnimationFrame(step);
} }
function snapWheel() {
const step = segmentAngle.value;
const target = Math.round((wheelRotation.value - FOCUS_ANGLE) / step) * step + FOCUS_ANGLE;
wheelRotation.value = target;
}
function stopInertia() { function stopInertia() {
if (inertiaFrame !== null) cancelAnimationFrame(inertiaFrame); if (inertiaFrame !== null) cancelAnimationFrame(inertiaFrame);
inertiaFrame = null; inertiaFrame = null;
@ -257,7 +258,7 @@ function resetShellScroll() {
<div class="category-wheel-disc"></div> <div class="category-wheel-disc"></div>
<button <button
v-for="(option, index) in selectorOptions" v-for="(option, index) in selectorOptions"
:key="option.id" :key="`${selectorLevel}-${option.id}-${index}`"
type="button" type="button"
class="wheel-option" class="wheel-option"
:class="{ selected: isOptionSelected(option) }" :class="{ selected: isOptionSelected(option) }"

View File

@ -20,9 +20,7 @@ import {
HandCoins, HandCoins,
HeartPulse, HeartPulse,
House, House,
KeyRound,
Music2, Music2,
Package,
ParkingCircle, ParkingCircle,
Plane, Plane,
ReceiptText, ReceiptText,
@ -34,13 +32,10 @@ import {
Shirt, Shirt,
Smartphone, Smartphone,
Soup, Soup,
Sparkles,
Ticket, Ticket,
TrainFront, TrainFront,
Utensils, Utensils,
Wifi, Wifi,
Wrench,
Zap,
type LucideIcon, type LucideIcon,
} from "@lucide/vue"; } from "@lucide/vue";
@ -109,17 +104,14 @@ export const categories: Record<EntryType, Category[]> = {
{ id: "home", label: "家居", color: "#2b8a74", tint: "#e8f7f2", icon: House }, { id: "home", label: "家居", color: "#2b8a74", tint: "#e8f7f2", icon: House },
], ],
}, },
{ id: "shopping", label: "购物", color: "#d84486", tint: "#fff0f7", icon: ShoppingBag },
{ {
id: "housing", id: "shopping",
label: "住房", label: "购物",
color: "#387b70", color: "#d84486",
tint: "#eaf6f3", tint: "#fff0f7",
icon: House, icon: ShoppingBag,
children: [ children: [
{ id: "rent", label: "房租房贷", color: "#387b70", tint: "#eaf6f3", icon: KeyRound }, { id: "clothing", label: "服饰", color: "#b14e79", tint: "#faeef4", icon: Shirt },
{ id: "utilities", label: "水电燃气", color: "#b27a22", tint: "#fff7e7", icon: Zap },
{ id: "maintenance", label: "维修", color: "#697970", tint: "#f0f4f2", icon: Wrench },
], ],
}, },
{ {
@ -158,17 +150,14 @@ export const categories: Record<EntryType, Category[]> = {
{ id: "insurance", label: "保险", color: "#477866", tint: "#edf6f2", icon: ShieldCheck }, { id: "insurance", label: "保险", color: "#477866", tint: "#edf6f2", icon: ShieldCheck },
], ],
}, },
{ id: "clothing", label: "服饰", color: "#b14e79", tint: "#faeef4", icon: Shirt },
{ id: "beauty", label: "美容", color: "#b75e91", tint: "#fbf0f6", icon: Sparkles },
{ id: "delivery", label: "快递", color: "#9a6b35", tint: "#f8f1e8", icon: Package },
{ id: "travel", label: "旅行", color: "#7559d9", tint: "#f2efff", icon: Plane }, { id: "travel", label: "旅行", color: "#7559d9", tint: "#f2efff", icon: Plane },
], ],
income: [ income: [
{ id: "salary", label: "工资", color: "#15956d", tint: "#e8faf3", icon: BriefcaseBusiness }, { id: "salary", label: "工资", color: "#15956d", tint: "#e8faf3", icon: BriefcaseBusiness },
{ id: "bonus", label: "奖金", color: "#b57a0e", tint: "#fff7dd", icon: Gift }, { id: "bonus", label: "奖金", color: "#b57a0e", tint: "#fff7dd", icon: Gift },
{ id: "part-time", label: "兼职", color: "#3577c9", tint: "#edf5ff", icon: HandCoins }, { id: "part-time", label: "兼职", color: "#3577c9", tint: "#edf5ff", icon: HandCoins },
{ id: "investment", label: "投资收益", color: "#28805d", tint: "#eaf7f1", icon: ChartNoAxesCombined }, { id: "investment", label: "投资", color: "#28805d", tint: "#eaf7f1", icon: ChartNoAxesCombined },
{ id: "refund", label: "退款报销", color: "#3c7d9b", tint: "#edf6fa", icon: RotateCcw }, { id: "refund", label: "报销", color: "#3c7d9b", tint: "#edf6fa", icon: RotateCcw },
{ id: "other-income", label: "其他", color: "#8559cf", tint: "#f4efff", icon: Banknote }, { id: "other-income", label: "其他", color: "#8559cf", tint: "#f4efff", icon: Banknote },
], ],
}; };

71
docs/分类体系.md Normal file
View File

@ -0,0 +1,71 @@
# 分类体系
本文记录快速记账当前使用的一级、二级和三级分类。二级、三级分类的排列顺序与分类轮盘一致。
## 一级分类
| 顺序 | 分类 | ID |
| --- | --- | --- |
| 1 | 支出 | `expense` |
| 2 | 收入 | `income` |
一级逻辑分类仍然只有两个。为了缩小单个扇区,轮盘按“支出、收入、支出、收入”重复显示为四个象限。
## 二级分类
### 支出
共 9 个。
| 顺序 | 分类 | ID |
| --- | --- | --- |
| 1 | 餐饮 | `food` |
| 2 | 交通 | `transport` |
| 3 | 日用 | `daily` |
| 4 | 购物 | `shopping` |
| 5 | 娱乐 | `entertainment` |
| 6 | 医疗 | `health` |
| 7 | 教育 | `education` |
| 8 | 账单 | `bills` |
| 9 | 旅行 | `travel` |
### 收入
共 6 个。
| 顺序 | 分类 | ID |
| --- | --- | --- |
| 1 | 工资 | `salary` |
| 2 | 奖金 | `bonus` |
| 3 | 兼职 | `part-time` |
| 4 | 投资 | `investment` |
| 5 | 报销 | `refund` |
| 6 | 其他 | `other-income` |
## 三级分类
代码中的 `children` 即三级分类,也就是二级分类下的次级选项。
### 支出
| 二级分类 | 三级分类 |
| --- | --- |
| 餐饮 | 外卖 `takeout`、堂食 `restaurant`、咖啡 `coffee`、零食 `snacks`、饮品 `drinks` |
| 交通 | 打车 `taxi`、公交地铁 `public-transit`、火车 `train`、骑行 `cycling`、加油 `fuel`、停车 `parking` |
| 日用 | 杂货 `groceries`、数码 `digital`、家居 `home` |
| 购物 | 服饰 `clothing` |
| 娱乐 | 游戏 `games`、电影 `movies`、音乐 `music` |
| 医疗 | 无 |
| 教育 | 书籍 `books`、课程 `courses` |
| 账单 | 话费 `mobile`、宽带网络 `internet`、保险 `insurance` |
| 旅行 | 无 |
### 收入
当前收入分类均无三级分类。
## 层级说明
- 一级分类决定账目类型,即支出或收入。
- 二级分类是账目所属的大类。
- 三级分类是部分二级分类下的细分类。