553 lines
20 KiB
Vue
553 lines
20 KiB
Vue
<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>
|