feat: 实现账本核心交互与导航

This commit is contained in:
openclaw 2026-07-19 02:43:06 +08:00
parent af546883c4
commit 7cc2f51594
56 changed files with 9601 additions and 0 deletions

14
.env.example Normal file
View File

@ -0,0 +1,14 @@
# Production/public domain. Must resolve to the VPS running frps.
DOMAIN=cents.homemade.net.cn
# Public VPS / frps address.
FRPC_SERVER_HOST=119.28.12.24
FRPC_SERVER_PORT=7000
# Production target. For host-debug mode, keep host.docker.internal.
APP_HOST=host.docker.internal
APP_PORT=6064
APP_PROTOCOL=http
# Use false only after the tunnel is known to work.
TEST_MODE=false

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
legacy-cent-app/
node_modules/
dist/
.env
.env.*
!.env.example
*.log
.DS_Store

View File

@ -0,0 +1,31 @@
# Cents
家庭共享账本 App。
目标是做一个精简、可靠、可离线使用的手机应用,支持 Android 和 iOS。用户在无网络时也能记账网络恢复后自动同步到家庭共享账本。
当前阶段:产品边界和技术设计。
- [产品边界](docs/产品边界.md)
- [技术方案](docs/技术方案.md)
- [数据与同步](docs/数据与同步.md)
- [测试策略](docs/测试策略.md)
- [内网穿透调试](docs/内网穿透调试.md)
- [HTML 原型图](docs/原型图/原型说明.md)
- [评审问题](docs/评审问题.md)
## 本地开发
```bash
npm install
npm run dev
```
前端默认运行在 `http://localhost:5173/`
常用检查:
```bash
npm run typecheck
npm run build
```

15
apps/web/index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f7f4ee" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Cents</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

25
apps/web/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "@cents/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@cents/domain": "0.1.0",
"@lucide/vue": "^1.25.0",
"@vitejs/plugin-vue": "^6.0.0",
"dexie": "^4.0.11",
"pinia": "^3.0.3",
"vite": "^7.0.5",
"vue": "^3.5.17",
"vue-router": "^4.5.1"
},
"devDependencies": {
"typescript": "^5.8.3",
"vue-tsc": "^3.0.1"
}
}

6
apps/web/public/icon.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="24" fill="#214e4b"/>
<path d="M34 38h60v52H34z" fill="#f7f4ee"/>
<path d="M44 50h40M44 64h30M44 78h22" stroke="#214e4b" stroke-width="8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 274 B

View File

@ -0,0 +1,18 @@
{
"name": "Cents",
"short_name": "Cents",
"description": "家庭共享账本",
"start_url": "/",
"display": "standalone",
"background_color": "#f7f4ee",
"theme_color": "#f7f4ee",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

25
apps/web/public/sw.js Normal file
View File

@ -0,0 +1,25 @@
const CACHE_NAME = "cents-shell-v2";
const APP_SHELL = ["/", "/manifest.webmanifest", "/icon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))),
);
});
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).catch(() => caches.match("/"));
}),
);
});

4
apps/web/src/App.vue Normal file
View File

@ -0,0 +1,4 @@
<template>
<RouterView />
</template>

View File

@ -0,0 +1,506 @@
<script setup lang="ts">
import type { EntryType } from "@cents/domain";
import { BookOpen, CalendarDays, Check, ChevronDown, ChevronRight, Delete as BackspaceIcon } 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;
};
type SelectorLevel = "type" | "category" | "subcategory";
defineProps<{
ledgerId: string;
ledgerName: string;
ledgerOptions: Array<{ id: string; name: string }>;
}>();
const emit = defineEmits<{
close: [];
save: [entry: SavedEntry];
changeLedger: [ledgerId: string];
}>();
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 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 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 dateLabel = computed(() => {
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 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 saveEntry() {
const amount = Math.round(calculatedAmount.value * 100);
if (!Number.isFinite(amount) || amount <= 0) return;
emit("save", {
type: entryType.value,
amount,
categoryId: selectedCategory.value?.id ?? entryType.value,
note: note.value.trim(),
occurredAt: new Date(occurredAt.value).toISOString(),
});
}
</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>
<label class="quick-ledger-target">
<BookOpen :size="13" />
<span>记入 {{ ledgerName }}</span>
<ChevronDown :size="13" />
<select
:value="ledgerId"
aria-label="记入账本"
@change="emit('changeLedger', ($event.target as HTMLSelectElement).value)"
>
<option v-for="ledger in ledgerOptions" :key="ledger.id" :value="ledger.id">{{ ledger.name }}</option>
</select>
</label>
</div>
<button class="header-date-button" type="button" @click="openDatePicker">
<CalendarDays :size="16" />
<span>{{ dateLabel }}</span>
</button>
<input
ref="dateInput"
v-model="occurredAt"
class="native-date-input"
type="datetime-local"
aria-label="发生时间"
tabindex="-1"
/>
</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>
<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 ? "确定" : categoryPathLabel }}</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" 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>
</template>

View File

@ -0,0 +1,76 @@
<script setup lang="ts">
import { BarChart3, BookOpen, Check, CircleUserRound, LibraryBig, Plus } from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { createEntry } from "../data/entries";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
import QuickEntryDrawer from "./QuickEntryDrawer.vue";
const route = useRoute();
const entryStore = useEntryStore();
const ledgerStore = useLedgerStore();
const drawerOpen = ref(false);
const savedToast = ref(false);
const ledgerOptions = computed(() =>
ledgerStore.ledgers.map((ledger) => ({ id: ledger.id, name: ledger.name })),
);
onMounted(async () => {
if (!ledgerStore.loaded) await ledgerStore.loadLedgers();
});
async function changeLedger(ledgerId: string) {
await ledgerStore.setCurrentLedger(ledgerId);
}
async function saveEntry(input: {
type: "expense" | "income";
amount: number;
categoryId: string;
note: string;
occurredAt: string;
}) {
const ledger = ledgerStore.currentLedger;
if (!ledger) return;
await entryStore.addEntry(createEntry({
...input,
ledgerId: ledger.id,
currency: ledger.defaultCurrency,
}));
drawerOpen.value = false;
savedToast.value = true;
window.setTimeout(() => (savedToast.value = false), 1800);
}
</script>
<template>
<nav class="bottom-nav" aria-label="主导航">
<RouterLink to="/" :class="{ active: route.name === 'ledger' }"><BookOpen :size="20" /><span>流水</span></RouterLink>
<RouterLink to="/stats" :class="{ active: route.name === 'stats' }"><BarChart3 :size="20" /><span>统计</span></RouterLink>
<span class="nav-spacer"></span>
<RouterLink to="/ledgers" :class="{ active: route.name === 'ledgers' }"><LibraryBig :size="20" /><span>账本</span></RouterLink>
<RouterLink to="/me" :class="{ active: route.name === 'me' }"><CircleUserRound :size="20" /><span>我的</span></RouterLink>
</nav>
<button v-if="!drawerOpen" class="add-entry-button" type="button" aria-label="记一笔" title="记一笔" @click="drawerOpen = true">
<Plus :size="28" />
</button>
<Transition name="drawer">
<QuickEntryDrawer
v-if="drawerOpen && ledgerStore.currentLedger"
:ledger-id="ledgerStore.currentLedger.id"
:ledger-name="ledgerStore.currentLedger.name"
:ledger-options="ledgerOptions"
@change-ledger="changeLedger"
@close="drawerOpen = false"
@save="saveEntry"
/>
</Transition>
<Transition name="toast">
<div v-if="savedToast" class="saved-toast" role="status"><Check :size="17" />已记入账本</div>
</Transition>
</template>

View File

@ -0,0 +1,115 @@
import type { EntryType } from "@cents/domain";
import {
Banknote,
Bike,
BriefcaseBusiness,
BusFront,
CarTaxiFront,
Coffee,
CookingPot,
Gift,
GraduationCap,
HeartPulse,
House,
Plane,
ShoppingBag,
ShoppingBasket,
Smartphone,
Soup,
Ticket,
Utensils,
WalletCards,
type LucideIcon,
} from "@lucide/vue";
export type Category = {
id: string;
label: string;
color: string;
tint: string;
icon: LucideIcon;
children?: Category[];
};
export type EntryTypeOption = {
id: EntryType;
label: string;
color: string;
tint: string;
icon: LucideIcon;
};
export const entryTypes: EntryTypeOption[] = [
{ id: "expense", label: "支出", color: "#ef5b3f", tint: "#fff0ec", icon: WalletCards },
{ id: "income", label: "收入", color: "#16966f", tint: "#e9faf4", icon: Banknote },
];
export const categories: Record<EntryType, Category[]> = {
expense: [
{
id: "food",
label: "餐饮",
color: "#f06a34",
tint: "#fff0e8",
icon: Utensils,
children: [
{ id: "takeout", label: "外卖", color: "#f06a34", tint: "#fff0e8", icon: Soup },
{ id: "restaurant", label: "堂食", color: "#e9572e", tint: "#ffede7", icon: CookingPot },
{ id: "coffee", label: "咖啡", color: "#9b6a48", tint: "#f8eee6", icon: Coffee },
],
},
{
id: "transport",
label: "交通",
color: "#3478e5",
tint: "#edf4ff",
icon: BusFront,
children: [
{ id: "taxi", label: "打车", color: "#3478e5", tint: "#edf4ff", icon: CarTaxiFront },
{ id: "public-transit", label: "公交地铁", color: "#2563c7", tint: "#edf4ff", icon: Ticket },
{ id: "cycling", label: "骑行", color: "#168c87", tint: "#e8f8f6", icon: Bike },
],
},
{
id: "daily",
label: "日用",
color: "#1a9b62",
tint: "#eaf9f1",
icon: ShoppingBasket,
children: [
{ 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: "shopping", label: "购物", color: "#d84486", tint: "#fff0f7", icon: ShoppingBag },
{ id: "health", label: "医疗", color: "#df3f5f", tint: "#fff0f2", icon: HeartPulse },
{ id: "travel", label: "旅行", color: "#7559d9", tint: "#f2efff", icon: Plane },
],
income: [
{ id: "salary", label: "工资", color: "#15956d", tint: "#e8faf3", icon: BriefcaseBusiness },
{ id: "bonus", label: "奖金", color: "#b57a0e", tint: "#fff7dd", icon: Gift },
{ id: "part-time", label: "兼职", color: "#3577c9", tint: "#edf5ff", icon: GraduationCap },
{ id: "other-income", label: "其他", color: "#8559cf", tint: "#f4efff", icon: Banknote },
],
};
export function findCategory(categoryId: string): Category | undefined {
for (const group of Object.values(categories)) {
for (const category of group) {
if (category.id === categoryId) return category;
const child = category.children?.find((item) => item.id === categoryId);
if (child) return child;
}
}
return undefined;
}
export function findCategoryPath(type: EntryType, categoryId: string): Category[] {
for (const category of categories[type]) {
if (category.id === categoryId) return [category];
const child = category.children?.find((item) => item.id === categoryId);
if (child) return [category, child];
}
return [];
}

22
apps/web/src/data/db.ts Normal file
View File

@ -0,0 +1,22 @@
import Dexie, { type EntityTable } from "dexie";
import type { LedgerEntry, SyncOperation } from "@cents/domain";
import type { LedgerRecord, UserPreference } from "./ledgers";
export const db = new Dexie("cents") as Dexie & {
entries: EntityTable<LedgerEntry, "id">;
syncOperations: EntityTable<SyncOperation, "id">;
ledgers: EntityTable<LedgerRecord, "id">;
userPreferences: EntityTable<UserPreference, "id">;
};
db.version(1).stores({
entries: "id, ledgerId, occurredAt, updatedAt, deletedAt",
syncOperations: "id, ledgerId, createdAt, syncedAt",
});
db.version(2).stores({
entries: "id, ledgerId, occurredAt, updatedAt, deletedAt",
syncOperations: "id, ledgerId, createdAt, syncedAt",
ledgers: "id, updatedAt, archivedAt",
userPreferences: "id, userId, key, updatedAt",
});

View File

@ -0,0 +1,57 @@
import type { CurrencyCode, EntryType, LedgerEntry } from "@cents/domain";
import { createId } from "./ids";
type EntryInput = {
ledgerId: string;
type: EntryType;
amount: number;
currency: CurrencyCode;
categoryId: string;
note: string;
occurredAt: string;
};
const baseCurrency: CurrencyCode = "CNY";
export function createEntry(input: EntryInput): LedgerEntry {
const now = new Date().toISOString();
return {
id: createId(),
ledgerId: input.ledgerId,
type: input.type,
amount: input.amount,
currency: input.currency,
baseCurrency,
baseAmount: input.amount,
exchangeRate: "1",
exchangeRateSource: "manual",
categoryId: input.categoryId,
note: input.note,
occurredAt: input.occurredAt,
createdBy: "local-user",
updatedBy: "local-user",
createdAt: now,
updatedAt: now,
deletedAt: null,
version: 1,
};
}
export function createDemoEntries(): LedgerEntry[] {
const now = new Date();
const at = (daysAgo: number, hours: number, minutes: number) => {
const date = new Date(now);
date.setDate(date.getDate() - daysAgo);
date.setHours(hours, minutes, 0, 0);
return date.toISOString();
};
return [
createEntry({ ledgerId: "local-ledger", type: "expense", amount: 5800, currency: "CNY", categoryId: "takeout", note: "午餐", occurredAt: at(0, 12, 28) }),
createEntry({ ledgerId: "local-ledger", type: "expense", amount: 3150, currency: "CNY", categoryId: "taxi", note: "打车", occurredAt: at(0, 9, 16) }),
createEntry({ ledgerId: "local-ledger", type: "income", amount: 824000, currency: "CNY", categoryId: "salary", note: "工资", occurredAt: at(1, 18, 2) }),
createEntry({ ledgerId: "local-ledger", type: "expense", amount: 23680, currency: "CNY", categoryId: "groceries", note: "超市采购", occurredAt: at(1, 16, 45) }),
createEntry({ ledgerId: "local-ledger", type: "expense", amount: 2600, currency: "CNY", categoryId: "coffee", note: "咖啡", occurredAt: at(2, 14, 10) }),
];
}

9
apps/web/src/data/ids.ts Normal file
View File

@ -0,0 +1,9 @@
export function createId() {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}

View File

@ -0,0 +1,46 @@
import type { CurrencyCode } from "@cents/domain";
export type LedgerRecord = {
id: string;
name: string;
color: string;
defaultCurrency: CurrencyCode;
createdAt: string;
updatedAt: string;
archivedAt: string | null;
};
export type UserPreference = {
id: string;
userId: string;
key: "currentLedgerId";
value: string;
updatedAt: string;
};
export const localUserId = "local-user";
export const currentLedgerPreferenceId = `${localUserId}:currentLedgerId`;
export function createDefaultLedgers(): LedgerRecord[] {
const now = new Date().toISOString();
return [
{
id: "local-ledger",
name: "家庭日常",
color: "#087f72",
defaultCurrency: "CNY",
createdAt: now,
updatedAt: now,
archivedAt: null,
},
{
id: "travel-ledger",
name: "旅行账本",
color: "#3478e5",
defaultCurrency: "CNY",
createdAt: now,
updatedAt: now,
archivedAt: null,
},
];
}

21
apps/web/src/main.ts Normal file
View File

@ -0,0 +1,21 @@
import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "./App.vue";
import { router } from "./router";
import "./styles.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");
if (import.meta.env.PROD && "serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js");
});
} else if ("serviceWorker" in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => registration.unregister());
});
}

43
apps/web/src/router.ts Normal file
View File

@ -0,0 +1,43 @@
import { createRouter, createWebHistory } from "vue-router";
import LedgerView from "./views/LedgerView.vue";
export const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/",
name: "ledger",
component: LedgerView,
},
{
path: "/prototype/entry-detail",
name: "entry-detail-prototype",
component: () => import("./views/EntryDetailPrototypeView.vue"),
},
{
path: "/entries/:id",
name: "entry-detail",
component: () => import("./views/EntryDetailView.vue"),
},
{
path: "/stats",
name: "stats",
component: () => import("./views/StatsView.vue"),
},
{
path: "/ledgers",
name: "ledgers",
component: () => import("./views/LedgersView.vue"),
},
{
path: "/ledger/settings",
name: "ledger-settings",
component: () => import("./views/LedgerSettingsView.vue"),
},
{
path: "/me",
name: "me",
component: () => import("./views/MeView.vue"),
},
],
});

View File

