cents/apps/web/src/components/QuickEntryDrawer.vue

553 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import type { EntryType } from "@cents/domain";
import { BookOpen, CalendarDays, Check, ChevronDown, ChevronRight, Delete as BackspaceIcon, X } from "@lucide/vue";
import { computed, ref } from "vue";
import {
categories,
entryTypes,
type Category,
type EntryTypeOption,
} from "../data/categories";
type SavedEntry = {
type: EntryType;
amount: number;
categoryId: string;
note: string;
occurredAt: string;
ledgerIds: string[];
};
type SelectorLevel = "type" | "category" | "subcategory";
const props = defineProps<{
ledgerId: string;
ledgerName: string;
personalLedgerId: string;
ledgerOptions: Array<{ id: string; name: string; color: string; isPersonal: boolean }>;
}>();
const emit = defineEmits<{
close: [];
save: [entry: SavedEntry];
}>();
const expression = ref("0");
const entryType = ref<EntryType>("expense");
const categoryPath = ref<Category[]>([]);
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(">"),
);
const categoryDisplayLabel = computed(() => selectedCategory.value?.label ?? typeOption.value.label);
const calculatedAmount = computed(() => evaluateExpression(expression.value));
const hasCalculation = computed(() => /[+-]/.test(expression.value));
const calculationLabel = computed(() => expression.value.replaceAll("-", "").replaceAll("+", " + ").replaceAll("", " "));
const displayAmount = computed(() =>
new Intl.NumberFormat("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(calculatedAmount.value),
);
const selectedLedgerLabel = computed(() => {
const visibleNames = props.ledgerOptions
.filter((ledger) => !ledger.isPersonal && selectedLedgerIds.value.includes(ledger.id))
.map((ledger) => ledger.name);
if (!visibleNames.length) return "个人账本";
const names = visibleNames.slice(0, 3).join(",");
return visibleNames.length > 1 ? `${names}${visibleNames.length} 个账本` : names;
});
const occurredAtValid = computed(() => Boolean(occurredAt.value) && !Number.isNaN(new Date(occurredAt.value).getTime()));
const dateLabel = computed(() => {
if (!occurredAtValid.value) return "选择时间";
const date = new Date(occurredAt.value);
const now = new Date();
const datePart = isSameDay(date, now)
? "今天"
: `${date.getMonth() + 1}${date.getDate()}`;
return `${datePart} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
});
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();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function pad(value: number) {
return String(value).padStart(2, "0");
}
function isSameDay(left: Date, right: Date) {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
function evaluateExpression(value: string) {
const operands = value.split(/[+-]/);
const operators = value.match(/[+-]/g) ?? [];
let result = Number(operands[0]) || 0;
operators.forEach((operator, index) => {
const operand = operands[index + 1];
if (operand === "" || operand === ".") return;
const right = Number(operand);
result = operator === "+" ? result + right : result - right;
result = Math.round(result * 100) / 100;
});
return result;
}
function currentOperand() {
return expression.value.split(/[+-]/).at(-1) ?? "";
}
function appendDigit(digit: string) {
const operand = currentOperand();
const decimalPlaces = operand.includes(".") ? operand.split(".")[1].length : 0;
if (decimalPlaces >= 2 || operand.replace(".", "").length >= 9) return;
if (expression.value === "0") expression.value = digit;
else if (operand === "0") expression.value = `${expression.value.slice(0, -1)}${digit}`;
else expression.value += digit;
}
function appendDecimal() {
const operand = currentOperand();
if (operand.includes(".")) return;
expression.value += operand === "" ? "0." : ".";
}
function backspace() {
expression.value = expression.value.length > 1 ? expression.value.slice(0, -1) : "0";
}
function selectOperator(operator: "+" | "-") {
if (expression.value.endsWith(".")) expression.value += "0";
if (/[+-]$/.test(expression.value)) expression.value = `${expression.value.slice(0, -1)}${operator}`;
else expression.value += operator;
}
function openSelector() {
if (selectorOpen.value) return;
selectorOpen.value = true;
resetAxisScroll();
}
function openDatePicker() {
confirmCategory();
const input = dateInput.value;
if (!input) return;
if (typeof input.showPicker === "function") input.showPicker();
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;
}
function startNoteEditing() {
confirmCategory();
noteEditing.value = true;
}
function openLedgerPicker() {
confirmCategory();
noteEditing.value = false;
ledgerPickerOpen.value = true;
}
function toggleLedger(ledgerId: string) {
if (ledgerId === props.personalLedgerId) return;
if (selectedLedgerIds.value.includes(ledgerId)) {
if (selectedLedgerIds.value.length > 1) {
selectedLedgerIds.value = selectedLedgerIds.value.filter((id) => id !== ledgerId);
}
return;
}
selectedLedgerIds.value = [...selectedLedgerIds.value, ledgerId];
}
function saveEntry() {
const amount = Math.round(calculatedAmount.value * 100);
if (!canSave.value) return;
emit("save", {
type: entryType.value,
amount,
categoryId: selectedCategory.value?.id ?? entryType.value,
note: note.value.trim(),
occurredAt: new Date(occurredAt.value).toISOString(),
ledgerIds: selectedLedgerIds.value,
});
}
</script>
<template>
<div class="drawer-scrim" :class="{ 'category-mode': selectorOpen }" @click="emit('close')"></div>
<section class="quick-drawer" aria-label="快速记账">
<div v-if="selectorOpen" class="category-focus-scrim" @click="confirmCategory"></div>
<div class="drawer-handle"></div>
<header class="drawer-header">
<div class="amount-block">
<div class="amount-result">
<span class="amount-prefix">¥</span>
<strong class="amount-value">{{ displayAmount }}</strong>
</div>
<span v-if="hasCalculation" class="calculation-line">{{ calculationLabel }}</span>
</div>
<button class="quick-ledger-target" type="button" @click="openLedgerPicker">
<BookOpen :size="14" />
<span>记入 {{ selectedLedgerLabel }}</span>
<ChevronDown :size="13" />
</button>
</header>
<div class="entry-meta-row">
<label class="note-field" :class="{ editing: noteEditing }">
<input
v-model="note"
maxlength="40"
placeholder="备注"
aria-label="备注"
@focus="startNoteEditing"
@blur="noteEditing = false"
/>
</label>
<button class="header-date-button" :class="{ invalid: !occurredAtValid }" type="button" :aria-invalid="!occurredAtValid" @click="openDatePicker">
<CalendarDays :size="15" />
<span>{{ dateLabel }}</span>
</button>
<input
ref="dateInput"
v-model="occurredAt"
class="native-date-input"
type="datetime-local"
aria-label="发生时间"
tabindex="-1"
/>
<div class="category-selector-anchor" :class="{ 'selector-open': selectorOpen }">
<button
type="button"
class="meta-category-button"
:class="{ active: selectorOpen }"
:style="{ '--category-color': selectorOpen ? '#087f72' : selectedCategory?.color ?? typeOption.color }"
:aria-label="selectorOpen ? '确认当前分类' : `选择分类,当前为${categoryPathLabel}`"
@click.stop="selectorOpen ? confirmCategory() : openSelector()"
>
<Check v-if="selectorOpen" :size="22" />
<component v-else :is="selectedCategory?.icon ?? typeOption.icon" :size="17" />
<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>
</div>
</div>
<div v-if="!noteEditing" class="keypad">
<button type="button" class="key number-key" @click="appendDigit('7')">7</button>
<button type="button" class="key number-key" @click="appendDigit('8')">8</button>
<button type="button" class="key number-key" @click="appendDigit('9')">9</button>
<button type="button" class="key operator-key" :class="{ active: expression.endsWith('+') }" @click="selectOperator('+')">+</button>
<button type="button" class="key number-key" @click="appendDigit('4')">4</button>
<button type="button" class="key number-key" @click="appendDigit('5')">5</button>
<button type="button" class="key number-key" @click="appendDigit('6')">6</button>
<button type="button" class="key operator-key" :class="{ active: expression.endsWith('-') }" @click="selectOperator('-')"></button>
<button type="button" class="key number-key" @click="appendDigit('1')">1</button>
<button type="button" class="key number-key" @click="appendDigit('2')">2</button>
<button type="button" class="key number-key" @click="appendDigit('3')">3</button>
<button type="button" class="key save-key" :disabled="!canSave" aria-label="完成记账" title="完成记账" @click="saveEntry">
<Check :size="27" />
</button>
<button type="button" class="key number-key" aria-label="小数点" @click="appendDecimal">.</button>
<button type="button" class="key number-key" @click="appendDigit('0')">0</button>
<button type="button" class="key utility-key" aria-label="退格" title="退格" @click="backspace">
<BackspaceIcon :size="22" />
</button>
</div>
</section>
<div v-if="ledgerPickerOpen" class="quick-ledger-picker-layer">
<button class="quick-ledger-picker-scrim" type="button" aria-label="关闭账本选择" @click="ledgerPickerOpen = false"></button>
<section class="quick-ledger-picker" aria-label="选择关联账本">
<div class="quick-ledger-picker-handle"></div>
<header><div><strong>选择账本</strong><span>至少选择一个账本</span></div><button type="button" aria-label="关闭" @click="ledgerPickerOpen = false"><X :size="19" /></button></header>
<div class="quick-ledger-options">
<button v-for="ledger in ledgerOptions" :key="ledger.id" type="button" :class="{ selected: selectedLedgerIds.includes(ledger.id), automatic: ledger.isPersonal }" :disabled="ledger.isPersonal" @click="toggleLedger(ledger.id)">
<span :style="{ background: ledger.color }"><BookOpen :size="17" /></span>
<strong>{{ ledger.name }}<small v-if="ledger.isPersonal">自动记入</small></strong>
<Check v-if="selectedLedgerIds.includes(ledger.id)" :size="19" />
</button>
</div>
<button class="quick-ledger-picker-done" type="button" @click="ledgerPickerOpen = false"><Check :size="18" />完成</button>
</section>
</div>
</template>