From 8054d98b1db3996b5456724fcc14e6c0cc935076 Mon Sep 17 00:00:00 2001 From: wxs Date: Fri, 17 Jul 2026 11:41:37 +0800 Subject: [PATCH] docs: add market favorites plan --- .../plans/2026-07-17-market-favorites.md | 459 ++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-market-favorites.md diff --git a/docs/superpowers/plans/2026-07-17-market-favorites.md b/docs/superpowers/plans/2026-07-17-market-favorites.md new file mode 100644 index 0000000..e088f1f --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-market-favorites.md @@ -0,0 +1,459 @@ +# 星图达人收藏夹 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. + +**Goal:** 在星图达人市场页提供本地收藏夹、行内多收藏夹归类、右侧抽屉管理,并复用既有批次接口导入秒探。 + +**Architecture:** 新增独立的收藏数据仓库,将版本化数据保存在 chrome.storage.local,并把达人实体与收藏夹成员关系分离。表格同步层只暴露每个市场行的原生操作单元格;行内书签选择器和右侧抽屉分别负责收藏与管理。导入时仅将收藏的最小达人快照转换为 MarketRecord[],不重新采集星图列表。 + +**Tech Stack:** TypeScript, Chrome MV3 content script, chrome.storage.local, Vitest, jsdom. + +--- + +## File Structure + +- Create: src/content/market/favorites-store.ts - 版本化状态、校验、串行读写和 Chrome 存储适配。 +- Create: src/content/market/favorites-import.ts - 收藏达人到批次 MarketRecord 的去重映射。 +- Create: src/content/market/favorite-folder-dialog.ts - 新建和改名收藏夹的名称对话框。 +- Create: src/content/market/favorite-row-picker.ts - 原生操作单元格内的书签和多选收藏夹面板。 +- Create: src/content/market/favorites-drawer.ts - 右侧入口、抽屉、文件夹切换和导入 UI。 +- Modify: src/content/market/dom-sync.ts - 暴露原生操作单元格,不改变下单按钮。 +- Modify: src/content/market/index.ts - 协调收藏仓库、UI 与既有批次提交。 +- Create: tests/favorites-store.test.ts +- Create: tests/favorites-import.test.ts +- Create: tests/favorite-folder-dialog.test.ts +- Create: tests/favorite-row-picker.test.ts +- Create: tests/favorites-drawer.test.ts +- Modify: tests/market-dom-sync.test.ts +- Modify: tests/market-content-entry.test.ts + +### Task 1: Favorites Repository And Chrome Storage Boundary + +**Files:** +- Create: src/content/market/favorites-store.ts +- Test: tests/favorites-store.test.ts + +- [ ] **Step 1: Write failing repository tests** + +Use an injected asynchronous memory storage fake. Cover initial empty state, trimmed non-empty names, a creator in two folders, same-folder deduplication, deleting one folder while retaining a creator in another, malformed data recovery, and rejected storage writes. + +~~~ts +test("keeps one creator entity while it belongs to multiple folders", async () => { + const repository = createFavoritesRepository({ + createId: (() => { let id = 0; return () => "folder-" + ++id; })(), + now: () => "2026-07-17T00:00:00.000Z", + storage: createMemoryFavoritesStorage() + }); + const mom = await repository.createFolder(" 母婴优质达人 "); + const festival = await repository.createFolder("七夕备选"); + + await repository.setCreatorFolderIds( + { authorId: "111", authorName: "达人A", coreUserId: "core-111" }, + [mom.id, festival.id, mom.id] + ); + + const state = await repository.read(); + expect(state.folders.map((folder) => folder.name)).toEqual(["母婴优质达人", "七夕备选"]); + expect(state.creators).toHaveLength(1); + expect(state.memberships).toHaveLength(2); +}); +~~~ + +- [ ] **Step 2: Run the test to verify it fails** + +Run: npx vitest run tests/favorites-store.test.ts + +Expected: FAIL because favorites-store.ts does not exist. + +- [ ] **Step 3: Implement the repository and storage adapter** + +Create favorites-store.ts with these public types and no DOM dependency: + +~~~ts +export const FAVORITES_STORAGE_KEY = "sces:favorites:v1"; + +export interface FavoriteFolder { + createdAt: string; + id: string; + name: string; + updatedAt: string; +} +export interface FavoriteCreator { + authorId: string; + authorName: string; + coreUserId?: string; + savedAt: string; +} +export interface FavoriteMembership { + addedAt: string; + authorId: string; + folderId: string; +} +export interface FavoritesStateV1 { + creators: FavoriteCreator[]; + folders: FavoriteFolder[]; + memberships: FavoriteMembership[]; + version: 1; +} +export interface FavoriteCreatorInput { + authorId: string; + authorName: string; + coreUserId?: string; +} +export interface FavoritesStorage { + get(): Promise; + set(value: FavoritesStateV1): Promise; +} +export interface FavoritesRepository { + createFolder(name: string): Promise; + deleteFolder(folderId: string): Promise; + read(): Promise; + removeCreatorFromFolder(authorId: string, folderId: string): Promise; + renameFolder(folderId: string, name: string): Promise; + setCreatorFolderIds(creator: FavoriteCreatorInput, folderIds: string[]): Promise; +} +~~~ + +createFavoritesRepository({ storage, now?, createId? }) must serialize mutations through one promise chain, normalize missing/invalid/duplicate/dangling data into a valid state, reject blank names with 请输入收藏夹名称, reject empty IDs with 无法收藏该达人, and remove creators after their final membership is removed. Default now to new Date().toISOString() and createId to crypto.randomUUID(), with a Date.now-plus-random-string fallback for environments without crypto.randomUUID. Preserve savedAt on existing creators while merging current non-empty name and coreUserId. Create createChromeFavoritesStorage() around globalThis.chrome.storage.local; declare the small local chrome.storage shape needed by TypeScript, and make its get/set operations reject with 收藏夹存储不可用 when absent. Do not fall back to window.localStorage. + +- [ ] **Step 4: Run focused repository tests** + +Run: npx vitest run tests/favorites-store.test.ts + +Expected: PASS, including malformed-data recovery and failed-write assertions. + +- [ ] **Step 5: Commit the isolated storage layer** + +~~~bash +git add src/content/market/favorites-store.ts tests/favorites-store.test.ts +git commit -m "feat: add local favorites repository" +~~~ + +### Task 2: Favorites-To-Batch Mapping + +**Files:** +- Create: src/content/market/favorites-import.ts +- Test: tests/favorites-import.test.ts + +- [ ] **Step 1: Write failing mapping tests** + +Test empty input, a creator with coreUserId, and duplicate authors gathered from several folders. The output must be minimal MarketRecord values ordered by first occurrence. + +~~~ts +expect(toFavoriteMarketRecords([ + { authorId: "111", authorName: "达人A", coreUserId: "core-111", savedAt: "2026-07-17T00:00:00.000Z" }, + { authorId: "111", authorName: "达人A新名称", savedAt: "2026-07-17T01:00:00.000Z" }, + { authorId: "222", authorName: "达人B", savedAt: "2026-07-17T00:00:00.000Z" } +])).toEqual([ + { authorId: "111", authorName: "达人A", coreUserId: "core-111", status: "idle" }, + { authorId: "222", authorName: "达人B", status: "idle" } +]); +~~~ + +- [ ] **Step 2: Run the mapping test to verify it fails** + +Run: npx vitest run tests/favorites-import.test.ts + +Expected: FAIL because favorites-import.ts does not exist. + +- [ ] **Step 3: Implement the conversion helper** + +Create favorites-import.ts: + +~~~ts +import type { FavoriteCreator } from "./favorites-store"; +import type { MarketRecord } from "./types"; + +export function toFavoriteMarketRecords(creators: FavoriteCreator[]): MarketRecord[] { + const byAuthorId = new Map(); + creators.forEach((creator) => { + if (creator.authorId && !byAuthorId.has(creator.authorId)) { + byAuthorId.set(creator.authorId, creator); + } + }); + return Array.from(byAuthorId.values()).map((creator) => ({ + authorId: creator.authorId, + authorName: creator.authorName, + ...(creator.coreUserId ? { coreUserId: creator.coreUserId } : {}), + status: "idle" as const + })); +} +~~~ + +Do not modify batch-payload.ts; its current coreUserId to authorUid mapping remains authoritative. + +- [ ] **Step 4: Run mapping and payload tests** + +Run: npx vitest run tests/favorites-import.test.ts tests/batch-payload.test.ts + +Expected: PASS. + +- [ ] **Step 5: Commit the adapter** + +~~~bash +git add src/content/market/favorites-import.ts tests/favorites-import.test.ts +git commit -m "feat: map favorites to batch records" +~~~ + +### Task 3: Native Action Cells And Row-Level Folder Picker + +**Files:** +- Create: src/content/market/favorite-row-picker.ts +- Modify: src/content/market/dom-sync.ts:82-100, 599-686 +- Test: tests/favorite-row-picker.test.ts +- Test: tests/market-dom-sync.test.ts + +- [ ] **Step 1: Write failing DOM tests** + +Extend the real grid fixture test to expect aligned actionCell values without losing 下单. Add picker tests for valid, already-saved and missing-ID rows; verify one active popover, checkbox state, and callback values. + +~~~ts +expect(table?.rows.map((row) => row.actionCell?.dataset.testid)).toEqual([ + "action-cell-111", + "action-cell-222" +]); +expect(readRightRowTexts(0)).toEqual(["¥450,000", "下单"]); +~~~ + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: npx vitest run tests/market-dom-sync.test.ts tests/favorite-row-picker.test.ts + +Expected: FAIL because MarketRowDom.actionCell and the picker module do not exist. + +- [ ] **Step 3: Expose the existing action cells without changing native controls** + +Add actionCell?: HTMLElement to MarketRowDom. In syncDivGridRoot, obtain const actionCells = getDirectContentCells(actionColumn) beside priceCells, then assign actionCell: actionCells[index] for real-grid rows. Synthetic rows receive actionCell: undefined. Do not add a header, table column, or textContent write to native action cells. + +- [ ] **Step 4: Implement the picker contract** + +Create favorite-row-picker.ts: + +~~~ts +import type { FavoriteCreatorInput, FavoriteFolder } from "./favorites-store"; + +export interface FavoriteRowPickerRow extends FavoriteCreatorInput { + actionCell?: HTMLElement; +} + +export function syncFavoriteRowPickers(options: { + document: Document; + folders: FavoriteFolder[]; + folderIdsByAuthorId: ReadonlyMap>; + onCreateFolder: () => Promise; + onSetCreatorFolderIds: (creator: FavoriteCreatorInput, folderIds: string[]) => Promise; + rows: FavoriteRowPickerRow[]; +}): void; +~~~ + +Prepend/reuse button[data-sces-favorite-row-action="button"] in each native action cell. Its title is 加入收藏夹 at zero memberships and 已收藏至 N 个收藏夹 otherwise; empty authorId produces a disabled button. Click opens one document-level data-sces-favorite-row-picker="root" popover with folder checkboxes and 新建收藏夹. Checkbox changes await onSetCreatorFolderIds. The create action awaits onCreateFolder, then selects the returned folder. Escape, outside pointer-down, a different row click, and stale row removal close the popover. + +- [ ] **Step 5: Run focused DOM tests** + +Run: npx vitest run tests/market-dom-sync.test.ts tests/favorite-row-picker.test.ts + +Expected: PASS, including repeated table/picker synchronization. + +- [ ] **Step 6: Commit row-level UI** + +~~~bash +git add src/content/market/dom-sync.ts src/content/market/favorite-row-picker.ts tests/market-dom-sync.test.ts tests/favorite-row-picker.test.ts +git commit -m "feat: add row favorites picker" +~~~ + +### Task 4: Folder Dialog And Right-Side Drawer + +**Files:** +- Create: src/content/market/favorite-folder-dialog.ts +- Create: src/content/market/favorites-drawer.ts +- Test: tests/favorite-folder-dialog.test.ts +- Test: tests/favorites-drawer.test.ts + +- [ ] **Step 1: Write failing dialog and drawer tests** + +Test dialog prefill, trim-and-reject-empty, cancel and Escape cleanup. Test the drawer tab, right: 56px offset, closed initial state, deduplicated 全部达人, folder filtering, search, selected count, remove callback, selected import, and full-folder import only after a confirmation message including the deduplicated count. + +~~~ts +expect(document.querySelector('[data-sces-favorites-tab="button"]')).not.toBeNull(); +expect((document.querySelector('[data-sces-favorites-drawer="root"]') as HTMLElement).style.right).toBe("56px"); +expect(onImportFolder).toHaveBeenCalledWith("folder-mom", ["111", "222"]); +~~~ + +- [ ] **Step 2: Run the UI tests to verify they fail** + +Run: npx vitest run tests/favorite-folder-dialog.test.ts tests/favorites-drawer.test.ts + +Expected: FAIL because both modules do not exist. + +- [ ] **Step 3: Create the folder-name dialog** + +Implement this API in favorite-folder-dialog.ts, following the existing batch-name dialog pattern: + +~~~ts +export function promptForFavoriteFolderName( + document: Document, + options: { initialValue?: string; title: "新建收藏夹" | "重命名收藏夹" } +): Promise; +~~~ + +Use data-sces-favorite-folder-dialog selectors, focus the input, show 请输入收藏夹名称 inline for blank confirmation, and clean keydown listeners on cancel, Escape, overlay click and submit. Reuse an active dialog per document rather than stacking overlays. + +- [ ] **Step 4: Create the drawer render contract** + +Create favorites-drawer.ts: + +~~~ts +import type { FavoritesStateV1 } from "./favorites-store"; + +export interface FavoritesDrawerHandlers { + onCreateFolder(): Promise; + onDeleteFolder(folderId: string): Promise; + onImportFolder(folderId: string, authorIds: string[]): Promise; + onImportSelected(authorIds: string[]): Promise; + onRemoveCreatorFromFolder(authorId: string, folderId: string): Promise; + onRenameFolder(folderId: string): Promise; +} +export interface FavoritesDrawerController { + dispose(): void; + render(state: FavoritesStateV1): void; + setBusy(isBusy: boolean): void; + setError(message: string): void; +} +export function createFavoritesDrawer( + document: Document, + handlers: FavoritesDrawerHandlers, + confirmImport: (message: string) => boolean +): FavoritesDrawerController; +~~~ + +Mount a fixed data-sces-favorites-tab="button" and a closed-by-default 380px data-sces-favorites-drawer="root", both right: 56px so the Xingtu service rail remains usable. The drawer owns only view, query and selected IDs; prune invalid selections during each render. In all view derive creators by authorId once. In a concrete folder view, derive members from that folder and display 导入当前收藏夹全部达人. Confirm with a message built by joining 将当前收藏夹的, authorIds.length, 位达人导入秒探吗?. Disable management and import controls while busy. + +- [ ] **Step 5: Run focused UI tests** + +Run: npx vitest run tests/favorite-folder-dialog.test.ts tests/favorites-drawer.test.ts + +Expected: PASS, including declined full-import confirmation. + +- [ ] **Step 6: Commit drawer UI** + +~~~bash +git add src/content/market/favorite-folder-dialog.ts src/content/market/favorites-drawer.ts tests/favorite-folder-dialog.test.ts tests/favorites-drawer.test.ts +git commit -m "feat: add favorites drawer management" +~~~ + +### Task 5: Controller Integration And Batch Submission + +**Files:** +- Modify: src/content/market/index.ts:76-110, 115-192, 474-563, 619-638, 1561-1587 +- Modify: tests/market-content-entry.test.ts + +- [ ] **Step 1: Write failing integration tests** + +Use an injected memory FavoritesRepository, deterministic folder-name prompt and confirmation callback. Extend the real-market fixture row input with optional coreUserId and write the matching data-sces-market-rows serialized snapshot before controller creation, so readRowSnapshot carries core-111 through to the favorites repository. Cover tab/drawer/icon rendering without removing 下单; row-picker folder creation; deduplicated all view; selected import preserving authorUid; declined full import; and a rejected storage mutation that leaves existing export controls usable. + +~~~ts +expect(submitBatch).toHaveBeenCalledWith(expect.objectContaining({ + authors: [{ authorId: "111", authorName: "达人 A", authorUid: "core-111" }], + batchName: "收藏夹批次" +})); +expect(document.querySelector('[data-testid="action-cell-111"]')?.textContent).toContain("下单"); +~~~ + +- [ ] **Step 2: Run the integration test to verify it fails** + +Run: npx vitest run tests/market-content-entry.test.ts + +Expected: FAIL because CreateMarketControllerOptions has no favorites dependencies and no favorites UI mounts. + +- [ ] **Step 3: Add controller dependencies and refresh flow** + +Extend CreateMarketControllerOptions with: + +~~~ts +favoritesRepository?: FavoritesRepository; +confirmFavoriteImport?: (message: string) => boolean; +promptFavoriteFolderName?: ( + options: { initialValue?: string; title: "新建收藏夹" | "重命名收藏夹" } +) => Promise | string | null; +~~~ + +Default them to the Chrome repository, window.confirm.bind(window), and promptForFavoriteFolderName(options.document, dialogOptions). Create the drawer once. Add refreshFavorites() to read state, render the drawer, derive folderIdsByAuthorId, and pass current MarketRowDom identity plus actionCell to syncFavoriteRowPickers. Call it after hydrateCurrentPage() and in applyCurrentView() so pagination, sorting and DOM replacement retain correct icons. The row-picker new-folder callback must return the FavoriteFolder from repository.createFolder, while the drawer new-folder handler only awaits the same mutation and refreshes. If the initial read or any later read rejects, call favoritesDrawer.setError with 收藏夹暂不可用, skip picker synchronization for that render, and leave the existing market controller active. Storage actions refresh only after success and surface 收藏夹操作失败,请稍后重试 for unknown errors. + +- [ ] **Step 4: Reuse batch payload creation for favorites** + +Add one private controller function used by both drawer import handlers: + +~~~ts +async function submitFavoriteAuthors(authorIds: string[]): Promise { + const state = await favoritesRepository.read(); + const records = toFavoriteMarketRecords( + state.creators.filter((creator) => authorIds.includes(creator.authorId)) + ); + if (records.length === 0) { + favoritesDrawer.setError("当前没有可导入的达人"); + return; + } + const batchName = await promptBatchName(); + if (batchName === null) return; + const authState = await getAuthState(); + if (!authState.isAuthenticated) throw new Error("请先登录插件"); + await submitBatch(createBatchPayload({ + authState, + batchName, + createdAt: new Date().toISOString(), + records + })); +} +~~~ + +Set drawer busy around every import and reset it in finally. Successful import leaves repository data untouched. Favorite imports must not call exportRecords, silentExportController, list selection, spread filters, the market page bridge, or modify the existing submit endpoint/payload protocol. + +- [ ] **Step 5: Run controller regression tests** + +Run: npx vitest run tests/market-content-entry.test.ts tests/market-dom-sync.test.ts tests/batch-payload.test.ts + +Expected: PASS. Existing export, selection and normal batch-submit cases remain green. + +- [ ] **Step 6: Commit integration** + +~~~bash +git add src/content/market/index.ts tests/market-content-entry.test.ts +git commit -m "feat: import saved favorites to batches" +~~~ + +### Task 6: Final Verification And Scope Audit + +**Files:** +- Verify: all files from Tasks 1-5 + +- [ ] **Step 1: Run the focused suite** + +Run: + +~~~bash +npx vitest run tests/favorites-store.test.ts tests/favorites-import.test.ts tests/favorite-row-picker.test.ts tests/favorite-folder-dialog.test.ts tests/favorites-drawer.test.ts tests/market-dom-sync.test.ts tests/market-content-entry.test.ts tests/batch-payload.test.ts +~~~ + +Expected: PASS with zero failures. + +- [ ] **Step 2: Run the production build** + +Run: npm run build + +Expected: exit code 0. + +- [ ] **Step 3: Audit the final diff** + +Run: + +~~~bash +git diff --check HEAD +git diff --stat HEAD +git status --short +~~~ + +Expected: only favorites source/tests and intentional build artifacts. The manifest, batch-submit endpoint, payload shape, market page bridge, and unrelated untracked files remain unchanged. + +- [ ] **Step 4: Commit only verification-driven corrections** + +When a focused source or test correction is needed, stage it separately and commit with a matching message. When all checks pass without corrections, do not create an empty commit.