@ -0,0 +1,100 @@
import { defineStore } from "pinia";
import type { LedgerEntry, SyncOperation } from "@cents/domain";
import { db } from "../data/db";
import { createDemoEntries } from "../data/entries";
import { createId } from "../data/ids";
export const useEntryStore = defineStore("entries", {
state: () => ({
entries: [] as LedgerEntry[],
}),
actions: {
async loadEntries() {
if (import.meta.env.DEV && (await db.entries.count()) === 0) {
await db.entries.bulkAdd(createDemoEntries());
}
this.entries = (await db.entries.orderBy("occurredAt").reverse().toArray()).filter(
(entry) => entry.deletedAt === null,
);
},
async addEntry(entry: LedgerEntry) {
const operation: SyncOperation = {
id: createId(),
ledgerId: entry.ledgerId,
entity: "entry",
entityId: entry.id,
action: "create",
payload: entry,
createdAt: new Date().toISOString(),
syncedAt: null,
};
await db.transaction("rw", db.entries, db.syncOperations, async () => {
await db.entries.put(entry);
await db.syncOperations.put(operation);
});
await this.loadEntries();
},
async updateEntry(entry: LedgerEntry) {
const existing = await db.entries.get(entry.id);
if (!existing || existing.deletedAt) throw new Error("Entry not found");
const updated: LedgerEntry = {
...existing,
...entry,
updatedAt: new Date().toISOString(),
updatedBy: "local-user",
version: existing.version + 1,
};
const operation: SyncOperation = {
id: createId(),
ledgerId: updated.ledgerId,
entity: "entry",
entityId: updated.id,
action: "update",
payload: updated,
createdAt: updated.updatedAt,
syncedAt: null,
};
await db.transaction("rw", db.entries, db.syncOperations, async () => {
await db.entries.put(updated);
await db.syncOperations.put(operation);
});
await this.loadEntries();
return updated;
},
async deleteEntry(entryId: string) {
const existing = await db.entries.get(entryId);
if (!existing || existing.deletedAt) return;
const deletedAt = new Date().toISOString();
const deleted: LedgerEntry = {
...existing,
deletedAt,
updatedAt: deletedAt,
updatedBy: "local-user",
version: existing.version + 1,
};
const operation: SyncOperation = {
id: createId(),
ledgerId: deleted.ledgerId,
entity: "entry",
entityId: deleted.id,
action: "delete",
payload: deleted,
createdAt: deletedAt,
syncedAt: null,
};
await db.transaction("rw", db.entries, db.syncOperations, async () => {
await db.entries.put(deleted);
await db.syncOperations.put(operation);
});
await this.loadEntries();
},
},
});

View File

@ -0,0 +1,79 @@
import { defineStore } from "pinia";
import { db } from "../data/db";
import {
createDefaultLedgers,
currentLedgerPreferenceId,
localUserId,
type LedgerRecord,
type UserPreference,
} from "../data/ledgers";
let ledgerLoadPromise: Promise<void> | null = null;
export const useLedgerStore = defineStore("ledgers", {
state: () => ({
ledgers: [] as LedgerRecord[],
currentLedgerId: "",
loaded: false,
}),
getters: {
currentLedger(state) {
return state.ledgers.find((ledger) => ledger.id === state.currentLedgerId) ?? null;
},
},
actions: {
async loadLedgers() {
if (this.loaded) return;
if (ledgerLoadPromise) return ledgerLoadPromise;
ledgerLoadPromise = (async () => {
if ((await db.ledgers.count()) === 0) await db.ledgers.bulkAdd(createDefaultLedgers());
this.ledgers = (await db.ledgers.orderBy("updatedAt").reverse().toArray()).filter(
(ledger) => ledger.archivedAt === null,
);
const preference = await db.userPreferences.get(currentLedgerPreferenceId);
const preferredLedger = this.ledgers.find((ledger) => ledger.id === preference?.value);
const fallbackLedger = this.ledgers.find((ledger) => ledger.id === "local-ledger") ?? this.ledgers[0];
this.currentLedgerId = preferredLedger?.id ?? fallbackLedger?.id ?? "";
if (this.currentLedgerId !== preference?.value) await this.persistCurrentLedger();
this.loaded = true;
})();
try {
await ledgerLoadPromise;
} finally {
ledgerLoadPromise = null;
}
},
async setCurrentLedger(ledgerId: string) {
const ledger = this.ledgers.find((item) => item.id === ledgerId && item.archivedAt === null);
if (!ledger) throw new Error("Ledger not found");
this.currentLedgerId = ledger.id;
await this.persistCurrentLedger();
},
async persistCurrentLedger() {
if (!this.currentLedgerId) return;
const preference: UserPreference = {
id: currentLedgerPreferenceId,
userId: localUserId,
key: "currentLedgerId",
value: this.currentLedgerId,
updatedAt: new Date().toISOString(),
};
await db.userPreferences.put(preference);
},
async updateLedger(input: Pick<LedgerRecord, "id" | "name" | "color" | "defaultCurrency">) {
const existing = await db.ledgers.get(input.id);
if (!existing) throw new Error("Ledger not found");
const updated: LedgerRecord = {
...existing,
...input,
name: input.name.trim(),
updatedAt: new Date().toISOString(),
};
await db.ledgers.put(updated);
this.loaded = false;
await this.loadLedgers();
return updated;
},
},
});

