fix: query uncategorized entries

This commit is contained in:
openclaw 2026-08-23 20:59:57 +08:00
parent b3b614c20a
commit 8ce902c5de
4 changed files with 67 additions and 5 deletions

View File

@ -78,7 +78,7 @@ watch(
async (open) => {
stopInertia();
if (!open) return;
selectorLevel.value = "type";
selectorLevel.value = "category";
await nextTick();
resetShellScroll();
updateRotationBounds();

View File

@ -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) {
<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>
@ -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; }

View File

@ -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 });

View File

@ -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();
});
});