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

525 lines
36 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 { ArrowLeft, Check, ChevronDown, ChevronLeft, ChevronRight, CircleUserRound, Filter, Minus, ReceiptText, RotateCcw, WalletCards, X } from "@lucide/vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue";
import { categories, entryTypes, findCategory, findCategoryPath } from "../data/categories";
import { ledgerIconComponent } from "../data/ledger-icons";
import { ledgerTheme } from "../data/ledgers";
import { isReimbursement } from "../data/reimbursements";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
type RangeMode = "month" | "year" | "custom" | "all";
const UNCATEGORIZED_CATEGORY_ID = "uncategorized";
const route = useRoute();
const router = useRouter();
const entryStore = useEntryStore();
const ledgerStore = useLedgerStore();
const now = new Date();
const queryOpen = ref(false);
const expandedCategoryBranches = ref(new Set<string>());
function queryString(value: unknown) {
return typeof value === "string" ? value : "";
}
function queryDate(value: unknown, fallback: string) {
const candidate = queryString(value);
return /^\d{4}-\d{2}-\d{2}$/.test(candidate) ? candidate : fallback;
}
function parseList(value: unknown) {
return [...new Set(queryString(value).split(",").filter(Boolean))];
}
const rangeMode = ref<RangeMode>(["year", "custom", "all"].includes(queryString(route.query.range)) ? queryString(route.query.range) as RangeMode : "month");
const selectedMonth = ref(/^(\d{4})-(0[1-9]|1[0-2])$/.test(queryString(route.query.month)) ? queryString(route.query.month) : `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`);
const selectedYear = ref(/^\d{4}$/.test(queryString(route.query.year)) ? Number(queryString(route.query.year)) : now.getFullYear());
const customStart = ref(queryDate(route.query.start, `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`));
const customEnd = ref(queryDate(route.query.end, toDateInput(now)));
const selectedLedgers = ref(parseList(route.query.ledgers));
const selectedCategories = ref(parseList(route.query.cat));
const keyword = ref(queryString(route.query.q));
const effectiveLedgerIds = computed(() => selectedLedgers.value.length ? selectedLedgers.value : (ledgerStore.currentLedgerId ? [ledgerStore.currentLedgerId] : []));
const categoryTrees = computed(() => entryTypes.map((type) => ({
...type,
parents: categories[type.id].map((parent) => ({
...parent,
children: [...(parent.children ?? []), { id: "other", label: "其他", color: parent.color, tint: parent.tint, icon: WalletCards }],
})),
})));
const allRangeBounds = computed(() => {
const dates = entryStore.entries
.filter((entry) => entry.ledgerIds.some((ledgerId) => effectiveLedgerIds.value.includes(ledgerId)))
.map((entry) => new Date(entry.occurredAt).getTime())
.filter((value) => Number.isFinite(value));
if (!dates.length) return null;
const first = new Date(Math.min(...dates));
const last = new Date(Math.max(...dates));
return {
start: new Date(first.getFullYear(), first.getMonth(), first.getDate()),
end: new Date(last.getFullYear(), last.getMonth(), last.getDate() + 1),
};
});
const rangeBounds = computed(() => {
if (rangeMode.value === "all") return allRangeBounds.value;
if (rangeMode.value === "month") {
const [year, month] = selectedMonth.value.split("-").map(Number);
const start = new Date(year, month - 1, 1);
return { start, end: new Date(year, month, 1) };
}
if (rangeMode.value === "year") {
return { start: new Date(selectedYear.value, 0, 1), end: new Date(selectedYear.value + 1, 0, 1) };
}
const start = fromDateInput(customStart.value);
const selectedEnd = fromDateInput(customEnd.value);
if (!start || !selectedEnd || start > selectedEnd) return null;
const end = new Date(selectedEnd);
end.setDate(end.getDate() + 1);
return { start, end };
});
function matchesCategory(entry: LedgerEntry, key: string) {
const parts = key.split(":");
if (parts.length < 2 || entry.type !== parts[0]) return false;
const path = findCategoryPath(entry.type, entry.categoryId);
if (parts.length === 2) {
if (parts[1] === UNCATEGORIZED_CATEGORY_ID) return path.length === 0;
return parts[1] === "*" || path.some((item) => item.id === parts[1]);
}
return path[0]?.id === parts[1] && (parts[2] === "other" ? entry.categoryId === "other" : entry.categoryId === parts[2]);
}
const categoryLabel = computed(() => {
if (!selectedCategories.value.length) return "全部分类";
const labels = selectedCategories.value.map((key) => {
const separator = key.indexOf(":");
if (separator < 1) return "";
const parts = key.split(":");
const type = parts[0] as LedgerEntry["type"];
if (parts[1] === "*") return entryTypes.find((item) => item.id === type)?.label ?? "";
if (parts[1] === UNCATEGORIZED_CATEGORY_ID) {
const typeLabel = entryTypes.find((item) => item.id === type)?.label ?? "";
return typeLabel ? `${typeLabel} · 未分类` : "未分类";
}
const path = findCategoryPath(type, parts[1]);
if (parts.length === 2) return path.map((item) => item.label).join(" · ");
return `${path[0]?.label ?? ""} · ${parts[2] === "other" ? "其他" : findCategoryPath(type, parts[2]).at(-1)?.label ?? parts[2]}`;
}).filter(Boolean);
return labels.length > 2 ? `${labels.slice(0, 2).join("、")}${labels.length}` : labels.join("、");
});
const ledgerLabel = computed(() => {
const names = effectiveLedgerIds.value.map((id) => ledgerStore.ledgers.find((ledger) => ledger.id === id)?.name).filter(Boolean) as string[];
return names.length > 2 ? `${names.slice(0, 2).join("、")}${names.length}` : names.join("、") || "全部账本";
});
const selectedCategorySummary = computed(() => {
let parentCount = 0;
let childCount = 0;
for (const tree of categoryTrees.value) {
for (const parent of tree.parents) {
const selected = selectedChildCount(tree.id, parent.id);
if (selected) parentCount += 1;
childCount += selected;
}
}
const uncategorized = entryTypes
.filter((type) => isUncategorizedSelected(type.id))
.map((type) => `${type.label}未分类`);
return [`${parentCount} 个大类,${childCount} 个小类`, ...uncategorized].join("");
});
const entries = computed(() => {
const bounds = rangeBounds.value;
if (!bounds) return [];
return entryStore.entries
.filter((entry) => {
const occurredAt = new Date(entry.occurredAt);
if (!entry.ledgerIds.some((ledgerId) => effectiveLedgerIds.value.includes(ledgerId)) || occurredAt < bounds.start || occurredAt >= bounds.end) return false;
const tokens = keyword.value.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
if (tokens.some((token) => !entry.note.toLocaleLowerCase().includes(token))) return false;
return !selectedCategories.value.length || selectedCategories.value.some((key) => matchesCategory(entry, key));
})
.sort((left, right) => right.occurredAt.localeCompare(left.occurredAt));
});
const groupedEntries = computed(() => {
const groups = new Map<string, LedgerEntry[]>();
for (const entry of entries.value) {
const date = localDateKey(entry.occurredAt);
groups.set(date, [...(groups.get(date) ?? []), entry]);
}
return [...groups.entries()].map(([date, items]) => ({ date, entries: items }));
});
const rangeLabel = computed(() => {
if (rangeMode.value === "month") {
const [year, month] = selectedMonth.value.split("-");
return `${year}${Number(month)}`;
}
if (rangeMode.value === "year") return `${selectedYear.value}`;
if (rangeMode.value === "all" && rangeBounds.value) {
return `${formatFullDateLabel(toDateInput(rangeBounds.value.start))}${formatFullDateLabel(toDateInput(new Date(rangeBounds.value.end.getTime() - 86400000)))}`;
}
return `${customStart.value}${customEnd.value}`;
});
const currentTheme = computed(() => ledgerTheme(ledgerStore.currentLedger?.theme));
const yearOptions = computed(() => {
const years = new Set([now.getFullYear(), ...entryStore.entries.map((entry) => new Date(entry.occurredAt).getFullYear())]);
return [...years].sort((left, right) => right - left);
});
function toggleListValue(target: typeof selectedLedgers, value: string) {
target.value = target.value.includes(value) ? target.value.filter((item) => item !== value) : [...target.value, value];
}
function toggleLedger(id: string) {
if (selectedLedgers.value.length === 1 && selectedLedgers.value[0] === id) return;
toggleListValue(selectedLedgers, id);
}
function parentKey(type: LedgerEntry["type"], parentId: string) {
return `${type}:${parentId}`;
}
function childKey(type: LedgerEntry["type"], parentId: string, childId: string) {
return `${type}:${parentId}:${childId}`;
}
function isParentFullySelected(type: LedgerEntry["type"], parentId: string) {
const parent = categoryTrees.value.find((tree) => tree.id === type)?.parents.find((item) => item.id === parentId);
if (!parent) return false;
return selectedCategories.value.includes(parentKey(type, parentId))
|| parent.children.every((child) => selectedCategories.value.includes(childKey(type, parentId, child.id)));
}
function isChildSelected(type: LedgerEntry["type"], parentId: string, childId: string) {
return selectedCategories.value.includes(parentKey(type, parentId))
|| selectedCategories.value.includes(childKey(type, parentId, childId));
}
function isParentPartiallySelected(type: LedgerEntry["type"], parentId: string) {
return selectedChildCount(type, parentId) > 0 && !isParentFullySelected(type, parentId);
}
function selectedChildCount(type: LedgerEntry["type"], parentId: string) {
const parent = categoryTrees.value.find((tree) => tree.id === type)?.parents.find((item) => item.id === parentId);
if (!parent) return 0;
if (selectedCategories.value.includes(parentKey(type, parentId))) return parent.children.length;
return parent.children.filter((child) => selectedCategories.value.includes(childKey(type, parentId, child.id))).length;
}
function selectedTypeCount(type: LedgerEntry["type"]) {
const categorized = categoryTrees.value.find((tree) => tree.id === type)?.parents.reduce((total, parent) => total + selectedChildCount(type, parent.id), 0) ?? 0;
return categorized + (isUncategorizedSelected(type) ? 1 : 0);
}
function uncategorizedKey(type: LedgerEntry["type"]) {
return `${type}:${UNCATEGORIZED_CATEGORY_ID}`;
}
function isUncategorizedSelected(type: LedgerEntry["type"]) {
return selectedCategories.value.includes(uncategorizedKey(type));
}
function toggleUncategorized(type: LedgerEntry["type"]) {
toggleListValue(selectedCategories, uncategorizedKey(type));
}
function isCategoryBranchExpanded(type: LedgerEntry["type"], parentId: string) {
return expandedCategoryBranches.value.has(parentKey(type, parentId));
}
function toggleCategoryBranch(type: LedgerEntry["type"], parentId: string) {
const key = parentKey(type, parentId);
const next = new Set(expandedCategoryBranches.value);
if (next.has(key)) next.delete(key);
else next.add(key);
expandedCategoryBranches.value = next;
}
function toggleAllCategory(type: LedgerEntry["type"], parentId: string) {
const key = parentKey(type, parentId);
const parent = categoryTrees.value.find((tree) => tree.id === type)?.parents.find((item) => item.id === parentId);
if (!parent) return;
const next = selectedCategories.value.filter((item) => item !== key && !item.startsWith(`${key}:`));
if (!isParentFullySelected(type, parentId)) next.push(key);
selectedCategories.value = next;
}
function toggleChildCategory(type: LedgerEntry["type"], parentId: string, childId: string) {
const parent = categoryTrees.value.find((tree) => tree.id === type)?.parents.find((item) => item.id === parentId);
if (!parent) return;
const allChildren = parent.children.map((child) => childKey(type, parentId, child.id));
let next = selectedCategories.value.filter((item) => item !== parentKey(type, parentId) && !item.startsWith(`${parentKey(type, parentId)}:`));
const selected = isChildSelected(type, parentId, childId);
if (selected && selectedCategories.value.includes(parentKey(type, parentId))) {
next.push(...allChildren.filter((key) => key !== childKey(type, parentId, childId)));
} else if (!selected) {
next = [...selectedCategories.value, childKey(type, parentId, childId)];
} else {
next = selectedCategories.value.filter((key) => key !== childKey(type, parentId, childId));
}
selectedCategories.value = [...new Set(next)];
}
function chooseMonth(month: number) {
selectedMonth.value = `${selectedYear.value}-${String(month + 1).padStart(2, "0")}`;
rangeMode.value = "month";
}
function shiftYear(offset: -1 | 1) {
const index = yearOptions.value.indexOf(selectedYear.value);
const next = yearOptions.value[index + (offset < 0 ? 1 : -1)];
if (next !== undefined) selectedYear.value = next;
}
function syncFromRoute() {
const range = queryString(route.query.range);
rangeMode.value = ["year", "custom", "all"].includes(range) ? range as RangeMode : "month";
selectedMonth.value = /^(\d{4})-(0[1-9]|1[0-2])$/.test(queryString(route.query.month)) ? queryString(route.query.month) : selectedMonth.value;
selectedYear.value = /^\d{4}$/.test(queryString(route.query.year)) ? Number(route.query.year) : selectedYear.value;
customStart.value = queryDate(route.query.start, customStart.value);
customEnd.value = queryDate(route.query.end, customEnd.value);
selectedLedgers.value = parseList(route.query.ledgers);
selectedCategories.value = parseList(route.query.cat);
keyword.value = queryString(route.query.q);
}
watch(() => route.query, syncFromRoute, { deep: true });
watch([rangeMode, selectedMonth, selectedYear, customStart, customEnd, selectedLedgers, selectedCategories, keyword], () => {
if (route.name !== "query") return;
const query = { ...route.query };
query.range = rangeMode.value;
query.month = selectedMonth.value;
query.year = String(selectedYear.value);
query.start = customStart.value;
query.end = customEnd.value;
if (selectedLedgers.value.length) query.ledgers = selectedLedgers.value.join(",");
else delete query.ledgers;
if (selectedCategories.value.length) query.cat = selectedCategories.value.join(",");
else delete query.cat;
if (keyword.value.trim()) query.q = keyword.value.trim();
else delete query.q;
void router.replace({ query });
});
onMounted(async () => {
await Promise.all([entryStore.loadEntries(), ledgerStore.loadLedgers()]);
if (!selectedLedgers.value.length && ledgerStore.currentLedgerId) selectedLedgers.value = [ledgerStore.currentLedgerId];
});
function entryCategoryLabel(entry: LedgerEntry) {
if (isReimbursement(entry)) {
const source = entryStore.entries.find((item) => item.id === entry.reimbursementOfEntryId);
const label = source ? findCategoryPath("expense", source.categoryId).at(-1)?.label : "";
return `报销${label ? ` · ${label}` : ""}`;
}
return findCategoryPath(entry.type, entry.categoryId).map((item) => item.label).join(" · ")
|| (entry.type === "expense" ? "支出" : "收入");
}
function entryIcon(entry: LedgerEntry) {
if (isReimbursement(entry)) return RotateCcw;
return findCategory(entry.categoryId)?.icon ?? entryTypes.find((item) => item.id === entry.type)?.icon ?? ReceiptText;
}
function entryAmount(entry: LedgerEntry) {
if (entry.baseAmount !== null) return `${entry.type === "expense" ? "" : "+"}¥${money(entry.baseAmount)}`;
return `${entry.type === "expense" ? "" : "+"}${formatCurrencyAmount(entry.amount, entry.currency)}`;
}
function dayLabel(value: string) {
const date = new Date(`${value}T00:00:00`);
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}日 · ${new Intl.DateTimeFormat("zh-CN", { weekday: "short" }).format(date)}`;
}
function money(value: number) {
return (value / 100).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function toDateInput(value: Date) {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
}
function formatFullDateLabel(value: string) {
const date = fromDateInput(value);
return date ? `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}` : value;
}
function fromDateInput(value: string) {
const [year, month, day] = value.split("-").map(Number);
if (!year || !month || !day) return null;
return new Date(year, month - 1, day);
}
function localDateKey(value: string) {
return toDateInput(new Date(value));
}
</script>
<template>
<main class="app-shell stats-entries-shell">
<header class="stats-entries-header" :style="{ '--ledger-gradient': currentTheme.gradient }">
<button type="button" aria-label="返回" title="返回" @click="router.back()"><ArrowLeft :size="21" /></button>
<div><strong>查询</strong><span>{{ rangeLabel }} · {{ ledgerLabel }} · {{ selectedCategorySummary }}</span></div>
<button type="button" aria-label="打开查询条件" title="查询条件" @click="queryOpen = true"><Filter :size="20" /></button>
</header>
<section class="query-summary">
<button type="button" @click="queryOpen = true"><span>账本</span><strong>{{ ledgerLabel }}</strong><ChevronDown :size="15" /></button>
<button type="button" @click="queryOpen = true"><span>时间</span><strong>{{ rangeLabel }}</strong><ChevronDown :size="15" /></button>
<button type="button" @click="queryOpen = true"><span>分类</span><strong>{{ selectedCategorySummary }}</strong><ChevronDown :size="15" /></button>
<label class="query-keyword"><span>备注</span><input v-model="keyword" type="search" placeholder="支持模糊匹配" autocomplete="off" /></label>
</section>
<div class="stats-entries-scroll">
<div v-if="!entries.length" class="stats-entries-empty"><ReceiptText :size="28" /><strong>没有符合条件的流水</strong><span>{{ rangeLabel }}暂无{{ categoryLabel }}记录</span></div>
<section v-for="group in groupedEntries" :key="group.date" class="stats-entries-day">
<header><strong>{{ dayLabel(group.date) }}</strong><span>{{ group.entries.length }} 笔</span></header>
<RouterLink v-for="entry in group.entries" :key="entry.id" :to="`/entries/${entry.id}`" class="stats-entry-row">
<span class="stats-entry-icon" :style="{ color: isReimbursement(entry) ? '#287c70' : findCategory(entry.categoryId)?.color ?? '#087f72', background: isReimbursement(entry) ? '#e8f6f2' : findCategory(entry.categoryId)?.tint ?? '#e9f7f3' }"><component :is="entryIcon(entry)" :size="19" /></span>
<span class="stats-entry-copy"><strong>{{ entry.note || entryCategoryLabel(entry) }}</strong><small><CircleUserRound :size="12" />{{ entryCategoryLabel(entry) }}</small></span>
<strong class="stats-entry-amount" :class="entry.type">{{ entryAmount(entry) }}</strong>
</RouterLink>
</section>
</div>
<Transition name="month-picker">
<div v-if="queryOpen" class="month-picker-layer query-layer">
<button class="month-picker-scrim" type="button" aria-label="关闭查询条件" @click="queryOpen = false"></button>
<section class="month-picker-modal query-modal" role="dialog" aria-modal="true" aria-label="查询条件">
<header><span></span><strong>查询条件</strong><button type="button" aria-label="关闭" title="关闭" @click="queryOpen = false"><X :size="20" /></button></header>
<div class="query-modal-scroll">
<section class="query-filter-section"><strong>账本</strong><div class="query-option-grid">
<button v-for="ledger in ledgerStore.ledgers" :key="ledger.id" type="button" class="query-ledger-option" :class="{ selected: selectedLedgers.includes(ledger.id) }" @click="toggleLedger(ledger.id)"><span class="query-ledger-icon" :style="{ background: ledgerTheme(ledger.theme).gradient }"><component :is="ledgerIconComponent(ledger.icon)" :size="17" /></span><span>{{ ledger.name }}</span><Check v-if="selectedLedgers.includes(ledger.id)" :size="15" /></button>
</div></section>
<section class="query-filter-section"><strong>时间范围</strong><div class="stats-range-modes"><button type="button" :class="{ active: rangeMode === 'month' }" @click="rangeMode = 'month'">月</button><button type="button" :class="{ active: rangeMode === 'year' }" @click="rangeMode = 'year'">年</button><button type="button" :class="{ active: rangeMode === 'custom' }" @click="rangeMode = 'custom'">自定义</button><button type="button" :class="{ active: rangeMode === 'all' }" @click="rangeMode = 'all'">全部</button></div>
<template v-if="rangeMode === 'month'"><div class="query-year-nav"><button type="button" @click="shiftYear(-1)"><ChevronLeft :size="18" /></button><strong>{{ selectedYear }}</strong><button type="button" @click="shiftYear(1)"><ChevronRight :size="18" /></button></div><div class="query-month-grid"><button v-for="month in 12" :key="month" type="button" :class="{ selected: selectedMonth === `${selectedYear}-${String(month).padStart(2, '0')}` }" @click="chooseMonth(month - 1)">{{ month }}月</button></div></template>
<div v-else-if="rangeMode === 'year'" class="query-year-list"><button v-for="year in yearOptions" :key="year" type="button" :class="{ selected: selectedYear === year }" @click="selectedYear = year">{{ year }}年</button></div>
<div v-else-if="rangeMode === 'all'" class="query-all-range">{{ rangeLabel }}</div>
<div v-else class="query-custom-range"><label>开始<input v-model="customStart" type="date" /></label><label>结束<input v-model="customEnd" type="date" /></label></div>
</section>
<label class="query-modal-keyword"><strong>备注</strong><input v-model="keyword" type="search" placeholder="支持模糊匹配,关键字用空格分隔" autocomplete="off" /></label>
<section class="query-filter-section"><strong>分类</strong>
<div class="query-category-columns">
<div v-for="tree in categoryTrees" :key="tree.id" class="query-category-tree">
<header><span :style="{ color: tree.color }">{{ tree.label }}</span><b v-if="selectedTypeCount(tree.id)">{{ selectedTypeCount(tree.id) }}</b></header>
<div v-for="parent in tree.parents" :key="parent.id" class="query-category-branch">
<div class="query-category-parent"><button class="query-category-check" type="button" :class="{ selected: isParentFullySelected(tree.id, parent.id), partial: isParentPartiallySelected(tree.id, parent.id) }" :aria-label="`${isParentFullySelected(tree.id, parent.id) ? '取消' : '选择'}全部${parent.label}`" :aria-checked="isParentPartiallySelected(tree.id, parent.id) ? 'mixed' : isParentFullySelected(tree.id, parent.id)" role="checkbox" @click.stop="toggleAllCategory(tree.id, parent.id)"><Check v-if="isParentFullySelected(tree.id, parent.id)" :size="14" /><Minus v-else-if="isParentPartiallySelected(tree.id, parent.id)" :size="14" /></button><button type="button" class="query-category-main" :class="{ selected: isParentFullySelected(tree.id, parent.id) }" :aria-label="`${isCategoryBranchExpanded(tree.id, parent.id) ? '收起' : '展开'}${parent.label}`" @click="toggleCategoryBranch(tree.id, parent.id)"><component :is="parent.icon" :size="17" /><span>{{ parent.label }}</span><b v-if="selectedChildCount(tree.id, parent.id)">{{ selectedChildCount(tree.id, parent.id) }}</b></button><button class="query-category-toggle" type="button" :aria-label="`${isCategoryBranchExpanded(tree.id, parent.id) ? '收起' : '展开'}${parent.label}`" @click="toggleCategoryBranch(tree.id, parent.id)"><ChevronDown v-if="isCategoryBranchExpanded(tree.id, parent.id)" :size="14" /><ChevronRight v-else :size="14" /></button></div>
<div v-if="isCategoryBranchExpanded(tree.id, parent.id)" class="query-category-children"><button type="button" class="query-category-all" :class="{ selected: isParentFullySelected(tree.id, parent.id) }" @click="toggleAllCategory(tree.id, parent.id)">全部<Check v-if="isParentFullySelected(tree.id, parent.id)" :size="13" /></button><button v-for="child in parent.children" :key="child.id" type="button" :class="{ selected: isChildSelected(tree.id, parent.id, child.id) }" @click="toggleChildCategory(tree.id, parent.id, child.id)">{{ child.label }}<Check v-if="isChildSelected(tree.id, parent.id, child.id)" :size="13" /></button></div>
</div>
<button type="button" class="query-uncategorized" :class="{ selected: isUncategorizedSelected(tree.id) }" :aria-label="`${isUncategorizedSelected(tree.id) ? '取消' : '选择'}${tree.label}未分类`" @click="toggleUncategorized(tree.id)"><ReceiptText :size="15" /><span>未分类</span><Check v-if="isUncategorizedSelected(tree.id)" :size="14" /></button>
</div>
</div>
</section>
</div>
<button class="query-done" type="button" @click="queryOpen = false">完成</button>
</section>
</div>
</Transition>
<QuickEntryHost />
</main>
</template>
<style scoped>
.stats-entries-shell { display:flex; flex-direction:column; background:#f5f8f7; }
.stats-entries-header { height:74px; display:grid; grid-template-columns:40px 1fr 40px; align-items:center; gap:8px; padding:14px 16px 8px; background:var(--ledger-gradient,#087f72); color:#fff; }
.stats-entries-header > button { width:38px; height:38px; display:grid; place-items:center; border:0; border-radius:8px; background:rgba(255,255,255,.14); color:#fff; }
.stats-entries-header > div { min-width:0; display:grid; gap:2px; text-align:center; }
.stats-entries-header strong { font-size:17px; }
.stats-entries-header span { overflow:hidden; color:rgba(255,255,255,.76); font-size:11px; text-overflow:ellipsis; white-space:nowrap; }
.query-summary { position:sticky; top:0; z-index:2; display:grid; grid-template-columns:minmax(0,1fr); gap:7px; border-bottom:1px solid #dce7e4; padding:10px 16px; background:#f7faf9; box-shadow:0 2px 7px rgba(31,57,51,.06); }
.query-summary > button,.query-keyword { min-width:0; min-height:42px; display:grid; grid-template-columns:auto minmax(0,1fr) 15px; align-items:center; gap:5px; border:1px solid #d2dfdc; border-radius:8px; padding:0 9px; background:#fff; color:#52645e; text-align:left; }
.query-summary > button span,.query-keyword span { color:#84928e; font-size:10px; }
.query-summary > button strong { overflow:hidden; color:#344640; font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
.query-keyword { grid-column:auto; grid-template-columns:auto minmax(0,1fr); }
.query-keyword input,.query-modal-keyword input { width:100%; min-width:0; border:0; outline:0; background:transparent; color:#26342f; font-size:13px; }
.query-layer { z-index:17; }
.query-modal { max-height:90%; display:flex; flex-direction:column; overflow:hidden; }
.query-modal > header,.query-done { flex:0 0 auto; }
.query-modal-scroll { min-height:0; flex:1; overflow-y:auto; overscroll-behavior:contain; }
.query-filter-section { border-bottom:1px solid #e1ebe8; padding:14px 0; }
.query-filter-section > strong,.query-modal-keyword > strong { display:block; margin-bottom:9px; color:#7b8884; font-size:11px; }
.query-filter-section .stats-range-modes { display:grid; grid-template-columns:repeat(4,1fr); gap:4px; border-radius:8px; padding:4px; background:#e7eeec; }
.query-filter-section .stats-range-modes button { height:34px; border:0; border-radius:6px; background:transparent; color:#71807c; font-size:12px; font-weight:680; }
.query-filter-section .stats-range-modes button.active { background:#fff; color:#087f72; box-shadow:0 2px 7px rgba(28,52,47,.1); }
.query-option-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; }
.query-option-grid button { min-height:40px; display:flex; align-items:center; justify-content:space-between; gap:5px; border:1px solid #e1ebe8; border-radius:8px; padding:0 9px; background:#fff; color:#344640; text-align:left; }
.query-option-grid button.selected { border-color:#087f72; background:#eaf7f3; color:#087f72; box-shadow:inset 0 0 0 1px #087f72; font-weight:720; }
.query-ledger-option { justify-content:flex-start!important; }
.query-ledger-option > span:nth-child(2) { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.query-ledger-icon { width:26px; height:26px; display:grid; flex:0 0 auto; place-items:center; border-radius:6px; color:#fff; }
.query-year-nav { display:grid; grid-template-columns:38px 1fr 38px; align-items:center; gap:4px; margin:14px 0 10px; }
.query-year-nav button { min-height:34px; display:grid; place-items:center; border:0; border-radius:7px; background:transparent; color:#536762; }
.query-year-nav strong { text-align:center; color:#087f72; font-size:15px; }
.query-month-grid { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:7px; margin-top:8px; }
.query-month-grid button,.query-year-list button { min-height:42px; border:1px solid #e1ebe8; border-radius:8px; background:#fff; color:#344640; font-size:12px; }
.query-month-grid button.selected,.query-year-list button.selected { border-color:#087f72; background:#eaf7f3; color:#087f72; font-weight:720; }
.query-year-list { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:7px; margin-top:12px; }
.query-custom-range { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; margin-top:12px; }
.query-custom-range label { display:grid; gap:4px; color:#7b8884; font-size:10px; }
.query-custom-range input { width:100%; min-width:0; height:38px; border:1px solid #d2dfdc; border-radius:7px; padding:0 6px; background:#fff; color:#344640; font-size:12px; }
.query-all-range { margin-top:12px; border:1px solid #d2dfdc; border-radius:8px; padding:11px 10px; background:#f8fbfa; color:#536762; font-size:12px; text-align:center; }
.query-category-head { display:flex; align-items:center; justify-content:space-between; min-height:36px; margin-bottom:7px; color:#536762; font-size:12px; }
.query-category-head button { display:flex; align-items:center; gap:3px; border:0; background:transparent; color:#087f72; font-size:12px; }
.query-category-list { display:grid; gap:6px; }
.query-category-list > div { display:grid; grid-template-columns:minmax(0,1fr) 36px; gap:5px; }
.query-category-main,.query-category-next { min-height:40px; display:flex; align-items:center; gap:7px; border:1px solid #e1ebe8; background:#fff; color:#344640; }
.query-category-main { border-radius:8px 0 0 8px; padding:0 9px; text-align:left; }
.query-category-main.selected { border-color:#087f72; background:#eaf7f3; color:#087f72; font-weight:720; }
.query-category-next { justify-content:center; border-radius:0 8px 8px 0; color:#7b8884; }
.query-category-columns { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:9px; align-items:start; }
.query-category-tree { min-width:0; border:1px solid #e1ebe8; border-radius:9px; padding:7px; background:#fbfdfc; }
.query-category-tree > header { min-height:27px; display:flex; align-items:center; justify-content:space-between; padding:0 3px 6px; border-bottom:1px solid #e5edeb; font-size:12px; font-weight:760; }
.query-category-tree > header b,.query-category-parent b { min-width:17px; height:17px; display:inline-grid; place-items:center; border-radius:9px; background:#eaf7f3; color:#087f72; font-size:10px; font-weight:760; }
.query-category-branch { padding:6px 0 4px; border-bottom:1px solid #edf2f0; }
.query-category-branch:last-child { border-bottom:0; }
.query-category-parent { display:grid; grid-template-columns:22px minmax(0,1fr) 18px; gap:3px; }
.query-category-parent button { min-width:0; min-height:30px; display:flex; align-items:center; gap:4px; border:0; border-radius:6px; padding:0 4px; background:transparent; color:#344640; font-size:12px; text-align:left; }
.query-category-check { width:20px; height:20px; min-height:20px!important; justify-self:center; align-self:center; justify-content:center; border:1px solid #cbd9d5!important; border-radius:5px!important; padding:0!important; background:#fff!important; color:#fff!important; }
.query-category-check.selected { border-color:#087f72!important; background:#087f72!important; }
.query-category-check.partial { border-color:#087f72!important; background:#eaf7f3!important; color:#087f72!important; }
.query-category-main { border:0!important; background:transparent!important; }
.query-category-main.selected { background:#eaf7f3!important; color:#087f72; font-weight:720; }
.query-category-parent button span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.query-category-parent button b { margin-left:auto; }
.query-category-toggle { width:18px; min-height:30px; display:grid; place-items:center; border:0; border-radius:5px; background:transparent; color:#a1ada9; }
.query-category-children { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:3px; padding:4px 0 0 20px; }
.query-category-children button { min-width:0; min-height:27px; display:flex; align-items:center; justify-content:space-between; gap:2px; overflow:hidden; border:0; border-radius:5px; padding:0 3px; background:transparent; color:#71807c; font-size:11px; text-align:left; text-overflow:ellipsis; white-space:nowrap; }
.query-category-children button.selected { background:#eaf7f3; color:#087f72; font-weight:720; }
.query-category-children .query-category-all { color:#536762; font-weight:680; }
.query-uncategorized { width:100%; min-height:31px; display:flex; align-items:center; gap:5px; margin-top:6px; border:0; border-top:1px dashed #dbe5e2; padding:6px 4px 0; background:transparent; color:#7b8884; font-size:11px; text-align:left; }
.query-uncategorized svg:last-child { margin-left:auto; }
.query-uncategorized.selected { color:#087f72; font-weight:720; }
.query-modal-keyword { display:grid; gap:6px; border-bottom:1px solid #e1ebe8; padding:14px 0; }
.query-modal-keyword input { min-height:40px; border:1px solid #d2dfdc; border-radius:8px; padding:0 10px; background:#f8fbfa; }
.query-done { width:100%; min-height:44px; margin-top:14px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
.stats-entries-scroll { min-height:0; flex:1; overflow-y:auto; padding:0 16px 104px; }
.stats-entries-day { margin-top:14px; border-top:1px solid #dce7e4; border-bottom:1px solid #dce7e4; background:#fff; }
.stats-entries-day > header { min-height:42px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid #e4ece9; padding:0 12px; }
.stats-entries-day > header strong { color:#64726f; font-size:12px; }
.stats-entries-day > header span { color:#8a9994; font-size:11px; }
.stats-entry-row { min-height:64px; display:grid; grid-template-columns:36px minmax(0,1fr) auto; align-items:center; gap:10px; border-bottom:1px solid #edf2f0; padding:8px 12px; color:#26342f; text-decoration:none; }
.stats-entry-row:last-child { border-bottom:0; }
.stats-entry-icon { width:34px; height:34px; display:grid; place-items:center; border-radius:8px; }
.stats-entry-copy { min-width:0; display:grid; gap:4px; }
.stats-entry-copy strong { overflow:hidden; font-size:13px; text-overflow:ellipsis; white-space:nowrap; }
.stats-entry-copy small { display:flex; align-items:center; gap:3px; overflow:hidden; color:#7d8986; font-size:10px; text-overflow:ellipsis; white-space:nowrap; }
.stats-entry-amount { font-size:12px; font-variant-numeric:tabular-nums; white-space:nowrap; }
.stats-entry-amount.income { color:#0d8b67; }
.stats-entry-amount.expense { color:#d84c36; }
.stats-entries-empty { min-height:260px; display:grid; place-items:center; align-content:center; gap:8px; color:#7b8884; font-size:13px; text-align:center; }
.stats-entries-empty strong { color:#536762; }
</style>