1363
apps/web/src/styles.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,921 @@
<script setup lang="ts">
import type { EntryType } from "@cents/domain";
import {
ArrowLeft,
BookOpen,
CalendarDays,
Check,
ChevronRight,
CircleUserRound,
Edit3,
NotebookText,
Trash2,
WalletCards,
X,
} from "@lucide/vue";
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import {
categories,
entryTypes,
findCategory,
findCategoryPath,
type Category,
} from "../data/categories";
const router = useRouter();
const editing = ref(false);
const categorySheetOpen = ref(false);
const savedToast = ref(false);
const draftParentId = ref("food");
const detailDateInput = ref<HTMLInputElement | null>(null);
const amount = ref("58.00");
const entryType = ref<EntryType>("expense");
const categoryId = ref("takeout");
const note = ref("午餐");
const occurredAt = ref("2026-07-19T12:28");
const ledger = ref("家庭日常");
const member = ref("小王");
const selectedCategory = computed(() => findCategory(categoryId.value));
const categoryPath = computed(() => findCategoryPath(entryType.value, categoryId.value));
const categoryLabel = computed(() => categoryPath.value.map((item) => item.label).join(" > "));
const draftParent = computed(() =>
categories[entryType.value].find((category) => category.id === draftParentId.value),
);
const categoryChildren = computed(() => draftParent.value?.children ?? []);
const amountLabel = computed(() => {
const value = Number(amount.value) || 0;
return `${entryType.value === "expense" ? "" : "+"} ¥${value.toLocaleString("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
});
const dateLabel = computed(() => {
const date = new Date(occurredAt.value);
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
});
function startEditing() {
editing.value = true;
}
function cancelEditing() {
editing.value = false;
categorySheetOpen.value = false;
}
function savePrototype() {
editing.value = false;
categorySheetOpen.value = false;
savedToast.value = true;
window.setTimeout(() => (savedToast.value = false), 1600);
}
function setEntryType(type: EntryType) {
if (entryType.value === type) return;
entryType.value = type;
const firstCategory = categories[type][0];
draftParentId.value = firstCategory.id;
categoryId.value = firstCategory.children?.[0]?.id ?? firstCategory.id;
}
function openCategorySheet() {
draftParentId.value = categoryPath.value[0]?.id ?? categories[entryType.value][0].id;
categorySheetOpen.value = true;
}
function openDetailDatePicker() {
const input = detailDateInput.value;
if (!input) return;
if (typeof input.showPicker === "function") input.showPicker();
else input.click();
}
function chooseParent(category: Category) {
draftParentId.value = category.id;
if (!category.children?.length) {
categoryId.value = category.id;
categorySheetOpen.value = false;
}
}
function chooseChild(category: Category) {
categoryId.value = category.id;
categorySheetOpen.value = false;
}
</script>
<template>
<main class="app-shell detail-prototype-shell">
<header class="detail-appbar">
<button class="detail-icon-button" type="button" aria-label="返回流水" title="返回" @click="router.push('/')">
<ArrowLeft :size="22" />
</button>
<strong>条目明细</strong>
<button
v-if="!editing"
class="detail-icon-button edit-entry-button"
type="button"
aria-label="编辑条目"
title="编辑"
@click="startEditing"
>
<Edit3 :size="20" />
</button>
<button
v-else
class="detail-icon-button"
type="button"
aria-label="取消编辑"
title="取消"
@click="cancelEditing"
>
<X :size="21" />
</button>
</header>
<div class="detail-scroll">
<section class="detail-amount-band" :class="entryType">
<div
class="detail-category-mark"
:style="{
color: selectedCategory?.color,
background: selectedCategory?.tint,
}"
>
<component :is="selectedCategory?.icon ?? WalletCards" :size="28" />
</div>
<span>{{ entryType === "expense" ? "支出" : "收入" }}</span>
<strong v-if="!editing">{{ amountLabel }}</strong>
<label v-else class="amount-edit-field">
<span>¥</span>
<input v-model="amount" inputmode="decimal" aria-label="金额" />
</label>
</section>
<template v-if="!editing">
<section class="detail-section" aria-label="条目信息">
<div class="detail-row">
<span class="detail-row-icon category"><WalletCards :size="19" /></span>
<span class="detail-row-label">类别</span>
<strong>{{ categoryLabel }}</strong>
</div>
<div class="detail-row">
<span class="detail-row-icon date"><CalendarDays :size="19" /></span>
<span class="detail-row-label">日期</span>
<strong>{{ dateLabel }}</strong>
</div>
<div class="detail-row">
<span class="detail-row-icon ledger"><BookOpen :size="19" /></span>
<span class="detail-row-label">账本</span>
<strong>{{ ledger }}</strong>
</div>
<div class="detail-row">
<span class="detail-row-icon member"><CircleUserRound :size="19" /></span>
<span class="detail-row-label">记录人</span>
<strong>{{ member }}</strong>
</div>
</section>
<section class="detail-section note-section" aria-label="备注">
<div class="detail-row note-detail-row">
<span class="detail-row-icon note"><NotebookText :size="19" /></span>
<span class="detail-row-label">备注</span>
<strong>{{ note || "无" }}</strong>
</div>
</section>
<p class="entry-audit">由小王创建 · 2026年7月19日 12:29</p>
<button class="delete-entry-command" type="button">
<Trash2 :size="18" />
删除条目
</button>
</template>
<form v-else class="detail-edit-form" @submit.prevent="savePrototype">
<fieldset class="type-segmented-control" aria-label="收支类型">
<button
v-for="type in entryTypes"
:key="type.id"
type="button"
:class="{ active: entryType === type.id }"
:style="{ '--type-color': type.color }"
@click="setEntryType(type.id)"
>
<component :is="type.icon" :size="18" />
{{ type.label }}
</button>
</fieldset>
<section class="edit-fields" aria-label="编辑条目">
<button class="edit-field-button" type="button" @click="openCategorySheet">
<span class="edit-field-leading category"><WalletCards :size="19" /></span>
<span class="edit-field-copy"><small>类别</small><strong>{{ categoryLabel }}</strong></span>
<ChevronRight :size="19" />
</button>
<button class="edit-field-button" type="button" @click="openDetailDatePicker">
<span class="edit-field-leading date"><CalendarDays :size="19" /></span>
<span class="edit-field-copy">
<small>日期时间</small>
<strong>{{ dateLabel }}</strong>
</span>
<ChevronRight :size="19" />
</button>
<input
ref="detailDateInput"
v-model="occurredAt"
class="detail-native-date-input"
type="datetime-local"
aria-label="日期时间"
tabindex="-1"
/>
<label class="edit-field-control">
<span class="edit-field-leading ledger"><BookOpen :size="19" /></span>
<span class="edit-field-copy">
<small>账本</small>
<select v-model="ledger" aria-label="账本">
<option>家庭日常</option>
<option>旅行账本</option>
</select>
</span>
</label>
<label class="edit-field-control">
<span class="edit-field-leading member"><CircleUserRound :size="19" /></span>
<span class="edit-field-copy">
<small>记录人</small>
<select v-model="member" aria-label="记录人">
<option>小王</option>
<option>自己</option>
</select>
</span>
</label>
<label class="edit-note-control">
<span class="edit-field-leading note"><NotebookText :size="19" /></span>
<span class="edit-field-copy">
<small>备注</small>
<textarea v-model="note" maxlength="120" rows="3" placeholder="补充说明(可选)"></textarea>
</span>
</label>
</section>
<div class="edit-actions">
<button type="button" class="secondary-action" @click="cancelEditing">取消</button>
<button type="submit" class="primary-action"><Check :size="19" />保存修改</button>
</div>
</form>
</div>
<Transition name="sheet">
<div v-if="categorySheetOpen" class="category-sheet-layer">
<button class="category-sheet-scrim" type="button" aria-label="关闭类别选择" @click="categorySheetOpen = false"></button>
<section class="normal-category-sheet" aria-label="选择类别">
<div class="category-sheet-handle"></div>
<header>
<strong>选择类别</strong>
<button type="button" aria-label="关闭" title="关闭" @click="categorySheetOpen = false"><X :size="20" /></button>
</header>
<div class="sheet-type-tabs">
<button
v-for="type in entryTypes"
:key="type.id"
type="button"
:class="{ active: entryType === type.id }"
:style="{ '--type-color': type.color }"
@click="setEntryType(type.id)"
>
<component :is="type.icon" :size="17" />{{ type.label }}
</button>
</div>
<div class="category-grid" aria-label="一级类别">
<button
v-for="category in categories[entryType]"
:key="category.id"
type="button"
:class="{ active: draftParentId === category.id }"
:style="{ '--category-color': category.color, '--category-tint': category.tint }"
@click="chooseParent(category)"
>
<span><component :is="category.icon" :size="22" /></span>
<small>{{ category.label }}</small>
</button>
</div>
<div v-if="categoryChildren.length" class="subcategory-area">
<span>{{ draftParent?.label }}细分类</span>
<div class="subcategory-list">
<button
v-for="category in categoryChildren"
:key="category.id"
type="button"
:class="{ active: categoryId === category.id }"
:style="{ '--category-color': category.color, '--category-tint': category.tint }"
@click="chooseChild(category)"
>
<component :is="category.icon" :size="18" />
{{ category.label }}
<Check v-if="categoryId === category.id" :size="16" />
</button>
</div>
</div>
</section>
</div>
</Transition>
<Transition name="toast">
<div v-if="savedToast" class="detail-saved-toast" role="status"><Check :size="17" />修改已保存</div>
</Transition>
</main>
</template>
<style scoped>
.detail-prototype-shell {
color: #1d2926;
background: #f5f8f7;
}
.detail-appbar {
position: relative;
z-index: 2;
height: calc(58px + env(safe-area-inset-top));
display: grid;
grid-template-columns: 44px 1fr 44px;
align-items: end;
gap: 8px;
border-bottom: 1px solid #dce8e4;
padding: env(safe-area-inset-top) 12px 7px;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(14px);
}
.detail-appbar > strong {
align-self: center;
text-align: center;
font-size: 17px;
}
.detail-icon-button {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #31504a;
}
.edit-entry-button {
background: #e8f7f3;
color: #087f72;
}
.detail-scroll {
height: calc(100% - 58px - env(safe-area-inset-top));
overflow-y: auto;
padding-bottom: calc(28px + env(safe-area-inset-bottom));
}
.detail-amount-band {
min-height: 192px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-bottom: 1px solid #dce7e4;
padding: 24px 18px;
background: #ffffff;
}
.detail-category-mark {
width: 54px;
height: 54px;
display: grid;
place-items: center;
border-radius: 14px;
}
.detail-amount-band > span {
margin-top: 9px;
color: #73817d;
font-size: 13px;
}
.detail-amount-band > strong {
margin-top: 3px;
color: #d84c36;
font-size: 34px;
font-variant-numeric: tabular-nums;
line-height: 1.25;
}
.detail-amount-band.income > strong {
color: #0d8b67;
}
.detail-section {
margin-top: 12px;
border-top: 1px solid #dce7e4;
border-bottom: 1px solid #dce7e4;
padding: 0 16px;
background: #ffffff;
}
.detail-row {
min-height: 62px;
display: grid;
grid-template-columns: 34px 64px minmax(0, 1fr);
align-items: center;
gap: 8px;
border-bottom: 1px solid #e5edeb;
}
.detail-row:last-child {
border-bottom: 0;
}
.detail-row-icon,
.edit-field-leading {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 8px;
}
.detail-row-icon.category,
.edit-field-leading.category { background: #fff0e8; color: #e85e2b; }
.detail-row-icon.date,
.edit-field-leading.date { background: #fff7dd; color: #9b6d0f; }
.detail-row-icon.ledger,
.edit-field-leading.ledger { background: #e9f7f3; color: #087f72; }
.detail-row-icon.member,
.edit-field-leading.member { background: #edf4ff; color: #3478e5; }
.detail-row-icon.note,
.edit-field-leading.note { background: #f3efff; color: #7559d9; }
.detail-row-label {
color: #7b8884;
font-size: 13px;
}
.detail-row strong {
min-width: 0;
overflow-wrap: anywhere;
text-align: right;
font-size: 14px;
font-weight: 650;
}
.note-section {
margin-top: 12px;
}
.note-detail-row {
align-items: start;
min-height: 84px;
padding: 14px 0;
}
.note-detail-row .detail-row-label,
.note-detail-row strong {
padding-top: 6px;
}
.entry-audit {
margin: 18px 16px 10px;
color: #8b9693;
text-align: center;
font-size: 12px;
}
.delete-entry-command {
min-height: 42px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
margin: 0 auto;
border: 0;
padding: 0 12px;
background: transparent;
color: #c94635;
font-size: 14px;
}
.amount-edit-field {
width: min(100%, 270px);
height: 54px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
margin-top: 5px;
border-bottom: 2px solid #ef6a4d;
color: #d84c36;
}
.income .amount-edit-field {
border-bottom-color: #16966f;
color: #0d8b67;
}
.amount-edit-field > span {
font-size: 23px;
font-weight: 700;
}
.amount-edit-field input {
width: 200px;
border: 0;
padding: 0;
outline: 0;
background: transparent;
color: inherit;
font-size: 36px;
font-weight: 760;
font-variant-numeric: tabular-nums;
text-align: center;
}
.detail-edit-form {
padding-bottom: 86px;
}
.type-segmented-control {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin: 12px 16px;
border: 0;
border-radius: 8px;
padding: 4px;
background: #e9efed;
}
.type-segmented-control button {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 0;
border-radius: 6px;
background: transparent;
color: #70807c;
font-size: 14px;
font-weight: 680;
}
.type-segmented-control button.active {
background: #ffffff;
color: var(--type-color);
box-shadow: 0 2px 8px rgba(28, 52, 47, 0.12);
}
.edit-fields {
border-top: 1px solid #dce7e4;
border-bottom: 1px solid #dce7e4;
padding: 0 16px;
background: #ffffff;
}
.edit-field-button,
.edit-field-control,
.edit-note-control {
width: 100%;
min-height: 66px;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 0;
border-bottom: 1px solid #e3ece9;
padding: 8px 0;
background: transparent;
color: #27342f;
text-align: left;
}
.edit-field-control:last-of-type {
border-bottom: 1px solid #e3ece9;
}
.edit-field-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.edit-field-copy small {
color: #7b8884;
font-size: 11px;
}
.edit-field-copy strong {
font-size: 14px;
}
.edit-field-copy input,
.edit-field-copy select,
.edit-field-copy textarea {
width: 100%;
border: 0;
border-radius: 0;
padding: 0;
outline: 0;
background: transparent;
color: #27342f;
font-size: 14px;
}
.detail-native-date-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.edit-field-copy textarea {
min-height: 66px;
resize: none;
line-height: 1.45;
}
.edit-note-control {
align-items: start;
border-bottom: 0;
padding-top: 12px;
}
.edit-note-control .edit-field-leading {
margin-top: 3px;
}
.edit-actions {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 3;
display: grid;
grid-template-columns: 1fr 1.35fr;
gap: 10px;
border-top: 1px solid #d8e5e1;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.97);
backdrop-filter: blur(14px);
}
.edit-actions button {
height: 46px;
border-radius: 8px;
font-weight: 720;
}
.secondary-action {
border: 1px solid #cbdad6;
background: #ffffff;
color: #48605a;
}
.primary-action {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 0;
background: #087f72;
color: #ffffff;
}
.category-sheet-layer {
position: absolute;
inset: 0;
z-index: 10;
}
.category-sheet-scrim {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
background: rgba(18, 35, 32, 0.42);
backdrop-filter: blur(3px);
}
.normal-category-sheet {
position: absolute;
top: 22%;
right: 0;
bottom: 0;
left: 0;
overflow-y: auto;
border-radius: 14px 14px 0 0;
padding: 0 16px calc(20px + env(safe-area-inset-bottom));
background: #ffffff;
box-shadow: 0 -16px 36px rgba(22, 45, 40, 0.22);
}
.category-sheet-handle {
width: 38px;
height: 4px;
margin: 8px auto 3px;
border-radius: 2px;
background: #b9cac5;
}
.normal-category-sheet header {
height: 50px;
display: flex;
align-items: center;
justify-content: space-between;
}
.normal-category-sheet header > strong {
font-size: 16px;
}
.normal-category-sheet header button {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: #f0f5f3;
color: #536762;
}
.sheet-type-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 16px;
}
.sheet-type-tabs button {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid #d8e4e1;
border-radius: 8px;
background: #ffffff;
color: #60716d;
font-weight: 680;
}
.sheet-type-tabs button.active {
border-color: var(--type-color);
background: color-mix(in srgb, var(--type-color) 10%, white);
color: var(--type-color);
}
.category-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px 8px;
}
.category-grid button {
min-width: 0;
display: grid;
place-items: center;
gap: 5px;
border: 0;
padding: 0;
background: transparent;
color: #53615e;
}
.category-grid button > span {
width: 48px;
height: 48px;
display: grid;
place-items: center;
border: 2px solid transparent;
border-radius: 50%;
background: var(--category-tint);
color: var(--category-color);
}
.category-grid button.active > span {
border-color: var(--category-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--category-color) 13%, white);
}
.category-grid small {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.subcategory-area {
margin-top: 18px;
border-top: 1px solid #e1ebe8;
padding-top: 14px;
}
.subcategory-area > span {
color: #75827f;
font-size: 12px;
font-weight: 650;
}
.subcategory-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 9px;
}
.subcategory-list button {
min-width: 0;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--category-color) 24%, #d8e4e1);
border-radius: 8px;
padding: 0 8px;
background: var(--category-tint);
color: var(--category-color);
font-size: 12px;
font-weight: 650;
white-space: nowrap;
}
.subcategory-list button.active {
border-color: var(--category-color);
}
.detail-saved-toast {
position: absolute;
right: 50%;
bottom: 28px;
z-index: 20;
min-height: 40px;
display: flex;
align-items: center;
gap: 7px;
transform: translateX(50%);
border-radius: 8px;
padding: 0 14px;
background: #173c35;
color: #ffffff;
box-shadow: 0 10px 24px rgba(21, 50, 44, 0.2);
font-size: 13px;
white-space: nowrap;
}
.sheet-enter-active,
.sheet-leave-active {
transition: opacity 180ms ease;
}
.sheet-enter-active .normal-category-sheet,
.sheet-leave-active .normal-category-sheet {
transition: transform 220ms ease;
}
.sheet-enter-from,
.sheet-leave-to {
opacity: 0;
}
.sheet-enter-from .normal-category-sheet,
.sheet-leave-to .normal-category-sheet {
transform: translateY(100%);
}
@media (max-width: 360px) {
.detail-row {
grid-template-columns: 34px 54px minmax(0, 1fr);
}
.category-grid {
column-gap: 4px;
}
}
</style>

View File

@ -0,0 +1,853 @@
<script setup lang="ts">
import type { EntryType, LedgerEntry } from "@cents/domain";
import {
ArrowLeft,
BookOpen,
CalendarDays,
Check,
ChevronDown,
ChevronRight,
CircleUserRound,
NotebookText,
Trash2,
WalletCards,
X,
} from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
categories,
entryTypes,
findCategory,
findCategoryPath,
type Category,
} from "../data/categories";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
type CategorySheetMode = "parent" | "child";
const route = useRoute();
const router = useRouter();
const store = useEntryStore();
const ledgerStore = useLedgerStore();
const entry = ref<LedgerEntry | null>(null);
const loading = ref(true);
const saving = ref(false);
const savedToast = ref(false);
const errorMessage = ref("");
const categorySheetMode = ref<CategorySheetMode | null>(null);
const dateInput = ref<HTMLInputElement | null>(null);
const amount = ref("");
const ledgerId = ref("local-ledger");
const parentCategoryId = ref("");
const childCategoryId = ref("");
const note = ref("");
const occurredAt = ref("");
const entryType = computed<EntryType>(() => entry.value?.type ?? "expense");
const typeOption = computed(() => entryTypes.find((type) => type.id === entryType.value)!);
const parentCategories = computed(() => categories[entryType.value]);
const selectedParent = computed(() =>
parentCategories.value.find((category) => category.id === parentCategoryId.value),
);
const childCategories = computed(() => selectedParent.value?.children ?? []);
const selectedChild = computed(() =>
childCategories.value.find((category) => category.id === childCategoryId.value),
);
const selectedCategory = computed(() => selectedChild.value ?? selectedParent.value);
const ledgerOptions = computed(() =>
ledgerStore.ledgers.map((ledger) => ({ id: ledger.id, label: ledger.name })),
);
const dateLabel = computed(() => {
if (!occurredAt.value) return "请选择时间";
const date = new Date(occurredAt.value);
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
});
const timeInvalid = computed(() => {
if (!occurredAt.value) return true;
return Number.isNaN(new Date(occurredAt.value).getTime());
});
const ledgerLabel = computed(() =>
ledgerOptions.value.find((ledger) => ledger.id === ledgerId.value)?.label ?? "未知账本",
);
const createdAtLabel = computed(() => {
if (!entry.value) return "";
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(entry.value.createdAt));
});
onMounted(async () => {
await Promise.all([store.loadEntries(), ledgerStore.loadLedgers()]);
const found = store.entries.find((item) => item.id === String(route.params.id));
if (found) hydrate(found);
loading.value = false;
});
function hydrate(value: LedgerEntry) {
entry.value = value;
amount.value = (value.amount / 100).toFixed(2);
ledgerId.value = value.ledgerId;
note.value = value.note;
occurredAt.value = toLocalDateTime(new Date(value.occurredAt));
const path = findCategoryPath(value.type, value.categoryId);
parentCategoryId.value = path[0]?.id ?? "";
childCategoryId.value = path[1]?.id ?? "";
}
function toLocalDateTime(date: Date) {
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function openDatePicker() {
const input = dateInput.value;
if (!input) return;
if (typeof input.showPicker === "function") input.showPicker();
else input.click();
}
function chooseParent(category: Category) {
const parentChanged = parentCategoryId.value !== category.id;
parentCategoryId.value = category.id;
if (parentChanged) childCategoryId.value = "";
categorySheetMode.value = null;
}
function chooseChild(category: Category) {
childCategoryId.value = category.id;
categorySheetMode.value = null;
}
async function saveEntry() {
if (!entry.value || saving.value) return;
const numericAmount = Number(amount.value.replaceAll(",", ""));
if (!Number.isFinite(numericAmount) || numericAmount <= 0) {
errorMessage.value = "请输入有效金额";
return;
}
if (!parentCategoryId.value) {
errorMessage.value = "请选择大类";
return;
}
const occurredDate = new Date(occurredAt.value);
if (timeInvalid.value) {
errorMessage.value = "请选择时间";
return;
}
saving.value = true;
errorMessage.value = "";
const cents = Math.round(numericAmount * 100);
const exchangeRate = Number(entry.value.exchangeRate) || 1;
const updated = await store.updateEntry({
...entry.value,
amount: cents,
baseAmount: Math.round(cents * exchangeRate),
ledgerId: ledgerId.value,
categoryId: childCategoryId.value || parentCategoryId.value,
note: note.value.trim(),
occurredAt: occurredDate.toISOString(),
});
hydrate(updated);
saving.value = false;
savedToast.value = true;
window.setTimeout(() => (savedToast.value = false), 1600);
}
async function deleteEntry() {
if (!entry.value || !window.confirm("确定删除这笔条目吗?")) return;
await store.deleteEntry(entry.value.id);
await router.replace("/");
}
</script>
<template>
<main class="app-shell entry-detail-shell">
<header class="entry-detail-appbar">
<button class="entry-detail-icon-button" type="button" aria-label="返回流水" title="返回" @click="router.push('/')">
<ArrowLeft :size="22" />
</button>
<strong>条目明细</strong>
<span aria-hidden="true"></span>
</header>
<div v-if="loading" class="entry-detail-state">正在载入...</div>
<div v-else-if="!entry" class="entry-detail-state">
<strong>条目不存在</strong>
<button type="button" @click="router.replace('/')">返回流水</button>
</div>
<form v-else class="entry-detail-form" @submit.prevent="saveEntry">
<div class="entry-detail-scroll">
<section class="entry-detail-amount" :class="entryType">
<div
class="entry-detail-category-mark"
:style="{
color: selectedCategory?.color ?? typeOption.color,
background: selectedCategory?.tint ?? typeOption.tint,
}"
>
<component :is="selectedCategory?.icon ?? typeOption.icon" :size="27" />
</div>
<span class="entry-type-label">{{ typeOption.label }}</span>
<label class="entry-amount-input">
<span>¥</span>
<input v-model="amount" inputmode="decimal" aria-label="金额" />
</label>
</section>
<section class="entry-edit-fields" aria-label="编辑条目">
<button class="entry-edit-row" type="button" @click="categorySheetMode = 'parent'">
<span class="entry-field-icon category"><WalletCards :size="19" /></span>
<span class="entry-field-copy"><small>大类</small><strong>{{ selectedParent?.label ?? "请选择" }}</strong></span>
<ChevronRight :size="19" />
</button>
<button
class="entry-edit-row"
type="button"
:disabled="!childCategories.length"
@click="categorySheetMode = 'child'"
>
<span class="entry-field-icon subcategory">
<component :is="selectedParent?.icon ?? WalletCards" :size="19" />
</span>
<span class="entry-field-copy">
<small>小类</small>
<strong>{{ childCategories.length ? selectedChild?.label ?? "未选择" : "无小类" }}</strong>
</span>
<ChevronRight v-if="childCategories.length" :size="19" />
</button>
<button class="entry-edit-row" :class="{ invalid: timeInvalid }" type="button" @click="openDatePicker">
<span class="entry-field-icon date"><CalendarDays :size="19" /></span>
<span class="entry-field-copy"><small>时间</small><strong>{{ dateLabel }}</strong></span>
<ChevronRight :size="19" />
</button>
<input
ref="dateInput"
v-model="occurredAt"
class="entry-native-date-input"
type="datetime-local"
aria-label="时间"
:aria-invalid="timeInvalid"
tabindex="-1"
/>
<label class="entry-edit-row entry-select-row">
<span class="entry-field-icon ledger"><BookOpen :size="19" /></span>
<span class="entry-field-copy"><small>账本</small><strong>{{ ledgerLabel }}</strong></span>
<select v-model="ledgerId" aria-label="账本">
<option v-for="ledger in ledgerOptions" :key="ledger.id" :value="ledger.id">{{ ledger.label }}</option>
</select>
<ChevronDown :size="18" />
</label>
<div class="entry-edit-row readonly-row">
<span class="entry-field-icon member"><CircleUserRound :size="19" /></span>
<span class="entry-field-copy"><small>记录人</small><strong>小王</strong></span>
</div>
<label class="entry-note-row">
<span class="entry-field-icon note"><NotebookText :size="19" /></span>
<span class="entry-field-copy">
<small>备注</small>
<textarea v-model="note" maxlength="120" rows="3" placeholder="补充说明(可选)"></textarea>
</span>
</label>
</section>
<p class="entry-created-at">由小王创建 · {{ createdAtLabel }}</p>
<p v-if="errorMessage" class="entry-save-error" role="alert">{{ errorMessage }}</p>
</div>
<div class="entry-detail-actions">
<button class="entry-delete-button" type="button" aria-label="删除条目" title="删除条目" @click="deleteEntry">
<Trash2 :size="20" />
</button>
<button class="entry-save-button" type="submit" :disabled="saving">
<Check :size="19" />{{ saving ? "保存中" : "保存修改" }}
</button>
</div>
</form>
<Transition name="sheet">
<div v-if="categorySheetMode" class="entry-category-layer">
<button class="entry-category-scrim" type="button" aria-label="关闭类别选择" @click="categorySheetMode = null"></button>
<section class="entry-category-sheet" :aria-label="categorySheetMode === 'parent' ? '选择大类' : '选择小类'">
<div class="entry-sheet-handle"></div>
<header>
<strong>{{ categorySheetMode === "parent" ? "选择大类" : "选择小类" }}</strong>
<button type="button" aria-label="关闭" title="关闭" @click="categorySheetMode = null"><X :size="20" /></button>
</header>
<div v-if="categorySheetMode === 'parent'" class="entry-category-grid">
<button
v-for="category in parentCategories"
:key="category.id"
type="button"
:class="{ active: parentCategoryId === category.id }"
:style="{ '--category-color': category.color, '--category-tint': category.tint }"
@click="chooseParent(category)"
>
<span><component :is="category.icon" :size="23" /></span>
<small>{{ category.label }}</small>
<Check v-if="parentCategoryId === category.id" :size="15" />
</button>
</div>
<div v-else class="entry-subcategory-grid">
<button
v-for="category in childCategories"
:key="category.id"
type="button"
:class="{ active: childCategoryId === category.id }"
:style="{ '--category-color': category.color, '--category-tint': category.tint }"
@click="chooseChild(category)"
>
<span><component :is="category.icon" :size="22" /></span>
<strong>{{ category.label }}</strong>
<Check v-if="childCategoryId === category.id" :size="16" />
</button>
</div>
</section>
</div>
</Transition>
<Transition name="toast">
<div v-if="savedToast" class="entry-detail-toast" role="status"><Check :size="17" />修改已保存</div>
</Transition>
</main>
</template>
<style scoped>
.entry-detail-shell {
color: #1d2926;
background: #f5f8f7;
}
.entry-detail-appbar {
position: relative;
z-index: 3;
height: calc(58px + env(safe-area-inset-top));
display: grid;
grid-template-columns: 44px 1fr 44px;
align-items: end;
gap: 8px;
border-bottom: 1px solid #dce8e4;
padding: env(safe-area-inset-top) 12px 7px;
background: rgba(255, 255, 255, 0.97);
backdrop-filter: blur(14px);
}
.entry-detail-appbar > strong {
align-self: center;
text-align: center;
font-size: 17px;
}
.entry-detail-icon-button {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #31504a;
}
.entry-detail-form {
height: calc(100% - 58px - env(safe-area-inset-top));
}
.entry-detail-scroll {
height: 100%;
overflow-y: auto;
padding-bottom: calc(92px + env(safe-area-inset-bottom));
}
.entry-detail-amount {
min-height: 184px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-bottom: 1px solid #dce7e4;
padding: 22px 18px;
background: #ffffff;
}
.entry-detail-category-mark {
width: 52px;
height: 52px;
display: grid;
place-items: center;
border-radius: 12px;
}
.entry-type-label {
margin-top: 7px;
color: #73817d;
font-size: 13px;
font-weight: 650;
}
.entry-amount-input {
width: min(100%, 270px);
height: 52px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
color: #d84c36;
}
.income .entry-amount-input {
color: #0d8b67;
}
.entry-amount-input > span {
font-size: 23px;
font-weight: 700;
}
.entry-amount-input input {
width: 200px;
border: 0;
padding: 0;
outline: 0;
background: transparent;
color: inherit;
font-size: 36px;
font-weight: 760;
font-variant-numeric: tabular-nums;
text-align: center;
}
.entry-edit-fields {
margin-top: 12px;
border-top: 1px solid #dce7e4;
border-bottom: 1px solid #dce7e4;
padding: 0 16px;
background: #ffffff;
}
.entry-edit-row,
.entry-note-row {
width: 100%;
min-height: 64px;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 0;
border-bottom: 1px solid #e3ece9;
padding: 8px 0;
background: transparent;
color: #27342f;
text-align: left;
}
.entry-edit-row:disabled {
cursor: default;
opacity: 0.62;
}
.entry-edit-row.invalid {
border-bottom-color: transparent;
border-radius: 8px;
padding-right: 8px;
padding-left: 8px;
background: #fff3f0;
color: #c83f2d;
box-shadow: inset 0 0 0 1px #ef9a8c;
}
.entry-edit-row.invalid .entry-field-icon.date {
background: #ffe2dc;
color: #d44531;
}
.entry-edit-row.invalid .entry-field-copy small,
.entry-edit-row.invalid .entry-field-copy strong {
color: #c83f2d;
}
.entry-field-icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 8px;
}
.entry-field-icon.category { background: #fff0e8; color: #e85e2b; }
.entry-field-icon.subcategory { background: #fff7dd; color: #a56e05; }
.entry-field-icon.date { background: #fff7dd; color: #9b6d0f; }
.entry-field-icon.ledger { background: #e9f7f3; color: #087f72; }
.entry-field-icon.member { background: #edf4ff; color: #3478e5; }
.entry-field-icon.note { background: #f3efff; color: #7559d9; }
.entry-field-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.entry-field-copy small {
color: #7b8884;
font-size: 11px;
}
.entry-field-copy strong {
min-width: 0;
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-select-row {
position: relative;
grid-template-columns: 34px minmax(0, 1fr) 18px;
}
.entry-select-row select {
position: absolute;
inset: 0;
width: 100%;
opacity: 0;
cursor: pointer;
}
.readonly-row {
grid-template-columns: 34px minmax(0, 1fr);
}
.entry-note-row {
align-items: start;
border-bottom: 0;
padding-top: 12px;
}
.entry-note-row .entry-field-icon {
margin-top: 3px;
}
.entry-note-row textarea {
width: 100%;
min-height: 64px;
border: 0;
padding: 0;
outline: 0;
resize: none;
background: transparent;
color: #27342f;
font-size: 14px;
line-height: 1.45;
}
.entry-native-date-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.entry-created-at {
margin: 18px 16px 8px;
color: #8b9693;
text-align: center;
font-size: 12px;
}
.entry-save-error {
margin: 8px 16px;
color: #c94635;
text-align: center;
font-size: 13px;
}
.entry-detail-actions {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 4;
display: grid;
grid-template-columns: 48px 1fr;
gap: 10px;
border-top: 1px solid #d8e5e1;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.97);
backdrop-filter: blur(14px);
}
.entry-detail-actions button {
height: 46px;
border-radius: 8px;
}
.entry-delete-button {
display: grid;
place-items: center;
border: 1px solid #f1c8c2;
background: #fff7f5;
color: #c94635;
}
.entry-save-button {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 0;
background: #087f72;
color: #ffffff;
font-weight: 720;
}
.entry-save-button:disabled {
opacity: 0.65;
}
.entry-category-layer {
position: absolute;
inset: 0;
z-index: 10;
}
.entry-category-scrim {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
background: rgba(18, 35, 32, 0.42);
backdrop-filter: blur(3px);
}
.entry-category-sheet {
position: absolute;
right: 0;
bottom: 0;
left: 0;
max-height: 68%;
overflow-y: auto;
border-radius: 14px 14px 0 0;
padding: 0 16px calc(22px + env(safe-area-inset-bottom));
background: #ffffff;
box-shadow: 0 -16px 36px rgba(22, 45, 40, 0.22);
}
.entry-sheet-handle {
width: 38px;
height: 4px;
margin: 8px auto 3px;
border-radius: 2px;
background: #b9cac5;
}
.entry-category-sheet header {
height: 50px;
display: flex;
align-items: center;
justify-content: space-between;
}
.entry-category-sheet header > strong {
font-size: 16px;
}
.entry-category-sheet header button {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: #f0f5f3;
color: #536762;
}
.entry-category-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px 8px;
}
.entry-category-grid button {
position: relative;
min-width: 0;
display: grid;
place-items: center;
gap: 5px;
border: 0;
padding: 0;
background: transparent;
color: #53615e;
}
.entry-category-grid button > span {
width: 50px;
height: 50px;
display: grid;
place-items: center;
border: 2px solid transparent;
border-radius: 50%;
background: var(--category-tint);
color: var(--category-color);
}
.entry-category-grid button.active > span {
border-color: var(--category-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--category-color) 13%, white);
}
.entry-category-grid button > svg {
position: absolute;
top: 0;
right: max(0px, calc(50% - 30px));
border-radius: 50%;
padding: 2px;
background: var(--category-color);
color: #ffffff;
}
.entry-category-grid small {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-subcategory-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.entry-subcategory-grid button {
min-width: 0;
height: 58px;
display: grid;
grid-template-columns: 32px minmax(0, 1fr) 18px;
align-items: center;
gap: 8px;
border: 1px solid color-mix(in srgb, var(--category-color) 24%, #d8e4e1);
border-radius: 8px;
padding: 0 10px;
background: var(--category-tint);
color: var(--category-color);
text-align: left;
}
.entry-subcategory-grid button.active {
border-color: var(--category-color);
}
.entry-subcategory-grid button > span {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.72);
}
.entry-subcategory-grid strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-detail-toast {
position: absolute;
right: 50%;
bottom: 82px;
z-index: 20;
min-height: 40px;
display: flex;
align-items: center;
gap: 7px;
transform: translateX(50%);
border-radius: 8px;
padding: 0 14px;
background: #173c35;
color: #ffffff;
box-shadow: 0 10px 24px rgba(21, 50, 44, 0.2);
font-size: 13px;
white-space: nowrap;
}
.entry-detail-state {
height: calc(100% - 58px);
display: grid;
place-items: center;
align-content: center;
gap: 10px;
color: #71807c;
}
.entry-detail-state button {
height: 40px;
border: 0;
border-radius: 8px;
padding: 0 14px;
background: #087f72;
color: #ffffff;
}
.sheet-enter-active,
.sheet-leave-active {
transition: opacity 180ms ease;
}
.sheet-enter-active .entry-category-sheet,
.sheet-leave-active .entry-category-sheet {
transition: transform 220ms ease;
}
.sheet-enter-from,
.sheet-leave-to {
opacity: 0;
}
.sheet-enter-from .entry-category-sheet,
.sheet-leave-to .entry-category-sheet {
transform: translateY(100%);
}
@media (max-width: 360px) {
.entry-edit-fields {
padding-right: 12px;
padding-left: 12px;
}
.entry-category-grid {
column-gap: 4px;
}
.entry-subcategory-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,71 @@
<script setup lang="ts">
import { ArrowLeft, Check } from "@lucide/vue";
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useLedgerStore } from "../stores/ledgers";
const colors = ["#087f72", "#3478e5", "#7559d9", "#f05d3f", "#d84486", "#15956d"];
const router = useRouter();
const ledgerStore = useLedgerStore();
const name = ref("");
const color = ref(colors[0]);
const currency = ref<"CNY" | "USD" | "EUR" | "JPY" | "HKD">("CNY");
const saving = ref(false);
onMounted(async () => {
await ledgerStore.loadLedgers();
if (!ledgerStore.currentLedger) return;
name.value = ledgerStore.currentLedger.name;
color.value = ledgerStore.currentLedger.color;
currency.value = ledgerStore.currentLedger.defaultCurrency;
});
async function save() {
if (!ledgerStore.currentLedger || !name.value.trim()) return;
saving.value = true;
await ledgerStore.updateLedger({ id: ledgerStore.currentLedger.id, name: name.value, color: color.value, defaultCurrency: currency.value });
saving.value = false;
await router.back();
}
</script>
<template>
<main class="app-shell ledger-settings-shell">
<header class="settings-appbar">
<button type="button" aria-label="返回" title="返回" @click="router.back()"><ArrowLeft :size="22" /></button>
<strong>账本设置</strong><span></span>
</header>
<form class="settings-form" @submit.prevent="save">
<section>
<label><span>账本名称</span><input v-model="name" maxlength="24" required /></label>
<label><span>默认币种</span><select v-model="currency"><option value="CNY">人民币 CNY</option><option value="USD">美元 USD</option><option value="EUR">欧元 EUR</option><option value="JPY">日元 JPY</option><option value="HKD">港币 HKD</option></select></label>
</section>
<section class="color-setting">
<span>账本颜色</span>
<div>
<button v-for="item in colors" :key="item" type="button" :class="{ active: color === item }" :style="{ background: item }" :aria-label="`选择颜色 ${item}`" @click="color = item"><Check v-if="color === item" :size="17" /></button>
</div>
</section>
<button class="save-ledger-settings" type="submit" :disabled="saving || !name.trim()"><Check :size="19" />{{ saving ? "保存中" : "保存设置" }}</button>
</form>
</main>
</template>
<style scoped>
.ledger-settings-shell { background:#f5f8f7; }
.settings-appbar { height:58px; display:grid; grid-template-columns:44px 1fr 44px; align-items:center; border-bottom:1px solid #dce7e4; padding:0 12px; background:#fff; }
.settings-appbar button { width:42px; height:42px; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#31504a; }
.settings-appbar strong { text-align:center; font-size:17px; }
.settings-form { height:calc(100% - 58px); overflow-y:auto; padding:14px 16px 100px; }
.settings-form section { border-top:1px solid #dce7e4; border-bottom:1px solid #dce7e4; padding:0 14px; background:#fff; }
.settings-form label { min-height:66px; display:grid; gap:4px; border-bottom:1px solid #e5edeb; padding:9px 0; }
.settings-form label:last-child { border-bottom:0; }
.settings-form label span,.color-setting > span { color:#7b8884; font-size:11px; }
.settings-form input,.settings-form select { width:100%; border:0; padding:0; outline:0; background:transparent; color:#26342f; font-size:15px; }
.color-setting { margin-top:12px; padding-top:14px!important; padding-bottom:16px!important; }
.color-setting > div { display:flex; gap:12px; margin-top:12px; }
.color-setting button { width:38px; height:38px; display:grid; place-items:center; border:3px solid #fff; border-radius:50%; color:#fff; box-shadow:0 0 0 1px #d8e4e1; }
.color-setting button.active { box-shadow:0 0 0 2px #263b36; }
.save-ledger-settings { position:absolute; right:16px; bottom:16px; left:16px; height:48px; display:flex; align-items:center; justify-content:center; gap:7px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
.save-ledger-settings:disabled { opacity:.55; }
</style>

View File

@ -0,0 +1,379 @@
<script setup lang="ts">
import type { LedgerEntry } from "@cents/domain";
import {
BookOpen,
ChevronDown,
CircleUserRound,
Copy,
LoaderCircle,
MoreHorizontal,
Search,
Settings,
Share2,
X,
} from "@lucide/vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue";
import { findCategory, findCategoryPath } from "../data/categories";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
const ENTRY_PAGE_SIZE = 50;
const store = useEntryStore();
const ledgerStore = useLedgerStore();
const router = useRouter();
const moreMenuOpen = ref(false);
const participantOpen = ref(false);
const shareOpen = ref(false);
const shareFeedback = ref("");
const visibleCount = ref(ENTRY_PAGE_SIZE);
const ledgerContent = ref<HTMLElement | null>(null);
const loadMoreTrigger = ref<HTMLElement | null>(null);
let loadMoreObserver: IntersectionObserver | null = null;
const monthlyEntries = computed(() => {
const current = new Date();
return store.entries.filter((entry) => {
const occurredAt = new Date(entry.occurredAt);
return entry.ledgerId === ledgerStore.currentLedgerId
&& occurredAt.getFullYear() === current.getFullYear()
&& occurredAt.getMonth() === current.getMonth();
});
});
const totals = computed(() => {
return monthlyEntries.value.reduce(
(result, entry) => {
if (entry.type === "income") result.income += entry.baseAmount;
else result.expense += entry.baseAmount;
return result;
},
{ income: 0, expense: 0 },
);
});
const visibleMonthlyEntries = computed(() => monthlyEntries.value.slice(0, visibleCount.value));
const hasMoreEntries = computed(() => visibleCount.value < monthlyEntries.value.length);
const dailyTotals = computed(() => {
const summaries = new Map<string, { income: number; expense: number }>();
for (const entry of monthlyEntries.value) {
const key = localDateKey(entry.occurredAt);
const summary = summaries.get(key) ?? { income: 0, expense: 0 };
if (entry.type === "income") summary.income += entry.baseAmount;
else summary.expense += entry.baseAmount;
summaries.set(key, summary);
}
return summaries;
});
const groupedEntries = computed(() => {
const groups = new Map<string, LedgerEntry[]>();
for (const entry of visibleMonthlyEntries.value) {
const key = localDateKey(entry.occurredAt);
const list = groups.get(key) ?? [];
list.push(entry);
groups.set(key, list);
}
return Array.from(groups, ([date, entries]) => ({
date,
entries,
income: dailyTotals.value.get(date)?.income ?? 0,
expense: dailyTotals.value.get(date)?.expense ?? 0,
}));
});
const monthTitle = computed(() => {
const date = new Date();
return `${date.getFullYear()}${date.getMonth() + 1}`;
});
const monthListTitle = computed(() => {
const date = new Date();
return `${date.getFullYear()}${date.getMonth() + 1}月流水`;
});
onMounted(async () => {
await Promise.all([store.loadEntries(), ledgerStore.loadLedgers()]);
});
watch(monthlyEntries, () => {
visibleCount.value = ENTRY_PAGE_SIZE;
});
watch([ledgerContent, loadMoreTrigger], ([root, trigger]) => {
loadMoreObserver?.disconnect();
loadMoreObserver = null;
if (!root || !trigger) return;
loadMoreObserver = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
visibleCount.value = Math.min(visibleCount.value + ENTRY_PAGE_SIZE, monthlyEntries.value.length);
}
},
{ root, rootMargin: "160px 0px" },
);
loadMoreObserver.observe(trigger);
});
onBeforeUnmount(() => loadMoreObserver?.disconnect());
function localDateKey(value: string) {
const date = new Date(value);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function dateHeading(value: string) {
const date = new Date(`${value}T00:00:00`);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const label = localDateKey(today.toISOString()) === value
? "今天"
: localDateKey(yesterday.toISOString()) === value
? "昨天"
: `${date.getMonth() + 1}${date.getDate()}`;
const weekday = new Intl.DateTimeFormat("zh-CN", { weekday: "short" }).format(date);
return `${label} · ${weekday}`;
}
function formatMoney(value: number) {
return (value / 100).toLocaleString("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function formatTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
}
function categoryLabel(entry: LedgerEntry) {
const path = findCategoryPath(entry.type, entry.categoryId);
return path.map((item) => item.label).join(" · ") || (entry.type === "expense" ? "支出" : "收入");
}
function openParticipantSheet() {
participantOpen.value = true;
}
function openShareSheet() {
moreMenuOpen.value = false;
shareFeedback.value = "";
shareOpen.value = true;
}
function openLedgerSettings() {
moreMenuOpen.value = false;
router.push("/ledger/settings");
}
function invitationUrl() {
const url = new URL(window.location.origin);
url.searchParams.set("invite", ledgerStore.currentLedgerId);
return url.toString();
}
async function copyInvitation() {
const link = invitationUrl();
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(link);
else {
const input = document.createElement("textarea");
input.value = link;
document.body.append(input);
input.select();
document.execCommand("copy");
input.remove();
}
shareFeedback.value = "邀请链接已复制";
}
async function shareLedger() {
const data = {
title: ledgerStore.currentLedger?.name ?? "账本",
text: `邀请你加入我的${ledgerStore.currentLedger?.name ?? "账本"}`,
url: invitationUrl(),
};
if (!navigator.share) {
await copyInvitation();
return;
}
try {
await navigator.share(data);
shareFeedback.value = "已打开系统分享";
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
shareFeedback.value = "分享失败,请复制邀请链接";
}
}
</script>
<template>
<main class="app-shell">
<header class="ledger-header">
<div class="top-actions">
<button class="participant-avatars" type="button" aria-label="查看账本参与者" title="账本参与者" @click="openParticipantSheet">
<span class="participant-avatar owner"></span>
<span class="participant-avatar member-wang"></span>
<span class="participant-avatar member-li"></span>
</button>
<button class="ledger-switcher" type="button" @click="router.push('/ledgers')">
<span>{{ ledgerStore.currentLedger?.name ?? "选择账本" }}</span>
<ChevronDown :size="16" />
</button>
<button class="icon-button header-icon" type="button" aria-label="更多" title="更多" @click.stop="moreMenuOpen = true">
<MoreHorizontal :size="21" />
</button>
</div>
<Transition name="more-menu">
<div v-if="moreMenuOpen" class="ledger-more-layer">
<button class="ledger-more-scrim" type="button" aria-label="关闭更多菜单" @click="moreMenuOpen = false"></button>
<div class="ledger-more-menu" role="menu">
<button type="button" role="menuitem" @click="openLedgerSettings"><Settings :size="18" />设置</button>
<button type="button" role="menuitem" @click="openShareSheet"><Share2 :size="18" />分享</button>
</div>
</div>
</Transition>
<button class="month-switcher" type="button">
{{ monthTitle }}
<ChevronDown :size="14" />
</button>
<section class="monthly-summary" aria-label="本月收支">
<div class="summary-main">
<span>本月结余</span>
<strong>¥ {{ formatMoney(totals.income - totals.expense) }}</strong>
</div>
<div class="summary-stat income-stat">
<span>收入</span>
<strong>{{ formatMoney(totals.income) }}</strong>
</div>
<div class="summary-stat expense-stat">
<span>支出</span>
<strong>{{ formatMoney(totals.expense) }}</strong>
</div>
</section>
</header>
<section ref="ledgerContent" class="ledger-content" aria-label="账本流水">
<div class="list-toolbar">
<div class="list-toolbar-copy">
<div class="list-toolbar-heading">
<strong>{{ monthListTitle }}</strong>
<span>{{ monthlyEntries.length }} </span>
</div>
<div class="list-toolbar-totals">
<span class="income">收入 {{ formatMoney(totals.income) }}</span>
<span class="expense">支出 {{ formatMoney(totals.expense) }}</span>
</div>
</div>
<button class="icon-button list-action" type="button" aria-label="搜索流水" title="搜索流水">
<Search :size="19" />
</button>
</div>
<section v-for="group in groupedEntries" :key="group.date" class="day-group">
<div class="day-group-heading">
<h2>{{ dateHeading(group.date) }}</h2>
<div class="day-summary">
<span class="income"> {{ formatMoney(group.income) }}</span>
<span class="expense"> {{ formatMoney(group.expense) }}</span>
</div>
</div>
<RouterLink v-for="entry in group.entries" :key="entry.id" class="entry-row" :to="`/entries/${entry.id}`">
<div
class="entry-category-icon"
:style="{
color: findCategory(entry.categoryId)?.color ?? (entry.type === 'expense' ? '#ef5b3f' : '#16966f'),
background: findCategory(entry.categoryId)?.tint ?? (entry.type === 'expense' ? '#fff0ec' : '#e9faf4'),
}"
>
<component :is="findCategory(entry.categoryId)?.icon ?? BookOpen" :size="21" />
</div>
<div class="entry-copy">
<strong>{{ entry.note || categoryLabel(entry) }}</strong>
<div class="entry-subline">
<span class="entry-recorder"><CircleUserRound :size="12" />小王</span>
<span class="entry-category-label">{{ categoryLabel(entry) }}</span>
</div>
</div>
<div class="entry-value">
<strong :class="entry.type">
{{ entry.type === "expense" ? "" : "+" }}{{ formatMoney(entry.baseAmount) }}
</strong>
<span>{{ formatTime(entry.occurredAt) }}</span>
</div>
</RouterLink>
</section>
<div v-if="hasMoreEntries" ref="loadMoreTrigger" class="load-more-sentinel" aria-live="polite">
<LoaderCircle :size="17" />
正在加载更多流水
</div>
<div v-if="!monthlyEntries.length" class="empty-state">
<BookOpen :size="26" />
<strong>还没有流水</strong>
<span>从第一笔开始记录</span>
</div>
</section>
<Transition name="share-sheet">
<div v-if="participantOpen" class="share-sheet-layer">
<button class="share-sheet-scrim" type="button" aria-label="关闭参与者列表" @click="participantOpen = false"></button>
<section class="ledger-share-sheet participant-sheet" aria-label="账本参与者">
<div class="share-sheet-handle"></div>
<header>
<div>
<strong>{{ ledgerStore.currentLedger?.name ?? "账本" }}</strong>
<span>3 位参与者</span>
</div>
<button type="button" aria-label="关闭" title="关闭" @click="participantOpen = false"><X :size="20" /></button>
</header>
<div class="share-member-list" aria-label="当前参与者">
<div class="share-member-row">
<span class="share-member-avatar owner"></span>
<span><strong>自己</strong><small>拥有者</small></span>
</div>
<div class="share-member-row">
<span class="share-member-avatar member-wang"></span>
<span><strong>小王</strong><small>成员</small></span>
</div>
<div class="share-member-row">
<span class="share-member-avatar member-li"></span>
<span><strong>小李</strong><small>成员</small></span>
</div>
</div>
</section>
</div>
</Transition>
<Transition name="share-sheet">
<div v-if="shareOpen" class="share-sheet-layer">
<button class="share-sheet-scrim" type="button" aria-label="关闭账本分享" @click="shareOpen = false"></button>
<section class="ledger-share-sheet compact-share-sheet" aria-label="账本分享">
<div class="share-sheet-handle"></div>
<header>
<div>
<strong>分享 {{ ledgerStore.currentLedger?.name ?? "账本" }}</strong>
<span>邀请成员加入当前账本</span>
</div>
<button type="button" aria-label="关闭" title="关闭" @click="shareOpen = false"><X :size="20" /></button>
</header>
<div class="share-actions">
<button type="button" class="copy-invite-action" @click="copyInvitation"><Copy :size="18" />复制链接</button>
<button type="button" class="system-share-action" @click="shareLedger"><Share2 :size="18" />分享账本</button>
</div>
<p class="share-feedback" role="status">{{ shareFeedback || "通过邀请链接加入此账本" }}</p>
</section>
</div>
</Transition>
<QuickEntryHost />
</main>
</template>

View File

@ -0,0 +1,63 @@
<script setup lang="ts">
import { Check, ChevronRight, LibraryBig } from "@lucide/vue";
import { computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
const router = useRouter();
const entryStore = useEntryStore();
const ledgerStore = useLedgerStore();
const entryCounts = computed(() => {
const counts = new Map<string, number>();
for (const entry of entryStore.entries) counts.set(entry.ledgerId, (counts.get(entry.ledgerId) ?? 0) + 1);
return counts;
});
onMounted(async () => {
await Promise.all([entryStore.loadEntries(), ledgerStore.loadLedgers()]);
});
async function selectLedger(ledgerId: string) {
await ledgerStore.setCurrentLedger(ledgerId);
await router.push("/");
}
</script>
<template>
<main class="app-shell ledgers-shell">
<header class="section-appbar"><strong>账本</strong><span>{{ ledgerStore.ledgers.length }} </span></header>
<div class="ledgers-scroll">
<p class="ledgers-caption">当前账本决定流水统计和快速记账的默认归属</p>
<section class="ledger-list" aria-label="全部账本">
<button v-for="ledger in ledgerStore.ledgers" :key="ledger.id" type="button" @click="selectLedger(ledger.id)">
<span class="ledger-list-icon" :style="{ color: ledger.color, background: `${ledger.color}18` }"><LibraryBig :size="22" /></span>
<span><strong>{{ ledger.name }}</strong><small>{{ entryCounts.get(ledger.id) ?? 0 }} 笔流水 · {{ ledger.defaultCurrency }}</small></span>
<Check v-if="ledgerStore.currentLedgerId === ledger.id" class="current-ledger-check" :size="20" />
<ChevronRight v-else :size="19" />
</button>
</section>
</div>
<QuickEntryHost />
</main>
</template>
<style scoped>
.ledgers-shell { background:#f5f8f7; }
.section-appbar { height:70px; display:flex; align-items:end; justify-content:space-between; border-bottom:1px solid #dce7e4; padding:14px 18px 12px; background:#fff; }
.section-appbar strong { font-size:20px; }
.section-appbar span { color:#7b8884; font-size:12px; }
.ledgers-scroll { height:calc(100% - 70px); overflow-y:auto; padding:0 16px 104px; }
.ledgers-caption { margin:16px 0 10px; color:#70807c; font-size:12px; }
.ledger-list { border-top:1px solid #dce7e4; border-bottom:1px solid #dce7e4; background:#fff; }
.ledger-list button { width:100%; min-height:72px; display:grid; grid-template-columns:44px minmax(0,1fr) 22px; align-items:center; gap:11px; border:0; border-bottom:1px solid #e6eeec; padding:8px 12px; background:#fff; color:#26342f; text-align:left; }
.ledger-list button:last-child { border-bottom:0; }
.ledger-list-icon { width:42px; height:42px; display:grid; place-items:center; border-radius:9px; }
.ledger-list button > span:nth-child(2) { min-width:0; display:grid; gap:2px; }
.ledger-list strong { overflow:hidden; font-size:15px; text-overflow:ellipsis; white-space:nowrap; }
.ledger-list small { color:#7b8884; font-size:11px; }
.current-ledger-check { color:#087f72; }
@media (max-width:360px) { .ledgers-scroll { padding-right:12px; padding-left:12px; } }
</style>

View File

@ -0,0 +1,36 @@
<script setup lang="ts">
import { Bell, ChevronRight, CircleUserRound, Cloud, ShieldCheck } from "@lucide/vue";
import QuickEntryHost from "../components/QuickEntryHost.vue";
</script>
<template>
<main class="app-shell me-shell">
<header class="me-header"><strong>我的</strong></header>
<div class="me-scroll">
<section class="profile-band"><span><CircleUserRound :size="32" /></span><div><strong>自己</strong><small>本地用户</small></div></section>
<section class="me-settings">
<button type="button"><Bell :size="19" /><span>通知与提醒</span><ChevronRight :size="18" /></button>
<button type="button"><Cloud :size="19" /><span>同步状态</span><ChevronRight :size="18" /></button>
<button type="button"><ShieldCheck :size="19" /><span>隐私与安全</span><ChevronRight :size="18" /></button>
</section>
</div>
<QuickEntryHost />
</main>
</template>
<style scoped>
.me-shell { background:#f5f8f7; }
.me-header { height:70px; display:flex; align-items:end; border-bottom:1px solid #dce7e4; padding:14px 18px 12px; background:#fff; }
.me-header strong { font-size:20px; }
.me-scroll { height:calc(100% - 70px); overflow-y:auto; padding:14px 16px 104px; }
.profile-band { min-height:94px; display:grid; grid-template-columns:58px 1fr; align-items:center; gap:12px; border-top:1px solid #dce7e4; border-bottom:1px solid #dce7e4; padding:12px 14px; background:#fff; }
.profile-band > span { width:54px; height:54px; display:grid; place-items:center; border-radius:50%; background:#e8f7f3; color:#087f72; }
.profile-band > div { display:grid; gap:2px; }
.profile-band strong { font-size:16px; }
.profile-band small { color:#7b8884; font-size:11px; }
.me-settings { margin-top:12px; border-top:1px solid #dce7e4; border-bottom:1px solid #dce7e4; padding:0 14px; background:#fff; }
.me-settings button { width:100%; min-height:58px; display:grid; grid-template-columns:28px 1fr 20px; align-items:center; gap:8px; border:0; border-bottom:1px solid #e5edeb; padding:0; background:#fff; color:#31504a; text-align:left; }
.me-settings button:last-child { border-bottom:0; }
.me-settings span { color:#26342f; font-size:14px; }
@media (max-width:360px) { .me-scroll { padding-right:12px; padding-left:12px; } }
</style>

View File

@ -0,0 +1,211 @@
<script setup lang="ts">
import type { LedgerEntry } from "@cents/domain";
import { ChevronDown, CircleUserRound, TrendingDown, TrendingUp, WalletCards } from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue";
import { findCategory } from "../data/categories";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
type StatsTab = "flow" | "category" | "member";
const router = useRouter();
const entryStore = useEntryStore();
const ledgerStore = useLedgerStore();
const activeTab = ref<StatsTab>("flow");
const monthLabel = computed(() => {
const date = new Date();
return `${date.getFullYear()}${date.getMonth() + 1}`;
});
const entries = computed(() => {
const current = new Date();
return entryStore.entries.filter((entry) => {
const occurredAt = new Date(entry.occurredAt);
return entry.ledgerId === ledgerStore.currentLedgerId
&& occurredAt.getFullYear() === current.getFullYear()
&& occurredAt.getMonth() === current.getMonth();
});
});
const totals = computed(() => summarize(entries.value));
const dailyStats = computed(() => {
const groups = new Map<string, LedgerEntry[]>();
for (const entry of entries.value) {
const date = new Date(entry.occurredAt);
const key = `${date.getMonth() + 1}/${date.getDate()}`;
groups.set(key, [...(groups.get(key) ?? []), entry]);
}
return Array.from(groups, ([label, items]) => ({ label, ...summarize(items) })).reverse();
});
const maxDailyAmount = computed(() =>
Math.max(1, ...dailyStats.value.flatMap((day) => [day.income, day.expense])),
);
const categoryStats = computed(() => {
const groups = new Map<string, { amount: number; count: number }>();
for (const entry of entries.value) {
const current = groups.get(entry.categoryId) ?? { amount: 0, count: 0 };
current.amount += entry.baseAmount;
current.count += 1;
groups.set(entry.categoryId, current);
}
return Array.from(groups, ([categoryId, summary]) => ({
categoryId,
category: findCategory(categoryId),
...summary,
})).sort((left, right) => right.amount - left.amount);
});
const maxCategoryAmount = computed(() => Math.max(1, ...categoryStats.value.map((item) => item.amount)));
onMounted(async () => {
await Promise.all([entryStore.loadEntries(), ledgerStore.loadLedgers()]);
});
function summarize(items: LedgerEntry[]) {
return items.reduce(
(result, entry) => {
if (entry.type === "income") result.income += entry.baseAmount;
else result.expense += entry.baseAmount;
return result;
},
{ income: 0, expense: 0 },
);
}
function money(value: number) {
return (value / 100).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
</script>
<template>
<main class="app-shell stats-shell">
<header class="stats-header">
<strong>统计</strong>
<button type="button" @click="router.push('/ledgers')">
{{ ledgerStore.currentLedger?.name ?? "选择账本" }}
<ChevronDown :size="15" />
</button>
</header>
<div class="stats-scroll">
<button class="stats-month" type="button">{{ monthLabel }}<ChevronDown :size="14" /></button>
<section class="stats-summary" aria-label="本月统计">
<div>
<span>结余</span>
<strong>¥ {{ money(totals.income - totals.expense) }}</strong>
</div>
<div class="income"><TrendingUp :size="18" /><span>收入</span><strong>{{ money(totals.income) }}</strong></div>
<div class="expense"><TrendingDown :size="18" /><span>支出</span><strong>{{ money(totals.expense) }}</strong></div>
</section>
<div class="stats-tabs" role="tablist" aria-label="统计维度">
<button type="button" :class="{ active: activeTab === 'flow' }" @click="activeTab = 'flow'">收支</button>
<button type="button" :class="{ active: activeTab === 'category' }" @click="activeTab = 'category'">分类</button>
<button type="button" :class="{ active: activeTab === 'member' }" @click="activeTab = 'member'">记录者</button>
</div>
<section v-if="activeTab === 'flow'" class="stats-section" aria-label="每日收支">
<header><strong>每日收支</strong><span>{{ entries.length }} </span></header>
<div v-for="day in dailyStats" :key="day.label" class="daily-stat-row">
<span>{{ day.label }}</span>
<div class="daily-bars">
<i class="income" :style="{ width: `${(day.income / maxDailyAmount) * 100}%` }"></i>
<i class="expense" :style="{ width: `${(day.expense / maxDailyAmount) * 100}%` }"></i>
</div>
<div><small class="income">+{{ money(day.income) }}</small><small class="expense">{{ money(day.expense) }}</small></div>
</div>
</section>
<section v-else-if="activeTab === 'category'" class="stats-section" aria-label="分类统计">
<header><strong>分类金额</strong><span>{{ categoryStats.length }} </span></header>
<div v-for="item in categoryStats" :key="item.categoryId" class="category-stat-row">
<span
class="category-stat-icon"
:style="{ color: item.category?.color ?? '#087f72', background: item.category?.tint ?? '#e9f7f3' }"
>
<component :is="item.category?.icon ?? WalletCards" :size="19" />
</span>
<div>
<strong>{{ item.category?.label ?? "未分类" }}</strong>
<i :style="{ width: `${(item.amount / maxCategoryAmount) * 100}%`, background: item.category?.color ?? '#087f72' }"></i>
</div>
<span><strong>{{ money(item.amount) }}</strong><small>{{ item.count }} </small></span>
</div>
</section>
<section v-else class="stats-section" aria-label="记录者统计">
<header><strong>记录者</strong><span>1 </span></header>
<div class="member-stat-row">
<span><CircleUserRound :size="22" /></span>
<div><strong>小王</strong><small>{{ entries.length }} 笔记录</small></div>
<div><small class="income">收入 {{ money(totals.income) }}</small><small class="expense">支出 {{ money(totals.expense) }}</small></div>
</div>
</section>
<div v-if="!entries.length" class="stats-empty">当前月份还没有流水</div>
</div>
<QuickEntryHost />
</main>
</template>
<style scoped>
.stats-shell { background: #f5f8f7; }
.stats-header { height: 74px; display: flex; align-items: center; justify-content: space-between; padding: 14px 18px 8px; background: #087f72; color: #fff; }
.stats-header > strong { font-size: 20px; }
.stats-header button { display: flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 7px 9px; background: rgba(255,255,255,.14); color: #fff; font-size: 13px; }
.stats-scroll { height: calc(100% - 74px); overflow-y: auto; padding: 0 16px 104px; }
.stats-month { height: 46px; display: flex; align-items: center; gap: 5px; border: 0; padding: 0; background: transparent; color: #53645f; font-size: 13px; }
.stats-summary { display: grid; grid-template-columns: 1.35fr 1fr 1fr; align-items: center; gap: 10px; border-top: 1px solid #dce7e4; border-bottom: 1px solid #dce7e4; padding: 15px 0; background: #fff; }
.stats-summary > div { min-width: 0; display: grid; gap: 2px; padding: 0 12px; border-left: 1px solid #e1ebe8; }
.stats-summary > div:first-child { border-left: 0; }
.stats-summary span { color: #788581; font-size: 11px; }
.stats-summary strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.stats-summary > div:first-child strong { font-size: 19px; }
.stats-summary .income { color: #0d8b67; }
.stats-summary .expense { color: #d84c36; }
.stats-tabs { display: grid; grid-template-columns: repeat(3,1fr); gap: 4px; margin: 14px 0 10px; border-radius: 8px; padding: 4px; background: #e7eeec; }
.stats-tabs button { height: 38px; border: 0; border-radius: 6px; background: transparent; color: #71807c; font-size: 13px; font-weight: 680; }
.stats-tabs button.active { background: #fff; color: #087f72; box-shadow: 0 2px 7px rgba(28,52,47,.12); }
.stats-section { border-top: 1px solid #dce7e4; border-bottom: 1px solid #dce7e4; background: #fff; }
.stats-section > header { height: 48px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e4ece9; padding: 0 12px; }
.stats-section > header strong { font-size: 14px; }
.stats-section > header span { color: #7b8884; font-size: 11px; }
.daily-stat-row { min-height: 58px; display: grid; grid-template-columns: 34px minmax(0,1fr) 82px; align-items: center; gap: 9px; border-bottom: 1px solid #edf2f0; padding: 8px 12px; }
.daily-stat-row:last-child { border-bottom: 0; }
.daily-stat-row > span { color: #667570; font-size: 11px; }
.daily-bars { display: grid; gap: 5px; }
.daily-bars i { height: 5px; min-width: 2px; border-radius: 3px; }
.daily-bars i.income { background: #20a57d; }
.daily-bars i.expense { background: #ef6a4d; }
.daily-stat-row > div:last-child { display: grid; text-align: right; }
.daily-stat-row small { font-size: 10px; font-variant-numeric: tabular-nums; }
.income { color: #0d8b67; }
.expense { color: #d84c36; }
.category-stat-row { min-height: 64px; display: grid; grid-template-columns: 38px minmax(0,1fr) auto; align-items: center; gap: 10px; border-bottom: 1px solid #edf2f0; padding: 8px 12px; }
.category-stat-row:last-child { border-bottom: 0; }
.category-stat-icon { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 8px; }
.category-stat-row > div { min-width: 0; display: grid; gap: 7px; }
.category-stat-row > div strong { font-size: 13px; }
.category-stat-row > div i { height: 4px; min-width: 2px; border-radius: 2px; }
.category-stat-row > span:last-child { display: grid; text-align: right; }
.category-stat-row > span:last-child strong { font-size: 12px; }
.category-stat-row small { color: #7d8986; font-size: 10px; }
.member-stat-row { min-height: 72px; display: grid; grid-template-columns: 42px minmax(0,1fr) auto; align-items: center; gap: 10px; padding: 10px 12px; }
.member-stat-row > span { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 50%; background: #edf4ff; color: #3478e5; }
.member-stat-row > div { display: grid; }
.member-stat-row > div:last-child { text-align: right; }
.member-stat-row strong { font-size: 14px; }
.member-stat-row small { color: #7b8884; font-size: 10px; }
.member-stat-row small.income { color: #0d8b67; }
.member-stat-row small.expense { color: #d84c36; }
.stats-empty { min-height: 180px; display: grid; place-items: center; color: #7b8884; font-size: 13px; }
@media (max-width:360px) { .stats-scroll { padding-right: 12px; padding-left: 12px; } .stats-summary > div { padding: 0 8px; } .stats-summary > div:first-child strong { font-size: 17px; } }
</style>

10
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}

9
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,9 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [vue()],
server: {
allowedHosts: ["dev.cents.homemade.net.cn"],
},
});

34
compose.dev.yaml Normal file
View File

@ -0,0 +1,34 @@
services:
frpc:
image: homemade/auto-cert/proxy:latest
restart: unless-stopped
environment:
DOMAIN: dev.cents.homemade.net.cn
APP_HOST: host.docker.internal
APP_PORT: "6064"
APP_PROTOCOL: http
FRPC_SERVER_HOST: 119.28.12.24
FRPC_SERVER_PORT: "7000"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- frp_cert_volume:/etc/letsencrypt
networks:
- frp_dev
cert:
image: homemade/auto-cert/cert:latest
restart: "no"
command: --domain dev.cents.homemade.net.cn --test-mode false
volumes:
- frp_cert_volume:/etc/letsencrypt
networks:
- frp_dev
volumes:
frp_cert_volume:
networks:
frp_dev:
driver: bridge

34
compose.frp-prod.yml Normal file
View File

@ -0,0 +1,34 @@
services:
frpc:
image: homemade/auto-cert/proxy:latest
restart: unless-stopped
environment:
DOMAIN: ${DOMAIN}
APP_HOST: ${APP_HOST}
APP_PORT: ${APP_PORT}
APP_PROTOCOL: ${APP_PROTOCOL}
FRPC_SERVER_HOST: ${FRPC_SERVER_HOST}
FRPC_SERVER_PORT: ${FRPC_SERVER_PORT}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- frp_cert_volume:/etc/letsencrypt
networks:
- frp_prod
cert:
image: homemade/auto-cert/cert:latest
restart: "no"
command: --domain ${DOMAIN} --test-mode ${TEST_MODE}
volumes:
- frp_cert_volume:/etc/letsencrypt
networks:
- frp_prod
volumes:
frp_cert_volume:
networks:
frp_prod:
driver: bridge

View File

@ -0,0 +1,9 @@
# Copy this to the public VPS and adapt it for frps.
bindPort = 7000
vhostHTTPPort = 80
vhostHTTPSPort = 443
# If your frps requires authentication, configure it on both frps and frpc.
# auth.method = "token"
# auth.token = "replace-with-a-secret-token"

View File

@ -0,0 +1,228 @@
# 二期:报销与退款
## 背景
家庭账本里有一类收入并不代表真实新增收入,而是对过去支出的抵消,例如:
- 公司报销餐费、交通费、差旅费。
- 商家退款、退货退款、平台补贴返还。
- AA 或代付后,其他成员转回款项。
首版只记录普通收入和支出。二期需要支持“收入关联原支出”,让流水、统计和净支出更接近真实消费。
## 目标
- 报销和退款在录入时仍作为收入类型保存。
- 输入金额后,系统可以推荐匹配的历史支出。
- 用户可以把一笔报销或退款关联到一笔或多笔支出。
- 流水中能看出某笔收入是报销或退款,以及它关联了哪些支出。
- 月度统计能区分总支出、报销/退款抵扣、净支出。
## 非目标
- 不做企业报销流程审批。
- 不做发票、附件、小票 OCR。
- 不做复杂应收应付和债务结算。
- 不自动修改原支出金额。
- 不要求系统自动判断 100% 正确,必须允许用户手动选择或取消关联。
## 概念定义
### 普通收入
真实增加可支配金额的收入,例如工资、奖金、利息。
### 报销
对已发生支出的补偿。通常来自公司、组织或共同生活成员。
示例:用户先记一笔 `交通 120 元`,之后收到公司报销 `120 元`
### 退款
商家、平台或交易对方退回的金额。通常对应购物、服务、押金等历史支出。
示例:用户先记一笔 `购物 299 元`,之后退货收到 `299 元`
### 关联支出
被报销或退款收入抵消的原始支出记录。
## 录入流程
### 快速记账入口
收入类型下增加收入子类型:
- 普通收入
- 报销
- 退款
用户选择 `报销``退款` 后:
1. 输入收入金额。
2. 系统根据金额、时间、分类、备注、付款人推荐历史支出。
3. 用户选择一笔或多笔支出。
4. 用户确认保存。
5. 新收入记录保存,并建立与原支出的关联。
### 推荐匹配
默认推荐范围:
- 同一账本内。
- 未删除支出。
- 发生时间早于或等于当前报销/退款时间。
- 最近 180 天内优先。
- 金额相同或接近优先。
推荐排序建议:
1. 金额完全相同。
2. 金额差额较小。
3. 日期更近。
4. 分类更相关。
5. 备注文本有相似词。
6. 同一付款人。
### 手动关联
用户必须能:
- 搜索历史支出。
- 改选推荐结果。
- 选择多笔支出。
- 不关联任何支出,先保存为未关联报销/退款。
- 保存后再补充或修改关联。
## 金额规则
一笔报销或退款可以关联:
- 一笔支出。
- 多笔支出。
- 一笔支出的一部分金额。
关联金额需要单独记录,不能只靠收入金额和支出金额推导。
示例:
- 支出 `餐饮 100 元`
- 报销 `80 元`
- 关联金额为 `80 元`,原支出仍显示 `100 元`
多笔关联示例:
- 支出 A `交通 60 元`
- 支出 B `餐饮 40 元`
- 报销 `100 元`
- 报销记录同时关联 A 和 B。
## 统计规则
二期统计至少区分:
- 总收入:包含普通收入、报销、退款。
- 普通收入:只包含真实收入。
- 总支出:原始支出合计,不被报销/退款改写。
- 报销/退款抵扣:已关联到支出的报销和退款金额。
- 净支出:总支出减去报销/退款抵扣。
首选展示方式:
- 流水仍显示原始收入和支出,保持账目真实发生。
- 汇总页突出 `净支出`
- 报销和退款作为收入展示,但使用不同标签,避免被误认为工资等普通收入。
## 流水展示
报销/退款收入行应显示:
- 金额为正数。
- 类型标签:报销或退款。
- 关联状态:已关联、部分关联、未关联。
- 关联的原支出摘要。
原支出行应显示:
- 原始支出金额不变。
- 若已有报销/退款抵扣,显示抵扣金额。
- 可进入详情查看关联收入。
状态建议:
- `未关联`:没有任何关联支出。
- `部分抵扣`:关联金额小于原支出金额。
- `已抵扣`:关联金额大于或等于原支出金额。
## 数据模型建议
### entry 扩展
`entry` 上增加收入子类型字段:
- `income_kind``regular`、`reimbursement`、`refund`。
约束:
- `type = income` 时可填写 `income_kind`
- `type = expense``income_kind` 为空。
- 兼容旧数据时,收入默认视为 `regular`
### entry_link
新增关联表:
- `id`:客户端生成 UUID。
- `ledger_id`
- `source_entry_id`:报销/退款收入。
- `target_entry_id`:被关联支出。
- `link_type``reimbursement` 或 `refund`
- `amount`:本次关联金额,整数分。
- `created_by`
- `created_at`
- `updated_at`
- `deleted_at`
- `version`
约束:
- `source_entry_id` 必须指向收入。
- `target_entry_id` 必须指向支出。
- 两条账目必须属于同一账本。
- 关联金额必须大于 0。
- 同一收入关联多笔支出的金额合计不应超过收入金额,除非后续明确支持超额报销。
## 同步与离线
报销/退款关联也必须离线可用:
- 新增、修改、删除关联写入本地 IndexedDB。
- 每次关联变更生成同步操作。
- 服务端按 `operation_id` 幂等处理。
- 删除账目时不硬删除关联,使用软删除。
冲突策略二期先保持简单:
- 同一条关联被多设备修改时,最后写入胜出。
- 如果原支出或收入已删除,关联保留软删除或标记失效。
- 汇总统计只计算未删除且有效的关联。
## 原型需求
需要补充这些原型:
- 快速记账收入模式下的 `普通收入 / 报销 / 退款` 切换。
- 输入金额后的候选支出匹配列表。
- 手动搜索并选择历史支出。
- 一笔收入关联多笔支出的选择态。
- 流水中报销/退款和原支出的关联展示。
- 账目详情中的关联管理。
## 待决策问题
- 报销和退款是否共用同一套分类,还是独立于普通收入分类。
- 未关联的报销/退款是否计入净支出抵扣。
- 关联金额是否允许超过原支出剩余未抵扣金额。
- AA 或代付回款是否归入报销,还是后续独立成“代付结算”。
- 统计页默认展示总支出还是净支出。

70
docs/产品边界.md Normal file
View File

@ -0,0 +1,70 @@
# 产品边界
## 定位
家庭账本。重点是快速记账、账本权限、离线可用、恢复网络后同步。
## 用户
- 家庭、伴侣、合租、小型共同开支群体。
- 默认 2-8 人。
- 不面向大规模公开推广。
## 首版必须有
### 账号与访问
- 不开放自助注册。
- 后台分配用户名密码。
- 设备 ID 审批作为备选方案。
- 用户只能访问自己有权限的账本。
### 账本
- 创建账本。
- 查看账本流水。
- 账本可以独享。
- 账本可以邀请成员共享。
- 账本成员默认共享该账本的全量数据。
- 首版权限只区分 `owner``member`
### 邀请
- 账本 owner 可以邀请成员。
- 邀请可以通过链接或邀请码完成。
- 邀请需要过期时间。
- 被邀请用户加入后才能看到该账本。
### 记账
- 记录收入。
- 记录支出。
- 字段:金额、分类、日期、备注、付款人。
- 只记录流水,不做分摊和结算。
### 离线
- 无网络时可以新增、编辑、删除账目。
- 网络恢复后自动同步。
- 多设备同步有权限访问的账本。
### 汇总
- 按月查看流水。
- 按月查看分类汇总。
## 首版暂缓
- 外币记账。
- 汇率换算。
- 预算。
- 资产账户余额。
- 转账。
- 周期记账。
- 附件和图片小票。
- OCR、语音记账、AI 分类。
- 复杂成员权限。
- 导入导出。
- 专门的桌面大屏布局。
- 分摊和结算。
- 报销、退款和收入关联原支出,见 [二期:报销与退款](二期-报销与退款.md)。

View File

@ -0,0 +1,87 @@
# 内网穿透调试
## 目标
先用调试模式验证 PWA 可以通过公网域名访问。
模式:
- VPS 运行 `frps`
- 本机运行 Vite 开发服务。
- 本机运行 `frpc``cert` sidecar。
- `cert` 使用 Let's Encrypt 正式证书。
- `frpc` 通过 HTTPS 转发到本机 `6064`
## 前置条件
- 有一个域名,例如 `cents.example.com`
- 域名 A/AAAA 记录指向 VPS。
- VPS 开放 `80`、`443` 和 `7000`
- VPS 的 `frps` 配置了 HTTP/HTTPS vhost 端口。
参考配置:
- `deploy/frp/frps.example.toml`
## 本机配置
调试模式不读取 `.env`,配置直接写在 `compose.dev.yaml`
- `DOMAIN=dev.cents.homemade.net.cn`
- `FRPC_SERVER_HOST=119.28.12.24`
- `FRPC_SERVER_PORT=7000`
- `APP_HOST=host.docker.internal`
- `APP_PORT=6064`
- `APP_PROTOCOL=http`
- `TEST_MODE=false`
## 启动
先启动前端:
```bash
npm run dev --workspace @cents/web -- --port 6064
```
再启动内网穿透:
```bash
docker compose -f compose.dev.yaml up -d
```
查看日志:
```bash
docker compose -f compose.dev.yaml logs -f frpc cert
```
## 验证
```bash
curl -I http://$DOMAIN/.well-known/
curl -vkI https://$DOMAIN
```
预期:
- `cert` 能完成 HTTP-01 校验。
- `/etc/letsencrypt/live/dev.cents.homemade.net.cn/` 中生成正式证书。
- 浏览器可以通过 `https://$DOMAIN` 访问本地 PWA。
## 生产模式
`.env` 留给生产模式使用:
```bash
cp .env.example .env
docker compose -f compose.frp-prod.yml up -d
```
不要提交真实 `.env`
## 常见问题
- 证书申请失败:检查域名 DNS、VPS 80 端口、frps vhost HTTP 配置。
- HTTPS 访问失败:检查 frps 443 端口和 frpc 日志。
- 后端连不上:确认本机 Vite 服务正在监听 `6064`
- frps 有 token需要同步配置 frps 和 frpc不要把真实 token 提交到仓库。

View File

@ -0,0 +1,175 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>分类轮盘步骤示意</title>
<link rel="stylesheet" href="./原型样式.css" />
</head>
<body>
<main class="flow-stage">
<section class="flow-intro">
<h1>分类轮盘步骤示意</h1>
<p>快速记账在账本流水页底部抽屉内完成。轮盘是临时选择态,不能把所有层级同时放在一个画面里。</p>
</section>
<div class="flow-grid">
<section class="phone flow-phone">
<header class="mini-appbar">
<span>1</span>
<strong>抽屉默认态</strong>
</header>
<div class="drawer-demo">
<div class="drawer-handle" aria-hidden="true"></div>
<div class="amount-line drawer-amount-line">
<strong>58.00</strong>
</div>
<div class="selection-row">
<div class="selected-path">
<span>支出</span>
<span>餐饮</span>
<strong>外卖</strong>
</div>
<button class="date-pill">今天 12:30</button>
</div>
<div class="note-line drawer-note-line">
<input value="午餐" aria-label="备注" />
</div>
<div class="mini-keypad">
<span>7</span><span>8</span><span>9</span><span class="category-cell">支出</span>
<span>4</span><span>5</span><span>6</span><span>+</span>
<span>1</span><span>2</span><span>3</span><span>-</span>
<span>.</span><span>0</span><span></span><strong></strong>
</div>
</div>
</section>
<section class="phone flow-phone">
<header class="mini-appbar">
<span>2</span>
<strong>点击支出按钮</strong>
</header>
<div class="drawer-demo">
<div class="amount-line drawer-amount-line">
<strong>58.00</strong>
</div>
<div class="selection-row">
<div class="selected-path">
<span>支出</span>
</div>
<button class="date-pill">今天 12:30</button>
</div>
<div class="floating-choice">
<div class="radial-step type-step" aria-label="类型选择">
<button class="radial-center"></button>
<button class="radial-node type-expense active">支出</button>
<button class="radial-node type-income">收入</button>
</div>
</div>
<div class="mini-keypad active-picker">
<span>7</span><span>8</span><span>9</span><strong class="confirm-cell"></strong>
<span>4</span><span>5</span><span>6</span><span>+</span>
<span>1</span><span>2</span><span>3</span><span>-</span>
<span>.</span><span>0</span><span></span><strong></strong>
</div>
</div>
</section>
<section class="phone flow-phone">
<header class="mini-appbar">
<span>3</span>
<strong>选择支出后</strong>
</header>
<div class="drawer-demo">
<div class="amount-line drawer-amount-line">
<strong>58.00</strong>
</div>
<div class="selection-row">
<div class="selected-path">
<span>支出</span>
</div>
<button class="date-pill">今天 12:30</button>
</div>
<div class="floating-choice">
<div class="radial-step category-step" aria-label="支出分类选择">
<button class="radial-center"></button>
<button class="radial-node category-food active">餐饮</button>
<button class="radial-node category-transit">交通</button>
<button class="radial-node category-daily">日用</button>
<button class="radial-node category-shop">购物</button>
</div>
</div>
<div class="mini-keypad active-picker">
<span>7</span><span>8</span><span>9</span><strong class="confirm-cell"></strong>
<span>4</span><span>5</span><span>6</span><span>+</span>
<span>1</span><span>2</span><span>3</span><span>-</span>
<span>.</span><span>0</span><span></span><strong></strong>
</div>
</div>
</section>
<section class="phone flow-phone">
<header class="mini-appbar">
<span>4</span>
<strong>进入子分类</strong>
</header>
<div class="drawer-demo">
<div class="amount-line drawer-amount-line">
<strong>58.00</strong>
</div>
<div class="selection-row">
<div class="selected-path">
<span>支出</span>
<span>餐饮</span>
</div>
<button class="date-pill">今天 12:30</button>
</div>
<div class="floating-choice">
<div class="radial-step subcategory-step" aria-label="餐饮子分类选择">
<button class="radial-center"></button>
<button class="radial-node sub-takeout active">外卖</button>
<button class="radial-node sub-restaurant">饭店</button>
<button class="radial-node sub-coffee">咖啡</button>
</div>
</div>
<div class="mini-keypad active-picker">
<span>7</span><span>8</span><span>9</span><strong class="confirm-cell"></strong>
<span>4</span><span>5</span><span>6</span><span>+</span>
<span>1</span><span>2</span><span>3</span><span>-</span>
<span>.</span><span>0</span><span></span><strong></strong>
</div>
</div>
</section>
<section class="phone flow-phone">
<header class="mini-appbar">
<span>5</span>
<strong>确认当前层级</strong>
</header>
<div class="drawer-demo">
<div class="amount-line drawer-amount-line">
<strong>58.00</strong>
</div>
<div class="selection-row">
<div class="selected-path">
<span>支出</span>
<span>餐饮</span>
<strong>外卖</strong>
</div>
<button class="date-pill">今天 12:30</button>
</div>
<div class="note-line drawer-note-line">
<input value="午餐" aria-label="备注" />
</div>
<div class="mini-keypad">
<span>7</span><span>8</span><span>9</span><span class="category-cell">外卖</span>
<span>4</span><span>5</span><span>6</span><span>+</span>
<span>1</span><span>2</span><span>3</span><span>-</span>
<span>.</span><span>0</span><span></span><strong></strong>
</div>
</div>
</section>
</div>
</main>
</body>
</html>

View File

@ -0,0 +1,835 @@
:root {
color: #101827;
background:
radial-gradient(circle at 12% 0%, rgba(255, 122, 89, 0.28), transparent 30%),
radial-gradient(circle at 92% 8%, rgba(45, 212, 191, 0.24), transparent 32%),
linear-gradient(135deg, #fff3e8 0%, #ecfeff 48%, #fff1f8 100%);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.5;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
}
button,
input {
font: inherit;
}
.stage {
min-height: 100vh;
display: grid;
place-items: start center;
padding: 24px;
}
.phone {
width: min(100%, 390px);
height: 780px;
min-height: 780px;
overflow: hidden;
border: 1px solid rgba(20, 83, 45, 0.12);
border-radius: 28px;
background: #ffffff;
box-shadow: 0 24px 58px rgba(15, 23, 42, 0.14);
}
.appbar {
background: linear-gradient(135deg, #00a676 0%, #00b4d8 52%, #ff7a59 100%);
color: #fff;
padding: 18px 18px 14px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.title {
margin: 0;
font-size: 20px;
font-weight: 750;
letter-spacing: 0;
}
.icon-button {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border: 0;
border-radius: 18px;
background: rgba(255, 255, 255, 0.22);
color: inherit;
font-size: 26px;
line-height: 1;
}
.toolbar-spacer {
width: 36px;
height: 36px;
}
.icon {
width: 18px;
height: 18px;
display: block;
}
.deprecated-note {
margin: 24px 14px;
border: 1px solid #bdeee0;
border-radius: 8px;
padding: 16px;
background: #ffffff;
}
.deprecated-note h2 {
margin: 0 0 8px;
font-size: 18px;
}
.deprecated-note p {
margin: 0 0 12px;
color: #526072;
}
.deprecated-note a {
color: #007a78;
font-weight: 800;
}
.stats {
display: grid;
grid-template-columns: 1fr 1.2fr 1fr;
gap: 12px;
margin-top: 20px;
text-align: center;
}
.stat strong {
display: block;
font-size: 20px;
}
.stat span {
color: rgba(255, 255, 255, 0.76);
font-size: 12px;
}
.content {
padding: 14px 14px 2px;
}
.phone-relative .content {
padding-bottom: 250px;
}
.section-title {
margin: 14px 2px 8px;
color: #0f5f68;
font-size: 13px;
font-weight: 650;
}
.ledger-card {
border: 1px solid #d8f1e7;
border-radius: 8px;
padding: 14px;
background: #ffffff;
}
.ledger-card + .ledger-card {
margin-top: 10px;
}
.ledger-head,
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ledger-name {
margin: 0;
font-size: 19px;
line-height: 1.2;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 9px;
border-radius: 12px;
background: #d7fbef;
color: #007a5a;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.muted {
color: #526072;
font-size: 13px;
}
.card-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 14px;
border-top: 1px solid #dff5ee;
padding-top: 12px;
}
.action {
min-height: 36px;
border: 1px solid #ccefe5;
border-radius: 8px;
background: #fff;
color: #152033;
}
.day {
margin: 14px -14px 0;
padding: 7px 16px;
background: #e9fbf7;
color: #0f5f68;
font-size: 12px;
font-weight: 700;
}
.entry {
display: grid;
grid-template-columns: 40px 1fr auto;
gap: 10px;
align-items: center;
min-height: 62px;
border-bottom: 1px solid #e0f4ed;
}
.entry-icon {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 17px;
background: #00a676;
color: #fff;
font-size: 13px;
font-weight: 750;
}
.entry-main strong,
.entry-amount strong {
display: block;
}
.entry-main span,
.entry-amount span {
color: #526072;
font-size: 12px;
}
.entry-amount {
text-align: right;
}
.expense {
color: #d94836;
}
.income {
color: #008f5f;
}
.fab {
position: absolute;
right: 24px;
bottom: 24px;
width: 56px;
height: 56px;
border: 0;
border-radius: 28px;
background: #00a676;
color: #fff;
font-size: 28px;
box-shadow: 0 8px 24px rgba(0, 166, 118, 0.28);
}
.phone-relative {
position: relative;
}
.drawer-scrim {
position: absolute;
inset: 0;
z-index: 2;
background: linear-gradient(to bottom, rgba(15, 23, 42, 0.02), rgba(15, 23, 42, 0.2));
pointer-events: none;
}
.entry-drawer {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 3;
min-height: 292px;
overflow: hidden;
border-top: 1px solid #bdeee0;
border-radius: 18px 18px 0 0;
background: #ffffff;
box-shadow: 0 -18px 36px rgba(15, 23, 42, 0.16);
}
.drawer-handle {
width: 42px;
height: 5px;
margin: 9px auto 0;
border-radius: 999px;
background: #8ee8d4;
}
.drawer-amount-line {
padding-top: 12px;
padding-bottom: 8px;
}
.radial-step {
position: relative;
width: 176px;
height: 118px;
margin-left: auto;
}
.radial-center,
.radial-node {
position: absolute;
z-index: 1;
border: 1px solid transparent;
border-radius: 999px;
display: grid;
place-items: center;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
font-weight: 800;
white-space: nowrap;
}
.radial-center {
right: 0;
bottom: 0;
width: 54px;
height: 54px;
border: 0;
background: #00a676;
color: #fff;
font-size: 20px;
}
.radial-node {
min-width: 48px;
height: 34px;
padding: 0 10px;
background: #ffffff;
color: #101827;
font-size: 12px;
}
.radial-node.active {
border-color: #ffb703;
background: #fff0bf;
color: #9b4d00;
}
.type-expense {
right: 68px;
bottom: 8px;
}
.type-income {
right: 34px;
bottom: 70px;
}
.category-food {
right: 88px;
bottom: 4px;
border-color: #ffbf87;
}
.category-transit {
right: 104px;
bottom: 48px;
border-color: #93c5fd;
}
.category-daily {
right: 54px;
bottom: 82px;
border-color: #86efac;
}
.category-shop {
right: 0;
bottom: 96px;
border-color: #f9a8d4;
}
.sub-takeout {
right: 86px;
bottom: 8px;
}
.sub-restaurant {
right: 92px;
bottom: 54px;
}
.sub-coffee {
right: 40px;
bottom: 88px;
}
.selection-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 14px 10px;
}
.selected-path {
display: flex;
align-items: center;
min-width: 0;
flex-wrap: wrap;
gap: 5px;
padding: 0;
color: #0f5f68;
font-size: 12px;
font-weight: 750;
}
.selected-path span + span::before,
.selected-path span + strong::before {
content: ">";
margin-right: 5px;
color: #00a676;
}
.selected-path span,
.selected-path strong {
min-height: 24px;
border-radius: 12px;
padding: 3px 8px;
background: #e6fbf8;
}
.selected-path strong {
background: #fff0bf;
color: #9b4d00;
}
.drawer-note-line {
padding-bottom: 10px;
}
.flow-stage {
width: min(100%, 1180px);
margin: 0 auto;
padding: 28px 20px 48px;
}
.flow-intro {
margin-bottom: 20px;
}
.flow-intro h1 {
margin: 0 0 6px;
font-size: 26px;
line-height: 1.15;
}
.flow-intro p {
margin: 0;
color: #526072;
}
.flow-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(252px, 1fr));
gap: 18px;
align-items: start;
}
.flow-phone {
width: 100%;
height: 470px;
min-height: 470px;
border-radius: 20px;
}
.mini-appbar {
min-height: 58px;
padding: 12px 14px;
display: flex;
align-items: center;
gap: 10px;
background: linear-gradient(135deg, #00a676 0%, #00b4d8 100%);
color: #fff;
}
.mini-appbar span {
width: 28px;
height: 28px;
border-radius: 14px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.22);
font-weight: 850;
}
.drawer-demo {
position: relative;
min-height: 412px;
background: #ffffff;
}
.floating-choice {
position: absolute;
right: 7px;
bottom: 150px;
z-index: 4;
padding: 10px;
border: 1px solid rgba(189, 238, 224, 0.86);
border-radius: 16px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.16);
}
.mini-keypad {
display: grid;
grid-template-columns: repeat(4, 1fr);
margin-top: 8px;
border-top: 1px solid #bdeee0;
}
.mini-keypad span,
.mini-keypad strong {
min-height: 38px;
border-right: 1px solid #d7f5ec;
border-bottom: 1px solid #d7f5ec;
display: grid;
place-items: center;
font-size: 14px;
}
.mini-keypad span:nth-child(4n) {
border-right: 0;
background: #e6fbf8;
color: #007a78;
}
.mini-keypad strong {
border-right: 0;
background: #00a676;
color: #fff;
}
.mini-keypad .category-cell {
background: #fff0bf;
color: #9b4d00;
font-weight: 800;
}
.mini-keypad .confirm-cell {
background: #00a676;
color: #fff;
}
.active-picker {
margin-top: 170px;
}
.bottom-panel {
margin-top: 18px;
border-top: 1px solid #d7f5ec;
background: #ffffff;
}
.amount-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 16px;
}
.amount-line strong {
font-size: 34px;
line-height: 1;
}
.amount-meta {
display: grid;
justify-items: end;
gap: 7px;
min-width: max-content;
}
.toggle {
min-width: 58px;
min-height: 34px;
border: 0;
border-radius: 17px;
background: #ff5a47;
color: #fff;
font-weight: 750;
}
.date-pill {
min-height: 28px;
border: 1px solid #ffc56d;
border-radius: 14px;
padding: 0 10px;
background: #fff2cc;
color: #a45100;
font-size: 12px;
font-weight: 750;
}
.chips {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 0 14px 12px;
scrollbar-width: none;
}
.chips::-webkit-scrollbar {
display: none;
}
.chip {
min-width: max-content;
min-height: 34px;
border: 1px solid #bdeee0;
border-radius: 17px;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 6px;
background: #ffffff;
color: #115e59;
}
.chip.active {
border-color: #ffb703;
background: #fff0bf;
color: #9b4d00;
font-weight: 750;
}
.category-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
padding-bottom: 12px;
}
.category {
min-width: 0;
min-height: 78px;
border: 1px solid transparent;
border-radius: 8px;
padding: 8px 4px 7px;
display: grid;
justify-items: center;
align-content: center;
gap: 6px;
background: #fff;
color: #101827;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
}
.category span:last-child {
font-size: 12px;
font-weight: 700;
line-height: 1.1;
}
.category-mark {
width: 38px;
height: 38px;
border-radius: 19px;
display: grid;
place-items: center;
color: #fff;
font-size: 17px;
font-weight: 850;
}
.category.food {
border-color: #ffbf87;
background: #fff1e4;
}
.category.food .category-mark {
background: #ff6b35;
}
.category.daily {
border-color: #86efac;
background: #ecfdf3;
}
.category.daily .category-mark {
background: #16a34a;
}
.category.transit {
border-color: #93c5fd;
background: #eff6ff;
}
.category.transit .category-mark {
background: #2563eb;
}
.category.shopping {
border-color: #f9a8d4;
background: #fdf2f8;
}
.category.shopping .category-mark {
background: #e84393;
}
.category.income {
border-color: #bef264;
background: #f7fee7;
}
.category.income .category-mark {
background: #65a30d;
}
.category.active {
outline: 2px solid #00a676;
outline-offset: 0;
}
.note-line {
padding: 0 14px 12px;
}
.note-line input {
min-height: 42px;
width: 100%;
border: 1px solid #bdeee0;
border-radius: 8px;
padding: 0 12px;
background: #ffffff;
}
.chip-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
opacity: 0.75;
}
.keypad {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-top: 1px solid #bdeee0;
}
.key {
min-height: 58px;
border: 0;
border-right: 1px solid #d7f5ec;
border-bottom: 1px solid #d7f5ec;
background: #ffffff;
color: #101827;
font-size: 20px;
font-weight: 650;
}
.key:nth-child(4n) {
border-right: 0;
background: #e6fbf8;
color: #007a78;
}
.key:active {
background: #c9f7ef;
}
.commit-key {
background: #00a676 !important;
color: #fff !important;
}
.utility-key {
font-size: 16px;
}
.category-key {
font-size: 16px;
background: #fff0bf !important;
color: #9b4d00 !important;
}
.invite-box {
border: 1px dashed #86dfca;
border-radius: 8px;
padding: 14px;
background: #ffffff;
}
.member {
display: grid;
grid-template-columns: 38px 1fr auto;
gap: 10px;
align-items: center;
min-height: 58px;
border-bottom: 1px solid #d7f5ec;
}
.avatar {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 18px;
background: #d7fbef;
color: #007a5a;
font-weight: 800;
}
@media (max-width: 430px) {
.stage {
padding: 0;
}
.phone {
height: 100vh;
min-height: 100vh;
border: 0;
border-radius: 0;
box-shadow: none;
}
}

