From 8ce902c5deba0449d19ba0a3192f6abe9f001ca7 Mon Sep 17 00:00:00 2001 From: openclaw Date: Sun, 23 Aug 2026 20:59:57 +0800 Subject: [PATCH] fix: query uncategorized entries --- .../components/CategoryCoordinateSelector.vue | 2 +- apps/web/src/views/StatsEntriesView.vue | 34 +++++++++++++++++-- apps/web/src/views/StatsView.vue | 2 +- .../09-统计与查询/01-未分类与分类轮盘.spec.ts | 34 +++++++++++++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/09-统计与查询/01-未分类与分类轮盘.spec.ts diff --git a/apps/web/src/components/CategoryCoordinateSelector.vue b/apps/web/src/components/CategoryCoordinateSelector.vue index 6329741..2f75b7d 100644 --- a/apps/web/src/components/CategoryCoordinateSelector.vue +++ b/apps/web/src/components/CategoryCoordinateSelector.vue @@ -78,7 +78,7 @@ watch( async (open) => { stopInertia(); if (!open) return; - selectorLevel.value = "type"; + selectorLevel.value = "category"; await nextTick(); resetShellScroll(); updateRotationBounds(); diff --git a/apps/web/src/views/StatsEntriesView.vue b/apps/web/src/views/StatsEntriesView.vue index d14e91a..d7c0407 100644 --- a/apps/web/src/views/StatsEntriesView.vue +++ b/apps/web/src/views/StatsEntriesView.vue @@ -12,6 +12,7 @@ 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(); @@ -88,7 +89,10 @@ 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) return parts[1] === "*" || path.some((item) => item.id === parts[1]); + 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]); } @@ -100,6 +104,10 @@ const categoryLabel = computed(() => { 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]}`; @@ -122,7 +130,10 @@ const selectedCategorySummary = computed(() => { childCount += selected; } } - return `${parentCount} 个大类,${childCount} 个小类`; + const uncategorized = entryTypes + .filter((type) => isUncategorizedSelected(type.id)) + .map((type) => `${type.label}未分类`); + return [`${parentCount} 个大类,${childCount} 个小类`, ...uncategorized].join(","); }); const entries = computed(() => { @@ -206,7 +217,20 @@ function selectedChildCount(type: LedgerEntry["type"], parentId: string) { } function selectedTypeCount(type: LedgerEntry["type"]) { - return categoryTrees.value.find((tree) => tree.id === type)?.parents.reduce((total, parent) => total + selectedChildCount(type, parent.id), 0) ?? 0; + 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) { @@ -394,6 +418,7 @@ function localDateKey(value: string) {
+ @@ -474,6 +499,9 @@ function localDateKey(value: string) { .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; } diff --git a/apps/web/src/views/StatsView.vue b/apps/web/src/views/StatsView.vue index c2b74ec..021cd56 100644 --- a/apps/web/src/views/StatsView.vue +++ b/apps/web/src/views/StatsView.vue @@ -449,7 +449,7 @@ function toggleCategoryNode(node: CategorySummaryNode) { function openCategoryEntries(node: CategorySummaryNode) { const { tab: _tab, category: _category, ...restQuery } = route.query; - const query: LocationQueryRaw = { ...restQuery, category: node.key }; + const query: LocationQueryRaw = { ...restQuery }; query.ledgers = ledgerStore.currentLedgerId; query.cat = node.key; void router.push({ name: "query", query }); diff --git a/tests/e2e/09-统计与查询/01-未分类与分类轮盘.spec.ts b/tests/e2e/09-统计与查询/01-未分类与分类轮盘.spec.ts new file mode 100644 index 0000000..f0474d7 --- /dev/null +++ b/tests/e2e/09-统计与查询/01-未分类与分类轮盘.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from "@playwright/test"; +import { makeEntry, mockApi, 登录 } from "../测试夹具"; + +test.describe("统计与查询:未分类和分类轮盘", () => { + test("01-统计未分类跳转查询后能显示对应流水", async ({ page }) => { + await mockApi(page, { initialEntries: [makeEntry({ categoryId: "expense", note: "待归类午餐" })] }); + await 登录(page); + await page.goto("/stats?tab=category&range=month&month=2026-08"); + + await page.locator(".category-stat-row", { hasText: "支出" }).click(); + const uncategorized = page.locator(".category-stat-row", { hasText: "未分类" }); + await expect(uncategorized).toBeVisible(); + await uncategorized.getByRole("button", { name: "查看该分类流水" }).click(); + + await expect(page).toHaveURL(/\/query\?.*cat=expense(?::|%3A)uncategorized/); + await expect(page.getByText("待归类午餐")).toBeVisible(); + await expect(page.getByRole("button", { name: /分类.*支出未分类/ })).toBeVisible(); + await page.getByRole("button", { name: "打开查询条件" }).click(); + await expect(page.getByRole("button", { name: "取消支出未分类" })).toBeVisible(); + }); + + test("02-分类轮盘默认显示支出大类,返回后显示收支类型", async ({ page }) => { + await mockApi(page); + await 登录(page); + await page.getByRole("button", { name: "记一笔" }).click(); + await page.getByRole("button", { name: /选择分类,当前为支出/ }).click(); + + await expect(page.getByRole("button", { name: "餐饮" })).toBeVisible(); + await expect(page.getByRole("button", { name: "收入", exact: true })).not.toBeVisible(); + await page.getByRole("button", { name: "返回上一级" }).click(); + await expect(page.getByRole("button", { name: "支出", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "收入", exact: true })).toBeVisible(); + }); +});