feat: refine category wheel and design multi-currency support

This commit is contained in:
openclaw 2026-07-25 00:29:27 +08:00
parent 001847fa45
commit d3723b338b
6 changed files with 694 additions and 159 deletions

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import type { EntryType } from "@cents/domain";
import { ArrowLeft, Check } from "@lucide/vue";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { ArrowLeft, CircleEllipsis } from "@lucide/vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
categories,
entryTypes,
@ -10,7 +10,8 @@ import {
} from "../data/categories";
type SelectorLevel = "type" | "category" | "subcategory";
type SelectorOption = EntryTypeOption | Category;
type SelectorAction = "other-category" | "other-subcategory";
type SelectorOption = (EntryTypeOption | Category) & { selectorAction?: SelectorAction };
const props = defineProps<{
open: boolean;
@ -22,11 +23,12 @@ const emit = defineEmits<{
"update:entryType": [value: EntryType];
"update:categoryPath": [value: Category[]];
confirm: [];
close: [];
}>();
const FOCUS_ANGLE = 270;
const RING_GAP = 9;
const wheelEntryTypes = [...entryTypes, ...entryTypes, ...entryTypes];
const OPTION_ANGLE = 36;
const SCREEN_EDGE_GAP = 4;
const selectorLevel = ref<SelectorLevel>("type");
const wheelStage = ref<HTMLElement | null>(null);
const wheelRotation = ref(0);
@ -39,11 +41,14 @@ let inertiaFrame: number | null = null;
const selectedParent = computed(() => props.categoryPath[0]);
const selectorOptions = computed<SelectorOption[]>(() => {
if (selectorLevel.value === "type") return wheelEntryTypes;
if (selectorLevel.value === "category") return categories[props.entryType];
return selectedParent.value?.children ?? [];
if (selectorLevel.value === "type") return entryTypes;
if (selectorLevel.value === "category") {
return prioritizeOptions(withOtherOption(categories[props.entryType], "other-category"));
}
return prioritizeOptions(
withOtherOption(selectedParent.value?.children ?? [], "other-subcategory"),
);
});
const segmentAngle = computed(() => 360 / Math.max(1, selectorOptions.value.length));
const selectedIndex = computed(() => {
const selectedId =
selectorLevel.value === "type"
@ -58,15 +63,14 @@ const levelLabel = computed(() => {
if (selectorLevel.value === "category") return "选择大类";
return `选择${selectedParent.value?.label ?? ""}细类`;
});
const canGoBack = computed(() => selectorLevel.value !== "type");
const ringBackground = computed(() => createConicBackground((option) => option.color));
const ringBorderBackground = computed(() =>
createConicBackground((option) => darkenHexColor(option.color)),
);
const wheelIsCircular = computed(() => selectorOptions.value.length * OPTION_ANGLE >= 360);
const defaultRotation = computed(() => {
const optionSpan = Math.max(0, selectorOptions.value.length - 1) * OPTION_ANGLE;
return normalizeCircleAngle(-90 - optionSpan / 2);
});
const rotationBounds = ref({ min: 0, max: 0 });
const rotorStyle = computed(() => ({
"--wheel-rotation": `${wheelRotation.value}deg`,
"--ring-background": ringBackground.value,
"--ring-border-background": ringBorderBackground.value,
}));
watch(
@ -77,27 +81,35 @@ watch(
selectorLevel.value = "type";
await nextTick();
resetShellScroll();
alignSelectedOption();
updateRotationBounds();
arrangeOptions();
},
);
watch(selectorOptions, async () => {
await nextTick();
resetShellScroll();
alignSelectedOption();
updateRotationBounds();
arrangeOptions();
});
onBeforeUnmount(stopInertia);
onMounted(() => window.addEventListener("resize", updateRotationBounds));
onBeforeUnmount(() => {
stopInertia();
window.removeEventListener("resize", updateRotationBounds);
});
function optionStyle(index: number) {
function optionStyle(option: SelectorOption, index: number) {
return {
"--option-angle": `${index * segmentAngle.value}deg`,
"--option-angle": `${index * OPTION_ANGLE}deg`,
"--option-index": index,
"--option-color": option.color,
"--option-border-color": darkenHexColor(option.color),
};
}
function isOptionSelected(option: SelectorOption) {
if (selectorLevel.value === "type") return option.id === props.entryType;
function isOptionSelected(option: SelectorOption, index: number) {
if (selectorLevel.value === "type") return index === selectedIndex.value;
if (selectorLevel.value === "category") return option.id === selectedParent.value?.id;
return option.id === props.categoryPath[1]?.id;
}
@ -105,6 +117,17 @@ function isOptionSelected(option: SelectorOption) {
function chooseOption(option: SelectorOption) {
if (pointerTravel > 5) return;
if (option.selectorAction === "other-category") {
emit("update:categoryPath", []);
emit("confirm");
return;
}
if (option.selectorAction === "other-subcategory") {
if (selectedParent.value) emit("update:categoryPath", [selectedParent.value]);
emit("confirm");
return;
}
if (selectorLevel.value === "type") {
const nextType = option.id as EntryType;
if (nextType !== props.entryType) emit("update:categoryPath", []);
@ -127,28 +150,11 @@ 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);
const radius = Math.hypot(offsetX, offsetY);
const centerBounds = wheelStage.value
?.closest(".category-wheel-selector")
?.querySelector(".wheel-center-controls")
?.getBoundingClientRect();
const innerRadius = (centerBounds?.width ?? 0) / 2 + RING_GAP;
if (radius < innerRadius || radius > 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 === "type") {
emit("close");
return;
}
if (selectorLevel.value === "subcategory") {
if (selectedParent.value) emit("update:categoryPath", [selectedParent.value]);
selectorLevel.value = "category";
@ -160,13 +166,87 @@ function goBack() {
}
}
function confirmSelection() {
emit("confirm");
function arrangeOptions() {
// Keep the unused arc centered on the clipped, screen-right side.
wheelRotation.value = defaultRotation.value;
}
function alignSelectedOption() {
const index = Math.max(0, selectedIndex.value);
wheelRotation.value = FOCUS_ANGLE - index * segmentAngle.value;
function updateRotationBounds() {
if (wheelIsCircular.value) return;
const stageBounds = wheelStage.value?.getBoundingClientRect();
const shellBounds = wheelStage.value?.closest<HTMLElement>(".app-shell")?.getBoundingClientRect();
const option = wheelStage.value?.querySelector<HTMLElement>(".wheel-option");
const optionBounds = option?.getBoundingClientRect();
if (!stageBounds || !shellBounds || !option || !optionBounds) {
rotationBounds.value = { min: defaultRotation.value, max: defaultRotation.value };
wheelRotation.value = clampRotation(wheelRotation.value);
return;
}
const centerX = stageBounds.left + stageBounds.width / 2;
const optionCenterX = optionBounds.left + optionBounds.width / 2;
const optionCenterY = optionBounds.top + optionBounds.height / 2;
const centerY = stageBounds.top + stageBounds.height / 2;
const orbitRadius = Math.hypot(optionCenterX - centerX, optionCenterY - centerY);
const optionRadius = Number.parseFloat(getComputedStyle(option).width) / 2;
const centerLimitX = shellBounds.right - optionRadius - SCREEN_EDGE_GAP;
const edgeRatio = Math.max(-1, Math.min(1, (centerLimitX - centerX) / orbitRadius));
const edgeAngle = (Math.asin(edgeRatio) * 180) / Math.PI;
const optionSpan = Math.max(0, selectorOptions.value.length - 1) * OPTION_ANGLE;
const visibleArc = 180 + edgeAngle * 2;
if (optionSpan <= visibleArc) {
rotationBounds.value = { min: defaultRotation.value, max: defaultRotation.value };
wheelRotation.value = clampRotation(wheelRotation.value);
return;
}
rotationBounds.value = {
min: 360 + edgeAngle - optionSpan,
max: 180 - edgeAngle,
};
wheelRotation.value = clampRotation(wheelRotation.value);
}
function withOtherOption(options: Category[], action: SelectorAction): SelectorOption[] {
if (options.some((option) => option.label === "其他")) return options;
const source =
action === "other-category"
? entryTypes.find((option) => option.id === props.entryType)
: selectedParent.value;
if (!source) return options;
return [
...options,
{
id: `__${action}`,
label: "其他",
color: source.color,
tint: source.tint,
icon: CircleEllipsis,
selectorAction: action,
},
];
}
function prioritizeOptions(options: SelectorOption[]) {
const other = options.find((option) => option.label === "其他");
if (!other) return options;
const regularOptions = options.filter((option) => option !== other);
const arranged = Array<SelectorOption>(options.length);
const lastRegularIndex = options.length - 2;
const centerIndex = Math.floor((options.length - 1) / 2);
const priorityIndexes = [centerIndex];
for (let offset = 1; priorityIndexes.length < regularOptions.length; offset += 1) {
if (centerIndex - offset >= 0) priorityIndexes.push(centerIndex - offset);
if (centerIndex + offset <= lastRegularIndex) priorityIndexes.push(centerIndex + offset);
}
regularOptions.forEach((option, index) => {
arranged[priorityIndexes[index]] = option;
});
arranged[options.length - 1] = other;
return arranged;
}
function pointerAngle(event: PointerEvent) {
@ -193,7 +273,7 @@ function moveWheelDrag(event: PointerEvent) {
const now = performance.now();
const elapsed = Math.max(1, now - pointerLastTime);
const instantaneousVelocity = delta / elapsed;
wheelRotation.value += delta;
wheelRotation.value = applyBoundaryResistance(wheelRotation.value + delta);
pointerTravel += Math.abs(delta);
if (pointerTravel > 5 && wheelStage.value && !wheelStage.value.hasPointerCapture(event.pointerId)) {
wheelStage.value.setPointerCapture(event.pointerId);
@ -214,6 +294,16 @@ function endWheelDrag(event: PointerEvent) {
}
const releaseDelay = Math.max(0, performance.now() - pointerLastTime - 16);
pointerVelocity *= Math.exp(-releaseDelay / 90);
const boundedRotation = clampRotation(wheelRotation.value);
if (boundedRotation !== wheelRotation.value) {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
wheelRotation.value = boundedRotation;
pointerVelocity = 0;
} else {
startBoundarySpring(boundedRotation);
}
return;
}
if (
pointerTravel > 5 &&
Math.abs(pointerVelocity) > 0.008 &&
@ -237,7 +327,15 @@ function startInertia() {
const step = (now: number) => {
const elapsed = Math.min(32, now - previousTime);
previousTime = now;
wheelRotation.value += pointerVelocity * elapsed;
const nextRotation = wheelRotation.value + pointerVelocity * elapsed;
const boundedRotation = clampRotation(nextRotation);
if (boundedRotation !== nextRotation) {
wheelRotation.value = applyBoundaryResistance(nextRotation);
inertiaFrame = null;
startBoundarySpring(boundedRotation);
return;
}
wheelRotation.value = nextRotation;
pointerVelocity *= Math.exp(-elapsed / 280);
if (Math.abs(pointerVelocity) < 0.002) {
inertiaFrame = null;
@ -249,6 +347,27 @@ function startInertia() {
inertiaFrame = requestAnimationFrame(step);
}
function startBoundarySpring(target: number) {
let velocity = pointerVelocity * 1000;
let previousTime = performance.now();
const step = (now: number) => {
const elapsed = Math.min(0.032, (now - previousTime) / 1000);
previousTime = now;
const displacement = target - wheelRotation.value;
const acceleration = displacement * 150 - velocity * 20;
velocity += acceleration * elapsed;
wheelRotation.value += velocity * elapsed;
if (Math.abs(displacement) < 0.05 && Math.abs(velocity) < 2) {
wheelRotation.value = target;
inertiaFrame = null;
pointerVelocity = 0;
return;
}
inertiaFrame = requestAnimationFrame(step);
};
inertiaFrame = requestAnimationFrame(step);
}
function stopInertia() {
if (inertiaFrame !== null) cancelAnimationFrame(inertiaFrame);
inertiaFrame = null;
@ -263,14 +382,16 @@ function normalizeCircleAngle(value: number) {
return ((value % 360) + 360) % 360;
}
function createConicBackground(colorFor: (option: SelectorOption) => string) {
const options = selectorOptions.value;
if (!options.length) return "transparent";
const step = segmentAngle.value;
const stops = options
.map((option, index) => `${colorFor(option)} ${index * step}deg ${(index + 1) * step}deg`)
.join(",");
return `conic-gradient(from ${-step / 2}deg,${stops})`;
function clampRotation(value: number) {
if (wheelIsCircular.value) return value;
return Math.max(rotationBounds.value.min, Math.min(rotationBounds.value.max, value));
}
function applyBoundaryResistance(value: number) {
const bounded = clampRotation(value);
if (bounded === value) return value;
const overflow = value - bounded;
return bounded + Math.sign(overflow) * (1 - Math.exp(-Math.abs(overflow) / 45)) * 18;
}
function darkenHexColor(hex: string) {
@ -302,37 +423,34 @@ function resetShellScroll() {
@pointerup="endWheelDrag"
@pointercancel="endWheelDrag"
@click.capture="cancelOptionClick"
@click="chooseWheelSegment"
>
<div :key="selectorLevel" class="category-wheel-rotor" :style="rotorStyle">
<div class="category-wheel-disc"></div>
<button
v-for="(option, index) in selectorOptions"
:key="`${selectorLevel}-${option.id}-${index}`"
type="button"
class="wheel-option"
:class="{ selected: isOptionSelected(option) }"
:style="optionStyle(index)"
:class="{
selected: isOptionSelected(option, index),
'other-option': option.label === '其他',
}"
:style="optionStyle(option, index)"
:aria-label="option.label"
@click.stop="chooseOption(option)"
>
<span class="wheel-option-content">
<span class="wheel-option-label">{{ option.label }}</span>
<component :is="option.icon" :size="21" />
<span class="wheel-option-label">{{ option.label }}</span>
</span>
</button>
</div>
</div>
<div class="wheel-center-controls">
<button type="button" class="wheel-back" :disabled="!canGoBack" aria-label="返回上一级" @click="goBack">
<button type="button" class="wheel-back" :aria-label="selectorLevel === 'type' ? '退出分类选择' : '返回上一级'" @click="goBack">
<ArrowLeft :size="16" />
<span>返回</span>
</button>
<button type="button" class="wheel-confirm" aria-label="确认当前分类" @click="confirmSelection">
<Check :size="17" />
<span>确认</span>
</button>
</div>
</div>
</Transition>

View File

@ -199,7 +199,7 @@ function saveEntry() {
<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 v-if="selectorOpen" class="category-focus-scrim"></div>
<div class="drawer-handle"></div>
<header class="drawer-header">
@ -247,13 +247,12 @@ function saveEntry() {
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()"
:style="{ '--category-color': selectedCategory?.color ?? typeOption.color }"
:aria-label="`选择分类,当前为${categoryPathLabel}`"
@click.stop="openSelector"
>
<Check v-if="selectorOpen" :size="22" />
<component v-else :is="selectedCategory?.icon ?? typeOption.icon" :size="17" />
<span>{{ selectorOpen ? "确定" : categoryDisplayLabel }}</span>
<component :is="selectedCategory?.icon ?? typeOption.icon" :size="17" />
<span>{{ categoryDisplayLabel }}</span>
</button>
<CategoryCoordinateSelector
@ -261,6 +260,7 @@ function saveEntry() {
v-model:category-path="categoryPath"
:open="selectorOpen"
@confirm="confirmCategory"
@close="confirmCategory"
/>
</div>
</div>

View File

@ -970,9 +970,10 @@ input:focus-visible {
.category-wheel-selector {
--wheel-size: clamp(286px, 88vw, 344px);
--center-size: clamp(122px, 35vw, 140px);
--ring-gap: 9px;
--option-radius: calc((var(--wheel-size) + var(--center-size)) / 4);
--center-size: clamp(72px, 20vw, 78px);
--option-size: clamp(54px, 14.5vw, 58px);
--option-gap: clamp(29px, 8vw, 33px);
--option-radius: calc((var(--center-size) + var(--option-size)) / 2 + var(--option-gap));
position: absolute;
top: 0;
right: 0;
@ -1020,58 +1021,23 @@ input:focus-visible {
will-change: transform;
}
.category-wheel-disc {
position: absolute;
inset: 0;
border-radius: 50%;
background: var(--ring-background);
box-shadow: 0 14px 34px rgba(29, 55, 48, 0.24);
-webkit-mask: radial-gradient(
circle,
transparent 0 calc(var(--center-size) / 2 + var(--ring-gap)),
#000 calc(var(--center-size) / 2 + var(--ring-gap) + 1px)
);
mask: radial-gradient(
circle,
transparent 0 calc(var(--center-size) / 2 + var(--ring-gap)),
#000 calc(var(--center-size) / 2 + var(--ring-gap) + 1px)
);
}
.category-wheel-disc::after {
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--ring-border-background);
pointer-events: none;
content: "";
-webkit-mask: radial-gradient(
circle closest-side,
transparent 0 calc(100% - 2px),
#000 calc(100% - 2px)
);
mask: radial-gradient(
circle closest-side,
transparent 0 calc(100% - 2px),
#000 calc(100% - 2px)
);
}
.wheel-option {
position: absolute;
top: 50%;
left: 50%;
width: 48px;
height: 54px;
width: var(--option-size);
height: var(--option-size);
display: grid;
place-items: center;
transform:
translate(-50%, -50%)
rotate(var(--option-angle))
translateY(calc(0px - var(--option-radius)));
border: 0;
border: 1px solid var(--option-border-color);
border-radius: 50%;
padding: 0;
background: transparent;
background: var(--option-color);
box-shadow: 0 7px 16px rgba(27, 49, 44, 0.22);
color: #ffffff;
font-size: 11px;
line-height: 1;
@ -1079,6 +1045,7 @@ input:focus-visible {
text-shadow: 0 1px 3px rgba(19, 35, 31, 0.3);
white-space: nowrap;
pointer-events: auto;
transition: border-color 140ms ease, box-shadow 140ms ease, scale 140ms ease;
}
.wheel-option-content {
@ -1087,33 +1054,41 @@ input:focus-visible {
align-items: center;
justify-content: center;
gap: 3px;
transform: rotate(calc(0deg - var(--option-angle) - var(--wheel-rotation)));
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;
will-change: transform;
}
.wheel-option:active .wheel-option-content {
transform: scale(0.88);
.wheel-option:active {
scale: 0.92;
}
.wheel-option.other-option {
border-color: #cbd6d2;
background: #ffffff;
color: #25332f;
text-shadow: none;
}
.wheel-option-label {
max-width: 48px;
max-width: calc(var(--option-size) - 10px);
overflow: hidden;
text-overflow: ellipsis;
}
.wheel-option.selected::after {
position: absolute;
bottom: -5px;
left: 50%;
width: 5px;
height: 5px;
transform: translateX(-50%);
border-radius: 50%;
background: #ffffff;
box-shadow: 0 1px 3px rgba(19, 35, 31, 0.25);
.wheel-option.selected {
z-index: 1;
border: 3px solid rgba(255, 255, 255, 0.96);
box-shadow:
0 0 0 2px rgba(255, 255, 255, 0.24),
0 9px 20px rgba(27, 49, 44, 0.26);
scale: 1.06;
animation: wheel-selection-pop 320ms cubic-bezier(0.22, 0.8, 0.28, 1);
content: "";
}
.wheel-option.selected:active {
scale: 1.02;
}
.wheel-center-controls {
@ -1124,7 +1099,6 @@ input:focus-visible {
width: var(--center-size);
height: var(--center-size);
display: grid;
grid-template-rows: 1fr 1fr;
transform: translate(-50%, -50%);
overflow: hidden;
border-radius: 50%;
@ -1151,21 +1125,10 @@ input:focus-visible {
}
.wheel-back {
border-bottom: 1px solid rgba(56, 72, 67, 0.14) !important;
background: #eef3f1;
color: #52635e;
}
.wheel-back:disabled {
color: #a6b0ad;
cursor: default;
}
.wheel-confirm {
background: #6f7f83;
color: #ffffff;
}
@keyframes wheel-level-reveal {
from {
opacity: 0.35;
@ -1181,24 +1144,24 @@ input:focus-visible {
@keyframes wheel-option-reveal {
from {
opacity: 0;
transform: translateY(-5px) scale(0.9);
scale: 0.9;
}
to {
opacity: 1;
transform: translateY(0) scale(1);
scale: 1;
}
}
@keyframes wheel-selection-pop {
from {
opacity: 0;
scale: 0.4;
scale: 0.72;
}
to {
opacity: 1;
scale: 1;
scale: 1.06;
}
}
@ -1217,12 +1180,12 @@ input:focus-visible {
@media (prefers-reduced-motion: reduce) {
.category-wheel-rotor,
.wheel-option-content,
.wheel-option.selected::after,
.wheel-option.selected,
.wheel-center-controls {
animation: none;
}
.wheel-option-content,
.wheel-option,
.wheel-center-controls button {
transition: none;
}

View File

@ -38,7 +38,8 @@
- 记录收入。
- 记录支出。
- 字段:金额、分类、日期、备注、付款人。
- 字段:金额、币种、分类、日期、备注、付款人。
- 账本可以设置默认币种,账目保留原币金额并按记账日期固化人民币金额,见 [多币种与汇率](多币种与汇率.md)。
- 只记录流水,不做分摊和结算。
### 离线
@ -51,11 +52,10 @@
- 按月查看流水。
- 按月查看分类汇总。
- 流水和统计可以统一换算为人民币展示。
## 首版暂缓
- 外币记账。
- 汇率换算。
- 预算。
- 资产账户余额。
- 转账。

445
docs/多币种与汇率.md Normal file
View File

@ -0,0 +1,445 @@
# 多币种与汇率
## 状态
- 设计状态:待实现。
- 记账本位币固定为 `CNY`
- 本文描述多币种记账、汇率缓存、离线降级及人民币汇总的首版方案。
## 目标
1. 每个账本可以设置默认币种,默认值为 `CNY`
2. 每笔账目保存原币金额,并保存按记账当日汇率计算的人民币金额。
3. 流水和统计可以统一按人民币展示,不在查询报表时实时调用汇率服务。
4. 已查询成功的历史汇率持久化到数据库,同一币种、同一日期不重复依赖外部 API。
5. 汇率查询失败或设备离线时,不能阻止用户记账。
6. 恢复网络后,可以将临时使用的旧汇率或尚未换算的账目回补为记账当日汇率。
7. 汇率查询和人民币金额计算全部异步执行,不能增加确认记账的等待时间。
## 非目标
- 不支持用户自定义本位币,系统本位币固定为 `CNY`
- 不提供外汇交易、资产账户和汇兑损益核算。
- 不允许用户手工修改系统汇率。手工汇率可作为后续能力。
- 首版不按照账本分别复制账目或人民币金额。一笔账目仍是一个数据库记录,可以关联多个账本。
## 币种范围
首版常用币种:
| 代码 | 名称 | 符号 | 小数位 |
| --- | --- | --- | --- |
| `CNY` | 人民币 | `¥` | 2 |
| `USD` | 美元 | `$` | 2 |
| `EUR` | 欧元 | `€` | 2 |
| `JPY` | 日元 | `¥` | 0 |
| `THB` | 泰铢 | `฿` | 2 |
现有 `HKD` 可以继续作为非置顶币种保留,避免已有数据失去支持。币种选择器中必须同时显示币种名称和代码,不能只依赖符号区分人民币、日元等币种。
金额继续使用最小货币单位的整数存储,例如:
- `CNY 12.34` 存为 `1234`
- `USD 12.34` 存为 `1234`
- `JPY 1234` 存为 `1234`
所有金额格式化和输入精度必须由币种元数据决定,不能统一假设为两位小数。
## 账本默认币种
`ledgers.default_currency` 保留为账本默认币种,数据库默认值为 `CNY`
- 新建账本默认使用 `CNY`
- 账本设置中可以修改默认币种。
- 修改默认币种只影响之后打开的新记账草稿,不修改历史账目。
- 从某个账本发起快速记账时,初始币种取当前账本的默认币种。
- 一笔账目关联多个账本时,币种仍只有一个;其他关联账本的默认币种不参与计算。
- 个人账本自动关联规则不改变当前记账币种。
## 记账交互
金额输入框左侧显示当前币种符号。点击符号打开移动端底部模态框:
1. 顶部固定展示人民币、美元、欧元、日元和泰铢。
2. 每项展示符号、中文名称和 ISO 代码。
3. 其他已支持币种放在“更多”区域。
4. 选择币种后关闭模态框,金额输入精度立即按新币种更新。
5. 切换币种时保留金额数字本身,不自动换算用户正在输入的金额。
6. 当前草稿一旦由用户手动切换币种,后续切换关联账本不覆盖该选择。
外币金额下方可以非阻塞地展示换算结果和状态:
- 精确历史汇率:`约 ¥123.45 · 2026-07-25 汇率`
- 使用历史缓存降级:`约 ¥123.45 · 使用最近汇率`
- 尚无可用汇率:`人民币金额将在联网后补充`
汇率状态只作提示,不能禁用“确认”按钮。
点击“确认”后的主流程只有:
1. 校验并保存原币金额、币种、发生时间、分类和关联账本。
2. 将外币账目标记为 `pending`,写入一条待换算任务。
3. 立即关闭记账界面并反馈记账成功。
主流程不能等待 IndexedDB 汇率缓存查询、后端汇率接口、第三方 API 或人民币金额计算。草稿中的换算结果只能作为异步预览;没有预览结果也不影响提交。
## 汇率定义与计算
系统内部统一使用“1 单位外币等于多少 CNY”的直接汇率
```text
cny_per_unit = CNY / 1 source currency
```
例如 `1 USD = 7.210000 CNY` 时,`cny_per_unit = 7.210000`。
人民币最小单位金额按以下规则计算:
```text
cny_amount =
round(
amount_minor
/ 10 ^ source_currency_minor_units
* cny_per_unit
* 100
)
```
- `cny_amount` 始终是人民币分的整数。
- 舍入规则固定为四舍五入到人民币分。
- `CNY` 账目汇率固定为 `1``cny_amount = amount`,不查询汇率服务。
- 汇率使用高精度定点数,数据库建议使用 `numeric(24, 12)`,禁止使用浮点数参与持久化计算。
## 汇率日期
汇率日期根据账目的发生时间和创建账目时的用户时区确定,并作为日期单独固化。
- 新增账目时,将 `occurred_at` 转换为用户本地日历日期,写入 `exchange_rate_date`
- 修改金额不会重新查询汇率,但人民币金额要在后台使用账目已有的汇率快照重新计算。
- 修改币种或将发生时间改到另一天时,重新解析汇率。
- 修改备注、分类或关联账本不触发汇率变化。
- 后续改变用户时区不改变历史账目的汇率日期。
## 数据模型
### 账目
现有账目已经包含 `amount`、`currency`、`base_currency`、`base_amount`、`exchange_rate` 和 `exchange_rate_source`。首版沿用这些字段,明确其语义,并补充状态字段:
| 字段 | 语义 |
| --- | --- |
| `amount` | 原币最小单位整数 |
| `currency` | 原币 ISO 代码 |
| `base_currency` | 固定为 `CNY` |
| `base_amount` | 人民币分;没有任何可用汇率时允许暂时为空 |
| `exchange_rate` | `cny_per_unit` 汇率快照;待回补时为空 |
| `exchange_rate_date` | 账目应使用的汇率日期 |
| `exchange_rate_effective_date` | 实际采用的汇率日期;降级时可能早于目标日期 |
| `exchange_rate_source` | `system``manual`,首版只写 `system` |
| `conversion_status` | `exact`、`fallback` 或 `pending` |
`base_amount` 是后台换算任务生成的快照。流水和统计直接读取它,不在每次查询时重新计算。
`conversion_status` 的含义:
- `exact`:采用记账日期对应的历史汇率。
- `fallback`:历史汇率暂时查询失败,采用数据库或本地缓存中的最近可用汇率。
- `pending`:服务器和当前设备都没有可用汇率,原币账目已保存,等待联网回补。
### 历史汇率
新增 `exchange_rates` 表:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `currency` | `varchar(3)` | 外币代码 |
| `rate_date` | `date` | 汇率生效日期 |
| `cny_per_unit` | `numeric(24,12)` | 1 单位外币对应的 CNY |
| `provider` | `varchar(64)` | 数据提供方标识 |
| `provider_rate_date` | `date` | 提供方实际返回的日期 |
| `fetched_at` | `timestamptz` | 在线查询完成时间 |
| `created_at` | `timestamptz` | 入库时间 |
| `updated_at` | `timestamptz` | 更新时间 |
主键为 `(currency, rate_date)`。本位币固定为 CNY因此表中不需要重复保存 `base_currency`
周末或节假日若提供方返回最近交易日汇率:
- 以请求的日历日期作为 `rate_date`,使后续同日查询命中数据库。
- 以真实交易日作为 `provider_rate_date`,保留来源信息。
## 汇率解析流程
后端提供统一的汇率解析服务,客户端不直接调用第三方 API。该服务由后台换算任务调用不处在新增或编辑账目的请求链路中
1. `CNY` 直接返回汇率 `1`
2. 查询服务进程的内存缓存。
3. 内存没有可用精确值时查询 `exchange_rates` 数据库表。
4. 数据库精确命中后写入内存缓存并直接返回,不请求外部服务。
5. 数据库没有目标日期时,保留查询到的最近历史汇率作为降级候选,再调用外部汇率 API。
6. 外部查询成功后用 upsert 写入 `exchange_rates`,写入内存缓存,再返回 `exact` 结果。
7. 外部查询失败时返回第 5 步保留的数据库最近值,状态为 `fallback`
8. 数据库不存在更早记录时,降级候选取日期距离目标日最近的记录。
9. 内存、数据库和外部 API 均无可用结果时返回 `pending`,不能让记账请求失败。
查询顺序固定为:
```text
进程内存缓存 -> PostgreSQL -> 外部 API
```
数据库唯一约束负责处理并发查询同一汇率时的重复写入。外部请求必须设置短超时,汇率服务不可用时快速进入降级流程。
### 内存缓存
内存缓存是单个 API/worker 进程内的 LRU 缓存,不作为持久化来源。多实例各自维护缓存,不要求实例间同步。
- 缓存键:`provider + currency + requested_date`。
- 缓存值:汇率、请求日期、实际生效日期、提供方和换算状态。
- 历史精确汇率可以缓存到进程退出,仍受 LRU 容量限制。
- 当日汇率使用较短 TTL避免提供方尚未发布当日数据时长期保留上一工作日结果。
- `fallback` 使用短 TTL过期后允许后台任务重新尝试精确汇率。
- `pending` 可以做数十秒的负缓存,避免外部服务故障时形成请求风暴。
- 相同缓存键的并发未命中必须合并为一个查询任务,即 single-flight。
- 建议初始上限为 2,000 个键;淘汰不会影响正确性,下一次会回查数据库。
内存命中只用于减少数据库和外部请求。历史汇率的可靠来源仍是 PostgreSQL服务重启不能造成历史数据丢失。
## 外部汇率提供方
首版使用 [Frankfurter v2](https://frankfurter.dev/) 公共 API并固定筛选 `ECB` 数据源:
- 公共地址:`https://api.frankfurter.dev`
- 无需 API Key。
- 支持指定日期的历史汇率。
- CNY、USD、EUR、JPY、THB 和 HKD 均在 ECB 参考汇率覆盖范围内。
- API 是开源项目,后续可以自行部署,业务接口不需要变化。
查询某日外币兑人民币的接口:
```text
GET https://api.frankfurter.dev/v2/rate/{SOURCE}/CNY
?date={YYYY-MM-DD}
&providers=ECB
```
例如:
```text
GET https://api.frankfurter.dev/v2/rate/USD/CNY
?date=2024-07-25
&providers=ECB
```
响应:
```json
{
"date": "2024-07-25",
"base": "USD",
"quote": "CNY",
"rate": 7.2188
}
```
返回值方向与系统定义一致,即 `1 USD = 7.2188 CNY`,不需要取倒数。必须校验响应中的 `base`、`quote`、`date` 和正数汇率,不能只读取 `rate`
周末或节假日请求可能返回最近工作日。例如请求 2024-07-27 时可能返回 `date = 2024-07-26`。数据库仍以账目请求日期作为 `rate_date`,以响应日期作为 `provider_rate_date`
当日汇率也通过同一接口查询。ECB 通常在欧洲工作日下午发布参考汇率发布前查询当日、周末或节假日查询当日Frankfurter 会返回最近一个已有数据的工作日。系统不把这种情况视为查询失败:
- `rate_date` 保存账目对应的本地日历日期。
- `provider_rate_date` 保存 API 实际返回的工作日。
- 当日缓存使用短 TTL在提供方发布新数据后允许重新查询并回补。
ECB 参考汇率适合个人账本的统一估值但不是银行实际结算价。首版不自动切换到其他提供方Frankfurter 不可用时使用已有数据库汇率或保持 `pending`,避免不同来源的历史汇率口径混杂。未来如增加备用源,必须把提供方优先级和来源记录在汇率表中。
建议接口:
```text
GET /api/exchange-rates/resolve?currency=USD&date=2026-07-25
```
返回:
```json
{
"currency": "USD",
"requestedDate": "2026-07-25",
"effectiveDate": "2026-07-25",
"cnyPerUnit": "7.210000000000",
"status": "exact"
}
```
汇率提供方通过后端适配器隔离,业务层只依赖统一的解析结果。以后更换提供方不应修改账目和报表逻辑。
## 异步换算任务
### 客户端
客户端保存外币账目后,将账目 ID 放入本地待换算队列,随后立即结束记账交互。后台任务按以下顺序处理:
1. 查询 IndexedDB 中的精确日期汇率。
2. 命中后计算本地人民币金额并更新页面。
3. 未命中时尝试请求后端汇率解析接口。
4. API 不可用时使用本地最近汇率,或保持 `pending`
5. 将原币账目和当前换算状态交给正常同步流程。
本地换算只用于离线展示和提前提供结果。服务端异步换算结果仍是最终值。
### 服务端
服务端接收新增或编辑账目时:
1. 先保存原币账目。
2. 在同一数据库事务中写入待换算任务。
3. 立即返回账目保存成功,不在线查询汇率,也不在请求内计算人民币金额。
4. 后台 worker 领取任务,解析汇率、计算人民币金额并更新账目。
5. 通过现有同步版本机制把换算结果发送给客户端。
待换算任务应持久化在数据库中,不能只放在进程内存。可以新增 `entry_conversion_jobs` 表,并以账目 ID 去重worker 使用 `FOR UPDATE SKIP LOCKED` 等数据库机制安全领取任务。进程重启后未完成任务必须能够继续执行。
任务写回前必须重新读取账目,并校验任务对应的金额、币种、汇率日期或账目版本:
- 账目已删除时直接结束任务。
- 金额已变化时,使用当前金额重新计算。
- 币种或汇率日期已变化时,废弃旧解析结果并按新值重新排队。
- 仅备注、分类或关联账本变化时,可以继续写入换算结果。
任务必须幂等。同一账目被重复排队或 worker 重试不能产生重复账目,也不能让旧任务覆盖较新的账目内容。
## 离线与失败降级
Web 端 IndexedDB 需要保存服务端最近同步下来的汇率缓存。进入记账页或成功同步后,后台刷新常用币种的近期汇率,该刷新同样不能阻塞记账界面。
新增外币账目的顺序:
1. 先保存原币账目,状态写为 `pending`,人民币金额暂时为空。
2. 客户端后台任务优先查找本地精确日期汇率。
3. 本地未命中且后端可用时,异步请求后端解析。
4. 精确查询失败时,使用服务端数据库中的最近历史汇率。
5. API 不可访问时,客户端优先使用本地缓存中不晚于记账日期的最近汇率;没有更早记录时使用日期距离目标日最近的缓存。
6. 客户端也从未缓存过该币种时保持 `pending`,等待后续同步和后台回补。
7. 即使第三方 API 仍失败,服务端也必须接受账目并保留 `pending` 状态。
不允许用汇率 `1` 代替未知外币汇率。这样虽然能得到一个非空人民币金额,但会污染流水和统计。
`pending` 账目在人民币汇总中暂不计入合计,并在页面显示“有 N 条外币账目等待换算”。原币模式下仍可正常查看和编辑。
## 后台刷新与回补
后台任务每天执行以下工作:
1. 预取常用币种当日汇率,降低用户记账时的等待和失败概率。
2. 重试 `conversion_status = pending` 的账目。
3. 重试 `conversion_status = fallback` 的账目,查询其目标日期精确汇率。
4. 获得精确汇率后,原子更新账目的 `exchange_rate`、`base_amount`、生效日期和状态。
5. 通过现有同步版本机制让客户端收到更新。
`fallback` 是可回补的临时快照,而不是永久锁定的估值。回补可能使历史人民币合计发生小幅变化,原币金额不变。
已是 `exact` 的账目不因提供方后续修订自动重算,避免历史统计在没有用户操作时持续漂移。如未来需要重估,应另做显式管理操作和审计记录。
## 流水与统计
流水页和统计页增加显示币种模式:
- `原币`:单条账目显示原始金额和币种。混合币种不能直接相加,汇总按币种分组。
- `人民币`:单条账目显示 `base_amount`,并可用弱化文本保留原币金额;页头和图表统一汇总人民币金额。
默认采用人民币模式,以保持跨账本、跨币种统计可加总。显示模式属于用户界面偏好,不写入账目。
所有人民币统计只读取 `base_amount`
- 不查询外部 API。
- 不逐条重新计算汇率。
- 同一账目即使关联多个账本,在“个人收支”或跨账本统计中仍只按账目 ID 计算一次。
- 在单个共享账本中只统计关联到该账本的账目。
- `pending` 账目不计入人民币合计,并明确展示缺失数量,不能静默当作零元。
## 同步与服务端校验
客户端可以随离线账目提交本地异步计算的汇率快照,但服务端保存账目的请求不能为了校验而等待外部汇率。服务端先接受账目和任务,再由后台任务完成以下校验:
- 币种在允许列表中。
- 金额为符合该币种小数位的整数。
- `base_currency` 只能是 `CNY`
- 汇率和人民币金额的计算结果在允许的舍入误差内一致。
- 客户端声明 `exact` 时,服务端数据库必须存在对应日期和数值的汇率,否则降级为服务端解析结果。
- 客户端使用本地旧汇率时写为 `fallback`,不能伪装为 `exact`
服务端后台任务写回的状态和金额为最终值。同步冲突继续沿用账目现有版本规则,汇率回补视为服务端对同一账目的更新。
## 数据迁移
1. 将支持币种列表加入 `THB`,保留已有 `HKD`
2. 新增 `exchange_rates` 表。
3. 新增持久化的 `entry_conversion_jobs` 待换算任务表。
4. 为账目增加汇率目标日期、实际生效日期和换算状态。
5. 允许 `base_amount``exchange_rate``pending` 状态下为空,并由数据库约束保证其他状态下两者非空。
6. 将 `exchange_rate` 迁移为定点数;若暂不迁移类型,所有读写也必须通过十进制定点库处理。
7. `CNY` 历史账目设置汇率 `1`、`base_amount = amount`、状态 `exact`
8. 审计非 CNY 历史账目。当前实现曾将外币汇率写为 `1`,因此不能信任这些记录的 `base_amount`;应按发生日期重新查询并标记为 `exact`、`fallback` 或 `pending`
9. 在回填完成前,统计必须按换算状态处理,不展示错误的人民币合计。
## 可观测性
至少记录以下指标和结构化日志:
- 按提供方和币种统计的查询成功率、超时率及耗时。
- 数据库精确命中、在线查询、历史降级和待回补的数量。
- 当前 `pending`、`fallback` 账目数量及最老账龄。
- 后台回补成功和失败数量。
- 汇率计算校验失败的同步请求。
日志不得记录用户密码或完整账目备注。
## 测试重点
### 单元测试
- 各币种最小单位和格式化规则,尤其是 JPY 无小数。
- 汇率方向、定点计算和四舍五入。
- CNY 不查询外部服务。
- 精确命中、在线查询、最近历史降级和完全无缓存四条路径。
- 修改日期或币种触发重算,修改备注或分类不触发。
### API 与数据库测试
- 内存命中时不查询数据库和外部 API。
- 内存未命中、数据库精确命中时不查询外部 API。
- 两级缓存均未命中时查询 Frankfurter并同时回写数据库和内存。
- 同一币种和日期并发未命中时只发起一次外部请求。
- 服务重启清空内存后仍能从数据库恢复历史汇率。
- 同一币种、日期并发查询只保留一条汇率。
- 外部 API 超时不阻止账目写入。
- 人为延迟汇率接口时,新增和编辑账目的响应时间不随该延迟增加。
- 保存账目与创建待换算任务具有事务一致性。
- worker 重启、重复领取任务和账目并发编辑时保持幂等。
- `pending``fallback` 后台回补正确更新账目及同步版本。
- 服务端拒绝非法币种、错误汇率方向和不一致的人民币金额。
- 报表读取过程不调用外部汇率服务。
### 前端测试
- 点击金额符号打开币种模态框,常用币种顺序正确。
- 切换 JPY 后金额输入不允许小数。
- 完全离线时可以创建、编辑和删除外币账目。
- 点击确认后不等待汇率查询或人民币金额计算,记账界面立即关闭。
- 人民币模式、原币模式及混合币种汇总显示正确。
- 待换算提示不遮挡记账主流程,且不会把缺失金额当作零。
## 验收标准
1. 新账本和已有未设置币种的账本默认使用 `CNY`
2. 用户可以从金额左侧符号切换常用币种并成功记账。
3. 同一笔多账本关联账目只保存一份原币金额和人民币金额。
4. 流水和统计的人民币合计使用记账日期对应的汇率快照。
5. 查询过的历史汇率可以在外部服务不可用时从数据库读取。
6. 外部服务和网络都不可用时仍可完成记账。
7. 外币账目不存在被静默按 `1:1` 换算的情况。
8. `pending``fallback` 账目可以在后台恢复为精确汇率。
9. 报表查询不依赖外部汇率 API且不会为每次展示重复计算历史账目。
10. 汇率接口无论成功、失败还是超时,都不延长新增或编辑账目的主流程。
11. 汇率解析严格按照内存缓存、数据库、Frankfurter 的顺序执行。

View File

@ -2,7 +2,7 @@
## 首期选择
首期只使用 Playwright 做端到端测试,不写单元测试。
首期以 Playwright 端到端测试为主。金额精度、汇率换算和同步合并等不适合只靠界面验证的纯逻辑,应补充针对性单元测试。
## 原则
@ -18,6 +18,7 @@
- 新增一笔收入。
- 新增一笔支出。
- 使用不同币种记账。
- 编辑账目。
- 删除账目。
- 刷新页面后账目仍存在。
@ -32,6 +33,7 @@
### 离线
- 离线时可以新增账目。
- 离线且汇率服务不可用时仍可新增外币账目。
- 离线刷新后应用仍能打开。
- 恢复网络后触发同步。
@ -40,12 +42,19 @@
- 同一操作重复提交不会重复记账。
- 多设备拉取后能看到同一账本流水。
- 同一账目离线冲突时最后写入胜出。
- 待换算和临时汇率账目恢复网络后可以回补。
### 汇率与统计
- 按记账日期保存历史汇率快照。
- 外部汇率服务失败时使用缓存或进入待换算状态。
- 人民币汇总不重复调用外部汇率服务。
- 多账本关联的同一账目在跨账本统计中只计算一次。
- 不同最小单位和金额舍入规则通过单元测试覆盖。
## 暂不覆盖
- 单元测试。
- 组件测试。
- 外币和汇率。
- 大规模性能测试。
- 视觉回归测试。