View File

@ -0,0 +1,56 @@
# 原型说明
这些原型用于评审信息架构和移动端交互,不是最终视觉稿。
## 页面
- [账本列表](账本列表.html)
- [账本流水](账本流水.html)
- 条目明细:开发环境访问 `/prototype/entry-detail`,用于评审查看、编辑与常规分类选择流程。
- [分类轮盘步骤](分类轮盘步骤.html)
- [成员邀请](成员邀请.html)
## 快速记账
快速记账不再作为单独页面,而是账本流水页内的底部抽屉。
当前交互方向:
- 点击账本流水页右下角记账入口,打开底部抽屉。
- 抽屉内输入金额、备注、日期时间,并使用数字键盘确认。
- 金额与日期时间位于抽屉顶部;关闭按钮取消,点击抽屉外部关闭。
- 点击日期时间后直接打开系统日期时间选择器,不再展开额外输入行或二次确认按钮。
- 信息行仅保留备注和分类按钮,完整分类路径合并显示为 `支出>餐饮>外卖`,初始显示 `支出`
- 加减键用于连续计算;发生运算时金额下方显示完整算式,金额区实时显示结果,退格同时更新算式和结果。
- 点击键盘右上角的收支/分类按钮后,分类选择器覆盖原数字键盘区域,其他界面进入模糊的非交互状态。
- 分类键原位变为圆形“确定”坐标原点,已选路径使用圆形按钮向左延伸;横轴节点可点击返回对应层。
- 纵轴同时使用正负区域:原点上方最多放置两个候选,其余候选从原点下方排列并可纵向滚动。
- 坐标节点均使用带轻阴影的圆形“图标 + 小文字”按钮;横纵轴容器保持透明,不绘制面板底色。
- 坐标按钮为 `72px`,轴容器预留阴影安全区;纵轴上下端使用渐隐遮罩提示仍可继续滚动。
- “确定”上下是同一分类序列的同步滚动视口,拖动任意一端时另一端同步位移,分类可连续经过原点上下区域。
- Y 轴使用受边界约束的自定义拖动,不启用浏览器原生弹性滚动;触达首尾后继续拖动不会产生过度位移或回弹虚影。
- 松手后根据拖动速度提供短距离减速惯性,惯性同样受首尾边界约束,触边立即停止。
- 圆形按钮仅使用柔和投影表达悬浮层级,不使用拟物厚度或透视高光;靠近原点的阴影使用渐隐收尾,避免硬裁切。
- 横轴减少上下内边距;圆形节点使用更清晰的 `2px` 分类色边框区分按钮边界。
- 横轴已选路径使用分类色实底与白色前景,和未选择的浅色圆形候选形成明确区分。
- “确定”使用方形按钮并建立独立前景层Y 轴按钮与原点保留安全间距,滚动时不会被原点矩形覆盖。
- 选择器关闭时保留当前层级和选择状态,再次打开时从上一次状态继续,不重置到收支类型层。
- 横轴每一级均可点击并返回对应层重新选择;例如点击 `支出` 重新选择支出或收入,点击 `餐饮` 重新选择一级分类。
- 横轴固定占满抽屉可用宽度,纵轴固定覆盖完整键盘高度;候选数量只影响滚动,不改变坐标轴尺寸。
- 数字键盘不再放置分类键,右列为加、减和占据下面两格的整笔确认键。
- 点击选择器外部确认当前层级;选择到最后一级分类时自动确认并收起选择器。
- 备注为可选项,不自动获得焦点;用户手动点击备注后隐藏数字键盘,抽屉随内容收缩并交由系统键盘输入。
- [分类选择步骤](分类轮盘步骤.html) 用分步骤画面表达交互状态(文件名暂时保留旧称)。
- 收入后续可复用同一套二维选择器,二期再承载 `普通收入 / 报销 / 退款`
[快速记账](快速记账.html) 仅保留为旧版独立页参考,不再作为主原型入口。
## 参考来源
参考了 `legacy-cent-app` 中的旧版方向:
- 账本卡片列表。
- 账本内顶部收入、结余、支出三栏。
- 按日期分组的流水。
- 底部快捷记账面板。
- 数字键盘式金额输入。

