feat: 实现账本核心交互与导航
This commit is contained in:
parent
af546883c4
commit
7cc2f51594
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
legacy-cent-app/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
31
README.md
31
README.md
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 |
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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("/"));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 [];
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
});
|
||||||
|
|
@ -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) }),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -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("")}`;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
@ -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 或代付回款是否归入报销,还是后续独立成“代付结算”。
|
||||||
|
- 统计页默认展示总支出还是净支出。
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
# 产品边界
|
||||||
|
|
||||||
|
## 定位
|
||||||
|
|
||||||
|
家庭账本。重点是快速记账、账本权限、离线可用、恢复网络后同步。
|
||||||
|
|
||||||
|
## 用户
|
||||||
|
|
||||||
|
- 家庭、伴侣、合租、小型共同开支群体。
|
||||||
|
- 默认 2-8 人。
|
||||||
|
- 不面向大规模公开推广。
|
||||||
|
|
||||||
|
## 首版必须有
|
||||||
|
|
||||||
|
### 账号与访问
|
||||||
|
|
||||||
|
- 不开放自助注册。
|
||||||
|
- 后台分配用户名密码。
|
||||||
|
- 设备 ID 审批作为备选方案。
|
||||||
|
- 用户只能访问自己有权限的账本。
|
||||||
|
|
||||||
|
### 账本
|
||||||
|
|
||||||
|
- 创建账本。
|
||||||
|
- 查看账本流水。
|
||||||
|
- 账本可以独享。
|
||||||
|
- 账本可以邀请成员共享。
|
||||||
|
- 账本成员默认共享该账本的全量数据。
|
||||||
|
- 首版权限只区分 `owner` 和 `member`。
|
||||||
|
|
||||||
|
### 邀请
|
||||||
|
|
||||||
|
- 账本 owner 可以邀请成员。
|
||||||
|
- 邀请可以通过链接或邀请码完成。
|
||||||
|
- 邀请需要过期时间。
|
||||||
|
- 被邀请用户加入后才能看到该账本。
|
||||||
|
|
||||||
|
### 记账
|
||||||
|
|
||||||
|
- 记录收入。
|
||||||
|
- 记录支出。
|
||||||
|
- 字段:金额、分类、日期、备注、付款人。
|
||||||
|
- 只记录流水,不做分摊和结算。
|
||||||
|
|
||||||
|
### 离线
|
||||||
|
|
||||||
|
- 无网络时可以新增、编辑、删除账目。
|
||||||
|
- 网络恢复后自动同步。
|
||||||
|
- 多设备同步有权限访问的账本。
|
||||||
|
|
||||||
|
### 汇总
|
||||||
|
|
||||||
|
- 按月查看流水。
|
||||||
|
- 按月查看分类汇总。
|
||||||
|
|
||||||
|
## 首版暂缓
|
||||||
|
|
||||||
|
- 外币记账。
|
||||||
|
- 汇率换算。
|
||||||
|
- 预算。
|
||||||
|
- 资产账户余额。
|
||||||
|
- 转账。
|
||||||
|
- 周期记账。
|
||||||
|
- 附件和图片小票。
|
||||||
|
- OCR、语音记账、AI 分类。
|
||||||
|
- 复杂成员权限。
|
||||||
|
- 导入导出。
|
||||||
|
- 专门的桌面大屏布局。
|
||||||
|
- 分摊和结算。
|
||||||
|
- 报销、退款和收入关联原支出,见 [二期:报销与退款](二期-报销与退款.md)。
|
||||||
|
|
@ -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 提交到仓库。
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
# 原型说明
|
||||||
|
|
||||||
|
这些原型用于评审信息架构和移动端交互,不是最终视觉稿。
|
||||||
|
|
||||||
|
## 页面
|
||||||
|
|
||||||
|
- [账本列表](账本列表.html)
|
||||||
|
- [账本流水](账本流水.html)
|
||||||
|
- 条目明细:开发环境访问 `/prototype/entry-detail`,用于评审查看、编辑与常规分类选择流程。
|
||||||
|
- [分类轮盘步骤](分类轮盘步骤.html)
|
||||||
|
- [成员邀请](成员邀请.html)
|
||||||
|
|
||||||
|
## 快速记账
|
||||||
|
|
||||||
|
快速记账不再作为单独页面,而是账本流水页内的底部抽屉。
|
||||||
|
|
||||||
|
当前交互方向:
|
||||||
|
|
||||||
|
- 点击账本流水页右下角记账入口,打开底部抽屉。
|
||||||
|
- 抽屉内输入金额、备注、日期时间,并使用数字键盘确认。
|
||||||
|
- 金额与日期时间位于抽屉顶部;关闭按钮取消,点击抽屉外部关闭。
|
||||||
|
- 点击日期时间后直接打开系统日期时间选择器,不再展开额外输入行或二次确认按钮。
|
||||||
|
- 信息行仅保留备注和分类按钮,完整分类路径合并显示为 `支出>餐饮>外卖`,初始显示 `支出`。
|
||||||
|
- 加减键用于连续计算;发生运算时金额下方显示完整算式,金额区实时显示结果,退格同时更新算式和结果。
|
||||||
|
- 点击键盘右上角的收支/分类按钮后,分类选择器覆盖原数字键盘区域,其他界面进入模糊的非交互状态。
|
||||||
|
- 分类键原位变为圆形“确定”坐标原点,已选路径使用圆形按钮向左延伸;横轴节点可点击返回对应层。
|
||||||
|
- 纵轴同时使用正负区域:原点上方最多放置两个候选,其余候选从原点下方排列并可纵向滚动。
|
||||||
|
- 坐标节点均使用带轻阴影的圆形“图标 + 小文字”按钮;横纵轴容器保持透明,不绘制面板底色。
|
||||||
|
- 坐标按钮为 `72px`,轴容器预留阴影安全区;纵轴上下端使用渐隐遮罩提示仍可继续滚动。
|
||||||
|
- “确定”上下是同一分类序列的同步滚动视口,拖动任意一端时另一端同步位移,分类可连续经过原点上下区域。
|
||||||
|
- Y 轴使用受边界约束的自定义拖动,不启用浏览器原生弹性滚动;触达首尾后继续拖动不会产生过度位移或回弹虚影。
|
||||||
|
- 松手后根据拖动速度提供短距离减速惯性,惯性同样受首尾边界约束,触边立即停止。
|
||||||
|
- 圆形按钮仅使用柔和投影表达悬浮层级,不使用拟物厚度或透视高光;靠近原点的阴影使用渐隐收尾,避免硬裁切。
|
||||||
|
- 横轴减少上下内边距;圆形节点使用更清晰的 `2px` 分类色边框区分按钮边界。
|
||||||
|
- 横轴已选路径使用分类色实底与白色前景,和未选择的浅色圆形候选形成明确区分。
|
||||||
|
- “确定”使用方形按钮并建立独立前景层,Y 轴按钮与原点保留安全间距,滚动时不会被原点矩形覆盖。
|
||||||
|
- 选择器关闭时保留当前层级和选择状态,再次打开时从上一次状态继续,不重置到收支类型层。
|
||||||
|
- 横轴每一级均可点击并返回对应层重新选择;例如点击 `支出` 重新选择支出或收入,点击 `餐饮` 重新选择一级分类。
|
||||||
|
- 横轴固定占满抽屉可用宽度,纵轴固定覆盖完整键盘高度;候选数量只影响滚动,不改变坐标轴尺寸。
|
||||||
|
- 数字键盘不再放置分类键,右列为加、减和占据下面两格的整笔确认键。
|
||||||
|
- 点击选择器外部确认当前层级;选择到最后一级分类时自动确认并收起选择器。
|
||||||
|
- 备注为可选项,不自动获得焦点;用户手动点击备注后隐藏数字键盘,抽屉随内容收缩并交由系统键盘输入。
|
||||||
|
- [分类选择步骤](分类轮盘步骤.html) 用分步骤画面表达交互状态(文件名暂时保留旧称)。
|
||||||
|
- 收入后续可复用同一套二维选择器,二期再承载 `普通收入 / 报销 / 退款`。
|
||||||
|
|
||||||
|
[快速记账](快速记账.html) 仅保留为旧版独立页参考,不再作为主原型入口。
|
||||||
|
|
||||||
|
## 参考来源
|
||||||
|
|
||||||
|
参考了 `legacy-cent-app` 中的旧版方向:
|
||||||
|
|
||||||
|
- 账本卡片列表。
|
||||||
|
- 账本内顶部收入、结余、支出三栏。
|
||||||
|
- 按日期分组的流水。
|
||||||
|
- 底部快捷记账面板。
|
||||||
|
- 数字键盘式金额输入。
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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/
|
||||||
|
```
|
||||||
|
|
@ -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` 或修改分类。
|
||||||
|
- 两台手机恢复网络后上传。
|
||||||
|
- 服务端必须决定最终版本。
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
# 测试策略
|
||||||
|
|
||||||
|
## 首期选择
|
||||||
|
|
||||||
|
首期只使用 Playwright 做端到端测试,不写单元测试。
|
||||||
|
|
||||||
|
## 原则
|
||||||
|
|
||||||
|
- 测试真实用户路径,不测试内部实现。
|
||||||
|
- 优先覆盖高风险流程。
|
||||||
|
- 使用手机视口作为主要测试环境。
|
||||||
|
- 测试数据可重复创建和清理。
|
||||||
|
- 不为早期快速变化的组件写细碎测试。
|
||||||
|
|
||||||
|
## 首期覆盖
|
||||||
|
|
||||||
|
### 记账
|
||||||
|
|
||||||
|
- 新增一笔收入。
|
||||||
|
- 新增一笔支出。
|
||||||
|
- 编辑账目。
|
||||||
|
- 删除账目。
|
||||||
|
- 刷新页面后账目仍存在。
|
||||||
|
|
||||||
|
### 账本权限
|
||||||
|
|
||||||
|
- 用户只能看到自己有权限的账本。
|
||||||
|
- 独享账本不对其他用户可见。
|
||||||
|
- owner 可以生成邀请。
|
||||||
|
- 被邀请成员加入后可以看到账本。
|
||||||
|
|
||||||
|
### 离线
|
||||||
|
|
||||||
|
- 离线时可以新增账目。
|
||||||
|
- 离线刷新后应用仍能打开。
|
||||||
|
- 恢复网络后触发同步。
|
||||||
|
|
||||||
|
### 同步
|
||||||
|
|
||||||
|
- 同一操作重复提交不会重复记账。
|
||||||
|
- 多设备拉取后能看到同一账本流水。
|
||||||
|
- 同一账目离线冲突时最后写入胜出。
|
||||||
|
|
||||||
|
## 暂不覆盖
|
||||||
|
|
||||||
|
- 单元测试。
|
||||||
|
- 组件测试。
|
||||||
|
- 外币和汇率。
|
||||||
|
- 大规模性能测试。
|
||||||
|
- 视觉回归测试。
|
||||||
|
|
||||||
|
## 推荐命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
# 评审问题
|
||||||
|
|
||||||
|
## 必须确认
|
||||||
|
|
||||||
|
1. 首版登录方式:后台分配用户名密码,还是设备 ID 审批?
|
||||||
|
2. 是否确认首期不做外币?
|
||||||
|
3. 是否确认只记录流水,不做分摊?
|
||||||
|
4. 离线冲突时,最后写入胜出是否可接受?
|
||||||
|
5. 删除账目是否需要恢复入口?
|
||||||
|
6. 邀请用链接、邀请码,还是两者都支持?
|
||||||
|
7. 成员是否允许记账,还是只能查看?
|
||||||
|
8. 首版是否需要导出 CSV?
|
||||||
|
9. 是否需要自部署?
|
||||||
|
10. 数据安全和开发速度冲突时,是否明确优先数据安全?
|
||||||
|
|
||||||
|
## 当前默认答案
|
||||||
|
|
||||||
|
- 不开放注册。
|
||||||
|
- 先采用后台分配用户名密码。
|
||||||
|
- 设备 ID 审批作为备选。
|
||||||
|
- 首期只做人民币,不做外币。
|
||||||
|
- 账本可以独享,也可以邀请成员共享。
|
||||||
|
- 用户只能访问自己有权限的账本。
|
||||||
|
- 同一账本内成员共享全量账目。
|
||||||
|
- 首版权限只有 `owner` 和 `member`。
|
||||||
|
- 只记录收入和支出流水。
|
||||||
|
- 冲突最后写入胜出。
|
||||||
|
- 软删除,但不做恢复 UI。
|
||||||
|
- 暂不导出。
|
||||||
|
- 部署到普通云服务。
|
||||||
|
- 数据安全优先于功能数量。
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2022"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"module": "NodeNext"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue