392 lines
14 KiB
Vue
392 lines
14 KiB
Vue
<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>
|