View File

@ -0,0 +1,28 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>快速记账原型已合并</title>
<link rel="stylesheet" href="./原型样式.css" />
</head>
<body>
<main class="stage">
<section class="phone">
<header class="appbar">
<div class="toolbar">
<button class="icon-button" aria-label="关闭">×</button>
<h1 class="title">快速记账</h1>
<span class="toolbar-spacer" aria-hidden="true"></span>
</div>
</header>
<div class="deprecated-note">
<h2>已并入账本流水页</h2>
<p>快速记账不再作为独立页面。请在账本流水页查看底部抽屉和分类轮盘交互。</p>
<a href="./账本流水.html">打开账本流水原型</a>
</div>
</section>
</main>
</body>
</html>

View File

@ -0,0 +1,62 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>成员邀请原型</title>
<link rel="stylesheet" href="./原型样式.css" />
</head>
<body>
<main class="stage">
<section class="phone">
<header class="appbar">
<div class="toolbar">
<button class="icon-button" aria-label="返回">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 18-6-6 6-6"/></svg>
</button>
<h1 class="title">成员</h1>
<button class="icon-button" aria-label="邀请">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6"/><path d="M22 11h-6"/></svg>
</button>
</div>
</header>
<div class="content">
<p class="section-title">家庭日常</p>
<section class="invite-box">
<div class="row">
<div>
<strong>邀请成员加入账本</strong>
<div class="muted">链接 24 小时后过期</div>
</div>
<button class="primary">生成</button>
</div>
</section>
<p class="section-title">当前成员</p>
<article class="member">
<div class="avatar"></div>
<div><strong>自己</strong><div class="muted">owner · 可邀请和移除成员</div></div>
<span class="badge">拥有者</span>
</article>
<article class="member">
<div class="avatar"></div>
<div><strong>小王</strong><div class="muted">member · 可查看和记账</div></div>
<span class="badge">成员</span>
</article>
<article class="member">
<div class="avatar"></div>
<div><strong>小李</strong><div class="muted">member · 最近同步 10:42</div></div>
<span class="badge">成员</span>
</article>
<p class="section-title">权限说明</p>
<div class="ledger-card">
<div class="muted">首版只做账本级权限。同一账本内成员共享全量流水,不做单条账目权限。</div>
</div>
</div>
</section>
</main>
</body>
</html>

