Enhance category wheel interactions
This commit is contained in:
parent
5cc918bcd9
commit
0150a29e88
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
26
docs/分类体系.md
26
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 @@
|
|||
- 一级分类决定账目类型,即支出或收入。
|
||||
- 二级分类是账目所属的大类。
|
||||
- 三级分类是部分二级分类下的细分类。
|
||||
- 环形扇区、文字和图标均可点击选择。
|
||||
- 任意层级均可点击确认完成,不要求选择到三级分类。
|
||||
- 返回上一级时清除更深层级的选择。
|
||||
|
|
|
|||
Loading…
Reference in New Issue