939 lines
55 KiB
Vue
939 lines
55 KiB
Vue
<script setup lang="ts">
|
|
import type { LedgerEntry } from "@cents/domain";
|
|
import { ArrowLeft, BarChart3, CalendarDays, ChartNoAxesCombined, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, CircleUserRound, TrendingDown, TrendingUp, WalletCards, X } from "@lucide/vue";
|
|
import { computed, onMounted, ref, watch } from "vue";
|
|
import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
|
|
import QuickEntryHost from "../components/QuickEntryHost.vue";
|
|
import { entryTypes, findCategoryPath } from "../data/categories";
|
|
import { ledgerTheme } from "../data/ledgers";
|
|
import { isReimbursement, netBaseAmount } from "../data/reimbursements";
|
|
import { useEntryStore } from "../stores/entries";
|
|
import { useLedgerStore } from "../stores/ledgers";
|
|
|
|
type StatsTab = "flow" | "category" | "member";
|
|
type RangeMode = "month" | "year" | "custom" | "all";
|
|
type DailyView = "calendar" | "line" | "bar";
|
|
type FlowGranularity = "day" | "month" | "year";
|
|
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
const entryStore = useEntryStore();
|
|
const ledgerStore = useLedgerStore();
|
|
const now = new Date();
|
|
|
|
function queryString(value: unknown) {
|
|
return typeof value === "string" ? value : "";
|
|
}
|
|
|
|
function isStatsTab(value: string): value is StatsTab {
|
|
return value === "flow" || value === "category" || value === "member";
|
|
}
|
|
|
|
function isRangeMode(value: string): value is RangeMode {
|
|
return value === "month" || value === "year" || value === "custom" || value === "all";
|
|
}
|
|
|
|
function queryMonth(value: string) {
|
|
return /^\d{4}-(0[1-9]|1[0-2])$/.test(value) ? value : `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
|
}
|
|
|
|
function queryYear(value: string) {
|
|
return /^\d{4}$/.test(value) ? Number(value) : now.getFullYear();
|
|
}
|
|
|
|
function queryDate(value: string, fallback: string) {
|
|
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : fallback;
|
|
}
|
|
|
|
const tabQuery = queryString(route.query.tab);
|
|
const rangeQuery = queryString(route.query.range);
|
|
const dailyViewQuery = queryString(route.query.view);
|
|
const granularityQuery = queryString(route.query.granularity);
|
|
const activeTab = ref<StatsTab>(isStatsTab(tabQuery) ? tabQuery : "flow");
|
|
const rangeMode = ref<RangeMode>(isRangeMode(rangeQuery) ? rangeQuery : "month");
|
|
const dailyView = ref<DailyView>(dailyViewQuery === "line" || dailyViewQuery === "bar" ? dailyViewQuery : "calendar");
|
|
const flowGranularity = ref<FlowGranularity>(granularityQuery === "month" || granularityQuery === "year" ? granularityQuery : "day");
|
|
const rangePickerOpen = ref(false);
|
|
const rangePickerView = ref<"months" | "years">("months");
|
|
const rangePickerYear = ref(now.getFullYear());
|
|
const selectedMonth = ref(queryMonth(queryString(route.query.month)));
|
|
const selectedYear = ref(queryYear(queryString(route.query.year)));
|
|
const customStart = ref(queryDate(queryString(route.query.start), `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`));
|
|
const customEnd = ref(queryDate(queryString(route.query.end), toDateInput(now)));
|
|
const currentTheme = computed(() => ledgerTheme(ledgerStore.currentLedger?.theme));
|
|
|
|
function syncStatsStateFromRoute() {
|
|
const tab = queryString(route.query.tab);
|
|
const range = queryString(route.query.range);
|
|
activeTab.value = isStatsTab(tab) ? tab : "flow";
|
|
rangeMode.value = isRangeMode(range) ? range : "month";
|
|
const view = queryString(route.query.view);
|
|
dailyView.value = view === "line" || view === "bar" ? view : "calendar";
|
|
const granularity = queryString(route.query.granularity);
|
|
flowGranularity.value = granularity === "month" || granularity === "year" ? granularity : "day";
|
|
selectedMonth.value = queryMonth(queryString(route.query.month));
|
|
selectedYear.value = queryYear(queryString(route.query.year));
|
|
customStart.value = queryDate(queryString(route.query.start), `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`);
|
|
customEnd.value = queryDate(queryString(route.query.end), toDateInput(now));
|
|
}
|
|
|
|
watch(() => route.query, syncStatsStateFromRoute, { deep: true });
|
|
|
|
watch(
|
|
[activeTab, rangeMode, dailyView, flowGranularity, selectedMonth, selectedYear, customStart, customEnd],
|
|
() => {
|
|
if (route.name !== "stats") return;
|
|
|
|
const query = {
|
|
...route.query,
|
|
tab: activeTab.value,
|
|
range: rangeMode.value,
|
|
view: dailyView.value,
|
|
granularity: flowGranularity.value,
|
|
month: selectedMonth.value,
|
|
year: String(selectedYear.value),
|
|
start: customStart.value,
|
|
end: customEnd.value,
|
|
};
|
|
const current = [
|
|
queryString(route.query.tab),
|
|
queryString(route.query.range),
|
|
queryString(route.query.view),
|
|
queryString(route.query.granularity),
|
|
queryString(route.query.month),
|
|
queryString(route.query.year),
|
|
queryString(route.query.start),
|
|
queryString(route.query.end),
|
|
];
|
|
const next = [query.tab, query.range, query.view, query.granularity, query.month, query.year, query.start, query.end];
|
|
if (current.every((value, index) => value === next[index])) return;
|
|
void router.replace({ query });
|
|
},
|
|
);
|
|
|
|
const yearOptions = computed(() => {
|
|
const years = new Set<number>();
|
|
for (let year = now.getFullYear() + 1; year >= now.getFullYear() - 5; year -= 1) years.add(year);
|
|
for (const entry of entryStore.entries) years.add(new Date(entry.occurredAt).getFullYear());
|
|
return [...years].sort((left, right) => right - left);
|
|
});
|
|
|
|
const allRangeBounds = computed(() => {
|
|
const dates = entryStore.entries
|
|
.filter((entry) => entry.ledgerIds.includes(ledgerStore.currentLedgerId))
|
|
.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));
|
|
const start = new Date(first.getFullYear(), first.getMonth(), first.getDate());
|
|
const end = new Date(last.getFullYear(), last.getMonth(), last.getDate() + 1);
|
|
return { start, end };
|
|
});
|
|
|
|
const rangeBounds = computed(() => {
|
|
if (rangeMode.value === "all") return allRangeBounds.value;
|
|
if (rangeMode.value === "month") {
|
|
const match = /^(\d{4})-(\d{2})$/.exec(selectedMonth.value);
|
|
if (!match) return null;
|
|
const start = new Date(Number(match[1]), Number(match[2]) - 1, 1);
|
|
return { start, end: new Date(start.getFullYear(), start.getMonth() + 1, 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 };
|
|
});
|
|
|
|
const rangeLabel = computed(() => {
|
|
if (rangeMode.value === "month") {
|
|
const match = /^(\d{4})-(\d{2})$/.exec(selectedMonth.value);
|
|
return match ? `${match[1]}年${Number(match[2])}月` : "所选月份";
|
|
}
|
|
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)))}`;
|
|
}
|
|
if (!rangeBounds.value) return "自定义日期";
|
|
return `${formatDateLabel(customStart.value)} 至 ${formatDateLabel(customEnd.value)}`;
|
|
});
|
|
|
|
const entries = computed(() => {
|
|
const bounds = rangeBounds.value;
|
|
if (!bounds) return [];
|
|
return entryStore.entries.filter((entry) => {
|
|
const occurredAt = new Date(entry.occurredAt);
|
|
return entry.ledgerIds.includes(ledgerStore.currentLedgerId)
|
|
&& occurredAt >= bounds.start
|
|
&& occurredAt < bounds.end;
|
|
});
|
|
});
|
|
|
|
const totals = computed(() => summarize(entries.value));
|
|
const statsEntries = computed(() => entries.value.filter((entry) => !isReimbursement(entry)));
|
|
const convertedEntries = computed(() => statsEntries.value.filter((entry) => entry.baseAmount !== null));
|
|
const pendingConversionCount = computed(() => statsEntries.value.length - convertedEntries.value.length);
|
|
const calendarCursorMonth = ref(selectedMonth.value);
|
|
const calendarCursorYear = ref(selectedYear.value);
|
|
|
|
watch([rangeMode, selectedMonth, selectedYear, customStart, customEnd], () => {
|
|
const bounds = rangeBounds.value;
|
|
if (!bounds) return;
|
|
calendarCursorMonth.value = `${bounds.start.getFullYear()}-${String(bounds.start.getMonth() + 1).padStart(2, "0")}`;
|
|
calendarCursorYear.value = bounds.start.getFullYear();
|
|
}, { immediate: true });
|
|
|
|
const dailyStats = computed(() => {
|
|
const groups = new Map<string, LedgerEntry[]>();
|
|
for (const entry of convertedEntries.value) {
|
|
const date = new Date(entry.occurredAt);
|
|
const key = toDateInput(date);
|
|
groups.set(key, [...(groups.get(key) ?? []), entry]);
|
|
}
|
|
return Array.from(groups, ([date, items]) => ({ date, label: dailyLabel(date), ...summarize(items) }))
|
|
.sort((left, right) => right.date.localeCompare(left.date));
|
|
});
|
|
|
|
const maxDailyAmount = computed(() =>
|
|
Math.max(1, ...dailyStats.value.flatMap((day) => [day.income, day.expense])),
|
|
);
|
|
|
|
const flowStats = computed(() => {
|
|
if (flowGranularity.value === "day") return dailyStats.value;
|
|
const groups = new Map<string, LedgerEntry[]>();
|
|
for (const entry of convertedEntries.value) {
|
|
const date = new Date(entry.occurredAt);
|
|
const key = flowGranularity.value === "month"
|
|
? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`
|
|
: String(date.getFullYear());
|
|
groups.set(key, [...(groups.get(key) ?? []), entry]);
|
|
}
|
|
return [...groups.entries()]
|
|
.map(([date, items]) => ({ date, label: flowGranularity.value === "month" ? date.replace("-", "/") : `${date}年`, ...summarize(items) }))
|
|
.sort((left, right) => right.date.localeCompare(left.date));
|
|
});
|
|
|
|
const calendarMonthStart = computed(() => {
|
|
const bounds = rangeBounds.value;
|
|
if (!bounds) return null;
|
|
const match = /^(\d{4})-(\d{2})$/.exec(calendarCursorMonth.value);
|
|
const candidate = match ? new Date(Number(match[1]), Number(match[2]) - 1, 1) : bounds.start;
|
|
const first = new Date(bounds.start.getFullYear(), bounds.start.getMonth(), 1);
|
|
const last = new Date(bounds.end.getFullYear(), bounds.end.getMonth(), 1);
|
|
return candidate < first ? first : candidate >= last ? new Date(last.getFullYear(), last.getMonth() - 1, 1) : candidate;
|
|
});
|
|
|
|
const calendarMonthLabel = computed(() => {
|
|
const start = calendarMonthStart.value;
|
|
return start ? `${start.getFullYear()}年${start.getMonth() + 1}月` : "暂无日期";
|
|
});
|
|
|
|
const calendarDays = computed(() => {
|
|
const start = calendarMonthStart.value;
|
|
if (!start) return [];
|
|
const summary = new Map(dailyStats.value.map((day) => [day.date, day]));
|
|
const daysInMonth = new Date(start.getFullYear(), start.getMonth() + 1, 0).getDate();
|
|
const cells: Array<{ date: string; day: number; income: number; expense: number } | null> = Array.from({ length: start.getDay() }, () => null);
|
|
for (let day = 1; day <= daysInMonth; day += 1) {
|
|
const date = toDateInput(new Date(start.getFullYear(), start.getMonth(), day));
|
|
const item = summary.get(date);
|
|
cells.push({ date, day, income: item?.income ?? 0, expense: item?.expense ?? 0 });
|
|
}
|
|
return cells;
|
|
});
|
|
|
|
const calendarMonthCells = computed(() => {
|
|
if (!rangeBounds.value) return [];
|
|
const summary = new Map(flowStats.value.map((item) => [item.date, item]));
|
|
const cells: Array<{ date: string; label: string; income: number; expense: number }> = [];
|
|
for (let month = 0; month < 12; month += 1) {
|
|
const cursor = new Date(calendarCursorYear.value, month, 1);
|
|
const date = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}`;
|
|
const item = summary.get(date);
|
|
cells.push({ date, label: `${cursor.getFullYear()}年${cursor.getMonth() + 1}月`, income: item?.income ?? 0, expense: item?.expense ?? 0 });
|
|
}
|
|
return cells;
|
|
});
|
|
|
|
const calendarYearCells = computed(() => {
|
|
const bounds = rangeBounds.value;
|
|
if (!bounds) return [];
|
|
const summary = new Map(flowStats.value.map((item) => [item.date, item]));
|
|
const cells: Array<{ date: string; label: string; income: number; expense: number }> = [];
|
|
for (let year = bounds.start.getFullYear(); year <= new Date(bounds.end.getTime() - 1).getFullYear(); year += 1) {
|
|
const date = String(year);
|
|
const item = summary.get(date);
|
|
cells.push({ date, label: `${year}年`, income: item?.income ?? 0, expense: item?.expense ?? 0 });
|
|
}
|
|
return cells;
|
|
});
|
|
|
|
const lineStats = computed(() => [...flowStats.value].reverse());
|
|
const lineMaxAmount = computed(() => Math.max(1, ...lineStats.value.flatMap((day) => [day.income, day.expense])));
|
|
|
|
function linePoints(field: "income" | "expense") {
|
|
const values = lineStats.value;
|
|
if (!values.length) return "";
|
|
return values.map((day, index) => {
|
|
const x = values.length === 1 ? 168 : 32 + (index / (values.length - 1)) * 272;
|
|
const y = 154 - (day[field] / lineMaxAmount.value) * 126;
|
|
return `${x},${y}`;
|
|
}).join(" ");
|
|
}
|
|
|
|
function chartPoints(field: "income" | "expense") {
|
|
return lineStats.value.map((day, index) => {
|
|
const x = lineStats.value.length === 1 ? 168 : 32 + (index / (lineStats.value.length - 1)) * 272;
|
|
const y = 154 - (day[field] / lineMaxAmount.value) * 126;
|
|
return { x, y, key: `${field}-${day.date}` };
|
|
});
|
|
}
|
|
|
|
function chartBarWidth() {
|
|
return Math.max(2, Math.min(18, 240 / Math.max(1, lineStats.value.length)));
|
|
}
|
|
|
|
function chartBarX(index: number) {
|
|
const width = chartBarWidth();
|
|
const step = lineStats.value.length === 1 ? 0 : 272 / (lineStats.value.length - 1);
|
|
const center = lineStats.value.length === 1 ? 168 : 32 + index * step;
|
|
return center - width / 2;
|
|
}
|
|
|
|
function chartBarY(value: number) {
|
|
return 91 - (value / lineMaxAmount.value) * 63;
|
|
}
|
|
|
|
function chartBarHeight(value: number) {
|
|
return (value / lineMaxAmount.value) * 63;
|
|
}
|
|
|
|
const chartHoverIndex = ref<number | null>(null);
|
|
const hoveredChartPoint = computed(() => {
|
|
const index = chartHoverIndex.value;
|
|
const item = index === null ? null : lineStats.value[index];
|
|
if (!item || index === null) return null;
|
|
const x = lineStats.value.length === 1 ? 168 : 32 + (index / (lineStats.value.length - 1)) * 272;
|
|
return { ...item, x };
|
|
});
|
|
|
|
const chartTooltipStyle = computed(() => {
|
|
if (!hoveredChartPoint.value) return undefined;
|
|
const percentage = (hoveredChartPoint.value.x / 320) * 100;
|
|
return { left: `${Math.max(24, Math.min(76, percentage))}%` };
|
|
});
|
|
|
|
function updateChartHover(event: PointerEvent) {
|
|
const target = event.currentTarget as SVGElement | null;
|
|
if (!target || !lineStats.value.length) return;
|
|
const rect = target.getBoundingClientRect();
|
|
const viewBoxX = ((event.clientX - rect.left) / rect.width) * 320;
|
|
const normalizedX = Math.max(32, Math.min(304, viewBoxX));
|
|
const index = lineStats.value.length === 1
|
|
? 0
|
|
: Math.round(((normalizedX - 32) / 272) * (lineStats.value.length - 1));
|
|
chartHoverIndex.value = Math.max(0, Math.min(lineStats.value.length - 1, index));
|
|
}
|
|
|
|
function clearChartHover(event: PointerEvent) {
|
|
if (event.pointerType !== "touch") chartHoverIndex.value = null;
|
|
}
|
|
|
|
type CategorySummaryNode = {
|
|
key: string;
|
|
label: string;
|
|
color: string;
|
|
tint: string;
|
|
icon: typeof entryTypes[number]["icon"];
|
|
amount: number;
|
|
count: number;
|
|
children: CategorySummaryNode[];
|
|
};
|
|
|
|
const expandedCategoryKeys = ref(new Set<string>());
|
|
|
|
const categoryTree = computed(() => {
|
|
const roots = new Map<string, CategorySummaryNode>();
|
|
for (const entry of convertedEntries.value) {
|
|
const amount = entry.type === "expense" ? netBaseAmount(entry, entryStore.entries) ?? 0 : entry.baseAmount!;
|
|
const type = entryTypes.find((item) => item.id === entry.type);
|
|
if (!type) continue;
|
|
const root = roots.get(entry.type) ?? {
|
|
key: `${entry.type}:*`,
|
|
label: type.label,
|
|
color: type.color,
|
|
tint: type.tint,
|
|
icon: type.icon,
|
|
amount: 0,
|
|
count: 0,
|
|
children: [],
|
|
};
|
|
root.amount += amount;
|
|
root.count += 1;
|
|
|
|
const path = findCategoryPath(entry.type, entry.categoryId);
|
|
const groupCategory = path[0] ?? {
|
|
id: "uncategorized",
|
|
label: "未分类",
|
|
color: type.color,
|
|
tint: type.tint,
|
|
icon: WalletCards,
|
|
};
|
|
let group = root.children.find((item) => item.key === `${entry.type}:${groupCategory.id}`);
|
|
if (!group) {
|
|
group = {
|
|
key: `${entry.type}:${groupCategory.id}`,
|
|
label: groupCategory.label,
|
|
color: groupCategory.color,
|
|
tint: groupCategory.tint,
|
|
icon: groupCategory.icon,
|
|
amount: 0,
|
|
count: 0,
|
|
children: [],
|
|
};
|
|
root.children.push(group);
|
|
}
|
|
group.amount += amount;
|
|
group.count += 1;
|
|
|
|
const leafCategory = path[1];
|
|
if (leafCategory) {
|
|
let leaf = group.children.find((item) => item.key === `${entry.type}:${groupCategory.id}:${leafCategory.id}`);
|
|
if (!leaf) {
|
|
leaf = {
|
|
key: `${entry.type}:${groupCategory.id}:${leafCategory.id}`,
|
|
label: leafCategory.label,
|
|
color: leafCategory.color,
|
|
tint: leafCategory.tint,
|
|
icon: leafCategory.icon,
|
|
amount: 0,
|
|
count: 0,
|
|
children: [],
|
|
};
|
|
group.children.push(leaf);
|
|
}
|
|
leaf.amount += amount;
|
|
leaf.count += 1;
|
|
}
|
|
roots.set(entry.type, root);
|
|
}
|
|
return [...roots.values()];
|
|
});
|
|
|
|
const categoryRows = computed(() => {
|
|
const rows: Array<CategorySummaryNode & { level: number; expanded: boolean }> = [];
|
|
const expanded = expandedCategoryKeys.value;
|
|
const append = (node: CategorySummaryNode, level: number) => {
|
|
const isExpanded = expanded.has(node.key);
|
|
rows.push({ ...node, level, expanded: isExpanded });
|
|
if (isExpanded) for (const child of node.children) append(child, level + 1);
|
|
};
|
|
for (const root of categoryTree.value) append(root, 0);
|
|
return rows;
|
|
});
|
|
|
|
const maxCategoryAmount = computed(() => Math.max(1, ...categoryTree.value.map((item) => item.amount)));
|
|
|
|
function toggleCategoryNode(node: CategorySummaryNode) {
|
|
if (!node.children.length) return;
|
|
const next = new Set(expandedCategoryKeys.value);
|
|
if (next.has(node.key)) next.delete(node.key);
|
|
else next.add(node.key);
|
|
expandedCategoryKeys.value = next;
|
|
}
|
|
|
|
function openCategoryEntries(node: CategorySummaryNode) {
|
|
const { tab: _tab, category: _category, ...restQuery } = route.query;
|
|
const query: LocationQueryRaw = { ...restQuery, category: node.key };
|
|
query.ledgers = ledgerStore.currentLedgerId;
|
|
query.cat = node.key;
|
|
void router.push({ name: "query", query });
|
|
}
|
|
|
|
const memberStats = computed(() => {
|
|
const groups = new Map<string, LedgerEntry[]>();
|
|
for (const entry of convertedEntries.value) groups.set(entry.createdBy, [...(groups.get(entry.createdBy) ?? []), entry]);
|
|
return Array.from(groups, ([userId, items]) => ({
|
|
userId,
|
|
name: ledgerStore.memberName(userId),
|
|
count: items.length,
|
|
...summarize(items),
|
|
})).sort((left, right) => right.count - left.count);
|
|
});
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([entryStore.loadEntries(), ledgerStore.loadLedgers()]);
|
|
});
|
|
|
|
function summarize(items: LedgerEntry[]) {
|
|
return items.reduce(
|
|
(result, entry) => {
|
|
if (entry.baseAmount === null) return result;
|
|
if (isReimbursement(entry)) return result;
|
|
if (entry.type === "income") result.income += entry.baseAmount;
|
|
else result.expense += netBaseAmount(entry, entryStore.entries) ?? 0;
|
|
return result;
|
|
},
|
|
{ income: 0, expense: 0 },
|
|
);
|
|
}
|
|
|
|
function money(value: number) {
|
|
return (value / 100).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
}
|
|
|
|
function compactDailyAmount(value: number) {
|
|
const amount = value / 100;
|
|
if (amount >= 10000) return `${(amount / 10000).toFixed(1)}万`;
|
|
if (amount >= 1000) return `${(amount / 1000).toFixed(1)}k`;
|
|
return amount.toFixed(0);
|
|
}
|
|
|
|
function toDateInput(date: Date) {
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
function fromDateInput(value: string) {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
|
const date = new Date(`${value}T00:00:00`);
|
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
}
|
|
|
|
function formatDateLabel(value: string) {
|
|
const date = fromDateInput(value);
|
|
return date ? `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` : value;
|
|
}
|
|
|
|
function formatFullDateLabel(value: string) {
|
|
const date = fromDateInput(value);
|
|
return date ? `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日` : value;
|
|
}
|
|
|
|
function dailyLabel(value: string) {
|
|
const date = fromDateInput(value);
|
|
if (!date) return value;
|
|
const crossesYear = rangeBounds.value && rangeBounds.value.start.getFullYear() !== new Date(rangeBounds.value.end.getTime() - 1).getFullYear();
|
|
return crossesYear ? `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` : `${date.getMonth() + 1}/${date.getDate()}`;
|
|
}
|
|
|
|
function openRangePicker() {
|
|
const match = /^(\d{4})-(\d{2})$/.exec(selectedMonth.value);
|
|
rangePickerYear.value = match ? Number(match[1]) : selectedYear.value;
|
|
rangePickerView.value = rangeMode.value === "year" ? "years" : "months";
|
|
rangePickerOpen.value = true;
|
|
}
|
|
|
|
function closeRangePicker() {
|
|
rangePickerOpen.value = false;
|
|
}
|
|
|
|
function chooseRangeMode(mode: RangeMode) {
|
|
rangeMode.value = mode;
|
|
rangePickerView.value = mode === "year" ? "years" : "months";
|
|
}
|
|
|
|
function selectStatsMonth(month: number) {
|
|
selectedMonth.value = `${rangePickerYear.value}-${String(month + 1).padStart(2, "0")}`;
|
|
rangeMode.value = "month";
|
|
closeRangePicker();
|
|
}
|
|
|
|
function isSelectedStatsMonth(month: number) {
|
|
return selectedMonth.value === `${rangePickerYear.value}-${String(month).padStart(2, "0")}`;
|
|
}
|
|
|
|
function selectStatsYear(year: number) {
|
|
selectedYear.value = year;
|
|
rangeMode.value = "year";
|
|
closeRangePicker();
|
|
}
|
|
|
|
function shiftRangePickerYear(offset: -1 | 1) {
|
|
const years = yearOptions.value;
|
|
const index = years.indexOf(rangePickerYear.value);
|
|
const target = years[index + (offset < 0 ? 1 : -1)];
|
|
if (target !== undefined) rangePickerYear.value = target;
|
|
}
|
|
|
|
function shiftCalendarMonth(offset: -1 | 1) {
|
|
const match = /^(\d{4})-(\d{2})$/.exec(calendarCursorMonth.value);
|
|
if (!match) return;
|
|
const next = new Date(Number(match[1]), Number(match[2]) - 1 + offset, 1);
|
|
const bounds = rangeBounds.value;
|
|
if (!bounds) return;
|
|
const first = new Date(bounds.start.getFullYear(), bounds.start.getMonth(), 1);
|
|
const last = new Date(bounds.end.getFullYear(), bounds.end.getMonth(), 1);
|
|
if (next < first || next >= last) return;
|
|
calendarCursorMonth.value = `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}`;
|
|
}
|
|
|
|
function shiftCalendarYear(offset: -1 | 1) {
|
|
const bounds = rangeBounds.value;
|
|
if (!bounds) return;
|
|
const firstYear = bounds.start.getFullYear();
|
|
const lastYear = new Date(bounds.end.getTime() - 1).getFullYear();
|
|
const nextYear = calendarCursorYear.value + offset;
|
|
if (nextYear < firstYear || nextYear > lastYear) return;
|
|
calendarCursorYear.value = nextYear;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<main class="app-shell stats-shell">
|
|
<header class="stats-header" :style="{ '--ledger-gradient': currentTheme.gradient }">
|
|
<button class="stats-back-button" type="button" aria-label="返回" title="返回" @click="router.back()"><ArrowLeft :size="20" /></button>
|
|
<strong>统计</strong>
|
|
<button type="button" @click="router.push({ path: '/ledgers', query: { returnTo: route.fullPath } })">
|
|
{{ ledgerStore.currentLedger?.name ?? "选择账本" }}
|
|
<ChevronDown :size="15" />
|
|
</button>
|
|
</header>
|
|
|
|
<Transition name="month-picker">
|
|
<div v-if="rangePickerOpen" class="month-picker-layer">
|
|
<button class="month-picker-scrim" type="button" aria-label="关闭时间范围选择" @click="closeRangePicker"></button>
|
|
<section class="month-picker-modal stats-range-modal" role="dialog" aria-modal="true" aria-label="选择统计时间范围">
|
|
<header>
|
|
<button v-if="rangePickerView === 'years'" type="button" aria-label="返回月份选择" title="返回月份选择" @click="rangePickerView = 'months'"><ChevronLeft :size="20" /></button>
|
|
<span v-else></span>
|
|
<strong>统计时间范围</strong>
|
|
<button type="button" aria-label="关闭" title="关闭" @click="closeRangePicker"><X :size="20" /></button>
|
|
</header>
|
|
<div class="stats-range-modes" role="group" aria-label="时间范围类型">
|
|
<button type="button" :class="{ active: rangeMode === 'month' }" @click="chooseRangeMode('month')">月</button>
|
|
<button type="button" :class="{ active: rangeMode === 'year' }" @click="chooseRangeMode('year')">年</button>
|
|
<button type="button" :class="{ active: rangeMode === 'custom' }" @click="chooseRangeMode('custom')">自定义</button>
|
|
<button type="button" :class="{ active: rangeMode === 'all' }" @click="chooseRangeMode('all')">全部</button>
|
|
</div>
|
|
|
|
<template v-if="rangeMode === 'month' && rangePickerView === 'months'">
|
|
<div class="stats-picker-year-nav">
|
|
<button type="button" aria-label="上一年" title="上一年" @click="shiftRangePickerYear(-1)"><ChevronLeft :size="18" /></button>
|
|
<button type="button" class="selected" @click="rangePickerView = 'years'">{{ rangePickerYear }}</button>
|
|
<button type="button" aria-label="下一年" title="下一年" @click="shiftRangePickerYear(1)"><ChevronDown :size="16" /></button>
|
|
</div>
|
|
<div class="stats-month-grid">
|
|
<button v-for="month in 12" :key="month" type="button" :class="{ selected: isSelectedStatsMonth(month) }" @click="selectStatsMonth(month - 1)">{{ month }}月</button>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-else-if="rangeMode === 'year'" class="stats-year-list">
|
|
<button v-for="year in yearOptions" :key="year" type="button" :class="{ selected: selectedYear === year }" @click="selectStatsYear(year)">{{ year }}年</button>
|
|
</div>
|
|
|
|
<div v-else-if="rangeMode === 'all'" class="stats-all-range"><span>{{ rangeLabel }}</span></div>
|
|
|
|
<div v-else class="stats-custom-range stats-custom-modal">
|
|
<label><span>开始日期</span><input v-model="customStart" type="date" aria-label="开始日期" :max="customEnd || undefined" /></label>
|
|
<label><span>结束日期</span><input v-model="customEnd" type="date" aria-label="结束日期" :min="customStart || undefined" /></label>
|
|
<p v-if="!rangeBounds" role="alert">开始日期不能晚于结束日期</p>
|
|
<button type="button" class="stats-range-confirm" :disabled="!rangeBounds" @click="closeRangePicker">完成</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</Transition>
|
|
|
|
<div class="stats-scroll">
|
|
<section class="stats-range" aria-label="统计时间范围">
|
|
<button class="stats-range-trigger" type="button" @click="openRangePicker">
|
|
<span>{{ rangeLabel }}</span>
|
|
<ChevronDown :size="16" />
|
|
</button>
|
|
</section>
|
|
|
|
<section class="stats-summary" :aria-label="`${rangeLabel}统计`">
|
|
<div class="balance">
|
|
<span>结余</span>
|
|
<strong>¥ {{ money(totals.income - totals.expense) }}</strong>
|
|
</div>
|
|
<div class="income"><span><TrendingUp :size="17" />收入</span><strong>{{ money(totals.income) }}</strong></div>
|
|
<div class="expense"><span><TrendingDown :size="17" />支出</span><strong>{{ money(totals.expense) }}</strong></div>
|
|
</section>
|
|
<div class="stats-conversion-slot" aria-live="polite">
|
|
<p v-if="pendingConversionCount" class="stats-conversion-notice" role="status">
|
|
{{ pendingConversionCount }} 条外币账目等待换算,暂未计入人民币统计
|
|
</p>
|
|
</div>
|
|
|
|
<div class="stats-tabs" role="tablist" aria-label="统计维度">
|
|
<button type="button" :class="{ active: activeTab === 'flow' }" @click="activeTab = 'flow'">收支</button>
|
|
<button type="button" :class="{ active: activeTab === 'category' }" @click="activeTab = 'category'">分类</button>
|
|
<button type="button" :class="{ active: activeTab === 'member' }" @click="activeTab = 'member'">记录者</button>
|
|
</div>
|
|
|
|
<section v-if="activeTab === 'flow'" class="stats-section" aria-label="每日收支">
|
|
<header><strong>收支趋势</strong><div class="daily-controls"><div class="daily-granularity-switch" role="group" aria-label="统计粒度"><button type="button" :class="{ active: flowGranularity === 'day' }" @click="flowGranularity = 'day'">日</button><button type="button" :class="{ active: flowGranularity === 'month' }" @click="flowGranularity = 'month'">月</button><button type="button" :class="{ active: flowGranularity === 'year' }" @click="flowGranularity = 'year'">年</button></div><div class="daily-view-switch" role="group" aria-label="收支视图"><button type="button" :class="{ active: dailyView === 'calendar' }" aria-label="日历视图" title="日历视图" @click="dailyView = 'calendar'"><CalendarDays :size="15" /></button><button type="button" :class="{ active: dailyView === 'line' }" aria-label="折线图视图" title="折线图视图" @click="dailyView = 'line'"><ChartNoAxesCombined :size="15" /></button><button type="button" :class="{ active: dailyView === 'bar' }" aria-label="柱状图视图" title="柱状图视图" @click="dailyView = 'bar'"><BarChart3 :size="15" /></button></div></div></header>
|
|
<div v-if="dailyView === 'calendar'" class="daily-calendar">
|
|
<template v-if="flowGranularity === 'day'">
|
|
<div class="calendar-month-nav"><button type="button" aria-label="上个月" title="上个月" :disabled="calendarMonthStart?.getTime() === new Date(rangeBounds?.start.getFullYear() ?? 0, rangeBounds?.start.getMonth() ?? 0, 1).getTime()" @click="shiftCalendarMonth(-1)"><ChevronLeft :size="17" /></button><strong>{{ calendarMonthLabel }}</strong><button type="button" aria-label="下个月" title="下个月" :disabled="calendarMonthStart?.getMonth() === new Date(rangeBounds?.end.getFullYear() ?? 0, rangeBounds?.end.getMonth() ?? 0, 1).getMonth() && calendarMonthStart?.getFullYear() === new Date(rangeBounds?.end.getTime() ?? 0).getFullYear()" @click="shiftCalendarMonth(1)"><ChevronRight :size="17" /></button></div>
|
|
<div class="calendar-weekdays"><span v-for="weekday in ['日','一','二','三','四','五','六']" :key="weekday">{{ weekday }}</span></div>
|
|
<div class="calendar-grid">
|
|
<span v-for="(cell, index) in calendarDays" :key="cell?.date ?? `blank-${index}`" class="calendar-cell" :class="{ blank: !cell }">
|
|
<template v-if="cell"><b>{{ cell.day }}</b><i class="income" :style="{ width: `${Math.min(100, (cell.income / maxDailyAmount) * 100)}%` }"></i><i class="expense" :style="{ width: `${Math.min(100, (cell.expense / maxDailyAmount) * 100)}%` }"></i><small v-if="cell.income || cell.expense">{{ compactDailyAmount(cell.income || cell.expense) }}</small></template>
|
|
</span>
|
|
</div>
|
|
</template>
|
|
<template v-else-if="flowGranularity === 'month'">
|
|
<div class="calendar-month-nav"><button type="button" aria-label="上一年" title="上一年" :disabled="calendarCursorYear <= (rangeBounds?.start.getFullYear() ?? calendarCursorYear)" @click="shiftCalendarYear(-1)"><ChevronLeft :size="17" /></button><strong>{{ calendarCursorYear }}年</strong><button type="button" aria-label="下一年" title="下一年" :disabled="calendarCursorYear >= (rangeBounds ? new Date(rangeBounds.end.getTime() - 1).getFullYear() : calendarCursorYear)" @click="shiftCalendarYear(1)"><ChevronRight :size="17" /></button></div>
|
|
<div class="period-calendar-grid">
|
|
<span v-for="cell in calendarMonthCells" :key="cell.date" class="period-calendar-cell" :class="{ muted: !cell.income && !cell.expense }">
|
|
<b>{{ cell.label.replace(`${calendarCursorYear}年`, '') }}</b><i class="income" :style="{ width: `${Math.min(100, (cell.income / lineMaxAmount) * 100)}%` }"></i><i class="expense" :style="{ width: `${Math.min(100, (cell.expense / lineMaxAmount) * 100)}%` }"></i><small v-if="cell.income || cell.expense">{{ compactDailyAmount(cell.income || cell.expense) }}</small>
|
|
</span>
|
|
</div>
|
|
</template>
|
|
<div v-else class="period-calendar-grid">
|
|
<span v-for="cell in calendarYearCells" :key="cell.date" class="period-calendar-cell" :class="{ muted: !cell.income && !cell.expense }">
|
|
<b>{{ cell.label }}</b><i class="income" :style="{ width: `${Math.min(100, (cell.income / lineMaxAmount) * 100)}%` }"></i><i class="expense" :style="{ width: `${Math.min(100, (cell.expense / lineMaxAmount) * 100)}%` }"></i><small v-if="cell.income || cell.expense">{{ compactDailyAmount(cell.income || cell.expense) }}</small>
|
|
</span>
|
|
</div>
|
|
<div class="daily-view-legend"><span class="income">收入</span><span class="expense">支出</span></div>
|
|
</div>
|
|
<div v-else-if="dailyView === 'line'" class="daily-line-chart">
|
|
<div v-if="lineStats.length" class="line-chart-wrap">
|
|
<div v-if="hoveredChartPoint" class="chart-tooltip" :style="chartTooltipStyle">
|
|
<strong>{{ hoveredChartPoint.label }}</strong>
|
|
<span><i class="income">收入</i><b>¥ {{ money(hoveredChartPoint.income) }}</b></span>
|
|
<span><i class="expense">支出</i><b>¥ {{ money(hoveredChartPoint.expense) }}</b></span>
|
|
<small>结余 ¥ {{ money(hoveredChartPoint.income - hoveredChartPoint.expense) }}</small>
|
|
</div>
|
|
<svg viewBox="0 0 320 190" role="img" aria-label="收支折线图" @pointermove="updateChartHover" @pointerdown="updateChartHover" @pointerleave="clearChartHover" @pointercancel="clearChartHover">
|
|
<line x1="32" y1="28" x2="32" y2="154" class="chart-axis-line" /><line x1="32" y1="154" x2="304" y2="154" class="chart-axis-line" />
|
|
<line v-for="y in [28,70,112,154]" :key="y" x1="32" :y1="y" x2="304" :y2="y" class="chart-grid-line" />
|
|
<text v-for="(tick, index) in [1, .66, .33, 0]" :key="tick" x="0" :y="[28,70,112,154][index] + 3" class="chart-axis-label">{{ compactDailyAmount(lineMaxAmount * tick) }}</text>
|
|
<line v-if="hoveredChartPoint" :x1="hoveredChartPoint.x" y1="28" :x2="hoveredChartPoint.x" y2="154" class="chart-crosshair" />
|
|
<polyline :points="linePoints('income')" class="chart-line income-line" /><polyline :points="linePoints('expense')" class="chart-line expense-line" />
|
|
<circle v-for="(point, index) in chartPoints('income')" :key="point.key" :cx="point.x" :cy="point.y" :r="chartHoverIndex === index ? 5 : 2.5" class="chart-dot income-dot" :class="{ active: chartHoverIndex === index }" />
|
|
<circle v-for="(point, index) in chartPoints('expense')" :key="point.key" :cx="point.x" :cy="point.y" :r="chartHoverIndex === index ? 5 : 2.5" class="chart-dot expense-dot" :class="{ active: chartHoverIndex === index }" />
|
|
<rect x="32" y="28" width="272" height="126" class="chart-hit-area" aria-label="查看详细数据" />
|
|
</svg>
|
|
<div class="line-chart-labels"><span>{{ lineStats[0]?.label }}</span><span>{{ lineStats.at(-1)?.label }}</span></div>
|
|
</div><div v-else class="daily-view-empty">暂无可绘制的流水</div>
|
|
<div class="daily-view-legend"><span class="income">收入</span><span class="expense">支出</span></div>
|
|
</div>
|
|
<div v-else class="daily-line-chart">
|
|
<div v-if="lineStats.length" class="line-chart-wrap">
|
|
<div v-if="hoveredChartPoint" class="chart-tooltip" :style="chartTooltipStyle">
|
|
<strong>{{ hoveredChartPoint.label }}</strong>
|
|
<span><i class="income">收入</i><b>¥ {{ money(hoveredChartPoint.income) }}</b></span>
|
|
<span><i class="expense">支出</i><b>¥ {{ money(hoveredChartPoint.expense) }}</b></span>
|
|
<small>结余 ¥ {{ money(hoveredChartPoint.income - hoveredChartPoint.expense) }}</small>
|
|
</div>
|
|
<svg viewBox="0 0 320 190" role="img" aria-label="收支柱状图" @pointermove="updateChartHover" @pointerdown="updateChartHover" @pointerleave="clearChartHover" @pointercancel="clearChartHover">
|
|
<line x1="32" y1="28" x2="32" y2="154" class="chart-axis-line" /><line x1="32" y1="91" x2="304" y2="91" class="chart-zero-line" />
|
|
<line v-for="y in [28,60,91,122,154]" :key="y" x1="32" :y1="y" x2="304" :y2="y" class="chart-grid-line" />
|
|
<text v-for="(tick, index) in [1, .5, 0, -.5, -1]" :key="tick" x="0" :y="[28,60,91,122,154][index] + 3" class="chart-axis-label">{{ tick < 0 ? '-' : '' }}{{ compactDailyAmount(lineMaxAmount * Math.abs(tick)) }}</text>
|
|
<line v-if="hoveredChartPoint" :x1="hoveredChartPoint.x" y1="28" :x2="hoveredChartPoint.x" y2="154" class="chart-crosshair" />
|
|
<rect v-for="(item, index) in lineStats" :key="`income-${item.date}`" :x="chartBarX(index)" :y="chartBarY(item.income)" :width="chartBarWidth()" :height="chartBarHeight(item.income)" rx="1.5" class="bar income-bar" :class="{ active: chartHoverIndex === index }" />
|
|
<rect v-for="(item, index) in lineStats" :key="`expense-${item.date}`" :x="chartBarX(index)" y="91" :width="chartBarWidth()" :height="chartBarHeight(item.expense)" rx="1.5" class="bar expense-bar" :class="{ active: chartHoverIndex === index }" />
|
|
<rect x="32" y="28" width="272" height="126" class="chart-hit-area" aria-label="查看详细数据" />
|
|
</svg>
|
|
<div class="line-chart-labels"><span>{{ lineStats[0]?.label }}</span><span>{{ lineStats.at(-1)?.label }}</span></div>
|
|
</div><div v-else class="daily-view-empty">暂无可绘制的流水</div>
|
|
<div class="daily-view-legend"><span class="income">收入</span><span class="expense">支出</span></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-else-if="activeTab === 'category'" class="stats-section" aria-label="分类统计">
|
|
<header><strong>分类金额</strong><span>收入 / 支出</span></header>
|
|
<div v-for="item in categoryRows" :key="item.key" class="category-stat-row" :class="[`level-${item.level}`, { expandable: item.children.length, expanded: item.expanded }]" :style="{ '--category-indent': `${item.level * 18}px` }" role="button" tabindex="0" @click="toggleCategoryNode(item)" @keydown.enter="toggleCategoryNode(item)">
|
|
<span class="category-stat-expand-indicator" aria-hidden="true"><ChevronUp v-if="item.children.length && item.expanded" :size="15" /><ChevronDown v-else-if="item.children.length" :size="15" /></span>
|
|
<span
|
|
class="category-stat-icon"
|
|
:style="{ color: item.color, background: item.tint }"
|
|
>
|
|
<component :is="item.icon" :size="19" />
|
|
</span>
|
|
<div>
|
|
<strong>{{ item.label }}</strong>
|
|
<i :style="{ width: `${(item.amount / maxCategoryAmount) * 100}%`, background: item.color }"></i>
|
|
</div>
|
|
<span><strong>{{ money(item.amount) }}</strong><small>{{ item.count }} 笔</small></span>
|
|
<button class="category-stat-filter" type="button" aria-label="查看该分类流水" title="查看该分类流水" @click.stop="openCategoryEntries(item)"><ChevronRight :size="16" /></button>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-else class="stats-section" aria-label="记录者统计">
|
|
<header><strong>记录者</strong><span>{{ memberStats.length }} 人</span></header>
|
|
<div v-for="member in memberStats" :key="member.userId" class="member-stat-row">
|
|
<span><CircleUserRound :size="22" /></span>
|
|
<div><strong>{{ member.name }}</strong><small>{{ member.count }} 笔记录</small></div>
|
|
<div><small class="income">收入 {{ money(member.income) }}</small><small class="expense">支出 {{ money(member.expense) }}</small></div>
|
|
</div>
|
|
</section>
|
|
|
|
<div v-if="rangeBounds && !entries.length" class="stats-empty">{{ rangeLabel }}还没有流水</div>
|
|
</div>
|
|
|
|
<QuickEntryHost />
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.stats-shell { background: #f5f8f7; }
|
|
.stats-header { height:74px; display:grid; grid-template-columns:40px 1fr auto; align-items:center; gap:8px; padding:14px 18px 8px; background:var(--ledger-gradient, #087f72); color:#fff; }
|
|
.stats-back-button { width:38px; height:38px; display:grid; place-items:center; border:0; border-radius:8px; background:rgba(255,255,255,.14); color:#fff; }
|
|
.stats-header > strong { font-size: 20px; }
|
|
.stats-header button { display: flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 7px 9px; background: rgba(255,255,255,.14); color: #fff; font-size: 13px; }
|
|
.stats-scroll { position:relative; height: calc(100% - 74px); overflow-y: auto; padding: 0 16px 104px; }
|
|
.stats-range { display:grid; gap:10px; padding:12px 0; }
|
|
.stats-range-trigger { width:100%; min-height:44px; display:flex; align-items:center; justify-content:space-between; border:1px solid #d2dfdc; border-radius:8px; padding:0 12px; background:#fff; color:#344640; font-size:14px; text-align:left; }
|
|
.stats-range-modal .stats-range-modes { margin:12px 0; }
|
|
.stats-picker-year-nav { display:grid; grid-template-columns:38px 1fr 38px; align-items:center; gap:4px; min-height:52px; }
|
|
.stats-picker-year-nav button { min-height:38px; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#536762; font-size:19px; font-variant-numeric:tabular-nums; }
|
|
.stats-picker-year-nav button.selected { background:#eaf7f3; color:#087f72; font-weight:720; }
|
|
.stats-month-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
|
|
.stats-month-grid button { min-height:52px; border:1px solid #e1ebe8; border-radius:8px; background:#fff; color:#344640; font-size:14px; }
|
|
.stats-month-grid button.selected { border-color:#087f72; background:#eaf7f3; color:#087f72; box-shadow:inset 0 0 0 1px #087f72; font-weight:720; }
|
|
.stats-year-list { display:grid; gap:2px; padding-top:8px; }
|
|
.stats-year-list button { min-height:54px; border:0; border-bottom:1px solid #e5edeb; background:#fff; color:#344640; font-size:15px; text-align:left; }
|
|
.stats-year-list button.selected { color:#087f72; font-weight:720; }
|
|
.stats-custom-modal { margin-top:12px; }
|
|
.stats-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; }
|
|
.stats-range-confirm { grid-column:1 / -1; min-height:44px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
|
|
.stats-range-confirm:disabled { opacity:.5; }
|
|
.stats-range-modes { display:grid; grid-template-columns:repeat(4,1fr); gap:4px; border-radius:8px; padding:4px; background:#e7eeec; }
|
|
.stats-range-modes button { height:34px; border:0; border-radius:6px; background:transparent; color:#71807c; font-size:12px; font-weight:680; }
|
|
.stats-range-modes button.active { background:#fff; color:#087f72; box-shadow:0 2px 7px rgba(28,52,47,.1); }
|
|
.stats-range > input,.stats-range > select,.stats-custom-range input { width:100%; min-width:0; height:40px; border:1px solid #d2dfdc; border-radius:7px; padding:0 10px; background:#fff; color:#344640; font-size:13px; }
|
|
.stats-custom-range { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
|
.stats-custom-range label { min-width:0; display:grid; gap:4px; }
|
|
.stats-custom-range label > span { color:#7b8884; font-size:10px; }
|
|
.stats-range > p { margin:0; color:#c74d39; font-size:11px; }
|
|
.stats-summary { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); border-top:1px solid #dce7e4; border-bottom:1px solid #dce7e4; background:#fff; }
|
|
.stats-summary > div { min-width:0; display:grid; gap:5px; padding:12px; }
|
|
.stats-summary .balance { grid-column:1 / -1; border-bottom:1px solid #e1ebe8; padding-top:15px; padding-bottom:15px; }
|
|
.stats-summary > div:last-child { border-left:1px solid #e1ebe8; }
|
|
.stats-summary span { display:flex; align-items:center; gap:5px; color:#788581; font-size:11px; }
|
|
.stats-summary strong { min-width:0; font-size:15px; line-height:1.25; overflow-wrap:anywhere; font-variant-numeric:tabular-nums; }
|
|
.stats-summary .balance strong { font-size:24px; }
|
|
.stats-summary .income { color: #0d8b67; }
|
|
.stats-summary .expense { color: #d84c36; }
|
|
.stats-conversion-slot { position:absolute; top:0; right:16px; left:16px; z-index:3; height:0; overflow:visible; pointer-events:none; }
|
|
.stats-conversion-notice { margin:0; border-left:3px solid #d69a32; padding:7px 9px; background:#fff8e9; color:#755719; font-size:11px; box-shadow:0 2px 8px rgba(58,43,17,.08); pointer-events:auto; }
|
|
.stats-tabs { display: grid; grid-template-columns: repeat(3,1fr); gap: 4px; margin: 14px 0 10px; border-radius: 8px; padding: 4px; background: #e7eeec; }
|
|
.stats-tabs button { height: 38px; border: 0; border-radius: 6px; background: transparent; color: #71807c; font-size: 13px; font-weight: 680; }
|
|
.stats-tabs button.active { background: #fff; color: #087f72; box-shadow: 0 2px 7px rgba(28,52,47,.12); }
|
|
.stats-section { border-top: 1px solid #dce7e4; border-bottom: 1px solid #dce7e4; background: #fff; }
|
|
.stats-section > header { height: 48px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e4ece9; padding: 0 12px; }
|
|
.stats-section > header strong { font-size: 14px; }
|
|
.stats-section > header span { color: #7b8884; font-size: 11px; }
|
|
.daily-controls { display:flex; align-items:center; gap:6px; }
|
|
.daily-granularity-switch { display:flex; gap:2px; border-radius:6px; padding:3px; background:#e7eeec; }
|
|
.daily-granularity-switch button { min-width:25px; height:26px; border:0; border-radius:4px; background:transparent; color:#71807c; font-size:10px; }
|
|
.daily-granularity-switch button.active { background:#fff; color:#087f72; box-shadow:0 1px 4px rgba(28,52,47,.12); }
|
|
.daily-view-switch { display:flex; gap:3px; border-radius:6px; padding:3px; background:#e7eeec; }
|
|
.daily-view-switch button { width:28px; height:26px; display:grid; place-items:center; border:0; border-radius:4px; background:transparent; color:#71807c; }
|
|
.daily-view-switch button.active { background:#fff; color:#087f72; box-shadow:0 1px 4px rgba(28,52,47,.12); }
|
|
.daily-calendar { padding:12px; }
|
|
.calendar-month-nav { display:grid; grid-template-columns:30px 1fr 30px; align-items:center; margin-bottom:9px; }
|
|
.calendar-month-nav button { width:30px; height:28px; display:grid; place-items:center; border:0; border-radius:6px; background:#eef4f2; color:#58716a; }
|
|
.calendar-month-nav strong { color:#536762; font-size:12px; font-weight:720; text-align:center; }
|
|
.calendar-month-label { margin-bottom:9px; color:#536762; font-size:12px; font-weight:720; text-align:center; }
|
|
.calendar-weekdays,.calendar-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:4px; }
|
|
.calendar-weekdays { margin-bottom:4px; color:#8a9994; font-size:10px; text-align:center; }
|
|
.calendar-cell { min-width:0; min-height:58px; display:grid; align-content:start; justify-items:center; gap:3px; border:1px solid #e6eeec; border-radius:6px; padding:4px 2px; background:#fff; }
|
|
.calendar-cell.blank { border-color:transparent; background:transparent; }
|
|
.calendar-cell b { color:#536762; font-size:11px; font-weight:680; }
|
|
.calendar-cell i { width:0; max-width:80%; height:3px; display:block; border-radius:2px; }
|
|
.calendar-cell i.income { background:#20a57d; }
|
|
.calendar-cell i.expense { background:#ef6a4d; }
|
|
.calendar-cell small { color:#7b8884; font-size:8px; line-height:1; }
|
|
.period-calendar-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:7px; }
|
|
.period-calendar-cell { min-width:0; min-height:66px; display:grid; align-content:center; justify-items:center; gap:5px; border:1px solid #e6eeec; border-radius:7px; padding:7px 5px; background:#fff; }
|
|
.period-calendar-cell b { color:#536762; font-size:11px; font-weight:680; }
|
|
.period-calendar-cell.muted { background:#f8faf9; border-color:#edf2f0; }
|
|
.period-calendar-cell.muted b { color:#b0bbb7; }
|
|
.period-calendar-cell i { width:0; max-width:80%; height:4px; display:block; border-radius:3px; }
|
|
.period-calendar-cell i.income { background:#20a57d; }
|
|
.period-calendar-cell i.expense { background:#ef6a4d; }
|
|
.period-calendar-cell small { color:#7b8884; font-size:9px; line-height:1; }
|
|
.daily-view-legend { display:flex; justify-content:center; gap:15px; margin-top:10px; font-size:10px; }
|
|
.daily-view-legend span::before { display:inline-block; width:7px; height:7px; margin-right:4px; border-radius:50%; background:currentColor; content:""; }
|
|
.daily-line-chart { padding:12px; }
|
|
.line-chart-wrap { position:relative; min-width:0; }
|
|
.line-chart-wrap svg { width:100%; height:auto; overflow:visible; touch-action:none; }
|
|
.chart-axis-line { stroke:#aebdb8; stroke-width:1.2; }
|
|
.chart-zero-line { stroke:#879a93; stroke-width:1.4; }
|
|
.chart-grid-line { stroke:#e6eeec; stroke-width:1; }
|
|
.chart-axis-label { fill:#8a9994; font-size:9px; text-anchor:start; }
|
|
.chart-crosshair { stroke:#667c75; stroke-width:1; stroke-dasharray:3 3; }
|
|
.chart-line { fill:none; stroke-width:3; stroke-linecap:round; stroke-linejoin:round; }
|
|
.income-line { stroke:#20a57d; }
|
|
.expense-line { stroke:#ef6a4d; }
|
|
.chart-dot { stroke:#fff; stroke-width:2; }
|
|
.income-dot { fill:#20a57d; }
|
|
.expense-dot { fill:#ef6a4d; }
|
|
.chart-dot:not(.active) { opacity:.5; }
|
|
.bar { opacity:.78; }
|
|
.bar.active { opacity:1; filter:brightness(.92); }
|
|
.income-bar { fill:#20a57d; }
|
|
.expense-bar { fill:#ef6a4d; }
|
|
.chart-hit-area { fill:transparent; cursor:crosshair; }
|
|
.chart-tooltip { position:absolute; top:7px; z-index:2; display:grid; min-width:132px; gap:4px; transform:translateX(-50%); border:1px solid #435b54; border-radius:5px; padding:8px 9px; background:#263a35; color:#eaf4f1; box-shadow:0 5px 14px rgba(30,50,44,.2); pointer-events:none; }
|
|
.chart-tooltip strong { color:#fff; font-size:11px; font-weight:720; white-space:nowrap; }
|
|
.chart-tooltip span { display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:10px; }
|
|
.chart-tooltip span i { font-style:normal; }
|
|
.chart-tooltip span b { color:#fff; font-size:10px; font-variant-numeric:tabular-nums; }
|
|
.chart-tooltip .income { color:#6ee0b5; }
|
|
.chart-tooltip .expense { color:#ff9a83; }
|
|
.chart-tooltip small { border-top:1px solid rgba(231,245,241,.15); padding-top:4px; color:#b9ccc6; font-size:9px; }
|
|
.line-chart-labels { display:flex; justify-content:space-between; color:#8a9994; font-size:9px; }
|
|
.daily-view-empty { min-height:180px; display:grid; place-items:center; color:#7b8884; font-size:12px; }
|
|
.daily-stat-row { min-height: 58px; display: grid; grid-template-columns: 34px minmax(0,1fr) 82px; align-items: center; gap: 9px; border-bottom: 1px solid #edf2f0; padding: 8px 12px; }
|
|
.daily-stat-row:last-child { border-bottom: 0; }
|
|
.daily-stat-row > span { color: #667570; font-size: 11px; }
|
|
.daily-bars { display: grid; gap: 5px; }
|
|
.daily-bars i { height: 5px; min-width: 2px; border-radius: 3px; }
|
|
.daily-bars i.income { background: #20a57d; }
|
|
.daily-bars i.expense { background: #ef6a4d; }
|
|
.daily-stat-row > div:last-child { display: grid; text-align: right; }
|
|
.daily-stat-row small { font-size: 10px; font-variant-numeric: tabular-nums; }
|
|
.income { color: #0d8b67; }
|
|
.expense { color: #d84c36; }
|
|
.category-stat-row { width:100%; min-height:64px; display:grid; grid-template-columns:18px 38px minmax(0,1fr) auto 28px; align-items:center; gap:8px; border-bottom:1px solid #edf2f0; padding:8px 12px 8px calc(12px + var(--category-indent, 0px)); background:#fff; color:#26342f; text-align:left; cursor:pointer; }
|
|
.category-stat-row:last-child { border-bottom: 0; }
|
|
.category-stat-row:not(.expandable) { cursor:default; }
|
|
.category-stat-row.expandable:active { background:#f4f9f7; }
|
|
.category-stat-row.level-0 { min-height:68px; gap:10px; padding-top:10px; padding-bottom:10px; }
|
|
.category-stat-row.level-1 { min-height:56px; grid-template-columns:18px 34px minmax(0,1fr) auto 28px; gap:7px; padding-top:7px; padding-bottom:7px; background:#fbfdfc; }
|
|
.category-stat-row.level-2 { min-height:46px; grid-template-columns:18px 30px minmax(0,1fr) auto 28px; gap:5px; padding-top:5px; padding-bottom:5px; background:#fff; }
|
|
.category-stat-row.level-0 .category-stat-icon { width:38px; height:38px; }
|
|
.category-stat-row.level-0 .category-stat-icon :deep(svg) { width:22px; height:22px; }
|
|
.category-stat-row.level-0 > div strong { font-size:15px; }
|
|
.category-stat-row.level-0 > span:not(.category-stat-icon):not(.category-stat-chevron) strong { font-size:14px; }
|
|
.category-stat-row.level-1 .category-stat-icon { width:32px; height:32px; }
|
|
.category-stat-row.level-1 .category-stat-icon :deep(svg) { width:19px; height:19px; }
|
|
.category-stat-row.level-1 > div strong { font-size:13px; }
|
|
.category-stat-row.level-1 > span:not(.category-stat-icon):not(.category-stat-chevron) strong { font-size:12px; }
|
|
.category-stat-row.level-2 .category-stat-icon { width:28px; height:28px; }
|
|
.category-stat-row.level-2 .category-stat-icon :deep(svg) { width:16px; height:16px; }
|
|
.category-stat-row.level-2 > div strong { font-size:11px; }
|
|
.category-stat-row.level-2 > span:not(.category-stat-icon):not(.category-stat-chevron) strong { font-size:11px; }
|
|
.category-stat-icon { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 8px; }
|
|
.category-stat-row > div { min-width: 0; display: grid; gap: 7px; }
|
|
.category-stat-row > div strong { font-size: 13px; }
|
|
.category-stat-row > div i { height: 4px; min-width: 2px; border-radius: 2px; }
|
|
.category-stat-row > span:not(.category-stat-icon):not(.category-stat-chevron) { display: grid; text-align: right; }
|
|
.category-stat-row > span:not(.category-stat-icon):not(.category-stat-chevron) strong { font-size: 12px; }
|
|
.category-stat-filter { width:28px; height:32px; display:grid; place-items:center; border:0; border-radius:7px; background:transparent; color:#8a9994; }
|
|
.category-stat-filter:active { background:#eaf7f3; color:#087f72; }
|
|
.category-stat-expand-indicator { width:18px; height:24px; display:grid; place-items:center; color:#087f72; }
|
|
.category-stat-row:not(.expandable) .category-stat-expand-indicator { color:transparent; }
|
|
.category-stat-row small { color: #7d8986; font-size: 10px; }
|
|
.member-stat-row { min-height: 72px; display: grid; grid-template-columns: 42px minmax(0,1fr) auto; align-items: center; gap: 10px; padding: 10px 12px; }
|
|
.member-stat-row > span { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 50%; background: #edf4ff; color: #3478e5; }
|
|
.member-stat-row > div { display: grid; }
|
|
.member-stat-row > div:last-child { text-align: right; }
|
|
.member-stat-row strong { font-size: 14px; }
|
|
.member-stat-row small { color: #7b8884; font-size: 10px; }
|
|
.member-stat-row small.income { color: #0d8b67; }
|
|
.member-stat-row small.expense { color: #d84c36; }
|
|
.stats-empty { min-height: 180px; display: grid; place-items: center; color: #7b8884; font-size: 13px; }
|
|
@media (max-width:360px) { .stats-scroll { padding-right:12px; padding-left:12px; } .stats-custom-range { grid-template-columns:1fr; } .stats-custom-range input { padding:0 8px; font-size:12px; } .stats-summary > div { padding-right:10px; padding-left:10px; } }
|
|
</style>
|