View File

@ -0,0 +1,76 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>账本列表原型</title>
<link rel="stylesheet" href="./原型样式.css" />
</head>
<body>
<main class="stage">
<section class="phone">
<header class="appbar">
<div class="toolbar">
<button class="icon-button" aria-label="同步">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6v5h-5"/><path d="M4 18v-5h5"/><path d="M18 11a6 6 0 0 0-10-4l-4 4"/><path d="M6 13a6 6 0 0 0 10 4l4-4"/></svg>
</button>
<h1 class="title">账本</h1>
<button class="icon-button" aria-label="新增账本">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
</button>
</div>
</header>
<div class="content">
<p class="section-title">我的账本</p>
<article class="ledger-card">
<div class="ledger-head">
<div>
<h2 class="ledger-name">家庭日常</h2>
<span class="muted">3 名成员 · 今天已同步</span>
</div>
<span class="badge">共享</span>
</div>
<div class="card-actions">
<button class="action">设置</button>
<button class="action">报表</button>
<button class="action">成员</button>
</div>
</article>
<article class="ledger-card">
<div class="ledger-head">
<div>
<h2 class="ledger-name">旅行备用</h2>
<span class="muted">仅自己 · 12 条流水</span>
</div>
<span class="badge">独享</span>
</div>
<div class="card-actions">
<button class="action">设置</button>
<button class="action">报表</button>
<button class="action">成员</button>
</div>
</article>
<article class="ledger-card">
<div class="ledger-head">
<div>
<h2 class="ledger-name">装修记录</h2>
<span class="muted">2 名成员 · 有 2 条待同步</span>
</div>
<span class="badge">共享</span>
</div>
<div class="card-actions">
<button class="action">设置</button>
<button class="action">报表</button>
<button class="action">成员</button>
</div>
</article>
</div>
</section>
</main>
</body>
</html>

View File

@ -0,0 +1,84 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>账本流水原型</title>
<link rel="stylesheet" href="./原型样式.css" />
</head>
<body>
<main class="stage">
<section class="phone phone-relative">
<header class="appbar">
<div class="toolbar">
<button class="icon-button" aria-label="报表">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19V5"/><path d="M8 17V9"/><path d="M12 17V7"/><path d="M16 17v-4"/><path d="M20 17V6"/></svg>
</button>
<h1 class="title">家庭日常</h1>
<button class="icon-button" aria-label="更多">
<svg class="icon" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button>
</div>
<div class="stats">
<div class="stat"><strong>8,240</strong><span>收入</span></div>
<div class="stat"><strong>5,986</strong><span>结余</span></div>
<div class="stat"><strong>2,254</strong><span>支出</span></div>
</div>
</header>
<div class="content">
<div class="day">今天 · 7 月 18 日</div>
<article class="entry">
<div class="entry-icon"></div>
<div class="entry-main"><strong>午餐</strong><span>餐饮 · 小王</span></div>
<div class="entry-amount"><strong class="expense">-58.00</strong><span>12:28</span></div>
</article>
<article class="entry">
<div class="entry-icon"></div>
<div class="entry-main"><strong>打车</strong><span>交通 · 自己</span></div>
<div class="entry-amount"><strong class="expense">-31.50</strong><span>09:16</span></div>
</article>
<div class="day">昨天 · 7 月 17 日</div>
<article class="entry">
<div class="entry-icon"></div>
<div class="entry-main"><strong>工资</strong><span>收入 · 自己</span></div>
<div class="entry-amount"><strong class="income">+8,240.00</strong><span>18:02</span></div>
</article>
<article class="entry">
<div class="entry-icon"></div>
<div class="entry-main"><strong>超市采购</strong><span>日用 · 小王</span></div>
<div class="entry-amount"><strong class="expense">-236.80</strong><span>16:45</span></div>
</article>
</div>
<div class="drawer-scrim" aria-hidden="true"></div>
<section class="entry-drawer" aria-label="快速记账抽屉">
<div class="drawer-handle" aria-hidden="true"></div>
<div class="amount-line drawer-amount-line">
<strong>58.00</strong>
</div>
<div class="selection-row">
<div class="selected-path" aria-label="当前分类">
<span>支出</span>
<span>餐饮</span>
<strong>外卖</strong>
</div>
<button class="date-pill" aria-label="选择日期时间">今天 12:30</button>
</div>
<div class="note-line drawer-note-line">
<input value="午餐" aria-label="备注" />
</div>
<div class="keypad">
<button class="key">7</button><button class="key">8</button><button class="key">9</button><button class="key category-key" aria-label="分类选择">支出</button>
<button class="key">4</button><button class="key">5</button><button class="key">6</button><button class="key">+</button>
<button class="key">1</button><button class="key">2</button><button class="key">3</button><button class="key">-</button>
<button class="key">.</button><button class="key">0</button><button class="key utility-key" aria-label="退格"></button><button class="key commit-key" aria-label="确认"></button>
</div>
</section>
</section>
</main>
</body>
</html>

