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

392 lines
14 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 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 { apiRequest, ApiError } from "../data/api";
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 invitationLink = ref("");
const generatingInvitation = ref(false);
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;
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">
<div class="top-actions">
<button 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>
<button class="ledger-switcher" type="button" @click="router.push('/ledgers')">
<span>{{ ledgerStore.currentLedger?.name ?? "选择账本" }}</span>
<ChevronDown :size="16" />
</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>
<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" />{{ ledgerStore.memberName(entry.createdBy) }}</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>{{ 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>