cents/apps/web/src/views/LedgerView.vue

650 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<script setup lang="ts">
import { formatCurrencyAmount, type LedgerEntry } from "@cents/domain";
import {
BookOpen,
ArrowLeftRight,
ChevronLeft,
ChevronDown,
ChevronRight,
ChevronUp,
CircleUserRound,
CloudOff,
Copy,
LoaderCircle,
Clock3,
MoreHorizontal,
RefreshCw,
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 { apiRequest, ApiError } from "../data/api";
import { entryTypes, findCategory, findCategoryPath } from "../data/categories";
import { ledgerTheme } from "../data/ledgers";
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 showBaseCurrency = ref(true);
const participantOpen = ref(false);
const shareOpen = ref(false);
const shareFeedback = ref("");
const invitationLink = ref("");
const generatingInvitation = ref(false);
const visibleCount = ref(ENTRY_PAGE_SIZE);
const ledgerContent = ref<HTMLElement | null>(null);
const loadMoreTrigger = ref<HTMLElement | null>(null);
const monthInput = ref<HTMLInputElement | null>(null);
const selectedMonth = ref(toMonthValue(new Date()));
const monthSwipeStart = ref<{ x: number; y: number } | null>(null);
const monthSwipeDelta = ref(0);
const monthSwipeDragging = ref(false);
const pullStartY = ref<number | null>(null);
const pullDistance = ref(0);
const pullEdge = ref<"top" | "bottom" | null>(null);
const pullLoading = ref(false);
let loadMoreObserver: IntersectionObserver | null = null;
const selectedMonthParts = computed(() => {
const match = /^(\d{4})-(\d{2})$/.exec(selectedMonth.value);
if (!match) return null;
return { year: Number(match[1]), month: Number(match[2]) - 1 };
});
const monthlyEntries = computed(() => {
const selected = selectedMonthParts.value;
if (!selected) return [];
return store.entries.filter((entry) => {
const occurredAt = new Date(entry.occurredAt);
return entry.ledgerIds.includes(ledgerStore.currentLedgerId)
&& occurredAt.getFullYear() === selected.year
&& occurredAt.getMonth() === selected.month;
});
});
const totals = computed(() => {
return monthlyEntries.value.reduce(
(result, entry) => {
if (entry.baseAmount === null) return result;
if (entry.type === "income") result.income += entry.baseAmount;
else result.expense += entry.baseAmount;
return result;
},
{ income: 0, expense: 0 },
);
});
const balanceText = computed(() => formatMoney(totals.value.income - totals.value.expense));
const incomeText = computed(() => formatMoney(totals.value.income));
const expenseText = computed(() => formatMoney(totals.value.expense));
const visibleMonthlyEntries = computed(() => monthlyEntries.value.slice(0, visibleCount.value));
const hasMoreEntries = computed(() => visibleCount.value < monthlyEntries.value.length);
const pendingEntryCount = computed(() => store.pendingEntryCount(ledgerStore.currentLedgerId));
const pendingConversionCount = computed(() =>
monthlyEntries.value.filter((entry) => entry.conversionStatus === "pending").length,
);
const currentTheme = computed(() => ledgerTheme(ledgerStore.currentLedger?.theme));
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.baseAmount === null) continue;
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 selected = selectedMonthParts.value;
return selected ? `${selected.year}${selected.month + 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());
const pullAction = computed(() => {
if (pullEdge.value === "bottom") return pullDistance.value >= 72 ? "next" : null;
if (pullEdge.value !== "top") return null;
if (pullDistance.value >= 88) return "previous";
if (pullDistance.value >= 44) return "refresh";
return null;
});
const pullLabel = computed(() => {
if (pullAction.value === "previous") return "加载上一月流水";
if (pullAction.value === "refresh") return "刷新流水";
if (pullEdge.value === "bottom") return "继续上拉加载下一月流水";
return "继续下拉刷新流水";
});
const pullProgress = computed(() => {
const threshold = pullEdge.value === "bottom" ? 72 : pullDistance.value < 44 ? 44 : 88;
return Math.min(1, pullDistance.value / threshold);
});
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 toMonthValue(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
}
function openMonthPicker() {
const input = monthInput.value;
if (!input) return;
if (typeof input.showPicker === "function") input.showPicker();
else input.click();
}
function startMonthSwipe(event: TouchEvent) {
const touch = event.touches[0];
if (touch) {
monthSwipeStart.value = { x: touch.clientX, y: touch.clientY };
monthSwipeDelta.value = 0;
monthSwipeDragging.value = false;
}
}
function moveMonthSwipe(event: TouchEvent) {
const start = monthSwipeStart.value;
const touch = event.touches[0];
if (!start || !touch) return;
const deltaX = touch.clientX - start.x;
const deltaY = touch.clientY - start.y;
if (Math.abs(deltaY) > Math.abs(deltaX) && Math.abs(deltaY) > 8) {
monthSwipeDelta.value = 0;
monthSwipeDragging.value = false;
return;
}
monthSwipeDelta.value = Math.max(-96, Math.min(96, deltaX));
monthSwipeDragging.value = Math.abs(deltaX) > 8;
}
function finishMonthSwipe(event: TouchEvent) {
const start = monthSwipeStart.value;
const touch = event.changedTouches[0];
monthSwipeStart.value = null;
if (!start || !touch) return;
const deltaX = touch.clientX - start.x;
const deltaY = touch.clientY - start.y;
if (Math.abs(deltaX) < 48 || Math.abs(deltaX) <= Math.abs(deltaY)) {
monthSwipeDelta.value = 0;
monthSwipeDragging.value = false;
return;
}
shiftMonth(deltaX < 0 ? 1 : -1);
monthSwipeDelta.value = 0;
monthSwipeDragging.value = false;
}
function cancelMonthSwipe() {
monthSwipeStart.value = null;
monthSwipeDelta.value = 0;
monthSwipeDragging.value = false;
}
function startLedgerPull(event: TouchEvent) {
if (pullLoading.value) return;
const content = ledgerContent.value;
const touch = event.touches[0];
if (!content || !touch) return;
const atTop = content.scrollTop <= 0;
const isShortList = content.scrollHeight <= content.clientHeight + 4;
const atBottom = isShortList || content.scrollTop + content.clientHeight >= content.scrollHeight - 2;
pullEdge.value = atTop ? "top" : atBottom ? "bottom" : null;
pullStartY.value = pullEdge.value ? touch.clientY : null;
pullDistance.value = 0;
}
function moveLedgerPull(event: TouchEvent) {
const startY = pullStartY.value;
const edge = pullEdge.value;
const touch = event.touches[0];
const content = ledgerContent.value;
if (startY === null || !edge || !touch || !content) return;
const isShortList = content.scrollHeight <= content.clientHeight + 4;
if (isShortList && edge === "top" && touch.clientY < startY) {
pullEdge.value = "bottom";
} else if (isShortList && edge === "bottom" && touch.clientY > startY) {
pullEdge.value = "top";
}
const activeEdge = pullEdge.value;
if (!activeEdge) return;
const delta = activeEdge === "top" ? touch.clientY - startY : startY - touch.clientY;
if (delta <= 0) {
pullDistance.value = 0;
return;
}
pullDistance.value = Math.min(144, delta * 0.55);
if (activeEdge === "bottom") {
requestAnimationFrame(() => {
const current = ledgerContent.value;
if (current && pullEdge.value === "bottom") {
current.scrollTop = current.scrollHeight - current.clientHeight;
}
});
}
}
async function finishLedgerPull() {
const action = pullAction.value;
pullStartY.value = null;
if (!action || pullLoading.value) {
pullEdge.value = null;
pullDistance.value = 0;
return;
}
pullLoading.value = true;
try {
if (action === "refresh" || action === "previous" || action === "next") await store.syncEntries();
if (action === "previous") shiftMonth(-1);
if (action === "next") shiftMonth(1);
} finally {
pullLoading.value = false;
pullEdge.value = null;
pullDistance.value = 0;
}
}
function cancelLedgerPull() {
pullStartY.value = null;
pullEdge.value = null;
pullDistance.value = 0;
}
function shiftMonth(offset: number) {
const selected = selectedMonthParts.value;
if (!selected) return;
selectedMonth.value = toMonthValue(new Date(selected.year, selected.month + offset, 1));
}
const monthSwipePreview = computed(() => {
const selected = selectedMonthParts.value;
if (!selected || !monthSwipeDelta.value) return "";
const date = new Date(selected.year, selected.month + (monthSwipeDelta.value < 0 ? 1 : -1), 1);
return `${date.getFullYear()}${date.getMonth() + 1}`;
});
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 categoryIcon(entry: LedgerEntry) {
return findCategory(entry.categoryId)?.icon ?? entryTypes.find((type) => type.id === entry.type)?.icon ?? BookOpen;
}
function openParticipantSheet() {
participantOpen.value = true;
void ledgerStore.loadCurrentMembers();
}
async function openShareSheet() {
moreMenuOpen.value = false;
shareFeedback.value = "正在生成邀请链接";
shareOpen.value = true;
await ensureInvitation();
}
function openLedgerSettings() {
moreMenuOpen.value = false;
router.push("/ledger/settings");
}
async function ensureInvitation() {
if (invitationLink.value || generatingInvitation.value || !ledgerStore.currentLedgerId) return invitationLink.value;
generatingInvitation.value = true;
try {
const result = await apiRequest<{ invitation: { url: string; expiresAt: string } }>(
`/api/ledgers/${encodeURIComponent(ledgerStore.currentLedgerId)}/invitations`,
{ method: "POST" },
);
invitationLink.value = result.invitation.url;
shareFeedback.value = "邀请链接将在 7 天后失效";
} catch (error) {
shareFeedback.value = error instanceof ApiError ? error.message : "邀请链接生成失败";
} finally {
generatingInvitation.value = false;
}
return invitationLink.value;
}
async function copyInvitation() {
const link = await ensureInvitation();
if (!link) return;
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 url = await ensureInvitation();
if (!url) return;
const data = {
title: ledgerStore.currentLedger?.name ?? "账本",
text: `邀请你加入我的${ledgerStore.currentLedger?.name ?? "账本"}`,
url,
};
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" :style="{ '--ledger-gradient': currentTheme.gradient, '--ledger-accent': currentTheme.accent }">
<div class="top-actions">
<button v-if="!ledgerStore.currentLedger?.isPersonal" class="participant-avatars" type="button" aria-label="查看账本参与者" title="账本参与者" @click="openParticipantSheet">
<span v-for="member in ledgerStore.currentMembers.slice(0, 3)" :key="member.id" class="participant-avatar" :class="member.role">
{{ member.name.slice(0, 1) }}
</span>
</button>
<span v-else aria-hidden="true"></span>
<button class="ledger-switcher" type="button" @click="router.push('/ledgers')">
<span>{{ ledgerStore.currentLedger?.name ?? "选择账本" }}</span>
<ChevronDown :size="16" />
</button>
<div class="header-right-actions">
<button
class="currency-display-button"
type="button"
:class="{ active: showBaseCurrency }"
:aria-label="showBaseCurrency ? '当前显示本币,点击显示原币' : '当前显示原币,点击显示本币'"
:title="showBaseCurrency ? '当前显示本币,点击切换为原币' : '当前显示原币,点击切换为本币'"
:aria-pressed="showBaseCurrency"
@click="showBaseCurrency = !showBaseCurrency"
>
<ArrowLeftRight :size="17" />
</button>
<button v-if="ledgerStore.currentRole === 'owner'" class="icon-button header-icon" type="button" aria-label="更多" title="更多" @click.stop="moreMenuOpen = true">
<MoreHorizontal :size="21" />
</button>
</div>
</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 v-if="!ledgerStore.currentLedger?.isPersonal" type="button" role="menuitem" @click="openShareSheet"><Share2 :size="18" />分享</button>
</div>
</div>
</Transition>
<section class="monthly-summary" aria-label="月度收支">
<div class="summary-main">
<span>结余</span>
<strong :class="{ compact: balanceText.length > 12 }">¥ {{ balanceText }}</strong>
</div>
<div class="summary-secondary">
<div class="summary-stat income-stat">
<span>收入</span>
<strong :class="{ compact: incomeText.length > 10 }">{{ incomeText }}</strong>
</div>
<div class="summary-stat expense-stat">
<span>支出</span>
<strong :class="{ compact: expenseText.length > 10 }">{{ expenseText }}</strong>
</div>
</div>
</section>
</header>
<section
ref="ledgerContent"
class="ledger-content"
aria-label="账本流水"
@touchstart.passive="startLedgerPull"
@touchmove.passive="moveLedgerPull"
@touchend="finishLedgerPull"
@touchcancel="cancelLedgerPull"
>
<div class="ledger-notice-slot" aria-live="polite">
<div v-if="pendingEntryCount" class="sync-notice" role="status">
<span><CloudOff :size="16" />{{ pendingEntryCount }} 条账目未同步云端</span>
<button type="button" :disabled="store.syncing" @click="store.syncEntries">
<RefreshCw :size="15" :class="{ spinning: store.syncing }" />{{ store.syncing ? "同步中" : "立即同步" }}
</button>
</div>
<div v-else-if="pendingConversionCount" class="conversion-notice" role="status">
<span><Clock3 :size="15" />{{ pendingConversionCount }} 条外币账目等待换算</span>
</div>
</div>
<div class="list-toolbar" @touchstart.passive="startMonthSwipe" @touchmove.passive="moveMonthSwipe" @touchend="finishMonthSwipe" @touchcancel="cancelMonthSwipe">
<div class="list-toolbar-content" :class="{ dragging: monthSwipeDragging }" :style="{ '--month-swipe-offset': `${monthSwipeDelta * 0.22}px` }">
<div class="list-toolbar-copy">
<div class="list-toolbar-heading">
<button class="list-month-switcher" type="button" @click="openMonthPicker">
<strong>{{ monthTitle }}</strong>
<ChevronDown :size="14" />
</button>
<input ref="monthInput" v-model="selectedMonth" class="native-month-input" type="month" aria-label="选择流水月份" tabindex="-1" />
<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>
<div v-if="monthSwipeDragging" class="month-swipe-hint" :class="{ next: monthSwipeDelta < 0 }" aria-hidden="true">
<ChevronRight v-if="monthSwipeDelta < 0" :size="14" />
<ChevronLeft v-else :size="14" />
<span>{{ monthSwipePreview }}</span>
</div>
</div>
<div
v-if="pullEdge === 'top' && pullDistance > 8"
:class="['ledger-pull-space', 'top', pullAction]"
:style="{ height: `${pullDistance}px`, '--pull-progress': pullProgress }"
role="status"
>
<RefreshCw v-if="pullAction !== 'previous'" :size="15" :class="{ spinning: pullLoading }" />
<ChevronDown v-else :size="15" />
<span>{{ pullLoading ? "正在加载" : pullLabel }}</span>
</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="categoryIcon(entry)" :size="21" />
</div>
<div class="entry-copy">
<strong>{{ entry.note || categoryLabel(entry) }}</strong>
<div class="entry-subline">
<span class="entry-recorder"><CircleUserRound :size="12" />{{ ledgerStore.memberName(entry.createdBy) }}</span>
<span class="entry-category-label">{{ categoryLabel(entry) }}</span>
<span v-if="store.isEntryPending(entry.id)" class="entry-sync-pending" role="img" aria-label="未同步云端" title="未同步云端"><CloudOff :size="13" /></span>
</div>
</div>
<div class="entry-value">
<strong :class="entry.type">
{{ entry.type === "expense" ? "" : "+" }}{{ showBaseCurrency && entry.baseAmount !== null
? `¥${formatMoney(entry.baseAmount)}`
: formatCurrencyAmount(entry.amount, entry.currency) }}
</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>
<div
v-if="pullEdge === 'bottom' && pullDistance > 8"
:class="['ledger-pull-space', 'bottom', pullAction]"
:style="{ height: `${pullDistance}px`, '--pull-progress': pullProgress }"
role="status"
>
<ChevronUp :size="15" />
<span>{{ pullLoading ? "正在加载" : pullLabel }}</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>{{ ledgerStore.currentMembers.length }} 位参与者</span>
</div>
<button type="button" aria-label="关闭" title="关闭" @click="participantOpen = false"><X :size="20" /></button>
</header>
<div class="share-member-list" aria-label="当前参与者">
<div v-for="member in ledgerStore.currentMembers" :key="member.id" class="share-member-row">
<span class="share-member-avatar" :class="member.role">{{ member.name.slice(0, 1) }}</span>
<span><strong>{{ member.name }}</strong><small>{{ member.role === "owner" ? "拥有者" : "成员" }}</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" :disabled="generatingInvitation" @click="copyInvitation"><Copy :size="18" />复制链接</button>
<button type="button" class="system-share-action" :disabled="generatingInvitation" @click="shareLedger"><Share2 :size="18" />分享账本</button>
</div>
<p class="share-feedback" role="status">{{ shareFeedback || "通过邀请链接加入此账本" }}</p>
</section>
</div>
</Transition>
<QuickEntryHost />
</main>
</template>