82
docs/技术方案.md Normal file
View File

@ -0,0 +1,82 @@
# 技术方案
## 客户端
默认选 Web-first PWA。
技术栈:
- Vue 3 + TypeScript。
- Vite。
- PWA manifest + Service Worker。
- Vue Router。
- Pinia。
- Dexie + IndexedDB。
选择理由:
- 不需要应用商店安装。
- 更新由服务端发布,用户无需手动升级 App。
- 同一套代码覆盖 iOS、Android 和桌面浏览器。
- 账本主要是表单、列表、汇总和同步Web 足够。
本地存储:
- IndexedDB 是客户端主数据源。
- 所有账目先写本地,再进入同步队列。
- Service Worker 缓存应用壳,使无网络时仍能打开。
- localStorage 只存轻量配置。
## 后端
默认选 Node.js + TypeScript + Fastify + PostgreSQL。
职责:
- 账号登录。
- 设备审批。
- 账本、成员和邀请管理。
- 按账本权限过滤数据访问。
- 同步 push/pull。
- 服务端变更日志。
## 测试
首期只做 Playwright 端到端测试。
覆盖重点:
- 手机视口下的核心操作。
- 账本权限和邀请。
- 离线记账。
- 恢复网络后的同步。
暂不引入单元测试和组件测试。
## 备选
### React Native
仅在这些情况再考虑:
- 必须应用商店分发。
- 必须强后台同步。
- 必须调用大量原生能力。
- PWA 在目标设备上体验不可接受。
### Supabase / Firebase
可以加快早期开发,但离线同步和冲突语义仍要自己把控。当前不作为默认方案。
## 工程结构
```text
apps/
web/ # PWA Web App
services/
api/ # Fastify API
packages/
domain/ # 共享类型、同步操作定义
db/ # 数据库 schema 和迁移
docs/
```

102
docs/数据与同步.md Normal file
View File

@ -0,0 +1,102 @@
# 数据与同步
## 核心实体
- `user`:用户。
- `device`:设备。
- `ledger`:账本。
- `ledger_member`:账本成员。
- `ledger_invitation`:账本邀请。
- `category`:分类。
- `entry`:账目。
- `sync_operation`:同步操作。
## 账本字段
- `id`
- `name`
- `visibility``private` 或 `shared`
- `owner_id`
- `created_at`
- `updated_at`
- `deleted_at`
## 成员字段
- `ledger_id`
- `user_id`
- `role``owner` 或 `member`
- `joined_at`
- `removed_at`
## 邀请字段
- `id`
- `ledger_id`
- `token`
- `created_by`
- `expires_at`
- `accepted_by`
- `accepted_at`
- `revoked_at`
## 权限规则
- 用户只能同步自己有成员关系的账本。
- 独享账本只有 owner 一名成员。
- 共享账本可以有多名成员。
- `owner` 可以邀请和移除成员。
- `member` 可以查看和记账。
- 首版不做单条账目权限。
## 账目字段
- `id`:客户端生成 UUID。
- `ledger_id`
- `type``expense` 或 `income`
- `amount`:金额,整数分。
- `category_id`
- `occurred_at`
- `note`
- `created_by`
- `updated_by`
- `created_at`
- `updated_at`
- `deleted_at`
- `version`
## 同步原则
- 客户端先写 IndexedDB界面立即成功。
- 每次本地变更生成一个 `operation_id`
- 同步 API 必须幂等。
- 服务端保存账本变更日志。
- 客户端用游标拉取增量。
- 同步范围必须按账本成员权限过滤。
## 同步流程
1. 用户新增、编辑或删除账目。
2. 客户端写 IndexedDB。
3. 客户端写本地同步队列。
4. 网络可用时推送队列。
5. 服务端按 `operation_id` 去重并应用。
6. 客户端拉取远端增量。
7. 客户端合并到 IndexedDB。
## 冲突策略
- 新增记录基本无冲突ID 由客户端生成。
- 删除使用软删除。
- 同一条账目被多人离线修改时,首版最后写入胜出。
- 不做字段级合并。
- 冲突历史和人工选择版本后置。
冲突示例:
- 手机 A 和手机 B 都同步过同一笔账:`午餐 100 元`。
- 两台手机同时离线。
- 手机 A 改成 `120 CNY`
- 手机 B 改成 `110 CNY` 或修改分类。
- 两台手机恢复网络后上传。
- 服务端必须决定最终版本。

56
docs/测试策略.md Normal file
View File

@ -0,0 +1,56 @@
# 测试策略
## 首期选择
首期只使用 Playwright 做端到端测试,不写单元测试。
## 原则
- 测试真实用户路径,不测试内部实现。
- 优先覆盖高风险流程。
- 使用手机视口作为主要测试环境。
- 测试数据可重复创建和清理。
- 不为早期快速变化的组件写细碎测试。
## 首期覆盖
### 记账
- 新增一笔收入。
- 新增一笔支出。
- 编辑账目。
- 删除账目。
- 刷新页面后账目仍存在。
### 账本权限
- 用户只能看到自己有权限的账本。
- 独享账本不对其他用户可见。
- owner 可以生成邀请。
- 被邀请成员加入后可以看到账本。
### 离线
- 离线时可以新增账目。
- 离线刷新后应用仍能打开。
- 恢复网络后触发同步。
### 同步
- 同一操作重复提交不会重复记账。
- 多设备拉取后能看到同一账本流水。
- 同一账目离线冲突时最后写入胜出。
## 暂不覆盖
- 单元测试。
- 组件测试。
- 外币和汇率。
- 大规模性能测试。
- 视觉回归测试。
## 推荐命令
```bash
npm run test:e2e
```

31
docs/评审问题.md Normal file
View File

@ -0,0 +1,31 @@
# 评审问题
## 必须确认
1. 首版登录方式:后台分配用户名密码,还是设备 ID 审批?
2. 是否确认首期不做外币?
3. 是否确认只记录流水,不做分摊?
4. 离线冲突时,最后写入胜出是否可接受?
5. 删除账目是否需要恢复入口?
6. 邀请用链接、邀请码,还是两者都支持?
7. 成员是否允许记账,还是只能查看?
8. 首版是否需要导出 CSV
9. 是否需要自部署?
10. 数据安全和开发速度冲突时,是否明确优先数据安全?
## 当前默认答案
- 不开放注册。
- 先采用后台分配用户名密码。
- 设备 ID 审批作为备选。
- 首期只做人民币,不做外币。
- 账本可以独享,也可以邀请成员共享。
- 用户只能访问自己有权限的账本。
- 同一账本内成员共享全量账目。
- 首版权限只有 `owner``member`
- 只记录收入和支出流水。
- 冲突最后写入胜出。
- 软删除,但不做恢复 UI。
- 暂不导出。
- 部署到普通云服务。
- 数据安全优先于功能数量。

2259
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "cents",
"version": "0.1.0",
"private": true,
"type": "module",
"workspaces": [
"apps/*",
"services/*",
"packages/*"
],
"scripts": {
"dev": "npm run dev --workspace @cents/web",
"dev:api": "npm run dev --workspace @cents/api",
"build": "npm run build --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present",
"lint": "npm run lint --workspaces --if-present"
},
"engines": {
"node": ">=22"
}
}

View File

@ -0,0 +1,16 @@
{
"name": "@cents/domain",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.8.3"
}
}

View File

@ -0,0 +1,40 @@
export type EntryType = "expense" | "income";
export type CurrencyCode = "CNY" | "USD" | "EUR" | "JPY" | "HKD";
export type ExchangeRateSource = "manual" | "system";
export type LedgerEntry = {
id: string;
ledgerId: string;
type: EntryType;
amount: number;
currency: CurrencyCode;
baseCurrency: CurrencyCode;
baseAmount: number;
exchangeRate: string;
exchangeRateSource: ExchangeRateSource;
categoryId: string;
note: string;
occurredAt: string;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
version: number;
};
export type SyncOperationAction = "create" | "update" | "delete";
export type SyncOperation = {
id: string;
ledgerId: string;
entity: "entry";
entityId: string;
action: SyncOperationAction;
payload: LedgerEntry;
createdAt: string;
syncedAt: string | null;
};

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"]
},
"include": ["src/**/*.ts"]
}

20
services/api/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "@cents/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@cents/domain": "0.1.0",
"fastify": "^5.4.0"
},
"devDependencies": {
"@types/node": "^22.13.14",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
}
}

View File

@ -0,0 +1,25 @@
import Fastify from "fastify";
const server = Fastify({
logger: true,
});
server.get("/health", async () => ({
ok: true,
service: "cents-api",
}));
server.post("/sync/push", async () => ({
accepted: true,
operations: [],
}));
server.get("/sync/pull", async () => ({
cursor: null,
operations: [],
}));
const port = Number(process.env.PORT ?? 3000);
const host = process.env.HOST ?? "0.0.0.0";
await server.listen({ port, host });

View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"],
"types": ["node"],
"moduleResolution": "NodeNext",
"module": "NodeNext"
},
"include": ["src/**/*.ts"]
}

14
tsconfig.base.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
}
}