fix: query uncategorized entries
This commit is contained in:
parent
b3b614c20a
commit
8ce902c5de
|
|
@ -78,7 +78,7 @@ watch(
|
||||||
async (open) => {
|
async (open) => {
|
||||||
stopInertia();
|
stopInertia();
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
selectorLevel.value = "type";
|
selectorLevel.value = "category";
|
||||||
await nextTick();
|
await nextTick();
|
||||||
resetShellScroll();
|
resetShellScroll();
|
||||||
updateRotationBounds();
|
updateRotationBounds();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { useEntryStore } from "../stores/entries";
|
||||||
import { useLedgerStore } from "../stores/ledgers";
|
import { useLedgerStore } from "../stores/ledgers";
|
||||||
|
|
||||||
type RangeMode = "month" | "year" | "custom" | "all";
|
type RangeMode = "month" | "year" | "custom" | "all";
|
||||||
|
const UNCATEGORIZED_CATEGORY_ID = "uncategorized";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -88,7 +89,10 @@ function matchesCategory(entry: LedgerEntry, key: string) {
|
||||||
const parts = key.split(":");
|
const parts = key.split(":");
|
||||||
if (parts.length < 2 || entry.type !== parts[0]) return false;
|
if (parts.length < 2 || entry.type !== parts[0]) return false;
|
||||||
const path = findCategoryPath(entry.type, entry.categoryId);
|
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]);
|
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 parts = key.split(":");
|
||||||
const type = parts[0] as LedgerEntry["type"];
|
const type = parts[0] as LedgerEntry["type"];
|
||||||
if (parts[1] === "*") return entryTypes.find((item) => item.id === type)?.label ?? "";
|
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]);
|
const path = findCategoryPath(type, parts[1]);
|
||||||
if (parts.length === 2) return path.map((item) => item.label).join(" · ");
|
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]}`;
|
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;
|
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(() => {
|
const entries = computed(() => {
|
||||||
|
|
@ -206,7 +217,20 @@ function selectedChildCount(type: LedgerEntry["type"], parentId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedTypeCount(type: LedgerEntry["type"]) {
|
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) {
|
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 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 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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 { 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 button.selected { background:#eaf7f3; color:#087f72; font-weight:720; }
|
||||||
.query-category-children .query-category-all { color:#536762; font-weight:680; }
|
.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 { 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-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; }
|
.query-done { width:100%; min-height:44px; margin-top:14px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
|
||||||
|
|
|
||||||
|
|
@ -449,7 +449,7 @@ function toggleCategoryNode(node: CategorySummaryNode) {
|
||||||
|
|
||||||
function openCategoryEntries(node: CategorySummaryNode) {
|
function openCategoryEntries(node: CategorySummaryNode) {
|
||||||
const { tab: _tab, category: _category, ...restQuery } = route.query;
|
const { tab: _tab, category: _category, ...restQuery } = route.query;
|
||||||
const query: LocationQueryRaw = { ...restQuery, category: node.key };
|
const query: LocationQueryRaw = { ...restQuery };
|
||||||
query.ledgers = ledgerStore.currentLedgerId;
|
query.ledgers = ledgerStore.currentLedgerId;
|
||||||
query.cat = node.key;
|
query.cat = node.key;
|
||||||
void router.push({ name: "query", query });
|
void router.push({ name: "query", query });
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue