Extract quick entry category selector

This commit is contained in:
openclaw 2026-07-23 20:51:08 +08:00
parent 322ee737fd
commit e7250c4cf7
2 changed files with 291 additions and 252 deletions

View File

@ -0,0 +1,282 @@
<script setup lang="ts">
import type { EntryType } from "@cents/domain";
import { ChevronRight } from "@lucide/vue";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import {
categories,
entryTypes,
type Category,
type EntryTypeOption,
} from "../data/categories";
type SelectorLevel = "type" | "category" | "subcategory";
const props = defineProps<{
open: boolean;
entryType: EntryType;
categoryPath: Category[];
}>();
const emit = defineEmits<{
"update:entryType": [value: EntryType];
"update:categoryPath": [value: Category[]];
confirm: [];
}>();
const selectorLevel = ref<SelectorLevel>("type");
const axisOffset = ref(0);
let axisPointerId: number | null = null;
let axisDragStartY = 0;
let axisDragStartOffset = 0;
let axisDragged = false;
let axisLastY = 0;
let axisLastTime = 0;
let axisVelocity = 0;
let axisInertiaFrame: number | null = null;
const typeOption = computed(() => entryTypes.find((item) => item.id === props.entryType)!);
const selectedCategory = computed(() => props.categoryPath.at(-1));
const selectedParent = computed(() => props.categoryPath[0]);
const selectorOptions = computed<(EntryTypeOption | Category)[]>(() => {
if (selectorLevel.value === "type") {
const current = entryTypes.find((option) => option.id === props.entryType);
return [...entryTypes.filter((option) => option.id !== props.entryType), ...(current ? [current] : [])];
}
if (selectorLevel.value === "category") return categories[props.entryType];
return selectedParent.value?.children ?? [];
});
const upperSelectorOptions = computed(() => {
const count = Math.min(2, Math.floor(selectorOptions.value.length / 2));
return selectorOptions.value.slice(0, count);
});
const upperAxisHeight = computed(() => {
const count = upperSelectorOptions.value.length;
return count * 72 + Math.max(0, count - 1) * 10 + 34;
});
const axisBaseOffset = computed(() => upperSelectorOptions.value.length * 82);
const axisMaxOffset = computed(() =>
Math.max(0, (selectorOptions.value.length - upperSelectorOptions.value.length - 2) * 82),
);
const selectorPath = computed<(EntryTypeOption | Category)[]>(() => {
if (selectorLevel.value === "type") return [];
if (selectorLevel.value === "category") return [typeOption.value];
return selectedParent.value ? [typeOption.value, selectedParent.value] : [typeOption.value];
});
watch(() => props.open, (open) => {
if (open) resetAxisScroll();
else stopAxisInertia();
});
onBeforeUnmount(stopAxisInertia);
function chooseSelectorOption(option: EntryTypeOption | Category) {
if (selectorLevel.value === "type") {
const nextType = option.id as EntryType;
if (nextType !== props.entryType) emit("update:categoryPath", []);
emit("update:entryType", nextType);
selectorLevel.value = "category";
resetAxisScroll();
return;
}
const category = option as Category;
if (selectorLevel.value === "category") {
const currentChild = selectedParent.value?.id === category.id ? props.categoryPath[1] : undefined;
emit("update:categoryPath", currentChild ? [category, currentChild] : [category]);
if (category.children?.length) {
selectorLevel.value = "subcategory";
resetAxisScroll();
} else {
emit("confirm");
}
return;
}
if (!selectedParent.value) return;
emit("update:categoryPath", [selectedParent.value, category]);
emit("confirm");
}
function choosePathLevel(index: number) {
selectorLevel.value = index === 0 ? "type" : "category";
resetAxisScroll();
}
function isSelectorOptionSelected(option: EntryTypeOption | Category) {
if (selectorLevel.value === "type") return option.id === props.entryType;
if (selectorLevel.value === "category") return option.id === selectedParent.value?.id;
return props.categoryPath.length > 1 && option.id === selectedCategory.value?.id;
}
function resetAxisScroll() {
stopAxisInertia();
axisOffset.value = 0;
}
function clampAxisOffset(value: number) {
return Math.max(0, Math.min(axisMaxOffset.value, value));
}
function startAxisDrag(event: PointerEvent) {
stopAxisInertia();
axisPointerId = event.pointerId;
axisDragStartY = event.clientY;
axisDragStartOffset = axisOffset.value;
axisDragged = false;
axisLastY = event.clientY;
axisLastTime = performance.now();
axisVelocity = 0;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function moveAxisDrag(event: PointerEvent) {
if (axisPointerId !== event.pointerId) return;
const distance = axisDragStartY - event.clientY;
if (Math.abs(distance) > 5) axisDragged = true;
axisOffset.value = clampAxisOffset(axisDragStartOffset + distance);
const now = performance.now();
const elapsed = Math.max(1, now - axisLastTime);
const instantaneousVelocity = (axisLastY - event.clientY) / elapsed;
axisVelocity = axisVelocity * 0.65 + instantaneousVelocity * 0.35;
axisLastY = event.clientY;
axisLastTime = now;
}
function endAxisDrag(event: PointerEvent) {
if (axisPointerId !== event.pointerId) return;
axisPointerId = null;
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
if (axisDragged) startAxisInertia();
}
function handleAxisClick(event: MouseEvent) {
if (!axisDragged) return;
event.preventDefault();
event.stopPropagation();
axisDragged = false;
}
function scrollAxisBy(delta: number) {
stopAxisInertia();
axisOffset.value = clampAxisOffset(axisOffset.value + delta);
}
function startAxisInertia() {
if (Math.abs(axisVelocity) < 0.04) return;
let previousTime = performance.now();
const step = (now: number) => {
const elapsed = Math.min(32, now - previousTime);
previousTime = now;
const current = axisOffset.value;
const next = clampAxisOffset(current + axisVelocity * elapsed);
axisOffset.value = next;
if (next === current && (next === 0 || next === axisMaxOffset.value)) {
stopAxisInertia();
return;
}
axisVelocity *= Math.pow(0.91, elapsed / 16);
if (Math.abs(axisVelocity) < 0.025) {
stopAxisInertia();
return;
}
axisInertiaFrame = requestAnimationFrame(step);
};
axisInertiaFrame = requestAnimationFrame(step);
}
function stopAxisInertia() {
if (axisInertiaFrame !== null) cancelAnimationFrame(axisInertiaFrame);
axisInertiaFrame = null;
axisVelocity = 0;
}
</script>
<template>
<Transition name="coordinate-selector">
<div v-if="open" class="category-coordinate" aria-label="二维分类选择器" @click.stop>
<div class="selector-path" aria-label="已选分类路径">
<template v-for="(option, index) in selectorPath" :key="option.id">
<ChevronRight v-if="index > 0" :size="18" aria-hidden="true" />
<button
type="button"
:style="{ '--path-color': option.color }"
:aria-label="`返回${option.label}层重新选择`"
@click="choosePathLevel(index)"
>
<component :is="option.icon" :size="22" />
<small>{{ option.label }}</small>
</button>
</template>
<ChevronRight v-if="selectorPath.length" :size="18" aria-hidden="true" />
</div>
<div
v-if="upperSelectorOptions.length"
class="candidate-above-axis"
:style="{ height: `${upperAxisHeight}px` }"
>
<div
class="candidate-axis-viewport"
@pointerdown="startAxisDrag"
@pointermove="moveAxisDrag"
@pointerup="endAxisDrag"
@pointercancel="endAxisDrag"
@click.capture="handleAxisClick"
@wheel.prevent="scrollAxisBy($event.deltaY)"
>
<div class="candidate-axis-list" :style="{ transform: `translate3d(0, -${axisOffset}px, 0)` }">
<button
v-for="option in selectorOptions"
:key="option.id"
type="button"
class="coordinate-option"
:class="{ selected: isSelectorOptionSelected(option) }"
:style="{ '--option-color': option.color, '--option-tint': option.tint }"
@click="chooseSelectorOption(option)"
>
<span class="coordinate-icon"><component :is="option.icon" :size="24" /></span>
<span>{{ option.label }}</span>
</button>
</div>
</div>
</div>
<div class="candidate-below-axis">
<div
class="candidate-axis-viewport"
@pointerdown="startAxisDrag"
@pointermove="moveAxisDrag"
@pointerup="endAxisDrag"
@pointercancel="endAxisDrag"
@click.capture="handleAxisClick"
@wheel.prevent="scrollAxisBy($event.deltaY)"
>
<div
class="candidate-axis-list"
:style="{ transform: `translate3d(0, -${axisBaseOffset + axisOffset}px, 0)` }"
>
<button
v-for="option in selectorOptions"
:key="option.id"
type="button"
class="coordinate-option"
:class="{ selected: isSelectorOptionSelected(option) }"
:style="{ '--option-color': option.color, '--option-tint': option.tint }"
@click="chooseSelectorOption(option)"
>
<span class="coordinate-icon"><component :is="option.icon" :size="24" /></span>
<span>{{ option.label }}</span>
</button>
</div>
</div>
</div>
</div>
</Transition>
</template>

View File

@ -1,13 +1,9 @@
<script setup lang="ts">
import type { EntryType } from "@cents/domain";
import { BookOpen, CalendarDays, Check, ChevronDown, ChevronRight, Delete as BackspaceIcon, X } from "@lucide/vue";
import { BookOpen, CalendarDays, Check, ChevronDown, Delete as BackspaceIcon, X } from "@lucide/vue";
import { computed, ref } from "vue";
import {
categories,
entryTypes,
type Category,
type EntryTypeOption,
} from "../data/categories";
import { entryTypes, type Category } from "../data/categories";
import CategoryCoordinateSelector from "./CategoryCoordinateSelector.vue";
type SavedEntry = {
type: EntryType;
@ -18,8 +14,6 @@ type SavedEntry = {
ledgerIds: string[];
};
type SelectorLevel = "type" | "category" | "subcategory";
const props = defineProps<{
ledgerId: string;
ledgerName: string;
@ -39,23 +33,12 @@ const note = ref("");
const occurredAt = ref(toLocalDateTime(new Date()));
const dateInput = ref<HTMLInputElement | null>(null);
const selectorOpen = ref(false);
const selectorLevel = ref<SelectorLevel>("type");
const noteEditing = ref(false);
const ledgerPickerOpen = ref(false);
const selectedLedgerIds = ref([...new Set([props.ledgerId, props.personalLedgerId].filter(Boolean))]);
const axisOffset = ref(0);
let axisPointerId: number | null = null;
let axisDragStartY = 0;
let axisDragStartOffset = 0;
let axisDragged = false;
let axisLastY = 0;
let axisLastTime = 0;
let axisVelocity = 0;
let axisInertiaFrame: number | null = null;
const typeOption = computed(() => entryTypes.find((item) => item.id === entryType.value)!);
const selectedCategory = computed(() => categoryPath.value.at(-1));
const selectedParent = computed(() => categoryPath.value[0]);
const categoryPathLabel = computed(() =>
[typeOption.value.label, ...categoryPath.value.map((category) => category.label)].join(">"),
);
@ -91,31 +74,6 @@ const canSave = computed(() => {
const amount = Math.round(calculatedAmount.value * 100);
return Number.isFinite(amount) && amount > 0 && occurredAtValid.value;
});
const selectorOptions = computed<(EntryTypeOption | Category)[]>(() => {
if (selectorLevel.value === "type") {
const current = entryTypes.find((option) => option.id === entryType.value);
return [...entryTypes.filter((option) => option.id !== entryType.value), ...(current ? [current] : [])];
}
if (selectorLevel.value === "category") return categories[entryType.value];
return selectedParent.value?.children ?? [];
});
const upperSelectorOptions = computed(() => {
const count = Math.min(2, Math.floor(selectorOptions.value.length / 2));
return selectorOptions.value.slice(0, count);
});
const upperAxisHeight = computed(() => {
const count = upperSelectorOptions.value.length;
return count * 72 + Math.max(0, count - 1) * 10 + 34;
});
const axisBaseOffset = computed(() => upperSelectorOptions.value.length * 82);
const axisMaxOffset = computed(() =>
Math.max(0, (selectorOptions.value.length - upperSelectorOptions.value.length - 2) * 82),
);
const selectorPath = computed<(EntryTypeOption | Category)[]>(() => {
if (selectorLevel.value === "type") return [];
if (selectorLevel.value === "category") return [typeOption.value];
return selectedParent.value ? [typeOption.value, selectedParent.value] : [typeOption.value];
});
function toLocalDateTime(date: Date) {
const year = date.getFullYear();
@ -187,7 +145,6 @@ function selectOperator(operator: "+" | "-") {
function openSelector() {
if (selectorOpen.value) return;
selectorOpen.value = true;
resetAxisScroll();
}
function openDatePicker() {
@ -198,132 +155,6 @@ function openDatePicker() {
else input.click();
}
function chooseSelectorOption(option: EntryTypeOption | Category) {
if (selectorLevel.value === "type") {
const typeChanged = option.id !== entryType.value;
entryType.value = option.id as EntryType;
if (typeChanged) categoryPath.value = [];
selectorLevel.value = "category";
resetAxisScroll();
return;
}
const category = option as Category;
if (selectorLevel.value === "category") {
const currentChild = selectedParent.value?.id === category.id ? categoryPath.value[1] : undefined;
categoryPath.value = currentChild ? [category, currentChild] : [category];
if (category.children?.length) {
selectorLevel.value = "subcategory";
resetAxisScroll();
}
else confirmCategory();
return;
}
if (!selectedParent.value) return;
categoryPath.value = [selectedParent.value, category];
confirmCategory();
}
function choosePathLevel(index: number) {
selectorLevel.value = index === 0 ? "type" : "category";
resetAxisScroll();
}
function isSelectorOptionSelected(option: EntryTypeOption | Category) {
if (selectorLevel.value === "type") return option.id === entryType.value;
if (selectorLevel.value === "category") return option.id === selectedParent.value?.id;
return categoryPath.value.length > 1 && option.id === selectedCategory.value?.id;
}
function resetAxisScroll() {
stopAxisInertia();
axisOffset.value = 0;
}
function clampAxisOffset(value: number) {
return Math.max(0, Math.min(axisMaxOffset.value, value));
}
function startAxisDrag(event: PointerEvent) {
stopAxisInertia();
axisPointerId = event.pointerId;
axisDragStartY = event.clientY;
axisDragStartOffset = axisOffset.value;
axisDragged = false;
axisLastY = event.clientY;
axisLastTime = performance.now();
axisVelocity = 0;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function moveAxisDrag(event: PointerEvent) {
if (axisPointerId !== event.pointerId) return;
const distance = axisDragStartY - event.clientY;
if (Math.abs(distance) > 5) axisDragged = true;
axisOffset.value = clampAxisOffset(axisDragStartOffset + distance);
const now = performance.now();
const elapsed = Math.max(1, now - axisLastTime);
const instantaneousVelocity = (axisLastY - event.clientY) / elapsed;
axisVelocity = axisVelocity * 0.65 + instantaneousVelocity * 0.35;
axisLastY = event.clientY;
axisLastTime = now;
}
function endAxisDrag(event: PointerEvent) {
if (axisPointerId !== event.pointerId) return;
axisPointerId = null;
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
if (axisDragged) startAxisInertia();
}
function handleAxisClick(event: MouseEvent) {
if (!axisDragged) return;
event.preventDefault();
event.stopPropagation();
axisDragged = false;
}
function scrollAxisBy(delta: number) {
stopAxisInertia();
axisOffset.value = clampAxisOffset(axisOffset.value + delta);
}
function startAxisInertia() {
if (Math.abs(axisVelocity) < 0.04) return;
let previousTime = performance.now();
const step = (now: number) => {
const elapsed = Math.min(32, now - previousTime);
previousTime = now;
const current = axisOffset.value;
const next = clampAxisOffset(current + axisVelocity * elapsed);
axisOffset.value = next;
if (next === current && (next === 0 || next === axisMaxOffset.value)) {
stopAxisInertia();
return;
}
axisVelocity *= Math.pow(0.91, elapsed / 16);
if (Math.abs(axisVelocity) < 0.025) {
stopAxisInertia();
return;
}
axisInertiaFrame = requestAnimationFrame(step);
};
axisInertiaFrame = requestAnimationFrame(step);
}
function stopAxisInertia() {
if (axisInertiaFrame !== null) cancelAnimationFrame(axisInertiaFrame);
axisInertiaFrame = null;
axisVelocity = 0;
}
function confirmCategory() {
selectorOpen.value = false;
}
@ -425,86 +256,12 @@ function saveEntry() {
<span>{{ selectorOpen ? "确定" : categoryDisplayLabel }}</span>
</button>
<Transition name="coordinate-selector">
<div v-if="selectorOpen" class="category-coordinate" aria-label="二维分类选择器" @click.stop>
<div class="selector-path" aria-label="已选分类路径">
<template v-for="(option, index) in selectorPath" :key="option.id">
<ChevronRight v-if="index > 0" :size="18" aria-hidden="true" />
<button
type="button"
:style="{ '--path-color': option.color }"
:aria-label="`返回${option.label}层重新选择`"
@click="choosePathLevel(index)"
>
<component :is="option.icon" :size="22" />
<small>{{ option.label }}</small>
</button>
</template>
<ChevronRight v-if="selectorPath.length" :size="18" aria-hidden="true" />
</div>
<div
v-if="upperSelectorOptions.length"
class="candidate-above-axis"
:style="{ height: `${upperAxisHeight}px` }"
>
<div
class="candidate-axis-viewport"
@pointerdown="startAxisDrag"
@pointermove="moveAxisDrag"
@pointerup="endAxisDrag"
@pointercancel="endAxisDrag"
@click.capture="handleAxisClick"
@wheel.prevent="scrollAxisBy($event.deltaY)"
>
<div class="candidate-axis-list" :style="{ transform: `translate3d(0, -${axisOffset}px, 0)` }">
<button
v-for="option in selectorOptions"
:key="option.id"
type="button"
class="coordinate-option"
:class="{ selected: isSelectorOptionSelected(option) }"
:style="{ '--option-color': option.color, '--option-tint': option.tint }"
@click="chooseSelectorOption(option)"
>
<span class="coordinate-icon"><component :is="option.icon" :size="24" /></span>
<span>{{ option.label }}</span>
</button>
</div>
</div>
</div>
<div class="candidate-below-axis">
<div
class="candidate-axis-viewport"
@pointerdown="startAxisDrag"
@pointermove="moveAxisDrag"
@pointerup="endAxisDrag"
@pointercancel="endAxisDrag"
@click.capture="handleAxisClick"
@wheel.prevent="scrollAxisBy($event.deltaY)"
>
<div
class="candidate-axis-list"
:style="{ transform: `translate3d(0, -${axisBaseOffset + axisOffset}px, 0)` }"
>
<button
v-for="option in selectorOptions"
:key="option.id"
type="button"
class="coordinate-option"
:class="{ selected: isSelectorOptionSelected(option) }"
:style="{ '--option-color': option.color, '--option-tint': option.tint }"
@click="chooseSelectorOption(option)"
>
<span class="coordinate-icon"><component :is="option.icon" :size="24" /></span>
<span>{{ option.label }}</span>
</button>
</div>
</div>
</div>
</div>
</Transition>
<CategoryCoordinateSelector
v-model:entry-type="entryType"
v-model:category-path="categoryPath"
:open="selectorOpen"
@confirm="confirmCategory"
/>
</div>
</div>