525 lines
36 KiB
Vue
525 lines
36 KiB
Vue
<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>
|