29 Commits
Author SHA1 Message Date
wxs d121849d3e feat: streamline export field selection 2026-07-29 10:51:24 +08:00
wxs 53db9b0777 feat: make metric filters field-driven 2026-07-27 17:58:10 +08:00
wxs e03dedd91e feat: honor all-range export and batch scope 2026-07-27 16:00:57 +08:00
wxs e15bc0657a feat: make audience export fields selective 2026-07-27 15:37:35 +08:00
wxs e062be6431 fix: respect selected audience export fields 2026-07-20 11:16:40 +08:00
wxs c683e1c0c2 fix: hide closed favorites drawer 2026-07-17 19:41:04 +08:00
wxs 9ed2c23230 fix: refresh favorite identity from market bridge 2026-07-17 16:54:19 +08:00
wxs ddf9a75e82 fix: preserve favorite identity and ready lifecycle 2026-07-17 16:41:11 +08:00
wxs 8ca9f95583 fix: await initial market synchronization 2026-07-17 16:06:51 +08:00
wxs 01efa9f847 fix: recover favorites controller refresh 2026-07-17 15:32:51 +08:00
wxs 8ebb50524c fix: harden favorites controller lifecycle 2026-07-17 15:12:00 +08:00
wxs ff1decd4a7 feat: import saved favorites to batches 2026-07-17 14:18:35 +08:00
wxs 0f1edcd181 fix: preserve drawer on dialog escape 2026-07-17 13:27:41 +08:00
wxs b973e1099d fix: harden favorites drawer interactions 2026-07-17 13:22:25 +08:00
wxs ba4a1052e8 test: verify folder dialog listener cleanup 2026-07-17 13:06:48 +08:00
wxs 6570eb32b4 test: cover favorites drawer lifecycle 2026-07-17 13:03:27 +08:00
wxs 2f2a590df4 feat: add favorites drawer management 2026-07-17 12:56:19 +08:00
wxs e08fc99def fix: harden favorite row picker 2026-07-17 12:40:01 +08:00
wxs 4c1ccb92db test: cover favorite picker states 2026-07-17 12:29:20 +08:00
wxs 82e81959c0 feat: add row favorites picker 2026-07-17 12:23:35 +08:00
wxs 8ba1787177 test: cover empty favorite author ids 2026-07-17 12:13:59 +08:00
wxs 27a4fbb1aa feat: map favorites to batch records 2026-07-17 12:08:45 +08:00
wxs ac8e86ab7b fix: harden favorites storage recovery 2026-07-17 12:04:56 +08:00
wxs a7d62c7ec0 fix: validate favorite folder removal 2026-07-17 11:58:12 +08:00
wxs 30748ddc0f feat: add local favorites repository 2026-07-17 11:54:39 +08:00
wxs 8054d98b1d docs: add market favorites plan 2026-07-17 11:41:37 +08:00
wxs 370cf0f5d2 docs: add market favorites design 2026-07-17 11:27:47 +08:00
wxs 4768f66c39 fix: remove metric catalog search 2026-07-17 10:54:41 +08:00
wxs c45dca8a13 fix: keep metric catalog above table header 2026-07-16 21:33:00 +08:00
30 changed files with 8062 additions and 4473 deletions
@@ -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<unknown>;
set(value: FavoritesStateV1): Promise<void>;
}
export interface FavoritesRepository {
createFolder(name: string): Promise<FavoriteFolder>;
deleteFolder(folderId: string): Promise<void>;
read(): Promise<FavoritesStateV1>;
removeCreatorFromFolder(authorId: string, folderId: string): Promise<void>;
renameFolder(folderId: string, name: string): Promise<void>;
setCreatorFolderIds(creator: FavoriteCreatorInput, folderIds: string[]): Promise<void>;
}
~~~
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<string, FavoriteCreator>();
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<string, ReadonlySet<string>>;
onCreateFolder: () => Promise<FavoriteFolder | null>;
onSetCreatorFolderIds: (creator: FavoriteCreatorInput, folderIds: string[]) => Promise<void>;
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<string | null>;
~~~
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<void>;
onDeleteFolder(folderId: string): Promise<void>;
onImportFolder(folderId: string, authorIds: string[]): Promise<void>;
onImportSelected(authorIds: string[]): Promise<void>;
onRemoveCreatorFromFolder(authorId: string, folderId: string): Promise<void>;
onRenameFolder(folderId: string): Promise<void>;
}
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> | 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<void> {
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.
@@ -0,0 +1,158 @@
# 星图达人收藏夹设计
## 背景
星图达人市场页已有列表筛选、导出和提交批次能力,但用户无法保存跨筛选条件、跨页面发现的优质达人。本功能新增本地收藏夹:用户可将达人归入多个不同主题的收藏夹,在需要时从收藏夹批量导入秒探。
收藏数据只保存在当前浏览器的当前插件配置中,不提供跨设备同步、团队共享或后端持久化。
## 目标
- 支持创建、改名和删除收藏夹。
- 支持将同一达人加入多个收藏夹;同一达人在同一收藏夹内只保留一条关联。
- 在市场列表每一行提供独立收藏入口,收藏时立即选择一个或多个收藏夹。
- 在页面右侧提供常驻的收藏夹入口和抽屉,用于浏览、整理、筛选和导入已收藏达人。
- 从抽屉内复用既有批次提交接口导入秒探,不重新采集星图列表。
## 非目标
- 不改变星图原生筛选、列表、下单或右侧服务栏功能。
- 不把收藏夹塞入现有导出和筛选工具栏。
- 不做插件弹窗中的第二套收藏夹管理界面。
- 不保存完整市场行、视频、指标和画像快照;收藏夹不是历史报表。
- 不实现跨设备同步、协作共享、后端备份或导入历史。
## 方案选择
### 方案 A:右侧入口 + 抽屉 + 行内收藏选择器
页面右侧提供收藏夹入口,点击后打开抽屉;每行收藏图标只打开贴近图标的收藏夹选择器。
优点:收藏、查看和导入各自处于最合适的位置;不挤占现有工具栏;适合频繁浏览和长期整理。
缺点:需要处理与星图原生右侧服务栏的空间和层级关系。
### 方案 B:行内图标直接打开右侧抽屉
每次收藏都先打开抽屉,再在抽屉中选择收藏夹。
优点:交互组件较少。
缺点:单个达人收藏需要跨越页面宽度,频繁操作成本高。
### 方案 C:工具栏按钮 + 模态框
在现有插件工具栏中增加收藏相关按钮,使用模态框管理收藏夹。
优点:实现边界集中。
缺点:工具栏已经承担导出、提交和筛选;收藏夹作为长期资产放在这里会造成信息和操作拥挤。
采用方案 A。
## 页面与交互
### 右侧入口
- 在星图原生右侧服务栏左侧固定一个窄入口,使用书签图标和 `收藏夹` 文本。
- 入口避开原生的帮助、联系和客服按钮;在窄视口保持图标入口,不遮挡页面内容。
- 入口可显示总收藏数,但不使用红色未读角标,以免被理解为通知。
- 点击入口打开右侧抽屉;再次点击入口或抽屉关闭按钮可关闭抽屉。
### 行内收藏
- 在每个有效达人行的原生 `操作`/`下单` 控件之前增加书签图标,不修改或覆盖原生操作。
- 未收藏时显示描边书签,悬停提示 `加入收藏夹`
- 已收藏至至少一个收藏夹时显示粉色实心书签,悬停提示 `已收藏至 N 个收藏夹`
- 点击图标在图标附近打开小型选择器,列出所有收藏夹及该达人的当前归属状态;用户可多选或取消选择。
- 小型选择器提供 `新建收藏夹`,成功创建后立即把当前达人加入新收藏夹。
- 达人没有可用 `authorId` 时,图标不可用并说明无法收藏该达人。
### 收藏夹抽屉
- 抽屉从右侧滑入,宽度为 `380-420px`,位于原生右侧服务栏左侧;打开或关闭都不改变星图列表的筛选、滚动和横向滚动位置。
- 顶部包含 `收藏夹` 标题和 `新建收藏夹` 操作。
- 默认显示 `全部达人`。同一达人即使在多个收藏夹中,也只显示一条记录。
- 用户可切换为某个收藏夹,只查看该收藏夹中的达人;支持按达人名称搜索。
- 每个达人显示最小身份信息、加入时间和操作菜单。操作菜单支持从当前收藏夹移除;在 `全部达人` 中仅展示归属,不提供破坏性的一键全局删除。
- 抽屉列表支持复选,底部固定操作区显示已选人数。
### 导入秒探
- `导入已选 N 位达人`:抽屉内至少勾选一位达人后可用。
- `导入当前收藏夹全部达人`:仅在查看具体收藏夹时可用,点击后必须显示二次确认,并写明本次导入人数。
- 两个入口都先按 `authorId` 去重,再映射为既有批次 payload 所需的最小 `MarketRecord` 数据。
- 沿用既有登录校验、批次名称输入、`createBatchPayload()` 和秒探批次提交客户端。
- 导入成功后保留收藏和收藏夹成员关系;失败时不改变本地数据,并显示既有提交错误或明确的收藏夹错误。
## 本地数据
使用 `chrome.storage.local`,不使用内容脚本页面的 `window.localStorage`。插件清单已声明 `storage` 权限,插件存储不会与星图网页站点数据混用。
首版数据采用一个版本化根对象,便于整体校验和迁移:
```ts
type FavoritesStateV1 = {
version: 1;
folders: Array<{
id: string;
name: string;
createdAt: string;
updatedAt: string;
}>;
creators: Array<{
authorId: string;
authorName: string;
coreUserId?: string;
savedAt: string;
}>;
memberships: Array<{
authorId: string;
folderId: string;
addedAt: string;
}>;
};
```
- `creator``authorId` 唯一;多收藏夹通过 `memberships` 表达,避免复制达人数据。
- `memberships``(folderId, authorId)` 唯一。
- 删除收藏夹只删除其成员关联;没有任何关联的达人记录可以一并清理。
- 导入时若存在 `coreUserId`,继续作为现有 payload 的 `authorUid` 发送。
- 读取缺失、格式错误或未知版本时视为未初始化状态,不应阻断市场页其他能力;写入失败必须向用户反馈。
## 模块边界
- 收藏数据仓库:读写、迁移、去重、收藏夹和成员关系规则;不依赖 DOM 或批次接口。
- 行内收藏装饰:从市场行读取最小达人身份,渲染书签和选择器;不负责抽屉列表。
- 收藏夹抽屉:呈现文件夹和达人,处理搜索、选择和管理操作;通过数据仓库获取状态。
- 收藏夹导入适配层:将收藏达人转换为最小 `MarketRecord[]`,调用现有批次提交流程;不直接读写浏览器存储。
- 市场控制器:组装这些模块并协调抽屉、行内状态与繁忙态。
## 状态与错误处理
- 收藏或取消收藏后,行内图标、选择器和已打开抽屉必须立刻同步。
- 新建、改名和删除收藏夹时,名称去除首尾空格;空名称不可保存。删除前确认。
- 导入执行期间,抽屉的导入和管理操作禁用,结束后恢复。
- 导入前没有有效达人时,不打开批次名称输入,也不调用提交接口。
- 对包含多个收藏夹成员的导入,提交前按 `authorId` 去重,避免发送重复作者。
- 读取或写入插件存储失败时,只提示收藏夹操作失败,不影响星图原生页面或既有导出/提交功能。
## 验收标准
1. 用户可创建、改名和删除收藏夹;删除收藏夹不删除其他收藏夹中相同达人。
2. 同一达人可加入多个收藏夹,但不会在同一收藏夹重复出现。
3. 每个有效达人行均有独立收藏图标,且不会影响星图原生 `下单` 等操作。
4. 行内图标可以打开多选收藏夹选择器,选择器可即时新建收藏夹并完成收藏。
5. 右侧入口不与星图原生右侧服务栏重叠;点击后抽屉能显示全部达人和具体收藏夹中的达人。
6. 抽屉的 `全部达人` 视图对跨收藏夹的同一达人去重;具体收藏夹视图只显示该收藏夹成员。
7. 用户可导入已选达人;用户也可导入当前收藏夹全部达人,但必须经过人数明确的二次确认。
8. 导入复用现有批次 payload 和提交接口,携带已有的 `authorId``authorName` 和可选 `authorUid`;成功后收藏不丢失。
9. 浏览器存储不可用、达人缺少 ID、空收藏夹、空选择和提交失败均有清晰反馈。
## 验证范围
- 收藏数据仓库的创建、改名、删除、成员去重、跨收藏夹关联、序列化错误恢复测试。
- 行内书签、选择器、新建收藏夹和状态同步的 DOM 测试。
- 抽屉的默认视图、文件夹切换、搜索、移除、选择和全量导入确认测试。
- 收藏达人到批次 payload 的映射、跨收藏夹去重、登录失败、提交成功/失败测试。
- 运行相关 Vitest 测试和 `npm run build`
+57 -83
View File
@@ -9,17 +9,17 @@
项目输入包括:
- 巨量星图达人市场页面中的达人列表、筛选条件和分页结果;
- 使用者在页面上勾选的达人;
- 使用者在页面上勾选的达人(可选)
- 使用者粘贴的达人星图 ID
- 使用者填写的批次名称;
- 使用者选择的导出字段和传播指标筛选规则
- 使用者选择的导出字段;
- 当前插件登录用户的 Logto 身份和访问令牌。
项目处理过程包括:
- 在星图达人市场页面挂载插件工具栏;
- 读取当前页面或星图列表接口返回的达人数据;
- 根据勾选范围分页范围、传播指标规则确定最终达人集合;
- 根据勾选范围分页范围确定最终达人集合;
- 调用星图接口补充看后搜率、画像、商业能力、传播指标等信息;
- 调用公司后端接口补充秒思 api 指标;
- 生成 CSV 文件,或组装批次 payload 提交到后端。
@@ -55,9 +55,9 @@
3. 打开巨量星图达人市场页面;
4. 插件读取登录状态并挂载工具栏;
5. 插件读取当前星图达人列表,并补充页面展示指标;
6. 使用者选择达人、字段、传播指标筛选规则或输入星图 ID
6. 使用者选择达人、检索并勾选导出字段,或输入星图 ID
7. 使用者触发导出或提交批次;
8. 插件收集达人数据,按规则过滤、去重补充字段;
8. 插件收集达人数据,去重补充已选字段;
9. 插件调用星图接口和公司后端接口补充数据;
10. 插件下载 CSV,或把批次提交给后端;
11. 使用者通过状态提示、下载文件或后端批次结果确认任务完成。
@@ -105,18 +105,18 @@
### 2.5 导出选中达人数据
- 触发者:使用者点击 `导出选中达人数据`
- 输入:当前勾选达人、当前导出范围字段选择配置、传播指标筛选规则
- 处理:必须先勾选达人;插件收集导出范围内的达人,只保留范围内已勾选的达人,然后补充内容数据、效果预估、画像、秒思指标等字段
- 输入:可选的当前勾选达人、当前导出范围字段选择配置。
- 处理:选择 `全部` 且没有勾选达人时,插件从第 1 页收集当前筛选结果的全部分页并全部导出;只要存在勾选达人,则勾选优先,只保留导出范围内已勾选的达人。其他范围仍需要勾选达人。随后按字段选择配置补充内容数据、效果预估、画像、看后搜率和秒思指标。未勾选的数据类别不会调用对应补充接口;从未保存过字段配置时保持默认全量导出
- 输出:CSV 文件下载到浏览器默认下载目录。
- 下一步:使用者检查 CSV 内容。
- 人工操作:需要使用者勾选达人并点击按钮
- 条件分支:如果当前导出范围内没有选中的达人,则不下载 CSV 并提示
- 人工操作:选择 `全部` 时可直接点击按钮;选择其他范围时需要先勾选达人
- 条件分支:存在勾选但全部范围内没有勾选达人时,不下载 CSV 并提示;单项补充失败不阻止下载,CSV 的 `导出状态``失败原因` 会标明部分成功及失败来源
### 2.6 按星图 ID 导出
- 触发者:使用者点击 `按星图ID导出`
- 输入:弹窗中粘贴的达人星图 ID。
- 处理:插件校验 ID 格式、去重、忽略非法 token,然后逐个 ID 请求基础信息看后搜率、传播指标、画像、商业能力和后端秒思指标。
- 处理:插件校验 ID 格式、去重、忽略非法 token,然后逐个 ID 获取基础信息,并按字段选择配置请求看后搜率、传播指标、画像、效果预估和后端秒思指标。未勾选的数据类别不调用对应补充接口。
- 输出:CSV 文件下载到浏览器默认下载目录。
- 下一步:使用者检查 CSV 中每个 ID 的导出状态和失败原因。
- 人工操作:需要使用者粘贴 ID 并确认。
@@ -126,7 +126,7 @@
- 触发者:使用者点击 `提交批次`
- 输入:当前范围或已勾选达人、批次名称、登录用户信息。
- 处理:插件先要求输入批次名称,再收集达人数据,应用传播指标规则和选中规则,检查登录状态,组装批次 payload,提交到后端。
- 处理:插件先要求输入批次名称,再按当前范围收集达人数据。选择 `全部` 且没有勾选达人时,提交当前筛选结果的全部分页;只要存在勾选达人,则勾选优先,不会回退为全部结果。最后检查登录状态,组装批次 payload,提交到后端。
- 输出:后端生成批次;页面显示 `批次提交成功` 或失败原因。
- 下一步:在后端系统中继续处理批次。
- 人工操作:需要使用者输入批次名称。
@@ -211,22 +211,22 @@
### 3.6 导出选中达人数据
- 步骤目的:把使用者选定的达人数据导出为 CSV。
- 输入内容:已勾选达人、当前导出范围字段选择配置、传播指标筛选规则
- 输入内容:可选的已勾选达人、当前导出范围字段选择配置。
- 处理规则:
- 必须先勾选达人
- 先按导出范围收集达人;
- 再严格保留当前导出范围内已勾选的达人
- 对每个达人补充画像、商业能力、传播指标、看后搜率秒思指标;
- 字段选择只控制可选字段,基础字段、导出状态和失败原因等固定保留;
- 如果全部画像请求失败,则不下载 CSV 并提示画像导出失败
- 选择 `全部` 且没有勾选达人时,从第 1 页收集并导出当前筛选结果的全部分页
- 存在勾选达人时,勾选优先,严格保留导出范围内已勾选的达人;
- 选择当前页、前 5 页、前 10 页或自定义页数且没有勾选时,提示并停止
- 只补充已选字段对应的画像、效果预估、传播指标、看后搜率秒思指标,未选数据类别不调用对应接口
- 基础字段、导出状态和失败原因等固定保留;
- 画像请求全部失败时仍下载 CSV,已成功的效果预估等字段正常写入
- 单个达人部分接口失败时,CSV 保留该行,并写入导出状态和失败原因。
- 输出结果:CSV 文件。
- 外部依赖:星图画像接口、商业能力接口、传播指标接口、公司后端指标接口、浏览器下载能力。
- 失败后如何处理:
- 没有勾选达人:提示并停止;
- 当前范围内无选中达人:提示并停止;
- `全部` 范围没有勾选达人:提示并停止;
- 存在勾选但全部范围内无选中达人:提示并停止;
- 单个达人部分失败:记录为部分成功或失败;
- 全部画像失败:不下载 CSV
- 画像失败:对应画像单元格留空,失败来源写入 `失败原因`,其他成功数据仍保留并下载
- 是否影响后续步骤:不影响外部系统写入;只影响本次下载结果。
### 3.7 按星图 ID 导出
@@ -238,7 +238,7 @@
- 只接受 16 到 20 位纯数字;
- 重复 ID 会去重;
- 非法 token 会计入提示,但不会进入导出;
- 对有效 ID 逐个补齐基础信息看后搜率、传播指标、画像、商业能力和后端秒思指标;
- 对有效 ID 逐个获取基础信息,只补齐已选字段对应的看后搜率、传播指标、画像、效果预估和后端秒思指标;
- 每个 ID 生成一行 CSV,并标记成功、部分成功或失败。
- 输出结果:按 ID 导出的 CSV 文件。
- 外部依赖:星图基础信息接口、星图指标接口、公司后端指标接口、浏览器下载能力。
@@ -248,38 +248,17 @@
- 整体流程异常:提示按 ID 导出失败。
- 是否影响后续步骤:不写入外部系统,不影响批次。
### 3.8 传播指标规则筛选
- 步骤目的:在导出或提交前按内容传播表现过滤达人。
- 输入内容:可选的完播率、互动率规则;每条已选规则包含非负阈值和独立的视频类别、是否只看指派、是否排除营销流量、时间范围。
- 处理规则:
- 默认不选择任何指标,不启用二次筛选;
- 选择指标后必须填写该指标阈值,并为该指标选择一套独立视频口径;
- 同时选择完播率和互动率时,两条规则必须全部满足;
- 两条规则口径相同时,同一达人复用一次接口响应;口径不同时分别请求;
- 每条个人视频规则固定为不限指派、不排除营销流量;
- 每条星图视频规则可独立选择只看指派、不限指派、排除营销流量或不排除营销流量;
- 完播率和互动率按显示百分数比较,例如 `30` 表示 `30%`
- 请求失败、缺少传播指标请求 ID 或缺少已选指标的达人视为不满足筛选。
- 输出结果:过滤后的达人集合。
- 外部依赖:星图传播指标接口。
- 失败后如何处理:
- 已选指标阈值为空或非法:阻止导出或提交,并提示具体指标;
- 单个达人筛选请求失败:跳过该达人;
- 全部不满足:导出时可能生成只有表头的 CSV;提交批次时会按空记录继续组装并提交,后端是否接受未确认。
- 是否影响后续步骤:影响。过滤后的结果才进入 CSV 或批次 payload。
### 3.9 批次提交
### 3.8 批次提交
- 步骤目的:把达人集合提交给后续业务系统。
- 输入内容:导出范围内达人、已选达人、传播指标筛选结果、批次名称登录用户信息。
- 输入内容:导出范围内达人、已选达人、批次名称登录用户信息。
- 处理规则:
- 点击后先输入批次名称;
- 取消输入则停止;
- 批次名为空则提示并停止;
- 如果存在已勾选达人,则优先提交当前范围内已勾选达人
- 如果没有勾选达人,则提交当前范围内所有达人;
- 如果已勾选达人不在当前范围内,则回退为提交当前范围内所有达人;
- 选择 `全部` 且没有勾选达人时,从第 1 页提交当前筛选结果的全部分页
- 选择 `全部` 且存在已勾选达人时,严格提交全部范围内已勾选达人;
- 选择 `全部` 时,如果已勾选达人不在结果内,则提示并停止,不会回退为提交全部达人;
- 前端不生成批次 ID,批次 ID 由后端生成;
- payload 包含登录用户 ID、创建人名称、资源地址、批次名称、创建时间和达人列表;
- 达人列表包含达人 ID、达人名称,存在核心用户 ID 时额外带上。
@@ -293,7 +272,7 @@
- 网络失败:提交失败。
- 是否影响后续步骤:影响外部后端数据。是否会重复创建批次取决于后端,当前项目前端没有确认幂等机制。
### 3.10 CSV 下载
### 3.9 CSV 下载
- 步骤目的:把导出结果交付给使用者。
- 输入内容:生成好的 CSV 字符串和文件名。
@@ -301,7 +280,7 @@
- 优先通过 Chrome 扩展后台下载;
- 如果扩展下载通道不可用,则使用页面中的临时下载链接;
- CSV 带 UTF-8 BOM,方便表格软件识别中文;
- 普通导出文件名使用插件名加时间戳;
- 选中达人导出文件名使用画像导出标识加时间戳;
- 按 ID 导出文件名包含按 ID 导出标识。
- 输出结果:浏览器下载列表中出现 CSV 文件。
- 外部依赖:Chrome downloads 能力或浏览器下载能力。
@@ -316,11 +295,11 @@
| `search_for_author_square` | 获取星图达人市场列表数据 | 后台分页导出、读取达人基础字段 | 当前星图列表请求参数、页码 | 达人列表、分页信息、基础字段 | 是 | 未确认 | 后台分页串行请求;`全部` 最多尝试 200 页 | 未单独设置 fetch 超时 | 无自动重试 | 0 | 无 | 请求失败、响应结构异常、解析失败 | 星图网页登录态和 cookie | 未确认 | 请求失败或解析失败时退回页面翻页读取 |
| `get_author_commerce_seed_base_info` | 优先获取看后搜率 | 页面增强、导出补充 | `o_author_id``range=90` | 商单视频/个人视频看后搜率相关字段 | 否 | 未确认 | 页面增强按当前页达人并发请求;导出补充按达人串行处理 | 8 秒 | 有备用接口回退,但不是同接口重试 | 0 | 非超时失败且未成功时转备用接口 | 超时、备用接口也失败 | 星图网页登录态和 cookie | 未确认 | 请求失败时转备用接口;超时直接记为失败 |
| `get_author_ase_info` | 备用获取看后搜率 | 页面增强、导出补充 | `author_id``range=30` | 看后搜率相关字段 | 否 | 未确认 | 页面增强按当前页达人并发请求;导出补充按达人串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、缺少指标 | 星图网页登录态和 cookie | 未确认 | 失败后标记该达人看后搜率失败 |
| `author_audience_distribution` | 获取观众画像 | 导出选中达人、按 ID 导出 | `o_author_id``platform_source=1``platform_channel=1``link_type=5` | 性别、年龄、省份、城市、兴趣、人群等分布 | 否 | 未确认 | 单个达人内画像和商业能力并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因;全部画像失败时不下载选中导出 CSV |
| `get_author_fans_distribution` | 获取粉丝画像和铁粉画像 | 导出选中达人、按 ID 导出 | `o_author_id``platform_source=1``author_type=1 或 5` | 粉丝或铁粉分布 | 否 | 未确认 | 单个达人内多个画像请求串行执行;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因 |
| `author_audience_distribution` | 获取观众画像 | 导出选中达人、按 ID 导出 | `o_author_id``platform_source=1``platform_channel=1``link_type=5` | 性别、年龄、省份、城市、兴趣、人群等分布 | 否 | 未确认 | 仅选择观众画像字段时调用;单个达人内与其他已选数据源并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入该行失败原因,其他已选数据继续并下载 CSV |
| `get_author_fans_distribution` | 获取粉丝画像和铁粉画像 | 导出选中达人、按 ID 导出 | `o_author_id``platform_source=1``author_type=1 或 5` | 粉丝或铁粉分布 | 否 | 未确认 | 粉丝画像和铁粉画像分别按是否选择对应字段决定是否调用;同一达人已选画像请求串行;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入该行失败原因,未选择的画像不请求也不记失败 |
| `get_author_base_info` | 按 ID 导出时获取达人基础信息 | 按星图 ID 导出 | `o_author_id``platform_source=1``platform_channel=1``recommend=true` 等 | 达人名称等基础信息 | 否 | 未确认 | 按 ID 导出中与看后搜率并发;不同 ID 串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单个 ID 基础信息失败,CSV 中记录失败 |
| `get_author_commerce_spread_info` | 获取商业能力和效果预估 | 导出选中达人、按 ID 导出 | `o_author_id` | 预期 CPM、预期 CPE、预期播放量、爆文率 | 否 | 未确认 | 画像请求并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败或超时 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因,其他数据继续 |
| `get_author_spread_info` | 获取内容传播指标,并用于规则筛选 | 内容数据导出、传播指标筛选 | `o_author_id``platform_source=1``platform_channel=1``type``flow_type``only_assign``range` | 完播率、播放量中位数、互动率、平均时长、平均评论、平均点赞、平均转发 | 否 | 未确认 | 指标补充对达人并发;单个达人多组参数串行;筛选对达人并发,同一达人相同口径的规则复用一次响应 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 指标补充失败时字段留空;筛选请求失败、缺少请求 ID 或缺少已选指标时该达人不满足筛选 |
| `get_author_commerce_spread_info` | 获取商业能力和效果预估 | 导出选中达人、按 ID 导出 | `o_author_id` | 预期 CPM、预期 CPE、预期播放量、爆文率 | 否 | 未确认 | 仅选择效果预估字段时调用;与已选画像请求并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败或超时 | 星图网页登录态和 cookie | 未确认 | 单项失败写入该行失败原因,画像失败或未选择画像都不阻止效果预估结果下载 |
| `get_author_spread_info` | 获取内容传播指标 | 内容数据导出 | `o_author_id``platform_source=1``platform_channel=1``type``flow_type``only_assign``range` | 完播率、播放量中位数、互动率、平均时长、平均评论、平均点赞、平均转发 | 否 | 未确认 | 只请求已勾选内容字段所属的参数组合;同一参数组合下勾选多个字段仍只请求一次;单个达人多组已选参数串行,达人之间并发 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 指标补充失败时对应字段留空并记录失败来源 |
| talent-search 后端 `POST /api/v1/history/talents/search` | 查询秒思 api 指标 | 页面增强、CSV 导出、按 ID 导出 | Bearer token、`type=star_id``values``page=1``size=max(20, ID数量)` | 看后搜率、看后搜数、新增 A3、CPA3、cp_search 等 | 请求固定第一页;接口本身是否支持更多页未确认 | 未确认 | 按页面或导出集合批量请求;同一批只有一个请求 | 未确认 | 无自动重试 | 0 | 无 | token 失败、请求失败、响应结构异常 | Logto access token,当前 resource 为 talent-search | 未确认 | 页面增强中失败标记后端指标失败;导出中失败则相关字段为空 |
| 批次提交后端 `POST /api/v1/batch-status/batches` | 创建达人批次 | 提交批次 | Bearer token、批次名称、创建人、达人列表 | 成功标志和后端数据 | 否 | 未确认 | 每次点击提交只发一个请求;按钮忙碌态防止流程内重复点击 | 未确认 | 无自动重试 | 0 | 无 | 401、403、非 2xx、后端 `success` 非 true、网络失败 | Logto access token;写权限 scope 是否足够未确认 | 未确认 | 401/403 或非成功响应会终止本次提交 |
| Logto | 插件登录和获取访问 token | 登录、后端接口调用 | appId、resource、scope、Chrome redirect URL | 登录态、ID claims、access token | 否 | 未确认 | 由 Logto SDK 管理,项目内未设并发规则 | 未确认 | 项目内无自动重试;SDK 内部是否重试未确认 | 未确认 | 未确认 | 登录失败、token 不可用、授权不足 | 公司 Logto 账号和 Chrome identity 回调权限 | 未确认 | 登录失败或 token 不可用时不进入业务流程,或后端调用失败 |
@@ -344,23 +323,22 @@
- 星图市场页面和列表接口:达人 ID、名称、地区、报价、粉丝、内容主题、预期播放、互动率、完播率等基础字段;
- 星图详情类接口:看后搜率、画像、商业能力、传播指标;
- 公司 talent-search 后端:秒思 api 指标;
- 使用者输入:勾选状态、星图 ID、批次名称字段选择和传播指标筛选规则
- 使用者输入:勾选状态、星图 ID、批次名称字段选择。
### 5.2 保留规则
- 有有效达人 ID 和达人名称的记录会进入页面记录集合;
- 按 ID 导出时,有效 ID 即使部分接口失败也会生成 CSV 行;
- CSV 基础字段、导出状态、失败原因等固定字段会保留;
- 字段选择只影响可选数据字段,不删除固定标识字段
- 字段选择只影响可选数据字段,不删除固定标识字段
- 字段弹窗左侧持续展示已选字段,右侧支持按字段名或分类检索,并实时同步勾选状态。
### 5.3 过滤规则
- 星图页面中缺少达人 ID 或达人名称的行会跳过;
- 导出选中达人数据必须有已勾选达人;
- 画像导出只保留当前导出范围内已勾选达人;
- 普通导出或提交批次如果存在已选达人,会优先保留当前范围内的已选达人;
- 如果当前范围内没有任何已选达人,普通导出或提交批次会回退为当前范围全部达人;
- 传播指标规则启用后,不满足全部已选规则的达人会被过滤;
- 导出选中达人数据在选择 `全部` 且没有勾选时,保留当前筛选结果的全部分页;其他范围必须有已勾选达人;
- 选择 `全部` 且存在勾选达人时,画像导出和提交批次都只保留全部范围内已勾选达人;
- 选择 `全部` 时,勾选达人不在当前筛选结果内则不回退为全部达人;
- 按 ID 导出时,非 16 到 20 位纯数字 token 会过滤。
### 5.4 去重规则
@@ -376,7 +354,7 @@
- 看后搜率优先使用列表中已有值,不完整时再请求星图指标接口;
- 秒思指标按 star_id 从后端补充;
- 传播指标按多组参数生成不同列,不合并同名业务指标;
- `代表视频`可能被读取,但不会进入最终普通市场 CSV
- `代表视频`可能被读取,但不会进入最终达人数据 CSV
- 画像和商业能力字段追加在基础字段之后;
- 传播指标字段追加在基础字段、看后搜率和秒思 api 字段之后。
@@ -423,7 +401,6 @@
- 浏览器刷新、插件重载或页面关闭会丢失内存中的中间状态。
- 多页导出按达人 ID 去重,重复分页读取同一达人不会在 CSV 中重复出现。
- 按 ID 导出对输入 ID 去重,重复输入同一 ID 不会产生重复行。
- 传播指标规则没有持久状态,重新执行时按当前页面选择和输入值重新判断。
重复执行相对安全的操作:
@@ -485,13 +462,12 @@
1. 在星图市场中完成筛选;
2. 等待页面列表加载完成;
3. 勾选需要导出的达人;
4. 可选:点击 `选择字段` 调整 CSV 字段;
5. 可选:选择完播率、互动率筛选指标,并分别填写阈值和视频口径
6. 点击 `导出选中达人数据`
7. 等待状态提示从导出中消失或浏览器下载完成
8. 在下载目录或 Chrome 下载列表中查看 CSV;
9. 检查 `导出状态``失败原因` 字段。
3. 选择 `全部` 时可不勾选;选择其他范围时勾选需要导出的达人;
4. 可选:点击 `选择字段`,通过关键词检索字段,并在已选区域确认最终字段;
5. 点击 `导出选中达人数据`
6. 等待状态提示从导出中消失或浏览器下载完成
7. 在下载目录或 Chrome 下载列表中查看 CSV
8. 检查 `导出状态``失败原因` 字段。
### 7.4 按 ID 执行导出任务
@@ -506,11 +482,10 @@
1. 在星图市场中完成筛选;
2. 可选:勾选需要提交的达人;
3. 可选:选择完播率、互动率筛选指标,并分别填写阈值和视频口径
4. 点击 `提交批次`
5. 输入批次名称
6. 等待页面提示 `批次提交成功`
7. 到后端系统确认批次是否生成。
3. 点击 `提交批次`
4. 输入批次名称
5. 等待页面提示 `批次提交成功`
6. 到后端系统确认批次是否生成。
### 7.6 只执行某个子流程
@@ -589,9 +564,8 @@
| 批次提交后端地址 | 提交达人批次的目标服务 | 当前工作区为 `http://localhost:8083` | 其他后端地址未确认 | 影响批次提交去向 | 需要重新构建并重新加载插件 | 提交到错误环境、重复或丢失业务数据 |
| 后端指标服务地址 | 查询秒思 api 指标 | `https://talent-search.intelligrow.cn` | 未确认 | 影响页面增强列和 CSV 秒思字段 | 需要重新构建并重新加载插件 | 指标为空、权限错误或消耗错误环境额度 |
| COS 更新清单 URL | 插件弹窗检查新版本 | `https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json` | 其他 HTTPS URL | 影响更新提示和安装包下载 | 需要重新构建并重新加载插件 | 用户无法更新或下载错误包 |
| 导出范围 | 决定收集哪些页面达人 | 当前工具栏默认隐藏,默认值为前 5 页;当前用户主入口通常要求勾选达人 | 当前页、前 5 页、前 10 页、全部、自定义 | 影响导出或提交的达人集合 | 不需要重启 | 范围过大增加接口调用量 |
| 传播指标规则 | 导出或提交前二次过滤达人 | 默认不选择任何指标 | 完播率、互动率;每个已选指标填写非负阈值并选择独立视频口径 | 影响最终保留达人集合和星图接口请求口径 | 不需要重启 | 指标、口径或阈值选错会过滤掉目标达人 |
| 字段选择 | 控制 CSV 可选字段 | 默认全选 | 可选字段集合 | 影响 CSV 列 | 不需要重启,会本地保存 | 漏导业务字段 |
| 导出范围 | 决定收集和最终处理哪些页面达人 | 当前工具栏默认值为前 5 页;选择 `全部` 且未勾选时处理当前筛选结果全量,存在勾选时勾选优先 | 当前页、前 5 页、前 10 页、全部、自定义 | 影响导出或提交的达人集合 | 不需要重启 | 范围过大增加接口调用量 |
| 字段选择 | 控制 CSV 可选字段 | 默认全选 | 支持按字段名或分类搜索;左侧显示已选字段 | 影响 CSV 列 | 不需要重启,会本地保存 | 漏导业务字段 |
## 9. 任务执行和结果确认
@@ -624,15 +598,15 @@
- 单个达人部分接口失败时,CSV 中该行 `导出状态` 为部分成功或失败,并在 `失败原因` 中列明失败项;
- 秒思 api 指标失败时,对应字段为空或页面显示失败,不一定影响 CSV 下载;
- 传播指标某组参数失败时,对应字段为空;
- 画像部分失败时,其他画像或商业能力字段仍可保留;
- 画像部分或全部失败时,对应单元格留空并写入失败原因,其他画像或效果预估字段仍可保留;
- 后端指标查询不到某个达人时显示暂无数据。
### 9.5 失败表现
- 未登录:星图页面显示登录提示,不出现业务工具栏;
- 没勾选就导出选中达人数据:提示请先勾选;
- 当前范围无选中达人:提示当前导出范围内没有选中的达人;
- 全部画像失败:提示画像导出失败,不下载 CSV
- `全部` 范围没勾选就导出选中达人数据:提示请先勾选;
- 选择 `全部` 且勾选达人不在当前筛选结果内:提示全部范围内没有选中的达人;
- 全部已选补充数据都失败:CSV 仍会下载,该行标记为部分成功或失败并列明失败原因
- 按 ID 没有有效 ID:提示请输入有效的达人星图 ID;
- 批次提交失败:状态区显示接口错误或通用失败提示;
- 已选传播指标未填写合法阈值:状态区提示具体指标,导出或提交不会开始;
@@ -699,7 +673,7 @@
- Drone 发布是否为唯一正式发布方式:未确认。
- 插件是否有统一日志、错误上报或审计记录:未确认。
- 页面增强指标是否有跨页面或跨浏览器持久缓存:未确认;当前仅确认有页面会话内记录。
- 导出全部页面时最多导出多少页:后台静默导出当前最多尝试 200 页;真实星图侧上限未确认。
- 导出全部页面时最多导出多少页:后台静默导出从第 1 页开始,当前最多尝试 200 页;真实星图侧上限未确认。
- 传播指标请求是否应该限制并发:当前筛选已按同一达人相同口径去重,但真实业务级并发上限仍未确认。
- 后续业务系统如何消费批次:未确认。
+83 -9
View File
@@ -5,6 +5,7 @@ import {
listRateCsvHeaders,
type CsvColumn
} from "./csv-exporter";
import type { MarketRecord } from "./types";
import type {
AudienceProfileDistributionItem,
AudienceProfileExportRow,
@@ -60,6 +61,12 @@ const CROWD_LABELS = [
];
const BUSINESS_ESTIMATE_SECTION_LABEL = "效果预估";
const FIXED_AUDIENCE_PROFILE_HEADERS = new Set([
"达人ID",
"达人名称",
"导出状态",
"失败原因"
]);
const BUSINESS_ESTIMATE_LAYOUTS: Array<{
key: BusinessAbilityDurationKind;
@@ -85,11 +92,20 @@ export function buildAudienceProfileCsv(
options: AudienceProfileCsvOptions = {}
): string {
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
const csvColumns = filterAudienceProfileCsvColumns([
const optionalColumns = [
...marketColumns.map(toMarketColumn),
...buildBusinessAbilityColumns(),
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
], options.selectedHeaders);
];
const csvColumns = filterAudienceProfileCsvColumns(
options.selectedHeaders === undefined
? optionalColumns
: deduplicateCsvColumns([
...buildFixedAudienceProfileColumns(),
...optionalColumns
]),
options.selectedHeaders
);
const headerLine = csvColumns.map((column) => column.header).join(",");
const rowLines = rows.map((row) =>
csvColumns.map((column) => escapeCsvCell(column.readValue(row))).join(",")
@@ -111,8 +127,22 @@ export function listAudienceProfileCsvHeaders(
];
}
export function listAudienceProfileSelectableFieldGroups(): AudienceProfileCsvFieldGroup[] {
export function listAudienceProfileSelectableFieldGroups(
marketListHeaders: string[] = []
): AudienceProfileCsvFieldGroup[] {
const selectableMarketListHeaders = marketListHeaders.filter(
(header) => !FIXED_AUDIENCE_PROFILE_HEADERS.has(header)
);
return [
...(selectableMarketListHeaders.length > 0
? [
{
headers: selectableMarketListHeaders,
label: "列表字段"
}
]
: []),
{
headers: listRateCsvHeaders(),
label: "看后搜率"
@@ -144,19 +174,63 @@ function filterAudienceProfileCsvColumns(
return columns;
}
const selectableHeaderSet = new Set(listAudienceProfileSelectableHeaders());
const selectedHeaderSet = new Set(selectedHeaders);
return columns.filter(
(column) =>
!selectableHeaderSet.has(column.header) ||
FIXED_AUDIENCE_PROFILE_HEADERS.has(column.header) ||
selectedHeaderSet.has(column.header)
);
}
function listAudienceProfileSelectableHeaders(): string[] {
return listAudienceProfileSelectableFieldGroups().flatMap(
(group) => group.headers
);
function buildFixedAudienceProfileColumns(): AudienceProfileCsvColumn[] {
return [
{
header: "达人ID",
readValue: (row) => row.record.exportFields?.ID ?? row.record.authorId
},
{
header: "达人名称",
readValue: (row) => row.record.exportFields?. ?? row.record.authorName
},
{
header: "导出状态",
readValue: (row) => row.record.exportFields?. ?? readExportStatus(row.record)
},
{
header: "失败原因",
readValue: (row) => row.record.exportFields?. ?? row.record.failureReason ?? ""
}
];
}
function readExportStatus(record: MarketRecord): string {
if (record.status === "failed") {
return "失败";
}
if (record.status === "loading") {
return "加载中";
}
if (record.status === "idle") {
return "待处理";
}
return "成功";
}
function deduplicateCsvColumns(
columns: AudienceProfileCsvColumn[]
): AudienceProfileCsvColumn[] {
const seenHeaders = new Set<string>();
return columns.filter((column) => {
if (seenHeaders.has(column.header)) {
return false;
}
seenHeaders.add(column.header);
return true;
});
}
function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] {
@@ -1,295 +1,729 @@
import type { AudienceProfileCsvFieldGroup } from "./audience-profile-csv";
interface ActiveFieldDialog {
focus(): void;
promise: Promise<string[] | null>;
}
interface SelectableField {
group: string;
header: string;
}
interface GroupControl {
headers: string[];
input: HTMLInputElement;
root: HTMLElement;
}
const FIELD_DIALOG_STYLE_ID = "sces-audience-profile-field-dialog-style";
const activeDialogs = new WeakMap<Document, ActiveFieldDialog>();
export function promptForAudienceProfileFields(
document: Document,
groups: AudienceProfileCsvFieldGroup[],
selectedHeaders: string[]
selectedHeaders: string[] | undefined
): Promise<string[] | null> {
return new Promise((resolve) => {
const selectableHeaders = groups.flatMap((group) => group.headers);
const selectedHeaderSet = new Set(
selectedHeaders.filter((header) => selectableHeaders.includes(header))
);
if (selectedHeaderSet.size === 0) {
selectableHeaders.forEach((header) => selectedHeaderSet.add(header));
}
const activeDialog = activeDialogs.get(document);
if (activeDialog) {
activeDialog.focus();
return activeDialog.promise;
}
const overlay = document.createElement("div");
overlay.dataset.audienceProfileFieldDialog = "overlay";
applyOverlayStyles(overlay);
ensureResponsiveStyles(document);
const fields = listSelectableFields(groups);
const selectableHeaderSet = new Set(fields.map((field) => field.header));
const selectedHeaderSet = new Set(
selectedHeaders === undefined
? fields.map((field) => field.header)
: selectedHeaders.filter((header) => selectableHeaderSet.has(header))
);
const dialog = document.createElement("section");
applyDialogStyles(dialog);
const title = document.createElement("h2");
applyTitleStyles(title);
const hint = document.createElement("p");
hint.textContent = "基础字段会固定保留。取消勾选后,本次及后续CSV将不包含对应列。";
applyHintStyles(hint);
const toolbar = document.createElement("div");
applyToolbarStyles(toolbar);
const selectAllButton = document.createElement("button");
selectAllButton.type = "button";
selectAllButton.textContent = "全选";
applySecondaryButtonStyles(selectAllButton);
const resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.textContent = "恢复默认";
applySecondaryButtonStyles(resetButton);
toolbar.append(selectAllButton, resetButton);
const groupContainer = document.createElement("div");
applyGroupContainerStyles(groupContainer);
const fieldInputs: HTMLInputElement[] = [];
groups.forEach((group) => {
const groupSection = document.createElement("section");
groupSection.dataset.audienceProfileFieldDialogGroup = "section";
applyGroupSectionStyles(groupSection);
const groupHeader = document.createElement("label");
applyGroupHeaderStyles(groupHeader);
const groupInput = document.createElement("input");
groupInput.type = "checkbox";
const groupTitle = document.createElement("span");
groupTitle.textContent = group.label;
groupHeader.append(groupInput, groupTitle);
const fieldList = document.createElement("div");
applyFieldListStyles(fieldList);
const groupFieldInputs = group.headers.map((header) => {
const fieldLabel = document.createElement("label");
applyFieldLabelStyles(fieldLabel);
const input = document.createElement("input");
input.type = "checkbox";
input.value = header;
input.dataset.audienceProfileFieldDialogField = "checkbox";
input.checked = selectedHeaderSet.has(header);
const text = document.createElement("span");
text.textContent = header;
fieldLabel.append(input, text);
fieldList.append(fieldLabel);
fieldInputs.push(input);
return input;
});
const syncGroupInput = () => {
const checkedCount = groupFieldInputs.filter((input) => input.checked).length;
groupInput.checked = checkedCount === groupFieldInputs.length;
groupInput.indeterminate = checkedCount > 0 && checkedCount < groupFieldInputs.length;
};
groupInput.addEventListener("change", () => {
groupFieldInputs.forEach((input) => {
input.checked = groupInput.checked;
});
syncTitle();
});
groupFieldInputs.forEach((input) => {
input.addEventListener("change", () => {
syncGroupInput();
syncTitle();
});
});
syncGroupInput();
groupSection.append(groupHeader, fieldList);
groupContainer.append(groupSection);
});
const actions = document.createElement("div");
applyActionsStyles(actions);
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.textContent = "取消";
applySecondaryButtonStyles(cancelButton);
const confirmButton = document.createElement("button");
confirmButton.type = "button";
confirmButton.dataset.audienceProfileFieldDialogSave = "button";
confirmButton.textContent = "保存";
applyPrimaryButtonStyles(confirmButton);
actions.append(cancelButton, confirmButton);
dialog.append(title, hint, toolbar, groupContainer, actions);
overlay.append(dialog);
document.body.appendChild(overlay);
function syncTitle() {
const checkedCount = fieldInputs.filter((input) => input.checked).length;
title.textContent = `可选字段(已选 ${checkedCount}/${fieldInputs.length} 个字段)`;
}
function close(value: string[] | null) {
overlay.remove();
resolve(value);
}
selectAllButton.addEventListener("click", () => {
fieldInputs.forEach((input) => {
input.checked = true;
});
syncTitle();
syncAllGroupInputs(dialog);
});
resetButton.addEventListener("click", () => {
fieldInputs.forEach((input) => {
input.checked = true;
});
syncTitle();
syncAllGroupInputs(dialog);
});
cancelButton.addEventListener("click", () => close(null));
confirmButton.addEventListener("click", () => {
const nextHeaders = fieldInputs
.filter((input) => input.checked)
.map((input) => input.value);
close(nextHeaders);
});
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
close(null);
}
});
syncTitle();
let resolveDialog: (value: string[] | null) => void = () => undefined;
const promise = new Promise<string[] | null>((resolve) => {
resolveDialog = resolve;
});
const overlay = document.createElement("div");
overlay.dataset.audienceProfileFieldDialog = "overlay";
applyOverlayStyles(overlay);
const dialog = document.createElement("section");
dialog.dataset.audienceProfileFieldDialog = "dialog";
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("role", "dialog");
applyDialogStyles(dialog);
const header = document.createElement("header");
applyDialogHeaderStyles(header);
const headingGroup = document.createElement("div");
const title = document.createElement("h2");
title.id = "sces-audience-profile-field-dialog-title";
title.textContent = "选择导出字段";
applyTitleStyles(title);
dialog.setAttribute("aria-labelledby", title.id);
const hint = document.createElement("p");
hint.textContent = "基础字段会固定保留。字段设置会用于本次及后续 CSV 导出。";
applyHintStyles(hint);
headingGroup.append(title, hint);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.dataset.audienceProfileFieldDialogClose = "button";
closeButton.textContent = "×";
closeButton.title = "关闭";
closeButton.setAttribute("aria-label", "关闭字段选择");
applyCloseButtonStyles(closeButton);
header.append(headingGroup, closeButton);
const content = document.createElement("div");
content.dataset.audienceProfileFieldDialogContent = "root";
applyContentStyles(content);
const selectedPane = document.createElement("section");
selectedPane.dataset.audienceProfileFieldDialogPane = "selected";
applyPaneStyles(selectedPane);
const selectedHeader = document.createElement("div");
applyPaneHeaderStyles(selectedHeader);
const selectedTitle = document.createElement("h3");
selectedTitle.textContent = "已选字段";
applyPaneTitleStyles(selectedTitle);
const selectedCount = document.createElement("span");
selectedCount.dataset.audienceProfileFieldDialogSelectedCount = "text";
selectedCount.setAttribute("aria-live", "polite");
applyCountStyles(selectedCount);
selectedHeader.append(selectedTitle, selectedCount);
const selectionActions = document.createElement("div");
applySelectionActionsStyles(selectionActions);
const selectAllButton = document.createElement("button");
selectAllButton.type = "button";
selectAllButton.textContent = "全选";
applyTextButtonStyles(selectAllButton);
const resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.textContent = "恢复默认";
applyTextButtonStyles(resetButton);
selectionActions.append(selectAllButton, resetButton);
const selectedList = document.createElement("div");
selectedList.dataset.audienceProfileFieldDialogSelectedList = "root";
applySelectedListStyles(selectedList);
const selectedEmptyState = document.createElement("p");
selectedEmptyState.dataset.audienceProfileFieldDialogSelectedEmpty = "text";
selectedEmptyState.textContent = "暂未选择字段";
applyEmptyStateStyles(selectedEmptyState);
selectedPane.append(
selectedHeader,
selectionActions,
selectedList,
selectedEmptyState
);
const resultPane = document.createElement("section");
resultPane.dataset.audienceProfileFieldDialogPane = "results";
applyPaneStyles(resultPane);
const resultHeader = document.createElement("div");
applyPaneHeaderStyles(resultHeader);
const resultTitle = document.createElement("h3");
resultTitle.textContent = "字段列表";
applyPaneTitleStyles(resultTitle);
const resultCount = document.createElement("span");
resultCount.dataset.audienceProfileFieldDialogResultCount = "text";
resultCount.setAttribute("aria-live", "polite");
applyCountStyles(resultCount);
resultHeader.append(resultTitle, resultCount);
const searchInput = document.createElement("input");
searchInput.type = "search";
searchInput.placeholder = "搜索字段名称或分类";
searchInput.dataset.audienceProfileFieldDialogSearch = "input";
searchInput.setAttribute("aria-label", "搜索导出字段");
searchInput.autocomplete = "off";
applySearchInputStyles(searchInput);
const resultList = document.createElement("div");
resultList.dataset.audienceProfileFieldDialogResults = "root";
applyResultListStyles(resultList);
const resultEmptyState = document.createElement("p");
resultEmptyState.dataset.audienceProfileFieldDialogResultEmpty = "text";
resultEmptyState.textContent = "没有匹配的字段";
resultEmptyState.hidden = true;
applyEmptyStateStyles(resultEmptyState);
const resultInputs = new Map<string, HTMLInputElement>();
const resultItems = new Map<string, HTMLElement>();
const groupControls: GroupControl[] = [];
groups.forEach((group) => {
const groupFields = fields.filter((field) => field.group === group.label);
if (groupFields.length === 0) return;
const groupSection = document.createElement("section");
groupSection.dataset.audienceProfileFieldDialogGroup = "section";
groupSection.dataset.audienceProfileFieldDialogGroupLabel = group.label;
applyGroupSectionStyles(groupSection);
const groupHeader = document.createElement("label");
applyGroupHeaderStyles(groupHeader);
const groupInput = document.createElement("input");
groupInput.type = "checkbox";
groupInput.dataset.audienceProfileFieldDialogGroupToggle = group.label;
applyCheckboxStyles(groupInput);
const groupTitle = document.createElement("span");
groupTitle.textContent = group.label;
groupHeader.append(groupInput, groupTitle);
const fieldList = document.createElement("div");
applyFieldListStyles(fieldList);
groupFields.forEach((field) => {
const fieldLabel = document.createElement("label");
fieldLabel.dataset.audienceProfileFieldDialogResult = field.header;
fieldLabel.dataset.audienceProfileFieldDialogResultGroup = field.group;
applyFieldLabelStyles(fieldLabel);
const input = document.createElement("input");
input.type = "checkbox";
input.value = field.header;
input.dataset.audienceProfileFieldDialogField = "checkbox";
applyCheckboxStyles(input);
const text = document.createElement("span");
text.textContent = field.header;
fieldLabel.append(input, text);
fieldList.append(fieldLabel);
resultInputs.set(field.header, input);
resultItems.set(field.header, fieldLabel);
input.addEventListener("change", () => {
setFieldSelected(field.header, input.checked);
});
});
const headers = groupFields.map((field) => field.header);
groupInput.addEventListener("change", () => {
headers.forEach((fieldHeader) => {
if (groupInput.checked) selectedHeaderSet.add(fieldHeader);
else selectedHeaderSet.delete(fieldHeader);
});
syncSelectionView();
});
groupControls.push({ headers, input: groupInput, root: groupSection });
groupSection.append(groupHeader, fieldList);
resultList.append(groupSection);
});
resultPane.append(
resultHeader,
searchInput,
resultList,
resultEmptyState
);
content.append(selectedPane, resultPane);
const actions = document.createElement("footer");
applyActionsStyles(actions);
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.textContent = "取消";
applySecondaryButtonStyles(cancelButton);
const confirmButton = document.createElement("button");
confirmButton.type = "button";
confirmButton.dataset.audienceProfileFieldDialogSave = "button";
confirmButton.textContent = "保存";
applyPrimaryButtonStyles(confirmButton);
actions.append(cancelButton, confirmButton);
dialog.append(header, content, actions);
overlay.append(dialog);
document.body.appendChild(overlay);
function setFieldSelected(fieldHeader: string, isSelected: boolean): void {
if (isSelected) selectedHeaderSet.add(fieldHeader);
else selectedHeaderSet.delete(fieldHeader);
syncSelectionView();
}
function syncSelectionView(): void {
resultInputs.forEach((input, fieldHeader) => {
input.checked = selectedHeaderSet.has(fieldHeader);
});
groupControls.forEach(({ headers, input }) => {
const checkedCount = headers.filter((fieldHeader) =>
selectedHeaderSet.has(fieldHeader)
).length;
input.checked = checkedCount === headers.length;
input.indeterminate = checkedCount > 0 && checkedCount < headers.length;
});
selectedList.replaceChildren();
fields.forEach((field) => {
if (!selectedHeaderSet.has(field.header)) return;
const fieldLabel = document.createElement("label");
fieldLabel.dataset.audienceProfileFieldDialogSelectedField = field.header;
applySelectedFieldStyles(fieldLabel);
const input = document.createElement("input");
input.type = "checkbox";
input.checked = true;
input.value = field.header;
input.dataset.audienceProfileFieldDialogSelectedCheckbox = "checkbox";
applyCheckboxStyles(input);
const textGroup = document.createElement("span");
applySelectedFieldTextStyles(textGroup);
const fieldName = document.createElement("span");
fieldName.textContent = field.header;
const groupName = document.createElement("small");
groupName.textContent = field.group;
applySelectedFieldGroupStyles(groupName);
textGroup.append(fieldName, groupName);
fieldLabel.append(input, textGroup);
input.addEventListener("change", () => {
setFieldSelected(field.header, input.checked);
});
selectedList.append(fieldLabel);
});
selectedCount.textContent = `${selectedHeaderSet.size}`;
setElementVisible(
selectedEmptyState,
selectedHeaderSet.size === 0,
"block"
);
}
function syncSearchResults(): void {
const query = searchInput.value.trim().toLocaleLowerCase();
let visibleCount = 0;
resultItems.forEach((item, fieldHeader) => {
const searchableText = `${item.dataset.audienceProfileFieldDialogResultGroup ?? ""} ${fieldHeader}`.toLocaleLowerCase();
const isVisible = !query || searchableText.includes(query);
setElementVisible(item, isVisible, "flex");
if (isVisible) visibleCount += 1;
});
groupControls.forEach(({ root }) => {
const hasVisibleField = Array.from(
root.querySelectorAll<HTMLElement>(
"[data-audience-profile-field-dialog-result]"
)
).some((item) => !item.hidden);
setElementVisible(root, hasVisibleField, "block");
});
resultCount.textContent = query
? `找到 ${visibleCount}`
: `${fields.length} 个可选`;
setElementVisible(resultEmptyState, visibleCount === 0, "block");
}
function close(value: string[] | null): void {
document.removeEventListener("keydown", onKeyDown);
activeDialogs.delete(document);
overlay.remove();
resolveDialog(value);
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape") close(null);
}
selectAllButton.addEventListener("click", () => {
fields.forEach((field) => selectedHeaderSet.add(field.header));
syncSelectionView();
});
resetButton.addEventListener("click", () => {
selectedHeaderSet.clear();
fields.forEach((field) => selectedHeaderSet.add(field.header));
syncSelectionView();
});
searchInput.addEventListener("input", syncSearchResults);
closeButton.addEventListener("click", () => close(null));
cancelButton.addEventListener("click", () => close(null));
confirmButton.addEventListener("click", () => {
close(
fields
.filter((field) => selectedHeaderSet.has(field.header))
.map((field) => field.header)
);
});
overlay.addEventListener("click", (event) => {
if (event.target === overlay) close(null);
});
document.addEventListener("keydown", onKeyDown);
syncSelectionView();
syncSearchResults();
searchInput.focus();
activeDialogs.set(document, {
focus: () => searchInput.focus(),
promise
});
return promise;
}
function syncAllGroupInputs(dialog: HTMLElement): void {
dialog
.querySelectorAll('[data-audience-profile-field-dialog-group="section"]')
.forEach((section) => {
const groupInput = section.querySelector(":scope > label > input");
const fieldInputs = Array.from(
section.querySelectorAll(":scope > div input")
) as HTMLInputElement[];
if (!(groupInput instanceof HTMLInputElement) || fieldInputs.length === 0) {
return;
}
function listSelectableFields(
groups: AudienceProfileCsvFieldGroup[]
): SelectableField[] {
const seenHeaders = new Set<string>();
return groups.flatMap((group) =>
group.headers.flatMap((header) => {
if (seenHeaders.has(header)) return [];
seenHeaders.add(header);
return [{ group: group.label, header }];
})
);
}
const checkedCount = fieldInputs.filter((input) => input.checked).length;
groupInput.checked = checkedCount === fieldInputs.length;
groupInput.indeterminate = checkedCount > 0 && checkedCount < fieldInputs.length;
});
function setElementVisible(
element: HTMLElement,
isVisible: boolean,
visibleDisplay: "block" | "flex"
): void {
element.hidden = !isVisible;
element.style.display = isVisible ? visibleDisplay : "none";
}
function applyOverlayStyles(overlay: HTMLElement): void {
overlay.style.position = "fixed";
overlay.style.inset = "0";
overlay.style.zIndex = "2147483647";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.background = "rgba(15, 23, 42, 0.38)";
Object.assign(overlay.style, {
alignItems: "center",
background: "rgba(15, 23, 42, 0.42)",
display: "flex",
inset: "0",
justifyContent: "center",
padding: "24px",
position: "fixed",
zIndex: "2147483647"
});
}
function applyDialogStyles(dialog: HTMLElement): void {
dialog.style.width = "680px";
dialog.style.maxWidth = "calc(100vw - 32px)";
dialog.style.maxHeight = "calc(100vh - 48px)";
dialog.style.display = "flex";
dialog.style.flexDirection = "column";
dialog.style.background = "#ffffff";
dialog.style.borderRadius = "8px";
dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)";
dialog.style.padding = "20px";
dialog.style.boxSizing = "border-box";
Object.assign(dialog.style, {
background: "#ffffff",
borderRadius: "8px",
boxShadow: "0 20px 52px rgba(15, 23, 42, 0.24)",
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
height: "min(680px, calc(100vh - 48px))",
maxWidth: "calc(100vw - 48px)",
minHeight: "420px",
overflow: "hidden",
width: "960px"
});
}
function applyDialogHeaderStyles(header: HTMLElement): void {
Object.assign(header.style, {
alignItems: "flex-start",
borderBottom: "1px solid #e5e7eb",
display: "flex",
flex: "0 0 auto",
justifyContent: "space-between",
padding: "20px 22px 16px"
});
}
function applyTitleStyles(title: HTMLElement): void {
title.style.margin = "0 0 8px";
title.style.fontSize = "18px";
title.style.fontWeight = "700";
title.style.color = "#1f2329";
Object.assign(title.style, {
color: "#1f2329",
fontSize: "18px",
fontWeight: "700",
letterSpacing: "0",
lineHeight: "26px",
margin: "0"
});
}
function applyHintStyles(hint: HTMLElement): void {
hint.style.margin = "0 0 12px";
hint.style.fontSize = "13px";
hint.style.lineHeight = "20px";
hint.style.color = "#64748b";
Object.assign(hint.style, {
color: "#64748b",
fontSize: "13px",
lineHeight: "20px",
margin: "4px 0 0"
});
}
function applyToolbarStyles(toolbar: HTMLElement): void {
toolbar.style.display = "flex";
toolbar.style.gap = "8px";
toolbar.style.marginBottom = "12px";
function applyCloseButtonStyles(button: HTMLButtonElement): void {
Object.assign(button.style, {
alignItems: "center",
background: "transparent",
border: "0",
borderRadius: "4px",
color: "#64748b",
cursor: "pointer",
display: "flex",
flex: "0 0 auto",
fontSize: "24px",
height: "32px",
justifyContent: "center",
padding: "0",
width: "32px"
});
}
function applyGroupContainerStyles(container: HTMLElement): void {
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.gap = "10px";
container.style.overflow = "auto";
container.style.paddingRight = "4px";
function applyContentStyles(content: HTMLElement): void {
Object.assign(content.style, {
display: "grid",
flex: "1 1 auto",
gap: "16px",
gridTemplateColumns: "minmax(260px, 0.8fr) minmax(380px, 1.4fr)",
minHeight: "0",
padding: "16px 22px"
});
}
function applyPaneStyles(pane: HTMLElement): void {
Object.assign(pane.style, {
border: "1px solid #e2e8f0",
borderRadius: "6px",
display: "flex",
flexDirection: "column",
minHeight: "0",
overflow: "hidden"
});
}
function applyPaneHeaderStyles(header: HTMLElement): void {
Object.assign(header.style, {
alignItems: "center",
background: "#f8fafc",
borderBottom: "1px solid #e2e8f0",
display: "flex",
flex: "0 0 auto",
justifyContent: "space-between",
minHeight: "44px",
padding: "0 14px"
});
}
function applyPaneTitleStyles(title: HTMLElement): void {
Object.assign(title.style, {
color: "#1f2329",
fontSize: "14px",
fontWeight: "700",
letterSpacing: "0",
margin: "0"
});
}
function applyCountStyles(count: HTMLElement): void {
Object.assign(count.style, {
color: "#64748b",
fontSize: "12px",
whiteSpace: "nowrap"
});
}
function applySelectionActionsStyles(actions: HTMLElement): void {
Object.assign(actions.style, {
alignItems: "center",
borderBottom: "1px solid #eef2f6",
display: "flex",
flex: "0 0 auto",
gap: "14px",
minHeight: "40px",
padding: "0 14px"
});
}
function applyTextButtonStyles(button: HTMLButtonElement): void {
Object.assign(button.style, {
background: "transparent",
border: "0",
color: "#2563eb",
cursor: "pointer",
fontSize: "12px",
fontWeight: "600",
padding: "4px 0"
});
}
function applySelectedListStyles(list: HTMLElement): void {
Object.assign(list.style, {
display: "flex",
flex: "1 1 auto",
flexDirection: "column",
minHeight: "0",
overflow: "auto",
padding: "6px 0"
});
}
function applySelectedFieldStyles(label: HTMLElement): void {
Object.assign(label.style, {
alignItems: "flex-start",
borderBottom: "1px solid #f1f5f9",
color: "#334155",
cursor: "pointer",
display: "flex",
fontSize: "13px",
gap: "8px",
lineHeight: "18px",
padding: "9px 14px"
});
}
function applySelectedFieldTextStyles(text: HTMLElement): void {
Object.assign(text.style, {
display: "flex",
flexDirection: "column",
minWidth: "0",
overflowWrap: "anywhere"
});
}
function applySelectedFieldGroupStyles(group: HTMLElement): void {
Object.assign(group.style, {
color: "#94a3b8",
fontSize: "11px",
lineHeight: "16px"
});
}
function applySearchInputStyles(input: HTMLInputElement): void {
Object.assign(input.style, {
background: "#ffffff",
border: "1px solid #cbd5e1",
borderRadius: "6px",
boxSizing: "border-box",
color: "#1f2329",
flex: "0 0 auto",
fontSize: "13px",
height: "38px",
margin: "12px 14px 8px",
outline: "none",
padding: "0 11px",
width: "calc(100% - 28px)"
});
}
function applyResultListStyles(list: HTMLElement): void {
Object.assign(list.style, {
flex: "1 1 auto",
minHeight: "0",
overflow: "auto",
padding: "0 14px 10px"
});
}
function applyGroupSectionStyles(section: HTMLElement): void {
section.style.border = "1px solid #e5e7eb";
section.style.borderRadius = "8px";
section.style.padding = "10px";
Object.assign(section.style, {
borderBottom: "1px solid #e5e7eb",
padding: "10px 0"
});
}
function applyGroupHeaderStyles(label: HTMLElement): void {
label.style.display = "flex";
label.style.alignItems = "center";
label.style.gap = "8px";
label.style.fontWeight = "700";
label.style.color = "#1f2329";
label.style.marginBottom = "8px";
Object.assign(label.style, {
alignItems: "center",
color: "#1f2329",
cursor: "pointer",
display: "flex",
fontSize: "13px",
fontWeight: "700",
gap: "8px",
marginBottom: "7px"
});
}
function applyFieldListStyles(list: HTMLElement): void {
list.style.display = "grid";
list.style.gridTemplateColumns = "repeat(auto-fit, minmax(220px, 1fr))";
list.style.gap = "8px";
Object.assign(list.style, {
display: "grid",
gap: "3px",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))"
});
}
function applyFieldLabelStyles(label: HTMLElement): void {
label.style.display = "flex";
label.style.alignItems = "center";
label.style.gap = "6px";
label.style.fontSize = "13px";
label.style.lineHeight = "18px";
label.style.color = "#374151";
Object.assign(label.style, {
alignItems: "flex-start",
borderRadius: "4px",
color: "#475569",
cursor: "pointer",
display: "flex",
fontSize: "12px",
gap: "7px",
lineHeight: "18px",
minWidth: "0",
overflowWrap: "anywhere",
padding: "6px"
});
}
function applyCheckboxStyles(input: HTMLInputElement): void {
Object.assign(input.style, {
accentColor: "#2563eb",
flex: "0 0 auto",
height: "14px",
margin: "2px 0 0",
width: "14px"
});
}
function applyEmptyStateStyles(state: HTMLElement): void {
Object.assign(state.style, {
color: "#94a3b8",
fontSize: "13px",
margin: "auto",
padding: "24px",
textAlign: "center"
});
}
function applyActionsStyles(actions: HTMLElement): void {
actions.style.display = "flex";
actions.style.justifyContent = "flex-end";
actions.style.columnGap = "8px";
actions.style.marginTop = "14px";
Object.assign(actions.style, {
borderTop: "1px solid #e5e7eb",
columnGap: "8px",
display: "flex",
flex: "0 0 auto",
justifyContent: "flex-end",
padding: "14px 22px"
});
}
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "32px";
button.style.padding = "0 15px";
button.style.border = "1px solid #7f1d2d";
button.style.borderRadius = "8px";
button.style.background = "#7f1d2d";
button.style.color = "#ffffff";
button.style.fontWeight = "600";
Object.assign(button.style, {
background: "#7f1d2d",
border: "1px solid #7f1d2d",
borderRadius: "8px",
color: "#ffffff",
fontWeight: "600",
height: "34px",
padding: "0 18px"
});
}
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "32px";
button.style.padding = "0 15px";
button.style.border = "1px solid #d0d7de";
button.style.borderRadius = "8px";
button.style.background = "#ffffff";
button.style.color = "#1f2329";
button.style.fontWeight = "600";
Object.assign(button.style, {
background: "#ffffff",
border: "1px solid #d0d7de",
borderRadius: "8px",
color: "#1f2329",
fontWeight: "600",
height: "34px",
padding: "0 18px"
});
}
function ensureResponsiveStyles(document: Document): void {
if (document.getElementById(FIELD_DIALOG_STYLE_ID)) return;
const style = document.createElement("style");
style.id = FIELD_DIALOG_STYLE_ID;
style.textContent = `
[data-audience-profile-field-dialog-field='checkbox']:focus-visible,
[data-audience-profile-field-dialog-selected-checkbox='checkbox']:focus-visible,
[data-audience-profile-field-dialog-group-toggle]:focus-visible,
[data-audience-profile-field-dialog-search='input']:focus-visible,
[data-audience-profile-field-dialog='dialog'] button:focus-visible {
outline: 2px solid #2563eb !important;
outline-offset: 2px !important;
}
[data-audience-profile-field-dialog-result]:hover,
[data-audience-profile-field-dialog-selected-field]:hover {
background: #f8fafc !important;
}
@media (max-width: 720px) {
[data-audience-profile-field-dialog='overlay'] { align-items: stretch !important; padding: 12px !important; }
[data-audience-profile-field-dialog='dialog'] { height: calc(100vh - 24px) !important; max-width: none !important; min-height: 0 !important; width: 100% !important; }
[data-audience-profile-field-dialog-content='root'] { display: flex !important; flex-direction: column !important; overflow: auto !important; padding: 12px 14px !important; }
[data-audience-profile-field-dialog-pane='selected'] { flex: 0 0 210px !important; }
[data-audience-profile-field-dialog-pane='results'] { flex: 1 0 340px !important; }
[data-audience-profile-field-dialog-results='root'] > section > div { grid-template-columns: 1fr !important; }
}
`;
document.head.appendChild(style);
}
+6 -1
View File
@@ -26,6 +26,10 @@ export interface AudienceProfileFailure {
status: "failed";
}
export interface AudienceProfileSkipped {
status: "skipped";
}
export interface AudienceProfileSet {
audience: AudienceProfileResult;
fans: AudienceProfileResult;
@@ -34,7 +38,8 @@ export interface AudienceProfileSet {
export type AudienceProfileResult =
| AudienceProfileSuccess
| AudienceProfileFailure;
| AudienceProfileFailure
| AudienceProfileSkipped;
export interface AudienceProfileExportRow {
businessAbility?: BusinessAbilityResult;
+90 -2
View File
@@ -8,6 +8,10 @@ export type CsvColumn = {
readValue: (record: MarketRecord) => string;
};
export interface MarketCsvOptions {
selectedHeaders?: string[];
}
const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
{
header: "达人ID",
@@ -80,6 +84,13 @@ const SPREAD_INFO_COLUMNS: CsvColumn[] = buildSpreadInfoColumns().map((header) =
readValue: (record: MarketRecord) => record.spreadMetrics?.[header] ?? ""
}));
const FIXED_MARKET_CSV_HEADERS = new Set([
"达人ID",
"达人名称",
"导出状态",
"失败原因"
]);
export function listRateCsvHeaders(): string[] {
return RATE_COLUMNS.map((column) => column.header);
}
@@ -88,8 +99,20 @@ export function listBackendMetricCsvHeaders(): string[] {
return BACKEND_METRIC_COLUMNS.map((column) => column.header);
}
export function buildMarketCsv(records: MarketRecord[]): string {
const csvColumns = buildMarketCsvColumns(records);
export function buildMarketCsv(
records: MarketRecord[],
options: MarketCsvOptions = {}
): string {
const optionalColumns = buildMarketCsvColumns(records);
const csvColumns = filterMarketCsvColumns(
options.selectedHeaders === undefined
? optionalColumns
: deduplicateCsvColumns([
...buildFixedMarketCsvColumns(),
...optionalColumns
]),
options.selectedHeaders
);
const headerLine = csvColumns.map((column) => column.header).join(",");
const rowLines = records.map((record) =>
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
@@ -133,3 +156,68 @@ export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
readValue: (record: MarketRecord) => record.exportFields?.[header] ?? ""
}));
}
function filterMarketCsvColumns(
columns: CsvColumn[],
selectedHeaders: string[] | undefined
): CsvColumn[] {
if (!selectedHeaders) {
return columns;
}
const selectedHeaderSet = new Set(selectedHeaders);
return columns.filter(
(column) =>
FIXED_MARKET_CSV_HEADERS.has(column.header) ||
selectedHeaderSet.has(column.header)
);
}
function buildFixedMarketCsvColumns(): CsvColumn[] {
return [
{
header: "达人ID",
readValue: (record) => record.exportFields?.ID ?? record.authorId
},
{
header: "达人名称",
readValue: (record) => record.exportFields?. ?? record.authorName
},
{
header: "导出状态",
readValue: (record) => record.exportFields?. ?? readExportStatus(record)
},
{
header: "失败原因",
readValue: (record) => record.exportFields?. ?? record.failureReason ?? ""
}
];
}
function readExportStatus(record: MarketRecord): string {
if (record.status === "failed") {
return "失败";
}
if (record.status === "loading") {
return "加载中";
}
if (record.status === "idle") {
return "待处理";
}
return "成功";
}
function deduplicateCsvColumns(columns: CsvColumn[]): CsvColumn[] {
const seenHeaders = new Set<string>();
return columns.filter((column) => {
if (seenHeaders.has(column.header)) {
return false;
}
seenHeaders.add(column.header);
return true;
});
}
+5
View File
@@ -80,9 +80,11 @@ type MarketDataRow = {
};
export interface MarketRowDom {
actionCell?: HTMLElement;
authorId: string;
authorName: string;
backendMetricsCells: Record<BackendMetricField, HTMLElement>;
coreUserId?: string;
exportFields?: Record<string, string>;
hasDirectRatesSource?: boolean;
location?: string;
@@ -587,6 +589,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
: []
);
const authorCells = getDirectContentCells(authorColumn);
const actionCells = getDirectContentCells(actionColumn);
const selectionCells = getDirectContentCells(selectionColumn);
const singleCells = getDirectContentCells(singleColumn);
const personalCells = getDirectContentCells(personalColumn);
@@ -654,9 +657,11 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
return [
{
actionCell: actionCells[index],
authorId,
authorName,
backendMetricsCells: backendMetricsCells as Record<BackendMetricField, HTMLElement>,
coreUserId: fallbackMarketRow?.coreUserId,
exportFields,
hasDirectRatesSource:
fallbackMarketRow?.hasDirectRatesSource ?? false,
@@ -0,0 +1,250 @@
const activeDialogs = new WeakMap<
Document,
{
input: HTMLInputElement;
promise: Promise<string | null>;
}
>();
export function promptForFavoriteFolderName(
document: Document,
options: {
initialValue?: string;
title: "新建收藏夹" | "重命名收藏夹";
}
): Promise<string | null> {
const activeDialog = activeDialogs.get(document);
if (activeDialog) {
activeDialog.input.focus();
activeDialog.input.select();
return activeDialog.promise;
}
const previousFocusedElement = document.activeElement;
const root = document.createElement("div");
root.dataset.scesFavoriteFolderDialog = "root";
root.setAttribute("role", "dialog");
root.setAttribute("aria-modal", "true");
root.setAttribute("aria-labelledby", "sces-favorite-folder-dialog-title");
applyOverlayStyles(root);
const panel = document.createElement("section");
applyPanelStyles(panel);
const title = document.createElement("h2");
title.id = "sces-favorite-folder-dialog-title";
title.textContent = options.title;
applyTitleStyles(title);
const input = document.createElement("input");
input.type = "text";
input.value = options.initialValue ?? "";
input.maxLength = 60;
input.placeholder = "请输入收藏夹名称";
input.dataset.scesFavoriteFolderDialog = "input";
input.setAttribute("aria-label", "收藏夹名称");
applyInputStyles(input);
const error = document.createElement("p");
error.dataset.scesFavoriteFolderDialog = "error";
error.setAttribute("aria-live", "polite");
applyErrorStyles(error);
const actions = document.createElement("div");
applyActionStyles(actions);
const cancelButton = createButton(document, "取消");
cancelButton.dataset.scesFavoriteFolderDialog = "cancel";
applySecondaryButtonStyles(cancelButton);
const confirmButton = createButton(document, "确认");
confirmButton.dataset.scesFavoriteFolderDialog = "confirm";
applyPrimaryButtonStyles(confirmButton);
actions.append(cancelButton, confirmButton);
panel.append(title, input, error, actions);
root.append(panel);
(document.body ?? document.documentElement).append(root);
let closeDialog: (value: string | null) => void = () => {};
const promise = new Promise<string | null>((resolve) => {
let closed = false;
const clearError = () => {
if (!input.value.trim()) {
return;
}
error.textContent = "";
input.removeAttribute("aria-invalid");
};
const submit = () => {
const value = input.value.trim();
if (!value) {
error.textContent = "请输入收藏夹名称";
input.setAttribute("aria-invalid", "true");
input.focus();
return;
}
closeDialog(value);
};
const onKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
closeDialog(null);
return;
}
if (event.key === "Tab") {
const focusableElements = Array.from(
root.querySelectorAll<HTMLElement>("button:not(:disabled), input:not(:disabled)")
);
const activeIndex = focusableElements.indexOf(
document.activeElement as HTMLElement
);
const nextIndex = event.shiftKey
? activeIndex <= 0
? focusableElements.length - 1
: activeIndex - 1
: activeIndex === focusableElements.length - 1
? 0
: activeIndex + 1;
const nextElement = focusableElements[nextIndex];
if (nextElement) {
event.preventDefault();
nextElement.focus();
}
return;
}
if (event.key === "Enter") {
event.preventDefault();
submit();
}
};
const onOverlayClick = (event: MouseEvent) => {
if (event.target === root) {
closeDialog(null);
}
};
const onCancel = () => closeDialog(null);
const onConfirm = () => submit();
closeDialog = (value) => {
if (closed) {
return;
}
closed = true;
activeDialogs.delete(document);
input.removeEventListener("input", clearError);
cancelButton.removeEventListener("click", onCancel);
confirmButton.removeEventListener("click", onConfirm);
root.removeEventListener("click", onOverlayClick);
document.removeEventListener("keydown", onKeydown, true);
root.remove();
if (
previousFocusedElement instanceof HTMLElement &&
previousFocusedElement.isConnected
) {
previousFocusedElement.focus();
}
resolve(value);
};
input.addEventListener("input", clearError);
cancelButton.addEventListener("click", onCancel);
confirmButton.addEventListener("click", onConfirm);
root.addEventListener("click", onOverlayClick);
document.addEventListener("keydown", onKeydown, true);
});
activeDialogs.set(document, { input, promise });
input.focus();
return promise;
}
function createButton(document: Document, label: string): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
return button;
}
function applyOverlayStyles(root: HTMLElement): void {
root.style.position = "fixed";
root.style.inset = "0";
root.style.zIndex = "2147483647";
root.style.display = "flex";
root.style.alignItems = "center";
root.style.justifyContent = "center";
root.style.padding = "24px";
root.style.boxSizing = "border-box";
root.style.background = "rgba(15, 23, 42, 0.38)";
}
function applyPanelStyles(panel: HTMLElement): void {
panel.style.width = "min(420px, calc(100vw - 32px))";
panel.style.padding = "20px";
panel.style.boxSizing = "border-box";
panel.style.border = "1px solid #d7dde6";
panel.style.borderRadius = "8px";
panel.style.background = "#ffffff";
panel.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)";
}
function applyTitleStyles(title: HTMLElement): void {
title.style.margin = "0";
title.style.color = "#172033";
title.style.fontSize = "18px";
title.style.lineHeight = "26px";
title.style.fontWeight = "700";
}
function applyInputStyles(input: HTMLInputElement): void {
input.style.width = "100%";
input.style.height = "40px";
input.style.marginTop = "18px";
input.style.padding = "0 12px";
input.style.boxSizing = "border-box";
input.style.border = "1px solid #b9c4d3";
input.style.borderRadius = "6px";
input.style.color = "#172033";
input.style.fontSize = "14px";
}
function applyErrorStyles(error: HTMLElement): void {
error.style.minHeight = "18px";
error.style.margin = "6px 0 0";
error.style.color = "#b42318";
error.style.fontSize = "12px";
error.style.lineHeight = "18px";
}
function applyActionStyles(actions: HTMLElement): void {
actions.style.display = "flex";
actions.style.justifyContent = "flex-end";
actions.style.gap = "8px";
actions.style.marginTop = "16px";
}
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "34px";
button.style.padding = "0 14px";
button.style.border = "1px solid #b9c4d3";
button.style.borderRadius = "6px";
button.style.background = "#ffffff";
button.style.color = "#334155";
button.style.cursor = "pointer";
}
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "34px";
button.style.padding = "0 14px";
button.style.border = "1px solid #0f766e";
button.style.borderRadius = "6px";
button.style.background = "#0f766e";
button.style.color = "#ffffff";
button.style.cursor = "pointer";
}
+512
View File
@@ -0,0 +1,512 @@
import type { FavoriteCreatorInput, FavoriteFolder } from "./favorites-store";
const ACTION_SELECTOR = 'button[data-sces-favorite-row-action="button"]';
const PICKER_ROOT_SELECTOR = '[data-sces-favorite-row-picker="root"]';
const PICKER_ROOT_ID = "sces-favorite-row-picker";
const PICKER_VIEWPORT_PADDING = 8;
const PICKER_FALLBACK_HEIGHT = 120;
const PICKER_FALLBACK_WIDTH = 240;
export interface FavoriteRowPickerRow extends FavoriteCreatorInput {
actionCell?: HTMLElement;
}
export function syncFavoriteRowPickers(options: {
document: Document;
folders: FavoriteFolder[];
folderIdsByAuthorId: ReadonlyMap<string, ReadonlySet<string>>;
onCreateFolder: () => Promise<FavoriteFolder | null>;
onSetCreatorFolderIds: (
creator: FavoriteCreatorInput,
folderIds: string[]
) => Promise<void>;
rows: FavoriteRowPickerRow[];
}): void {
const state = getPickerDocumentState(options.document);
const currentButtons = new Set<HTMLButtonElement>();
options.rows.forEach((row) => {
if (!row.actionCell) {
return;
}
const button = ensureFavoriteActionButton(row.actionCell);
const authorId = row.authorId.trim();
const pendingOperation = state.pendingOperations.get(button);
const context: FavoritePickerContext = {
button,
creator: {
authorId: row.authorId,
authorName: row.authorName,
coreUserId: row.coreUserId
},
folders: [...options.folders],
onCreateFolder: options.onCreateFolder,
onSetCreatorFolderIds: options.onSetCreatorFolderIds,
selectedFolderIds: new Set(
pendingOperation
? pendingOperation.confirmedFolderIds
: authorId
? options.folderIdsByAuthorId.get(authorId) ?? []
: []
)
};
currentButtons.add(button);
state.contextByButton.set(button, context);
syncFavoriteActionButton(
context,
Boolean(pendingOperation),
state.openPicker?.button === button
);
button.onclick = () => {
openFavoritePicker(options.document, state, button);
};
});
for (const [button] of state.contextByButton) {
if (currentButtons.has(button)) {
continue;
}
state.contextByButton.delete(button);
state.pendingOperations.delete(button);
button.onclick = null;
}
if (
state.openPicker &&
(!currentButtons.has(state.openPicker.button) ||
!state.openPicker.button.isConnected)
) {
closeFavoritePicker(options.document, state);
return;
}
if (state.openPicker) {
renderFavoritePicker(options.document, state, state.openPicker.button);
}
}
export function disposeFavoriteRowPickers(document: Document): void {
const state = pickerDocumentStates.get(document);
if (state) {
closeFavoritePicker(document, state);
state.contextByButton.forEach((_context, button) => {
button.onclick = null;
button.remove();
});
state.contextByButton.clear();
state.pendingOperations.clear();
pickerDocumentStates.delete(document);
}
document.querySelectorAll<HTMLButtonElement>(ACTION_SELECTOR).forEach((button) => {
button.onclick = null;
button.remove();
});
removeFavoritePickerRoots(document);
}
type FavoritePickerContext = {
button: HTMLButtonElement;
creator: FavoriteCreatorInput;
folders: FavoriteFolder[];
onCreateFolder: () => Promise<FavoriteFolder | null>;
onSetCreatorFolderIds: (
creator: FavoriteCreatorInput,
folderIds: string[]
) => Promise<void>;
selectedFolderIds: Set<string>;
};
type FavoritePickerOperation = {
confirmedFolderIds: Set<string>;
};
type OpenFavoritePicker = {
button: HTMLButtonElement;
root: HTMLElement;
};
type FavoritePickerDocumentState = {
contextByButton: Map<HTMLButtonElement, FavoritePickerContext>;
onDocumentKeydown: (event: KeyboardEvent) => void;
onDocumentPointerDown: (event: PointerEvent) => void;
openPicker: OpenFavoritePicker | null;
pendingOperations: Map<HTMLButtonElement, FavoritePickerOperation>;
};
const pickerDocumentStates = new WeakMap<Document, FavoritePickerDocumentState>();
function getPickerDocumentState(document: Document): FavoritePickerDocumentState {
const existingState = pickerDocumentStates.get(document);
if (existingState) {
return existingState;
}
const state: FavoritePickerDocumentState = {
contextByButton: new Map<HTMLButtonElement, FavoritePickerContext>(),
onDocumentKeydown: () => {},
onDocumentPointerDown: () => {},
openPicker: null,
pendingOperations: new Map<HTMLButtonElement, FavoritePickerOperation>()
};
state.onDocumentKeydown = (event) => {
if (event.key === "Escape") {
closeFavoritePicker(document, state);
}
};
state.onDocumentPointerDown = (event) => {
const openPicker = state.openPicker;
if (!openPicker) {
return;
}
const target = event.target;
if (
target instanceof Node &&
(openPicker.root.contains(target) || openPicker.button.contains(target))
) {
return;
}
closeFavoritePicker(document, state);
};
pickerDocumentStates.set(document, state);
return state;
}
function ensureFavoriteActionButton(actionCell: HTMLElement): HTMLButtonElement {
const controls = Array.from(actionCell.children).filter(
(child): child is HTMLButtonElement =>
child instanceof HTMLButtonElement && child.matches(ACTION_SELECTOR)
);
const button = controls.shift() ?? actionCell.ownerDocument.createElement("button");
controls.forEach((control) => control.remove());
button.dataset.scesFavoriteRowAction = "button";
button.type = "button";
if (button.parentElement !== actionCell) {
actionCell.insertBefore(button, actionCell.firstChild);
} else if (actionCell.firstElementChild !== button) {
actionCell.insertBefore(button, actionCell.firstChild);
}
return button;
}
function syncFavoriteActionButton(
context: FavoritePickerContext,
isPending = false,
isExpanded = false
): void {
const authorId = context.creator.authorId.trim();
const count = context.selectedFolderIds.size;
const isSaved = count > 0;
context.button.dataset.scesFavoriteCount = String(count);
context.button.dataset.scesFavoriteIcon = isSaved
? "bookmark-filled"
: "bookmark-outline";
context.button.dataset.scesFavoriteState = isSaved ? "saved" : "empty";
context.button.setAttribute("aria-controls", PICKER_ROOT_ID);
context.button.setAttribute("aria-expanded", String(isExpanded));
context.button.setAttribute("aria-haspopup", "dialog");
if (!authorId) {
context.button.disabled = true;
context.button.title = "无法收藏:缺少达人 ID";
context.button.setAttribute("aria-label", context.button.title);
context.button.textContent = "收藏";
return;
}
context.button.disabled = isPending;
context.button.title = isSaved ? `已收藏至 ${count} 个收藏夹` : "加入收藏夹";
context.button.setAttribute("aria-label", context.button.title);
context.button.textContent = isSaved ? "已收藏" : "收藏";
}
function openFavoritePicker(
document: Document,
state: FavoritePickerDocumentState,
button: HTMLButtonElement
): void {
const context = state.contextByButton.get(button);
if (!context || !context.creator.authorId.trim() || button.disabled) {
return;
}
if (state.openPicker?.button !== button) {
closeFavoritePicker(document, state);
removeFavoritePickerRoots(document);
const root = document.createElement("div");
root.dataset.scesFavoriteRowPicker = "root";
root.id = PICKER_ROOT_ID;
root.setAttribute("role", "dialog");
root.setAttribute("aria-label", "选择收藏夹");
root.style.position = "fixed";
root.style.zIndex = "2147483647";
(document.body ?? document.documentElement).appendChild(root);
state.openPicker = { button, root };
button.setAttribute("aria-expanded", "true");
document.addEventListener("pointerdown", state.onDocumentPointerDown);
document.addEventListener("keydown", state.onDocumentKeydown);
}
renderFavoritePicker(document, state, button);
if (state.openPicker?.button === button) {
focusFavoritePickerControl(state.openPicker.root);
}
}
function closeFavoritePicker(
document: Document,
state: FavoritePickerDocumentState
): void {
const openPicker = state.openPicker;
if (!openPicker) {
return;
}
state.openPicker = null;
openPicker.root.remove();
document.removeEventListener("pointerdown", state.onDocumentPointerDown);
document.removeEventListener("keydown", state.onDocumentKeydown);
openPicker.button.setAttribute("aria-expanded", "false");
if (openPicker.button.isConnected) {
openPicker.button.focus();
}
}
function removeFavoritePickerRoots(document: Document): void {
document.querySelectorAll(PICKER_ROOT_SELECTOR).forEach((root) => root.remove());
}
function renderFavoritePicker(
document: Document,
state: FavoritePickerDocumentState,
button: HTMLButtonElement
): void {
const openPicker = state.openPicker;
const context = state.contextByButton.get(button);
if (!openPicker || openPicker.button !== button || !context) {
return;
}
const root = openPicker.root;
const isPending = state.pendingOperations.has(button);
const folderList = document.createElement("div");
context.folders.forEach((folder) => {
const label = document.createElement("label");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.dataset.scesFavoriteFolderId = folder.id;
checkbox.checked = context.selectedFolderIds.has(folder.id);
checkbox.disabled = isPending;
checkbox.addEventListener("change", () => {
const nextFolderIds = readCheckedFolderIds(root, context);
beginFavoriteFolderSave(document, state, context, nextFolderIds);
});
label.append(checkbox, document.createTextNode(folder.name));
folderList.appendChild(label);
});
const createButton = document.createElement("button");
createButton.type = "button";
createButton.dataset.scesFavoriteRowPicker = "create-folder";
createButton.textContent = "新建收藏夹";
createButton.disabled = isPending;
createButton.addEventListener("click", () => {
void createAndSaveFavoriteFolder(document, state, context);
});
root.replaceChildren(folderList, createButton);
positionFavoritePicker(document, root, button);
}
function readCheckedFolderIds(
root: HTMLElement,
context: FavoritePickerContext
): string[] {
const knownFolderIds = new Set(context.folders.map((folder) => folder.id));
const selectedFolderIds = new Set(
[...context.selectedFolderIds].filter((folderId) => !knownFolderIds.has(folderId))
);
root
.querySelectorAll<HTMLInputElement>("input[data-sces-favorite-folder-id]")
.forEach((checkbox) => {
const folderId = checkbox.dataset.scesFavoriteFolderId;
if (folderId && checkbox.checked) {
selectedFolderIds.add(folderId);
}
});
return [...selectedFolderIds];
}
function beginFavoriteFolderSave(
document: Document,
state: FavoritePickerDocumentState,
context: FavoritePickerContext,
nextFolderIds: string[]
): void {
const operation = beginFavoritePickerOperation(state, context);
syncFavoriteActionButton(context, true, state.openPicker?.button === context.button);
renderFavoritePicker(document, state, context.button);
void completeFavoriteFolderSave(
document,
state,
context,
operation,
nextFolderIds
);
}
async function completeFavoriteFolderSave(
document: Document,
state: FavoritePickerDocumentState,
context: FavoritePickerContext,
operation: FavoritePickerOperation,
nextFolderIds: string[]
): Promise<void> {
try {
await context.onSetCreatorFolderIds(context.creator, nextFolderIds);
finishFavoritePickerOperation(document, state, context.button, operation, {
folderIds: nextFolderIds
});
} catch {
finishFavoritePickerOperation(document, state, context.button, operation);
}
}
async function createAndSaveFavoriteFolder(
document: Document,
state: FavoritePickerDocumentState,
context: FavoritePickerContext
): Promise<void> {
if (state.pendingOperations.has(context.button)) {
return;
}
const operation = beginFavoritePickerOperation(state, context);
syncFavoriteActionButton(context, true, state.openPicker?.button === context.button);
renderFavoritePicker(document, state, context.button);
try {
const folder = await context.onCreateFolder();
if (!folder) {
finishFavoritePickerOperation(document, state, context.button, operation);
return;
}
const nextFolderIds = [...new Set([...context.selectedFolderIds, folder.id])];
await context.onSetCreatorFolderIds(context.creator, nextFolderIds);
finishFavoritePickerOperation(document, state, context.button, operation, {
createdFolder: folder,
folderIds: nextFolderIds
});
} catch {
finishFavoritePickerOperation(document, state, context.button, operation);
}
}
function beginFavoritePickerOperation(
state: FavoritePickerDocumentState,
context: FavoritePickerContext
): FavoritePickerOperation {
const operation: FavoritePickerOperation = {
confirmedFolderIds: new Set(context.selectedFolderIds)
};
state.pendingOperations.set(context.button, operation);
return operation;
}
function finishFavoritePickerOperation(
document: Document,
state: FavoritePickerDocumentState,
button: HTMLButtonElement,
operation: FavoritePickerOperation,
result?: {
createdFolder?: FavoriteFolder;
folderIds: string[];
}
): void {
if (state.pendingOperations.get(button) !== operation) {
return;
}
state.pendingOperations.delete(button);
const currentContext = state.contextByButton.get(button);
if (!currentContext || !button.isConnected) {
return;
}
if (result) {
currentContext.selectedFolderIds = new Set(result.folderIds);
if (
result.createdFolder &&
!currentContext.folders.some((folder) => folder.id === result.createdFolder?.id)
) {
currentContext.folders = [...currentContext.folders, result.createdFolder];
}
}
syncFavoriteActionButton(
currentContext,
false,
state.openPicker?.button === button
);
renderFavoritePicker(document, state, button);
}
function focusFavoritePickerControl(root: HTMLElement): void {
const firstCheckbox = Array.from(
root.querySelectorAll<HTMLInputElement>("input[data-sces-favorite-folder-id]")
).find((checkbox) => !checkbox.disabled);
const createButton = root.querySelector<HTMLButtonElement>(
'[data-sces-favorite-row-picker="create-folder"]'
);
(firstCheckbox ?? (createButton && !createButton.disabled ? createButton : null))?.focus();
}
function positionFavoritePicker(
document: Document,
root: HTMLElement,
button: HTMLButtonElement
): void {
const triggerRect = button.getBoundingClientRect();
const pickerRect = root.getBoundingClientRect();
const pickerWidth = pickerRect.width || root.offsetWidth || PICKER_FALLBACK_WIDTH;
const pickerHeight = pickerRect.height || root.offsetHeight || PICKER_FALLBACK_HEIGHT;
const viewportWidth =
document.documentElement.clientWidth ||
document.defaultView?.innerWidth ||
pickerWidth + PICKER_VIEWPORT_PADDING * 2;
const viewportHeight =
document.documentElement.clientHeight ||
document.defaultView?.innerHeight ||
pickerHeight + PICKER_VIEWPORT_PADDING * 2;
const maxLeft = Math.max(
PICKER_VIEWPORT_PADDING,
viewportWidth - pickerWidth - PICKER_VIEWPORT_PADDING
);
const maxTop = Math.max(
PICKER_VIEWPORT_PADDING,
viewportHeight - pickerHeight - PICKER_VIEWPORT_PADDING
);
const belowTop = triggerRect.bottom + PICKER_VIEWPORT_PADDING;
const preferredTop =
belowTop + pickerHeight > viewportHeight - PICKER_VIEWPORT_PADDING
? triggerRect.top - PICKER_VIEWPORT_PADDING - pickerHeight
: belowTop;
const left = clamp(triggerRect.left, PICKER_VIEWPORT_PADDING, maxLeft);
const top = clamp(preferredTop, PICKER_VIEWPORT_PADDING, maxTop);
root.style.left = `${Math.round(left)}px`;
root.style.top = `${Math.round(top)}px`;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(Math.max(value, minimum), maximum);
}
+955
View File
@@ -0,0 +1,955 @@
import type {
FavoriteCreator,
FavoritesStateV1
} from "./favorites-store";
export interface FavoritesDrawerHandlers {
onCreateFolder(): Promise<void>;
onDeleteFolder(folderId: string): Promise<void>;
onImportFolder(folderId: string, authorIds: string[]): Promise<void>;
onImportSelected(authorIds: string[]): Promise<void>;
onRemoveCreatorFromFolder(authorId: string, folderId: string): Promise<void>;
onRenameFolder(folderId: string): Promise<void>;
}
export interface FavoritesDrawerController {
dispose(): void;
render(state: FavoritesStateV1): void;
setBusy(isBusy: boolean): void;
setError(message: string): void;
}
const DRAWER_ID = "sces-favorites-drawer";
const activeDrawers = new WeakMap<Document, FavoritesDrawerController>();
type DrawerView =
| { kind: "all" }
| { folderId: string; kind: "folder" };
type DrawerFocusDescriptor =
| {
end: number | null;
kind: "search";
start: number | null;
}
| { authorId: string; kind: "checkbox" }
| { folderId: string; kind: "folder" }
| { attribute: string; kind: "attribute"; value: string };
export function createFavoritesDrawer(
document: Document,
handlers: FavoritesDrawerHandlers,
confirmImport: (message: string) => boolean
): FavoritesDrawerController {
activeDrawers.get(document)?.dispose();
document
.querySelectorAll(
'[data-sces-favorites-tab="button"], [data-sces-favorites-drawer="root"]'
)
.forEach((element) => element.remove());
const tab = createButton(document, "收藏夹");
tab.dataset.scesFavoritesTab = "button";
tab.setAttribute("aria-controls", DRAWER_ID);
tab.setAttribute("aria-expanded", "false");
tab.setAttribute("aria-haspopup", "dialog");
applyTabStyles(tab);
const drawer = document.createElement("aside");
drawer.id = DRAWER_ID;
drawer.dataset.scesFavoritesDrawer = "root";
drawer.hidden = true;
drawer.tabIndex = -1;
drawer.setAttribute("role", "dialog");
drawer.setAttribute("aria-label", "收藏夹");
drawer.setAttribute("aria-modal", "false");
drawer.setAttribute("aria-hidden", "true");
applyDrawerStyles(drawer);
drawer.style.display = "none";
(document.body ?? document.documentElement).append(tab, drawer);
let busy = false;
let disposed = false;
let errorMessage = "";
let isOpen = false;
let pending = false;
let searchQuery = "";
let state = emptyState();
let view: DrawerView = { kind: "all" };
let selectedAuthorIds = new Set<string>();
const controller: FavoritesDrawerController = {
dispose,
render(nextState) {
if (disposed) {
return;
}
state = nextState;
renderDrawer();
},
setBusy(isBusy) {
if (disposed) {
return;
}
busy = isBusy;
renderDrawer();
},
setError(message) {
if (disposed) {
return;
}
errorMessage = message;
renderDrawer();
}
};
const onTabClick = () => {
if (isOpen) {
closeDrawer();
} else {
openDrawer();
}
};
const onDocumentKeydown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || !isOpen || event.defaultPrevented) {
return;
}
if (document.querySelector("[data-sces-favorite-folder-dialog]")) {
return;
}
event.preventDefault();
closeDrawer();
};
const onDrawerClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const button = target.closest("button");
if (!(button instanceof HTMLButtonElement)) {
return;
}
if (button.dataset.scesFavoritesDrawer === "close") {
closeDrawer();
return;
}
if (button.dataset.scesFavoritesDrawer === "view-all") {
view = { kind: "all" };
renderDrawer();
return;
}
if (button.hasAttribute("data-sces-favorites-folder-id")) {
view = {
folderId: button.getAttribute("data-sces-favorites-folder-id") ?? "",
kind: "folder"
};
renderDrawer();
return;
}
if (isInteractionLocked()) {
return;
}
if (button.dataset.scesFavoritesDrawer === "create-folder") {
invokeHandler(handlers.onCreateFolder);
return;
}
if (button.hasAttribute("data-sces-favorites-folder-rename")) {
const renameFolderId =
button.getAttribute("data-sces-favorites-folder-rename") ?? "";
invokeHandler(() => handlers.onRenameFolder(renameFolderId));
return;
}
if (button.hasAttribute("data-sces-favorites-folder-delete")) {
const deleteFolderId =
button.getAttribute("data-sces-favorites-folder-delete") ?? "";
invokeHandler(() => handlers.onDeleteFolder(deleteFolderId));
return;
}
if (
button.hasAttribute("data-sces-favorites-remove-author-id") &&
view.kind === "folder"
) {
const removeAuthorId =
button.getAttribute("data-sces-favorites-remove-author-id") ?? "";
const folderId = view.folderId;
invokeHandler(() =>
handlers.onRemoveCreatorFromFolder(removeAuthorId, folderId)
);
return;
}
if (button.dataset.scesFavoritesDrawer === "import-selected") {
const authorIds = selectedCreatorsInCurrentView().map((creator) => creator.authorId);
if (authorIds.length > 0) {
invokeHandler(() => handlers.onImportSelected(authorIds));
}
return;
}
if (
button.dataset.scesFavoritesDrawer === "import-folder" &&
view.kind === "folder"
) {
const authorIds = creatorsInCurrentView().map((creator) => creator.authorId);
const folderId = view.folderId;
const message = `将当前收藏夹的 ${authorIds.length} 位达人导入秒探吗?`;
if (confirmImport(message)) {
invokeHandler(() => handlers.onImportFolder(folderId, authorIds));
}
}
};
const onDrawerInput = (event: Event) => {
const target = event.target;
if (
target instanceof HTMLInputElement &&
target.dataset.scesFavoritesDrawer === "search"
) {
searchQuery = target.value;
target.focus();
renderDrawer();
}
};
const onDrawerChange = (event: Event) => {
const target = event.target;
if (
isInteractionLocked() ||
!(target instanceof HTMLInputElement) ||
target.type !== "checkbox"
) {
return;
}
const authorId = target.dataset.scesFavoritesSelectAuthorId;
if (!authorId) {
return;
}
if (target.checked) {
selectedAuthorIds.add(authorId);
} else {
selectedAuthorIds.delete(authorId);
}
renderDrawer();
};
tab.addEventListener("click", onTabClick);
drawer.addEventListener("click", onDrawerClick);
drawer.addEventListener("input", onDrawerInput);
drawer.addEventListener("change", onDrawerChange);
document.addEventListener("keydown", onDocumentKeydown, true);
renderDrawer();
activeDrawers.set(document, controller);
return controller;
function openDrawer(): void {
if (disposed) {
return;
}
isOpen = true;
drawer.hidden = false;
drawer.style.display = "flex";
drawer.setAttribute("aria-hidden", "false");
tab.setAttribute("aria-expanded", "true");
renderDrawer();
const closeButton = drawer.querySelector(
'[data-sces-favorites-drawer="close"]'
);
if (closeButton instanceof HTMLButtonElement) {
closeButton.focus();
} else {
drawer.focus();
}
}
function closeDrawer(): void {
if (disposed || !isOpen) {
return;
}
isOpen = false;
drawer.hidden = true;
drawer.style.display = "none";
drawer.setAttribute("aria-hidden", "true");
tab.setAttribute("aria-expanded", "false");
if (tab.isConnected) {
tab.focus();
}
}
function renderDrawer(): void {
const focusDescriptor = captureFocusedControl();
if (view.kind === "folder") {
const folderId = view.folderId;
if (!state.folders.some((folder) => folder.id === folderId)) {
view = { kind: "all" };
}
}
const currentCreators = creatorsInCurrentView();
const currentAuthorIds = new Set(currentCreators.map((creator) => creator.authorId));
selectedAuthorIds = new Set(
Array.from(selectedAuthorIds).filter((authorId) => currentAuthorIds.has(authorId))
);
const query = searchQuery.trim().toLocaleLowerCase();
const visibleCreators = currentCreators.filter((creator) =>
creator.authorName.toLocaleLowerCase().includes(query)
);
const selectedCount = selectedCreatorsInCurrentView().length;
const fragment = document.createDocumentFragment();
fragment.append(createHeader());
if (errorMessage) {
const error = document.createElement("p");
error.dataset.scesFavoritesDrawer = "error";
error.textContent = errorMessage;
applyErrorStyles(error);
fragment.append(error);
}
fragment.append(createFolderNavigation());
fragment.append(createSearch());
fragment.append(createCreatorList(visibleCreators));
fragment.append(createFooter(selectedCount));
drawer.replaceChildren(fragment);
restoreFocusedControl(focusDescriptor);
}
function createHeader(): HTMLElement {
const header = document.createElement("header");
applyHeaderStyles(header);
const title = document.createElement("h2");
title.textContent = "收藏夹";
applyTitleStyles(title);
const actions = document.createElement("div");
applyHeaderActionsStyles(actions);
const createFolder = createButton(document, "新建收藏夹");
createFolder.dataset.scesFavoritesDrawer = "create-folder";
createFolder.disabled = isInteractionLocked();
applySecondaryButtonStyles(createFolder);
const close = createButton(document, "x");
close.dataset.scesFavoritesDrawer = "close";
close.setAttribute("aria-label", "关闭收藏夹");
close.title = "关闭收藏夹";
applyIconButtonStyles(close);
actions.append(createFolder, close);
header.append(title, actions);
return header;
}
function createFolderNavigation(): HTMLElement {
const navigation = document.createElement("nav");
navigation.setAttribute("aria-label", "收藏夹列表");
applyNavigationStyles(navigation);
const allButton = createButton(document, "全部收藏");
allButton.dataset.scesFavoritesDrawer = "view-all";
allButton.setAttribute("aria-current", String(view.kind === "all"));
applyViewButtonStyles(allButton, view.kind === "all");
navigation.append(allButton);
state.folders.forEach((folder) => {
const row = document.createElement("div");
applyFolderRowStyles(row);
const viewButton = createButton(document, folder.name);
viewButton.dataset.scesFavoritesFolderId = folder.id;
const isCurrentFolder =
view.kind === "folder" && view.folderId === folder.id;
viewButton.setAttribute("aria-current", String(isCurrentFolder));
applyViewButtonStyles(viewButton, isCurrentFolder);
const rename = createButton(document, "重命名");
rename.dataset.scesFavoritesFolderRename = folder.id;
rename.setAttribute("aria-label", `重命名收藏夹 ${folder.name}`);
rename.disabled = isInteractionLocked();
applyTextActionStyles(rename);
const remove = createButton(document, "删除");
remove.dataset.scesFavoritesFolderDelete = folder.id;
remove.setAttribute("aria-label", `删除收藏夹 ${folder.name}`);
remove.disabled = isInteractionLocked();
applyDangerActionStyles(remove);
row.append(viewButton, rename, remove);
navigation.append(row);
});
return navigation;
}
function createSearch(): HTMLInputElement {
const search = document.createElement("input");
search.type = "search";
search.value = searchQuery;
search.placeholder = "搜索达人名称";
search.dataset.scesFavoritesDrawer = "search";
search.setAttribute("aria-label", "搜索达人名称");
applySearchStyles(search);
return search;
}
function createCreatorList(creators: FavoriteCreator[]): HTMLElement {
const list = document.createElement("section");
list.dataset.scesFavoritesDrawer = "creators";
list.setAttribute("aria-label", "收藏达人");
applyCreatorListStyles(list);
if (creators.length === 0) {
const empty = document.createElement("p");
empty.dataset.scesFavoritesDrawer = "empty";
empty.textContent = "暂无收藏达人";
applyEmptyStyles(empty);
list.append(empty);
return list;
}
creators.forEach((creator) => {
const row = document.createElement("article");
row.dataset.scesFavoritesAuthorId = creator.authorId;
applyCreatorRowStyles(row);
const selectLabel = document.createElement("label");
applySelectLabelStyles(selectLabel);
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = selectedAuthorIds.has(creator.authorId);
checkbox.disabled = isInteractionLocked();
checkbox.dataset.scesFavoritesSelectAuthorId = creator.authorId;
checkbox.setAttribute("aria-label", `选择达人 ${creator.authorName}`);
selectLabel.append(checkbox);
const details = document.createElement("div");
details.style.minWidth = "0";
details.style.flex = "1";
const name = document.createElement("strong");
name.textContent = creator.authorName;
applyCreatorNameStyles(name);
details.append(name);
if (view.kind === "all") {
const affiliation = document.createElement("p");
affiliation.textContent = `所属:${folderNamesForCreator(creator.authorId).join("、") || "未分类"}`;
applyAffiliationStyles(affiliation);
details.append(affiliation);
}
row.append(selectLabel, details);
if (view.kind === "folder") {
const remove = createButton(document, "移出收藏夹");
remove.dataset.scesFavoritesRemoveAuthorId = creator.authorId;
remove.setAttribute("aria-label", `${creator.authorName} 移出当前收藏夹`);
remove.disabled = isInteractionLocked();
applyDangerActionStyles(remove);
row.append(remove);
}
list.append(row);
});
return list;
}
function createFooter(selectedCount: number): HTMLElement {
const footer = document.createElement("footer");
footer.dataset.scesFavoritesDrawer = "footer";
applyFooterStyles(footer);
const selected = document.createElement("span");
selected.textContent = `已选择 ${selectedCount} 位达人`;
applySelectedCountStyles(selected);
const actions = document.createElement("div");
applyFooterActionsStyles(actions);
if (view.kind === "folder") {
const importFolder = createButton(document, "导入当前收藏夹全部达人");
importFolder.dataset.scesFavoritesDrawer = "import-folder";
importFolder.disabled = isInteractionLocked();
applySecondaryButtonStyles(importFolder);
applyFooterActionStyles(importFolder);
actions.append(importFolder);
}
const importSelected = createButton(document, `导入已选 ${selectedCount} 位达人`);
importSelected.dataset.scesFavoritesDrawer = "import-selected";
importSelected.disabled = isInteractionLocked() || selectedCount === 0;
applyPrimaryButtonStyles(importSelected);
applyFooterActionStyles(importSelected);
actions.append(importSelected);
footer.append(selected, actions);
return footer;
}
function creatorsInCurrentView(): FavoriteCreator[] {
const uniqueCreators = new Map<string, FavoriteCreator>();
if (view.kind === "all") {
state.creators.forEach((creator) => {
if (!uniqueCreators.has(creator.authorId)) {
uniqueCreators.set(creator.authorId, creator);
}
});
return Array.from(uniqueCreators.values());
}
const creatorsById = new Map<string, FavoriteCreator>();
state.creators.forEach((creator) => {
if (!creatorsById.has(creator.authorId)) {
creatorsById.set(creator.authorId, creator);
}
});
const folderId = view.folderId;
state.memberships.forEach((membership) => {
if (
membership.folderId !== folderId ||
uniqueCreators.has(membership.authorId)
) {
return;
}
const creator = creatorsById.get(membership.authorId);
if (creator) {
uniqueCreators.set(creator.authorId, creator);
}
});
return Array.from(uniqueCreators.values());
}
function selectedCreatorsInCurrentView(): FavoriteCreator[] {
return creatorsInCurrentView().filter((creator) => selectedAuthorIds.has(creator.authorId));
}
function folderNamesForCreator(authorId: string): string[] {
const folderNamesById = new Map(state.folders.map((folder) => [folder.id, folder.name]));
const names = new Set<string>();
state.memberships.forEach((membership) => {
if (membership.authorId !== authorId) {
return;
}
const name = folderNamesById.get(membership.folderId);
if (name) {
names.add(name);
}
});
return Array.from(names);
}
function captureFocusedControl(): DrawerFocusDescriptor | null {
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement) || !drawer.contains(activeElement)) {
return null;
}
if (
activeElement instanceof HTMLInputElement &&
activeElement.dataset.scesFavoritesDrawer === "search"
) {
return {
end: activeElement.selectionEnd,
kind: "search",
start: activeElement.selectionStart
};
}
if (
activeElement instanceof HTMLInputElement &&
activeElement.type === "checkbox" &&
activeElement.hasAttribute("data-sces-favorites-select-author-id")
) {
return {
authorId:
activeElement.getAttribute("data-sces-favorites-select-author-id") ?? "",
kind: "checkbox"
};
}
if (activeElement instanceof HTMLButtonElement) {
if (activeElement.hasAttribute("data-sces-favorites-folder-id")) {
return {
folderId:
activeElement.getAttribute("data-sces-favorites-folder-id") ?? "",
kind: "folder"
};
}
const actionAttribute = [
"data-sces-favorites-drawer",
"data-sces-favorites-folder-delete",
"data-sces-favorites-folder-rename",
"data-sces-favorites-remove-author-id"
].find((attribute) => activeElement.hasAttribute(attribute));
if (actionAttribute) {
return {
attribute: actionAttribute,
kind: "attribute",
value: activeElement.getAttribute(actionAttribute) ?? ""
};
}
}
return null;
}
function restoreFocusedControl(descriptor: DrawerFocusDescriptor | null): void {
if (!descriptor) {
return;
}
let target: HTMLElement | undefined;
if (descriptor.kind === "search") {
const search = drawer.querySelector(
'[data-sces-favorites-drawer="search"]'
);
if (search instanceof HTMLInputElement) {
target = search;
const start = Math.min(
descriptor.start ?? search.value.length,
search.value.length
);
const end = Math.min(descriptor.end ?? start, search.value.length);
search.setSelectionRange(start, end);
}
} else if (descriptor.kind === "checkbox") {
target = findElementByAttribute(
"data-sces-favorites-select-author-id",
descriptor.authorId
);
} else if (descriptor.kind === "folder") {
target = findElementByAttribute(
"data-sces-favorites-folder-id",
descriptor.folderId
);
} else {
target = findElementByAttribute(descriptor.attribute, descriptor.value);
}
if (target && isFocusable(target)) {
target.focus();
return;
}
const fallbackSearch = drawer.querySelector(
'[data-sces-favorites-drawer="search"]'
);
if (fallbackSearch instanceof HTMLInputElement && !fallbackSearch.disabled) {
fallbackSearch.focus();
}
}
function findElementByAttribute(attribute: string, value: string): HTMLElement | undefined {
return Array.from(drawer.querySelectorAll<HTMLElement>(`[${attribute}]`)).find(
(element) => element.getAttribute(attribute) === value
);
}
function isFocusable(element: HTMLElement): boolean {
return !(
(element instanceof HTMLButtonElement || element instanceof HTMLInputElement) &&
element.disabled
);
}
function invokeHandler(handler: () => Promise<void>): void {
if (isInteractionLocked() || disposed) {
return;
}
pending = true;
renderDrawer();
void (async () => {
try {
await handler();
} catch (error: unknown) {
errorMessage = error instanceof Error ? error.message : "操作失败";
} finally {
pending = false;
if (!disposed) {
renderDrawer();
}
}
})();
}
function isInteractionLocked(): boolean {
return busy || pending;
}
function dispose(): void {
if (disposed) {
return;
}
disposed = true;
tab.removeEventListener("click", onTabClick);
drawer.removeEventListener("click", onDrawerClick);
drawer.removeEventListener("input", onDrawerInput);
drawer.removeEventListener("change", onDrawerChange);
document.removeEventListener("keydown", onDocumentKeydown, true);
tab.remove();
drawer.remove();
if (activeDrawers.get(document) === controller) {
activeDrawers.delete(document);
}
}
}
function emptyState(): FavoritesStateV1 {
return { creators: [], folders: [], memberships: [], version: 1 };
}
function createButton(document: Document, label: string): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
return button;
}
function applyTabStyles(tab: HTMLButtonElement): void {
tab.style.position = "fixed";
tab.style.top = "50%";
tab.style.right = "56px";
tab.style.zIndex = "2147483645";
tab.style.padding = "12px 8px";
tab.style.border = "1px solid #0f766e";
tab.style.borderRadius = "6px 0 0 6px";
tab.style.background = "#ffffff";
tab.style.color = "#0f766e";
tab.style.boxShadow = "0 4px 14px rgba(15, 23, 42, 0.14)";
tab.style.cursor = "pointer";
tab.style.fontWeight = "700";
tab.style.writingMode = "vertical-rl";
}
function applyDrawerStyles(drawer: HTMLElement): void {
drawer.style.position = "fixed";
drawer.style.top = "64px";
drawer.style.right = "56px";
drawer.style.bottom = "24px";
drawer.style.zIndex = "2147483646";
drawer.style.display = "flex";
drawer.style.flexDirection = "column";
drawer.style.width = "380px";
drawer.style.maxWidth = "calc(100vw - 72px)";
drawer.style.boxSizing = "border-box";
drawer.style.overflow = "hidden";
drawer.style.border = "1px solid #d7dde6";
drawer.style.borderRadius = "8px";
drawer.style.background = "#ffffff";
drawer.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.2)";
drawer.style.color = "#172033";
drawer.style.fontFamily = "Arial, sans-serif";
}
function applyHeaderStyles(header: HTMLElement): void {
header.style.display = "flex";
header.style.alignItems = "center";
header.style.justifyContent = "space-between";
header.style.gap = "12px";
header.style.padding = "16px";
header.style.borderBottom = "1px solid #e2e8f0";
}
function applyTitleStyles(title: HTMLElement): void {
title.style.margin = "0";
title.style.fontSize = "18px";
title.style.lineHeight = "24px";
title.style.fontWeight = "700";
}
function applyHeaderActionsStyles(actions: HTMLElement): void {
actions.style.display = "flex";
actions.style.alignItems = "center";
actions.style.gap = "8px";
}
function applyNavigationStyles(navigation: HTMLElement): void {
navigation.style.display = "flex";
navigation.style.flexDirection = "column";
navigation.style.gap = "4px";
navigation.style.padding = "12px 16px";
navigation.style.borderBottom = "1px solid #e2e8f0";
}
function applyFolderRowStyles(row: HTMLElement): void {
row.style.display = "flex";
row.style.alignItems = "center";
row.style.gap = "4px";
}
function applyViewButtonStyles(button: HTMLButtonElement, active: boolean): void {
button.style.minWidth = "0";
button.style.flex = "1";
button.style.height = "30px";
button.style.padding = "0 8px";
button.style.border = "0";
button.style.borderRadius = "4px";
button.style.background = active ? "#e0f2f1" : "transparent";
button.style.color = active ? "#0f766e" : "#334155";
button.style.cursor = "pointer";
button.style.fontSize = "13px";
button.style.overflow = "hidden";
button.style.textAlign = "left";
button.style.textOverflow = "ellipsis";
button.style.whiteSpace = "nowrap";
}
function applyTextActionStyles(button: HTMLButtonElement): void {
button.style.height = "26px";
button.style.padding = "0 4px";
button.style.border = "0";
button.style.background = "transparent";
button.style.color = "#475569";
button.style.cursor = "pointer";
button.style.fontSize = "12px";
}
function applyDangerActionStyles(button: HTMLButtonElement): void {
applyTextActionStyles(button);
button.style.color = "#b42318";
}
function applySearchStyles(search: HTMLInputElement): void {
search.style.height = "36px";
search.style.margin = "12px 16px";
search.style.padding = "0 10px";
search.style.border = "1px solid #cbd5e1";
search.style.borderRadius = "6px";
search.style.fontSize = "13px";
}
function applyCreatorListStyles(list: HTMLElement): void {
list.style.display = "flex";
list.style.flex = "1";
list.style.flexDirection = "column";
list.style.gap = "8px";
list.style.minHeight = "0";
list.style.padding = "0 16px 16px";
list.style.overflowY = "auto";
}
function applyCreatorRowStyles(row: HTMLElement): void {
row.style.display = "flex";
row.style.alignItems = "center";
row.style.gap = "10px";
row.style.padding = "10px";
row.style.border = "1px solid #e2e8f0";
row.style.borderRadius = "6px";
}
function applySelectLabelStyles(label: HTMLElement): void {
label.style.display = "flex";
label.style.alignItems = "center";
}
function applyCreatorNameStyles(name: HTMLElement): void {
name.style.display = "block";
name.style.overflow = "hidden";
name.style.fontSize = "14px";
name.style.lineHeight = "20px";
name.style.textOverflow = "ellipsis";
name.style.whiteSpace = "nowrap";
}
function applyAffiliationStyles(affiliation: HTMLElement): void {
affiliation.style.margin = "2px 0 0";
affiliation.style.color = "#64748b";
affiliation.style.fontSize = "12px";
affiliation.style.lineHeight = "18px";
}
function applyEmptyStyles(empty: HTMLElement): void {
empty.style.margin = "24px 0";
empty.style.color = "#64748b";
empty.style.fontSize = "13px";
empty.style.textAlign = "center";
}
function applyFooterStyles(footer: HTMLElement): void {
footer.style.display = "flex";
footer.style.alignItems = "center";
footer.style.justifyContent = "space-between";
footer.style.flexWrap = "wrap";
footer.style.gap = "12px";
footer.style.padding = "12px 16px";
footer.style.width = "100%";
footer.style.minWidth = "0";
footer.style.boxSizing = "border-box";
footer.style.borderTop = "1px solid #e2e8f0";
}
function applySelectedCountStyles(selected: HTMLElement): void {
selected.style.flex = "1 1 100%";
selected.style.minWidth = "0";
selected.style.boxSizing = "border-box";
selected.style.color = "#475569";
selected.style.fontSize = "12px";
}
function applyFooterActionsStyles(actions: HTMLElement): void {
actions.style.display = "flex";
actions.style.alignItems = "center";
actions.style.justifyContent = "flex-end";
actions.style.flexWrap = "wrap";
actions.style.gap = "8px";
actions.style.minWidth = "0";
actions.style.width = "100%";
actions.style.boxSizing = "border-box";
}
function applyFooterActionStyles(button: HTMLButtonElement): void {
button.style.flex = "1 1 100%";
button.style.width = "100%";
button.style.minWidth = "0";
button.style.maxWidth = "100%";
button.style.boxSizing = "border-box";
}
function applyErrorStyles(error: HTMLElement): void {
error.style.margin = "0";
error.style.padding = "8px 16px";
error.style.background = "#fef2f2";
error.style.color = "#b42318";
error.style.fontSize = "12px";
error.style.lineHeight = "18px";
}
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "30px";
button.style.padding = "0 8px";
button.style.border = "1px solid #b9c4d3";
button.style.borderRadius = "5px";
button.style.background = "#ffffff";
button.style.color = "#334155";
button.style.cursor = "pointer";
button.style.fontSize = "12px";
button.style.boxSizing = "border-box";
button.style.maxWidth = "100%";
}
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "30px";
button.style.padding = "0 8px";
button.style.border = "1px solid #0f766e";
button.style.borderRadius = "5px";
button.style.background = "#0f766e";
button.style.color = "#ffffff";
button.style.cursor = "pointer";
button.style.fontSize = "12px";
button.style.boxSizing = "border-box";
button.style.maxWidth = "100%";
}
function applyIconButtonStyles(button: HTMLButtonElement): void {
button.style.width = "30px";
button.style.height = "30px";
button.style.padding = "0";
button.style.border = "1px solid #b9c4d3";
button.style.borderRadius = "5px";
button.style.background = "#ffffff";
button.style.color = "#334155";
button.style.cursor = "pointer";
button.style.fontSize = "12px";
}
+17
View File
@@ -0,0 +1,17 @@
import type { FavoriteCreator } from "./favorites-store";
import type { MarketRecord } from "./types";
export function toFavoriteMarketRecords(creators: FavoriteCreator[]): MarketRecord[] {
const byAuthorId = new Map<string, FavoriteCreator>();
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
}));
}
+424
View File
@@ -0,0 +1,424 @@
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<unknown>;
set(value: FavoritesStateV1): Promise<void>;
}
export interface FavoritesRepository {
createFolder(name: string): Promise<FavoriteFolder>;
deleteFolder(folderId: string): Promise<void>;
read(): Promise<FavoritesStateV1>;
removeCreatorFromFolder(authorId: string, folderId: string): Promise<void>;
renameFolder(folderId: string, name: string): Promise<void>;
setCreatorFolderIds(
creator: FavoriteCreatorInput,
folderIds: string[]
): Promise<void>;
}
interface ChromeStorageLocalArea {
get(key: string): Promise<Record<string, unknown>>;
set(items: Record<string, FavoritesStateV1>): Promise<void>;
}
interface ChromeApi {
storage?: {
local?: ChromeStorageLocalArea;
};
}
declare const chrome: ChromeApi;
export function createFavoritesRepository(options: {
storage: FavoritesStorage;
now?: () => string;
createId?: () => string;
}): FavoritesRepository {
const now = options.now ?? (() => new Date().toISOString());
const createId = options.createId ?? createDefaultId;
let mutationTail: Promise<void> = Promise.resolve();
return {
async createFolder(name) {
const folderName = normalizeFolderName(name);
return mutate((state) => {
const timestamp = now();
const folder: FavoriteFolder = {
createdAt: timestamp,
id: createId(),
name: folderName,
updatedAt: timestamp
};
state.folders.push(folder);
return { ...folder };
});
},
async deleteFolder(folderId) {
await mutate((state) => {
const folderIndex = state.folders.findIndex((folder) => folder.id === folderId);
if (folderIndex === -1) {
throw new Error(`收藏夹不存在: ${folderId}`);
}
state.folders.splice(folderIndex, 1);
state.memberships = state.memberships.filter(
(membership) => membership.folderId !== folderId
);
pruneOrphanCreators(state);
});
},
async read() {
await mutationTail;
return normalizeState(await options.storage.get(), now);
},
async removeCreatorFromFolder(authorId, folderId) {
await mutate((state) => {
const folder = state.folders.find((item) => item.id === folderId);
if (!folder) {
throw new Error(`收藏夹不存在: ${folderId}`);
}
state.memberships = state.memberships.filter(
(membership) =>
membership.authorId !== authorId || membership.folderId !== folderId
);
pruneOrphanCreators(state);
});
},
async renameFolder(folderId, name) {
const folderName = normalizeFolderName(name);
await mutate((state) => {
const folder = state.folders.find((item) => item.id === folderId);
if (!folder) {
throw new Error(`收藏夹不存在: ${folderId}`);
}
folder.name = folderName;
folder.updatedAt = now();
});
},
async setCreatorFolderIds(creator, folderIds) {
const authorId = normalizeText(creator.authorId);
if (!authorId) {
throw new Error("无法收藏该达人");
}
const uniqueFolderIds = Array.from(new Set(folderIds));
await mutate((state) => {
const knownFolderIds = new Set(state.folders.map((folder) => folder.id));
const unknownFolderId = uniqueFolderIds.find(
(folderId) => !knownFolderIds.has(folderId)
);
if (unknownFolderId !== undefined) {
throw new Error(`收藏夹不存在: ${unknownFolderId}`);
}
const timestamp = now();
const existingCreator = state.creators.find(
(item) => item.authorId === authorId
);
const currentMemberships = state.memberships.filter(
(membership) => membership.authorId === authorId
);
const existingMemberships = new Map(
currentMemberships.map(
(membership) => [membership.folderId, membership] as const
)
);
state.memberships = state.memberships.filter(
(membership) => membership.authorId !== authorId
);
if (uniqueFolderIds.length === 0) {
pruneOrphanCreators(state);
return;
}
const authorName = normalizeText(creator.authorName);
const coreUserId = normalizeText(creator.coreUserId);
if (existingCreator) {
if (authorName) {
existingCreator.authorName = authorName;
}
if (coreUserId) {
existingCreator.coreUserId = coreUserId;
}
} else {
const nextCreator: FavoriteCreator = {
authorId,
authorName: authorName ?? authorId,
savedAt: timestamp
};
if (coreUserId) {
nextCreator.coreUserId = coreUserId;
}
state.creators.push(nextCreator);
}
const requestedFolderIds = new Set(uniqueFolderIds);
const retainedMemberships = currentMemberships.filter((membership) =>
requestedFolderIds.has(membership.folderId)
);
state.memberships.push(...retainedMemberships);
uniqueFolderIds.forEach((folderId) => {
if (existingMemberships.has(folderId)) {
return;
}
state.memberships.push({ addedAt: timestamp, authorId, folderId });
});
});
}
};
function mutate<T>(mutator: (state: FavoritesStateV1) => T): Promise<T> {
const operation = mutationTail.then(async () => {
const state = normalizeState(await options.storage.get(), now);
const result = mutator(state);
await options.storage.set(state);
return result;
});
mutationTail = operation.then(
() => undefined,
() => undefined
);
return operation;
}
}
export function createChromeFavoritesStorage(): FavoritesStorage {
return {
async get() {
const local = getChromeLocalStorage();
const result = await local.get(FAVORITES_STORAGE_KEY);
return result[FAVORITES_STORAGE_KEY];
},
async set(value) {
const local = getChromeLocalStorage();
await local.set({ [FAVORITES_STORAGE_KEY]: value });
}
};
}
function getChromeLocalStorage(): ChromeStorageLocalArea {
const local =
typeof chrome === "undefined" ? undefined : chrome.storage?.local;
if (!local) {
throw new Error("收藏夹存储不可用");
}
return local;
}
function normalizeState(value: unknown, now: () => string): FavoritesStateV1 {
const source = isRecord(value) ? value : {};
if (source.version !== 1) {
return {
creators: [],
folders: [],
memberships: [],
version: 1
};
}
const folders = normalizeFolders(source.folders, now);
const creators = normalizeCreators(source.creators, now);
const knownFolderIds = new Set(folders.map((folder) => folder.id));
const knownCreatorIds = new Set(creators.map((creator) => creator.authorId));
const memberships = normalizeMemberships(
source.memberships,
knownFolderIds,
knownCreatorIds,
now
);
const memberAuthorIds = new Set(memberships.map((membership) => membership.authorId));
return {
creators: creators.filter((creator) => memberAuthorIds.has(creator.authorId)),
folders,
memberships,
version: 1
};
}
function normalizeFolders(value: unknown, now: () => string): FavoriteFolder[] {
if (!Array.isArray(value)) {
return [];
}
const ids = new Set<string>();
const folders: FavoriteFolder[] = [];
value.forEach((item) => {
if (!isRecord(item)) {
return;
}
const id = normalizeText(item.id);
const name = normalizeText(item.name);
if (!id || !name || ids.has(id)) {
return;
}
ids.add(id);
folders.push({
createdAt: normalizeText(item.createdAt) ?? now(),
id,
name,
updatedAt: normalizeText(item.updatedAt) ?? now()
});
});
return folders;
}
function normalizeCreators(value: unknown, now: () => string): FavoriteCreator[] {
if (!Array.isArray(value)) {
return [];
}
const creatorsByAuthorId = new Map<string, FavoriteCreator>();
value.forEach((item) => {
if (!isRecord(item)) {
return;
}
const authorId = normalizeText(item.authorId);
if (!authorId) {
return;
}
const authorName = normalizeText(item.authorName);
const coreUserId = normalizeText(item.coreUserId);
const existingCreator = creatorsByAuthorId.get(authorId);
if (existingCreator) {
if (coreUserId && !existingCreator.coreUserId) {
existingCreator.coreUserId = coreUserId;
}
if (authorName && existingCreator.authorName === authorId) {
existingCreator.authorName = authorName;
}
return;
}
const creator: FavoriteCreator = {
authorId,
authorName: authorName ?? authorId,
savedAt: normalizeText(item.savedAt) ?? now()
};
if (coreUserId) {
creator.coreUserId = coreUserId;
}
creatorsByAuthorId.set(authorId, creator);
});
return Array.from(creatorsByAuthorId.values());
}
function normalizeMemberships(
value: unknown,
knownFolderIds: Set<string>,
knownCreatorIds: Set<string>,
now: () => string
): FavoriteMembership[] {
if (!Array.isArray(value)) {
return [];
}
const membershipKeys = new Set<string>();
const memberships: FavoriteMembership[] = [];
value.forEach((item) => {
if (!isRecord(item)) {
return;
}
const authorId = normalizeText(item.authorId);
const folderId = normalizeText(item.folderId);
if (
!authorId ||
!folderId ||
!knownCreatorIds.has(authorId) ||
!knownFolderIds.has(folderId)
) {
return;
}
const key = `${folderId}\u0000${authorId}`;
if (membershipKeys.has(key)) {
return;
}
membershipKeys.add(key);
memberships.push({
addedAt: normalizeText(item.addedAt) ?? now(),
authorId,
folderId
});
});
return memberships;
}
function pruneOrphanCreators(state: FavoritesStateV1): void {
const memberAuthorIds = new Set(
state.memberships.map((membership) => membership.authorId)
);
state.creators = state.creators.filter((creator) =>
memberAuthorIds.has(creator.authorId)
);
}
function normalizeFolderName(name: string): string {
const normalized = name.trim();
if (!normalized) {
throw new Error("请输入收藏夹名称");
}
return normalized;
}
function normalizeText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim();
return normalized || undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function createDefaultId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -51,10 +51,11 @@ export function createSilentExportController(
const mergedRecords = new Map<string, MarketRecord>();
const maxPageCount = target.mode === "count" ? target.pageCount : 200;
const initialPage = target.mode === "all" ? 1 : baseRequest.initialPage;
let totalPagesHint: number | undefined;
for (let offset = 0; offset < maxPageCount; offset += 1) {
const pageNumber = baseRequest.initialPage + offset;
const pageNumber = initialPage + offset;
options.onProgress?.({
currentPage: offset + 1,
totalPages: target.mode === "count" ? target.pageCount : totalPagesHint
+66 -49
View File
@@ -1,12 +1,13 @@
import type {
SpreadInfoConfig,
SpreadInfoMetrics,
SpreadMetricFilterRule
SpreadInfoMetrics
} from "./types";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
status?: number;
statusText?: string;
}
type FetchLike = (
@@ -90,10 +91,13 @@ export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
const timeoutMs = options.timeoutMs ?? 8000;
return {
async loadAuthorSpreadMetrics(authorId: string): Promise<SpreadInfoMetrics> {
async loadAuthorSpreadMetrics(
authorId: string,
selectedConfigs: SpreadInfoConfig[] = configs
): Promise<SpreadInfoMetrics> {
const metrics: SpreadInfoMetrics = {};
for (const config of configs) {
for (const config of selectedConfigs) {
const mappedResponse = await loadSpreadInfoFromUrl(
buildSpreadInfoUrl(authorId, config, baseUrl)
);
@@ -105,12 +109,6 @@ export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
}
return metrics;
},
async loadAuthorSpreadMetricSnapshot(
authorId: string,
config: SpreadInfoConfig
): Promise<MappedSpreadInfoResponse> {
return loadSpreadInfoFromUrl(buildSpreadInfoUrl(authorId, config, baseUrl));
}
};
@@ -127,12 +125,22 @@ export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
signal: controller.signal
});
if (!response.ok) {
return {};
throw new Error(
`星图传播数据接口请求失败: HTTP ${response.status ?? "unknown"}${
response.statusText ? `: ${response.statusText}` : ""
}`
);
}
return mapSpreadInfoResponse(await response.json());
} catch {
return {};
const payload = await response.json();
const rateLimitMessage = readRateLimitMessage(payload);
if (rateLimitMessage) {
throw new Error(
`星图传播数据接口被限流 (status_code=31157): ${rateLimitMessage}`
);
}
return mapSpreadInfoResponse(payload);
} finally {
clearTimeout(timeoutId);
}
@@ -163,6 +171,30 @@ export function buildSpreadInfoColumns(
);
}
export function selectSpreadInfoConfigsForHeaders(
selectedHeaders: readonly string[],
configs: SpreadInfoConfig[] = DEFAULT_SPREAD_INFO_CONFIGS
): SpreadInfoConfig[] {
const selectedHeaderSet = new Set(selectedHeaders);
return configs.filter((config) =>
buildSpreadInfoColumns([config]).some((header) =>
selectedHeaderSet.has(header)
)
);
}
export function filterUnloadedSpreadInfoConfigs(
configs: SpreadInfoConfig[],
existingMetrics: SpreadInfoMetrics
): SpreadInfoConfig[] {
return configs.filter((config) =>
buildSpreadInfoColumns([config]).every(
(header) => !(header in existingMetrics)
)
);
}
export function buildSpreadInfoMetricMap(
config: SpreadInfoConfig,
metrics: MappedSpreadInfoResponse
@@ -204,32 +236,6 @@ export function mapSpreadInfoResponse(
};
}
export function normalizeSpreadInfoConfig(
config: SpreadInfoConfig
): SpreadInfoConfig {
return config.type === 1
? { ...config, flowType: 0, onlyAssign: false }
: { ...config };
}
export function buildSpreadInfoConfigKey(config: SpreadInfoConfig): string {
const normalized = normalizeSpreadInfoConfig(config);
return [
normalized.type,
normalized.onlyAssign ? 1 : 0,
normalized.flowType,
normalized.range
].join(":");
}
export function matchesSpreadMetricRule(
metrics: MappedSpreadInfoResponse,
rule: SpreadMetricFilterRule
): boolean {
const numericValue = readDisplayNumber(metrics[rule.metric]);
return numericValue !== null && numericValue >= rule.threshold;
}
function buildSpreadInfoColumnHeader(
config: SpreadInfoConfig,
metric: SpreadInfoMetricDefinition
@@ -278,6 +284,26 @@ function getPayloadData(payload: unknown): Record<string, unknown> | null {
return isRecord(payload.data) ? payload.data : payload;
}
function readRateLimitMessage(payload: unknown): string | null {
const payloadRecord = isRecord(payload) ? payload : null;
const dataRecord = payloadRecord && isRecord(payloadRecord.data)
? payloadRecord.data
: null;
const baseResponse =
(payloadRecord && isRecord(payloadRecord.base_resp) && payloadRecord.base_resp) ||
(dataRecord && isRecord(dataRecord.base_resp) && dataRecord.base_resp);
if (!baseResponse || readNumberLike(baseResponse.status_code) !== 31157) {
return null;
}
return (
readStringLike(baseResponse.status_message) ??
readStringLike(baseResponse.message) ??
"您访问过于频繁,请稍后重试"
);
}
function readNestedValue(value: unknown, key: string): unknown {
return isRecord(value) ? value[key] : undefined;
}
@@ -307,15 +333,6 @@ function readNumberLike(value: unknown): number | null {
return null;
}
function readDisplayNumber(value: string | undefined): number | null {
if (!hasTextValue(value)) {
return null;
}
const parsedValue = Number(value.replace(/[% ,]/g, ""));
return Number.isFinite(parsedValue) ? parsedValue : null;
}
function formatBasisPointPercent(value: number | null): string | undefined {
if (value === null) {
return undefined;
-12
View File
@@ -21,18 +21,6 @@ export interface SpreadInfoConfig {
type: 1 | 2;
}
export type SpreadFilterMetric = "finishRate" | "interactionRate";
export interface SpreadMetricFilterRule {
config: SpreadInfoConfig;
metric: SpreadFilterMetric;
threshold: number;
}
export interface SpreadThresholdFilter {
rules: SpreadMetricFilterRule[];
}
export type MarketSortField =
| keyof Required<AfterSearchRates>
| keyof Required<BackendMetrics>;
+39 -2
View File
@@ -201,13 +201,37 @@ describe("audience-profile-csv", () => {
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe(
"达人信息,连接用户数,内容数据-个人视频-近30天-播放量中位数,观众画像-男性占比"
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-近30天-播放量中位数,观众画像-男性占比"
);
expect(rowLine).toBe("达人 A,300w,10913233,71.7%");
expect(rowLine).toBe("123,达人 A,成功,,10913233,71.7%");
expect(headerLine).not.toContain("秒思api-看后搜数");
expect(headerLine).not.toContain("粉丝画像-女性占比");
});
test("omits unselected market-list columns after field settings are saved", () => {
const row = buildSuccessRow({
exportFields: {
: "达人 A",
: "300w",
CPM: "120"
}
});
const csv = buildAudienceProfileCsv([row], {
selectedHeaders: ["内容数据-个人视频-近30天-播放量中位数"]
});
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe(
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-近30天-播放量中位数"
);
expect(rowLine).toBe("123,达人 A,成功,,10913233");
expect(headerLine).not.toContain("达人信息");
expect(headerLine).not.toContain("粉丝数");
expect(headerLine).not.toContain("预期CPM");
});
test("always keeps fixed id export headers when filtering", () => {
const row = buildSuccessRow({
exportFields: {
@@ -267,6 +291,19 @@ describe("audience-profile-csv", () => {
])
);
});
test("includes current market-list columns in the field picker", () => {
expect(
listAudienceProfileSelectableFieldGroups(["达人信息", "粉丝数"])
).toEqual(
expect.arrayContaining([
{
headers: ["达人信息", "粉丝数"],
label: "列表字段"
}
])
);
});
});
function readCsvValue(csv: string, header: string): string {
+183
View File
@@ -0,0 +1,183 @@
/** @vitest-environment jsdom */
import { describe, expect, test } from "vitest";
import { promptForAudienceProfileFields } from "../src/content/market/audience-profile-field-dialog";
describe("audience-profile-field-dialog", () => {
test("keeps an explicitly empty field selection empty", async () => {
const result = promptForAudienceProfileFields(
document,
[
{
headers: ["粉丝数", "观众画像-男性占比"],
label: "测试字段"
}
],
[]
);
const fields = Array.from(
document.querySelectorAll(
'[data-audience-profile-field-dialog-field="checkbox"]'
)
) as HTMLInputElement[];
expect(fields).toHaveLength(2);
expect(fields.every((field) => !field.checked)).toBe(true);
expect(
document.querySelectorAll(
"[data-audience-profile-field-dialog-selected-field]"
)
).toHaveLength(0);
expect(
document.querySelector(
'[data-audience-profile-field-dialog-selected-empty="text"]'
)?.hidden
).toBe(false);
clickSave();
await expect(result).resolves.toEqual([]);
});
test("keeps selected fields visible while searching and synchronizes both panes", async () => {
const result = promptForAudienceProfileFields(
document,
[
{
headers: ["粉丝数"],
label: "列表字段"
},
{
headers: ["内容数据-近30天-完播率", "内容数据-近30天-播放量"],
label: "内容数据"
}
],
["粉丝数", "内容数据-近30天-播放量"]
);
expect(readSelectedFieldNames()).toEqual([
"粉丝数",
"内容数据-近30天-播放量"
]);
expect(
document.querySelector(
'[data-audience-profile-field-dialog-selected-count="text"]'
)?.textContent
).toBe("2 个");
const search = document.querySelector(
'[data-audience-profile-field-dialog-search="input"]'
) as HTMLInputElement;
search.value = "完播率";
search.dispatchEvent(new Event("input"));
expect(readResultItem("内容数据-近30天-完播率")?.hidden).toBe(false);
expect(readResultItem("粉丝数")?.hidden).toBe(true);
expect(readSelectedFieldNames()).toEqual([
"粉丝数",
"内容数据-近30天-播放量"
]);
expect(
document.querySelector(
'[data-audience-profile-field-dialog-result-count="text"]'
)?.textContent
).toBe("找到 1 个");
const finishRateInput = readResultInput("内容数据-近30天-完播率");
finishRateInput.checked = true;
finishRateInput.dispatchEvent(new Event("change"));
expect(readSelectedFieldNames()).toEqual([
"粉丝数",
"内容数据-近30天-完播率",
"内容数据-近30天-播放量"
]);
const selectedFansInput = readSelectedInput("粉丝数");
selectedFansInput.checked = false;
selectedFansInput.dispatchEvent(new Event("change"));
expect(readResultInput("粉丝数").checked).toBe(false);
expect(readSelectedFieldNames()).toEqual([
"内容数据-近30天-完播率",
"内容数据-近30天-播放量"
]);
clickSave();
await expect(result).resolves.toEqual([
"内容数据-近30天-完播率",
"内容数据-近30天-播放量"
]);
});
test("shows a dedicated empty state when no field matches the keyword", async () => {
const result = promptForAudienceProfileFields(
document,
[{ headers: ["粉丝数"], label: "列表字段" }],
undefined
);
const search = document.querySelector(
'[data-audience-profile-field-dialog-search="input"]'
) as HTMLInputElement;
search.value = "不存在的字段";
search.dispatchEvent(new Event("input"));
expect(
document.querySelector(
'[data-audience-profile-field-dialog-result-empty="text"]'
)?.hidden
).toBe(false);
expect(
document.querySelector(
'[data-audience-profile-field-dialog-result-count="text"]'
)?.textContent
).toBe("找到 0 个");
expect(readSelectedFieldNames()).toEqual(["粉丝数"]);
clickSave();
await expect(result).resolves.toEqual(["粉丝数"]);
});
});
function clickSave(): void {
const saveButton = document.querySelector(
'[data-audience-profile-field-dialog-save="button"]'
) as HTMLButtonElement;
saveButton.click();
}
function readResultItem(field: string): HTMLElement | undefined {
return Array.from(
document.querySelectorAll<HTMLElement>(
"[data-audience-profile-field-dialog-result]"
)
).find(
(element) => element.dataset.audienceProfileFieldDialogResult === field
);
}
function readResultInput(field: string): HTMLInputElement {
const input = Array.from(
document.querySelectorAll<HTMLInputElement>(
'[data-audience-profile-field-dialog-field="checkbox"]'
)
).find((element) => element.value === field);
if (!input) throw new Error(`Missing result field input: ${field}`);
return input;
}
function readSelectedInput(field: string): HTMLInputElement {
const input = Array.from(
document.querySelectorAll<HTMLInputElement>(
'[data-audience-profile-field-dialog-selected-checkbox="checkbox"]'
)
).find((element) => element.value === field);
if (!input) throw new Error(`Missing selected field input: ${field}`);
return input;
}
function readSelectedFieldNames(): string[] {
return Array.from(
document.querySelectorAll<HTMLElement>(
"[data-audience-profile-field-dialog-selected-field]"
)
).map((element) => element.dataset.audienceProfileFieldDialogSelectedField ?? "");
}
+25
View File
@@ -117,6 +117,31 @@ describe("csv-exporter", () => {
);
});
test("omits unselected columns from the standard CSV after field settings are saved", () => {
const csv = buildMarketCsv(
[
{
authorId: "123",
authorName: "Alice",
exportFields: {
: "Alice",
: "100w",
CPM: "120"
},
status: "success"
} satisfies MarketRecord
],
{
selectedHeaders: ["粉丝数"]
}
);
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe("达人ID,达人名称,导出状态,失败原因,粉丝数");
expect(rowLine).toBe("123,Alice,成功,,100w");
});
test("escapes commas and quotes", () => {
const csv = buildMarketCsv([
{
+200
View File
@@ -0,0 +1,200 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, test, vi } from "vitest";
import { promptForFavoriteFolderName } from "../src/content/market/favorite-folder-dialog";
function readDialog(): HTMLElement {
const dialog = document.querySelector(
'[data-sces-favorite-folder-dialog="root"]'
);
if (!(dialog instanceof HTMLElement)) {
throw new Error("Expected favorite folder dialog");
}
return dialog;
}
function readInput(): HTMLInputElement {
const input = document.querySelector(
'[data-sces-favorite-folder-dialog="input"]'
);
if (!(input instanceof HTMLInputElement)) {
throw new Error("Expected favorite folder name input");
}
return input;
}
function readButton(name: "confirm" | "cancel"): HTMLButtonElement {
const button = document.querySelector(
`[data-sces-favorite-folder-dialog="${name}"]`
);
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Expected ${name} button`);
}
return button;
}
describe("favorite-folder-dialog", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
test("renders the requested title and initial name, then trims a confirmation", async () => {
const result = promptForFavoriteFolderName(document, {
initialValue: " 重点合作 ",
title: "重命名收藏夹"
});
expect(readDialog().getAttribute("role")).toBe("dialog");
expect(readDialog().textContent).toContain("重命名收藏夹");
expect(readInput().value).toBe(" 重点合作 ");
expect(document.activeElement).toBe(readInput());
readButton("confirm").click();
await expect(result).resolves.toBe("重点合作");
expect(
document.querySelector('[data-sces-favorite-folder-dialog="root"]')
).toBeNull();
});
test("keeps an inline error open for blank names and clears it after input", async () => {
const result = promptForFavoriteFolderName(document, {
title: "新建收藏夹"
});
readInput().value = " ";
readButton("confirm").click();
expect(
document.querySelector('[data-sces-favorite-folder-dialog="error"]')?.textContent
).toBe("请输入收藏夹名称");
expect(readInput().getAttribute("aria-invalid")).toBe("true");
expect(document.activeElement).toBe(readInput());
readInput().value = "重点";
readInput().dispatchEvent(new Event("input", { bubbles: true }));
expect(
document.querySelector('[data-sces-favorite-folder-dialog="error"]')?.textContent
).toBe("");
readButton("cancel").click();
await expect(result).resolves.toBeNull();
});
test("cancels from its cancel button, overlay click, and Escape", async () => {
const fromCancel = promptForFavoriteFolderName(document, {
title: "新建收藏夹"
});
readButton("cancel").click();
await expect(fromCancel).resolves.toBeNull();
const fromOverlay = promptForFavoriteFolderName(document, {
title: "新建收藏夹"
});
readDialog().dispatchEvent(new MouseEvent("click", { bubbles: true }));
await expect(fromOverlay).resolves.toBeNull();
const fromEscape = promptForFavoriteFolderName(document, {
title: "新建收藏夹"
});
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
await expect(fromEscape).resolves.toBeNull();
});
test("confirms a valid name with Enter", async () => {
const result = promptForFavoriteFolderName(document, {
title: "新建收藏夹"
});
readInput().value = "待联系";
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
await expect(result).resolves.toBe("待联系");
});
test("reuses and focuses one active dialog per document, then allows a new one after close", async () => {
const first = promptForFavoriteFolderName(document, {
initialValue: "首个值",
title: "新建收藏夹"
});
const input = readInput();
input.blur();
const reused = promptForFavoriteFolderName(document, {
initialValue: "不会替换",
title: "重命名收藏夹"
});
expect(reused).toBe(first);
expect(
document.querySelectorAll('[data-sces-favorite-folder-dialog="root"]')
).toHaveLength(1);
expect(readInput().value).toBe("首个值");
expect(document.activeElement).toBe(input);
readButton("cancel").click();
await expect(first).resolves.toBeNull();
const second = promptForFavoriteFolderName(document, {
title: "新建收藏夹"
});
expect(
document.querySelectorAll('[data-sces-favorite-folder-dialog="root"]')
).toHaveLength(1);
readButton("cancel").click();
await expect(second).resolves.toBeNull();
});
test("removes the exact captured document keydown listener when closed", async () => {
const addEventListener = vi.spyOn(document, "addEventListener");
const removeEventListener = vi.spyOn(document, "removeEventListener");
try {
const result = promptForFavoriteFolderName(document, {
title: "新建收藏夹"
});
const keydownRegistration = addEventListener.mock.calls.find(
(call) => call[0] === "keydown" && call[2] === true
);
if (!keydownRegistration) {
throw new Error("Expected a captured keydown listener");
}
const keydownHandler = keydownRegistration[1];
readButton("cancel").click();
await expect(result).resolves.toBeNull();
expect(removeEventListener).toHaveBeenCalledWith(
"keydown",
keydownHandler,
true
);
} finally {
addEventListener.mockRestore();
removeEventListener.mockRestore();
}
});
test("traps Tab within the dialog and restores the opener focus on close", async () => {
const opener = document.createElement("button");
opener.type = "button";
opener.textContent = "打开收藏夹";
document.body.append(opener);
opener.focus();
const result = promptForFavoriteFolderName(document, {
title: "新建收藏夹"
});
readInput().focus();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true }));
expect(document.activeElement).toBe(readButton("confirm"));
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
expect(document.activeElement).toBe(readInput());
readButton("cancel").click();
await expect(result).resolves.toBeNull();
expect(document.activeElement).toBe(opener);
});
});
+491
View File
@@ -0,0 +1,491 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
disposeFavoriteRowPickers,
syncFavoriteRowPickers
} from "../src/content/market/favorite-row-picker";
import type { FavoriteCreatorInput, FavoriteFolder } from "../src/content/market/favorites-store";
const firstFolder = createFolder("folder-1", "合作");
const secondFolder = createFolder("folder-2", "待联系");
function createFolder(id: string, name: string): FavoriteFolder {
return {
createdAt: "2026-07-17T00:00:00.000Z",
id,
name,
updatedAt: "2026-07-17T00:00:00.000Z"
};
}
function createCreator(authorId = "author-1"): FavoriteCreatorInput {
return {
authorId,
authorName: "Alice",
coreUserId: "core-1"
};
}
function createActionCell(label = "下单"): HTMLElement {
const cell = document.createElement("div");
cell.dataset.testid = "native-action-cell";
const nativeButton = document.createElement("button");
nativeButton.dataset.nativeAction = "order";
nativeButton.textContent = label;
cell.appendChild(nativeButton);
document.body.appendChild(cell);
return cell;
}
function readFavoriteButton(actionCell: HTMLElement): HTMLButtonElement {
const button = actionCell.querySelector(
':scope > button[data-sces-favorite-row-action="button"]'
);
if (!(button instanceof HTMLButtonElement)) {
throw new Error("Expected favorite action button");
}
return button;
}
function readPopover(): HTMLElement | null {
return document.querySelector('[data-sces-favorite-row-picker="root"]');
}
function createDeferred(): {
promise: Promise<void>;
resolve: () => void;
} {
let resolve!: () => void;
const promise = new Promise<void>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function createRect(options: {
bottom?: number;
height?: number;
left?: number;
top?: number;
width?: number;
}): DOMRect {
const bottom = options.bottom ?? (options.top ?? 0) + (options.height ?? 0);
const height = options.height ?? bottom - (options.top ?? 0);
const left = options.left ?? 0;
const top = options.top ?? bottom - height;
const width = options.width ?? 0;
return {
bottom,
height,
left,
right: left + width,
toJSON() {
return {};
},
top,
width,
x: left,
y: top
} as DOMRect;
}
function sync(options: {
actionCell?: HTMLElement;
authorId?: string;
folders?: FavoriteFolder[];
folderIds?: string[];
onCreateFolder?: () => Promise<FavoriteFolder | null>;
onSetCreatorFolderIds?: (
creator: FavoriteCreatorInput,
folderIds: string[]
) => Promise<void>;
rows?: Array<{
actionCell?: HTMLElement;
authorId: string;
authorName: string;
coreUserId?: string;
}>;
} = {}): void {
const creator = createCreator(options.authorId);
syncFavoriteRowPickers({
document,
folderIdsByAuthorId: new Map([
[creator.authorId, new Set(options.folderIds ?? [])]
]),
folders: options.folders ?? [firstFolder, secondFolder],
onCreateFolder: options.onCreateFolder ?? (async () => null),
onSetCreatorFolderIds: options.onSetCreatorFolderIds ?? (async () => {}),
rows:
options.rows ??
(options.actionCell
? [{ ...creator, actionCell: options.actionCell }]
: [])
});
}
async function flushAsyncEvents(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("favorite-row-picker", () => {
beforeEach(() => {
disposeFavoriteRowPickers(document);
document.body.innerHTML = "";
});
test("prepends one picker control without replacing the native action", () => {
const actionCell = createActionCell();
sync({ actionCell });
const button = readFavoriteButton(actionCell);
expect(actionCell.firstElementChild).toBe(button);
expect(actionCell.querySelector('[data-native-action="order"]')?.textContent).toBe(
"下单"
);
expect(button.type).toBe("button");
expect(button.title).toBe("加入收藏夹");
expect(button.getAttribute("aria-label")).toBe("加入收藏夹");
expect(button.dataset.scesFavoriteCount).toBe("0");
expect(button.dataset.scesFavoriteIcon).toBe("bookmark-outline");
});
test("renders saved count and title from existing memberships", () => {
const actionCell = createActionCell();
sync({ actionCell, folderIds: [firstFolder.id, secondFolder.id] });
const button = readFavoriteButton(actionCell);
expect(button.title).toBe("已收藏至 2 个收藏夹");
expect(button.getAttribute("aria-label")).toBe("已收藏至 2 个收藏夹");
expect(button.dataset.scesFavoriteCount).toBe("2");
expect(button.dataset.scesFavoriteIcon).toBe("bookmark-filled");
});
test("disables a row with no author id and does not open a picker", () => {
const actionCell = createActionCell();
sync({ actionCell, authorId: " " });
const button = readFavoriteButton(actionCell);
expect(button.disabled).toBe(true);
expect(button.title).toContain("达人 ID");
button.click();
expect(readPopover()).toBeNull();
});
test("keeps one document popover when different row buttons are clicked", () => {
const firstCell = createActionCell("下单 A");
const secondCell = createActionCell("下单 B");
const firstCreator = createCreator("author-1");
const secondCreator = createCreator("author-2");
sync({
rows: [
{ ...firstCreator, actionCell: firstCell },
{ ...secondCreator, actionCell: secondCell }
]
});
expect(readFavoriteButton(firstCell).getAttribute("aria-label")).toBe(
"加入收藏夹"
);
expect(readFavoriteButton(secondCell).getAttribute("aria-label")).toBe(
"加入收藏夹"
);
readFavoriteButton(firstCell).click();
expect(readPopover()).not.toBeNull();
readFavoriteButton(secondCell).click();
expect(document.querySelectorAll('[data-sces-favorite-row-picker="root"]')).toHaveLength(1);
expect(readPopover()?.textContent).toContain("合作");
});
test("shows existing folder membership in the picker", () => {
const actionCell = createActionCell();
sync({ actionCell, folderIds: [firstFolder.id] });
readFavoriteButton(actionCell).click();
expect(
(
document.querySelector(
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
) as HTMLInputElement
).checked
).toBe(true);
expect(
(
document.querySelector(
`input[data-sces-favorite-folder-id="${secondFolder.id}"]`
) as HTMLInputElement
).checked
).toBe(false);
});
test("saves the complete checked folder selection for the matching creator", async () => {
const actionCell = createActionCell();
const onSetCreatorFolderIds = vi.fn(async () => {});
sync({
actionCell,
folderIds: [firstFolder.id],
onSetCreatorFolderIds
});
readFavoriteButton(actionCell).click();
const folderCheckbox = document.querySelector(
`input[data-sces-favorite-folder-id="${secondFolder.id}"]`
) as HTMLInputElement;
folderCheckbox.checked = true;
folderCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
await flushAsyncEvents();
expect(onSetCreatorFolderIds).toHaveBeenCalledWith(createCreator(), [
firstFolder.id,
secondFolder.id
]);
expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("2");
});
test("retains a confirmed save through a resync while it is pending", async () => {
const actionCell = createActionCell();
const deferredSave = createDeferred();
const onSetCreatorFolderIds = vi.fn(() => deferredSave.promise);
sync({ actionCell, onSetCreatorFolderIds });
readFavoriteButton(actionCell).click();
const firstCheckbox = document.querySelector(
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
) as HTMLInputElement;
firstCheckbox.checked = true;
firstCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
sync({ actionCell, onSetCreatorFolderIds });
expect(readFavoriteButton(actionCell).disabled).toBe(true);
expect(
(
document.querySelector(
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
) as HTMLInputElement
).disabled
).toBe(true);
deferredSave.resolve();
await flushAsyncEvents();
expect(readFavoriteButton(actionCell).disabled).toBe(false);
expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("1");
expect(
(
document.querySelector(
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
) as HTMLInputElement
).checked
).toBe(true);
expect(
(
document.querySelector(
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
) as HTMLInputElement
).disabled
).toBe(false);
});
test("adds a newly created folder to the creator selection", async () => {
const actionCell = createActionCell();
const createdFolder = createFolder("folder-3", "重点");
const onCreateFolder = vi.fn(async () => createdFolder);
const onSetCreatorFolderIds = vi.fn(async () => {});
sync({ actionCell, onCreateFolder, onSetCreatorFolderIds });
readFavoriteButton(actionCell).click();
const createButton = document.querySelector(
'[data-sces-favorite-row-picker="create-folder"]'
) as HTMLButtonElement;
createButton.click();
await flushAsyncEvents();
expect(onCreateFolder).toHaveBeenCalledTimes(1);
expect(onSetCreatorFolderIds).toHaveBeenCalledWith(createCreator(), [createdFolder.id]);
expect(
(
document.querySelector(
`input[data-sces-favorite-folder-id="${createdFolder.id}"]`
) as HTMLInputElement
).checked
).toBe(true);
expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("1");
});
test("keeps the visible selection when saving a checkbox change fails", async () => {
const actionCell = createActionCell();
const onSetCreatorFolderIds = vi.fn(async () => {
throw new Error("save failed");
});
sync({ actionCell, folderIds: [firstFolder.id], onSetCreatorFolderIds });
readFavoriteButton(actionCell).click();
const firstCheckbox = document.querySelector(
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
) as HTMLInputElement;
firstCheckbox.checked = false;
firstCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
await flushAsyncEvents();
expect(onSetCreatorFolderIds).toHaveBeenCalledWith(createCreator(), []);
expect(
(
document.querySelector(
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
) as HTMLInputElement
).checked
).toBe(true);
expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("1");
});
test("closes the popover on outside pointer-down and Escape", () => {
const actionCell = createActionCell();
sync({ actionCell });
readFavoriteButton(actionCell).click();
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
expect(readPopover()).toBeNull();
readFavoriteButton(actionCell).click();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(readPopover()).toBeNull();
});
test("disposes an open picker and clears its detached action handler", () => {
const actionCell = createActionCell();
const onSetCreatorFolderIds = vi.fn(async () => {});
sync({ actionCell, onSetCreatorFolderIds });
const button = readFavoriteButton(actionCell);
button.click();
expect(readPopover()).not.toBeNull();
disposeFavoriteRowPickers(document);
disposeFavoriteRowPickers(document);
expect(readPopover()).toBeNull();
expect(button.onclick).toBeNull();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
button.click();
expect(onSetCreatorFolderIds).not.toHaveBeenCalled();
});
test("clamps a bottom-right picker within the viewport", () => {
const actionCell = createActionCell();
const originalWidth = Object.getOwnPropertyDescriptor(
document.documentElement,
"clientWidth"
);
const originalHeight = Object.getOwnPropertyDescriptor(
document.documentElement,
"clientHeight"
);
Object.defineProperty(document.documentElement, "clientWidth", {
configurable: true,
value: 300
});
Object.defineProperty(document.documentElement, "clientHeight", {
configurable: true,
value: 200
});
const rectSpy = vi
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
.mockImplementation(function mockPickerRect(this: HTMLElement): DOMRect {
if (this.dataset.scesFavoriteRowAction === "button") {
return createRect({ bottom: 190, height: 20, left: 280, top: 170, width: 20 });
}
if (this.dataset.scesFavoriteRowPicker === "root") {
return createRect({ height: 60, width: 120 });
}
return createRect({});
});
try {
sync({ actionCell });
readFavoriteButton(actionCell).click();
const root = readPopover() as HTMLElement;
expect(Number.parseFloat(root.style.left)).toBeGreaterThanOrEqual(8);
expect(Number.parseFloat(root.style.left)).toBeLessThanOrEqual(172);
expect(Number.parseFloat(root.style.top)).toBeGreaterThanOrEqual(8);
expect(Number.parseFloat(root.style.top)).toBeLessThanOrEqual(132);
expect(root.style.top).toBe("102px");
} finally {
rectSpy.mockRestore();
if (originalWidth) {
Object.defineProperty(document.documentElement, "clientWidth", originalWidth);
} else {
delete (document.documentElement as { clientWidth?: number }).clientWidth;
}
if (originalHeight) {
Object.defineProperty(document.documentElement, "clientHeight", originalHeight);
} else {
delete (document.documentElement as { clientHeight?: number }).clientHeight;
}
}
});
test("sets trigger dialog state, focuses the picker, and restores focus on Escape", () => {
const actionCell = createActionCell();
sync({ actionCell });
const button = readFavoriteButton(actionCell);
expect(button.getAttribute("aria-haspopup")).toBe("dialog");
expect(button.getAttribute("aria-expanded")).toBe("false");
const pickerId = button.getAttribute("aria-controls");
expect(pickerId).not.toBeNull();
button.focus();
button.click();
const root = readPopover() as HTMLElement;
expect(root.id).toBe(pickerId);
expect(root.getAttribute("role")).toBe("dialog");
expect(root.getAttribute("aria-label")).toBe("选择收藏夹");
expect(button.getAttribute("aria-expanded")).toBe("true");
expect(document.activeElement).toBe(
document.querySelector(
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
)
);
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(readPopover()).toBeNull();
expect(button.getAttribute("aria-expanded")).toBe("false");
expect(document.activeElement).toBe(button);
});
test("resyncs one control with fresh callbacks and closes an obsolete popover", async () => {
const actionCell = createActionCell();
const staleCallback = vi.fn(async () => {});
const currentCallback = vi.fn(async () => {});
sync({ actionCell, onSetCreatorFolderIds: staleCallback });
readFavoriteButton(actionCell).click();
expect(readPopover()).not.toBeNull();
sync({ actionCell, onSetCreatorFolderIds: currentCallback });
expect(
actionCell.querySelectorAll('[data-sces-favorite-row-action="button"]')
).toHaveLength(1);
const folderCheckbox = document.querySelector(
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
) as HTMLInputElement;
folderCheckbox.checked = true;
folderCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
await flushAsyncEvents();
expect(staleCallback).not.toHaveBeenCalled();
expect(currentCallback).toHaveBeenCalledWith(createCreator(), [firstFolder.id]);
sync({ rows: [] });
expect(readPopover()).toBeNull();
});
});
+642
View File
@@ -0,0 +1,642 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
createFavoritesDrawer,
type FavoritesDrawerHandlers
} from "../src/content/market/favorites-drawer";
import { promptForFavoriteFolderName } from "../src/content/market/favorite-folder-dialog";
import type {
FavoriteCreator,
FavoriteFolder,
FavoriteMembership,
FavoritesStateV1
} from "../src/content/market/favorites-store";
const firstFolder = createFolder("folder-1", "合作");
const secondFolder = createFolder("folder-2", "待联系");
function createFolder(id: string, name: string): FavoriteFolder {
return {
createdAt: "2026-07-17T00:00:00.000Z",
id,
name,
updatedAt: "2026-07-17T00:00:00.000Z"
};
}
function createCreator(authorId: string, authorName: string): FavoriteCreator {
return {
authorId,
authorName,
savedAt: "2026-07-17T00:00:00.000Z"
};
}
function createMembership(authorId: string, folderId: string): FavoriteMembership {
return {
addedAt: "2026-07-17T00:00:00.000Z",
authorId,
folderId
};
}
function createState(options: {
creators?: FavoriteCreator[];
folders?: FavoriteFolder[];
memberships?: FavoriteMembership[];
} = {}): FavoritesStateV1 {
return {
creators:
options.creators ?? [createCreator("author-1", "Alpha"), createCreator("author-2", "Beta")],
folders: options.folders ?? [firstFolder, secondFolder],
memberships:
options.memberships ?? [
createMembership("author-1", firstFolder.id),
createMembership("author-2", secondFolder.id)
],
version: 1
};
}
function createHarness(options: {
confirmImport?: (message: string) => boolean;
handlers?: Partial<FavoritesDrawerHandlers>;
} = {}) {
const handlers: FavoritesDrawerHandlers = {
onCreateFolder: async () => {},
onDeleteFolder: async () => {},
onImportFolder: async () => {},
onImportSelected: async () => {},
onRemoveCreatorFromFolder: async () => {},
onRenameFolder: async () => {},
...options.handlers
};
const confirmImport = options.confirmImport ?? vi.fn(() => true);
const controller = createFavoritesDrawer(document, handlers, confirmImport);
return { confirmImport, controller, handlers };
}
function readTab(): HTMLButtonElement {
const tab = document.querySelector('[data-sces-favorites-tab="button"]');
if (!(tab instanceof HTMLButtonElement)) {
throw new Error("Expected favorites tab");
}
return tab;
}
function readDrawer(): HTMLElement {
const drawer = document.querySelector('[data-sces-favorites-drawer="root"]');
if (!(drawer instanceof HTMLElement)) {
throw new Error("Expected favorites drawer");
}
return drawer;
}
function click(selector: string): void {
const target = document.querySelector(selector);
if (!(target instanceof HTMLElement)) {
throw new Error(`Expected ${selector}`);
}
target.click();
}
async function flush(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
describe("favorites-drawer", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
test("mounts one fixed, closed trigger and drawer left of the native service rail", () => {
createHarness();
expect(document.querySelectorAll('[data-sces-favorites-tab="button"]')).toHaveLength(1);
expect(document.querySelectorAll('[data-sces-favorites-drawer="root"]')).toHaveLength(1);
expect(readTab().textContent).toContain("收藏夹");
expect(readTab().style.position).toBe("fixed");
expect(readTab().style.right).toBe("56px");
expect(readTab().style.writingMode).toBe("vertical-rl");
expect(readTab().getAttribute("aria-controls")).toBe(readDrawer().id);
expect(readTab().getAttribute("aria-expanded")).toBe("false");
expect(readTab().querySelector("[data-sces-favorites-unread]")).toBeNull();
expect(readTab().querySelector("[data-sces-favorites-badge]")).toBeNull();
expect(readTab().dataset.scesFavoritesUnread).toBeUndefined();
expect(readTab().dataset.scesFavoritesCount).toBeUndefined();
expect(readTab().textContent).not.toMatch(/\d/);
expect(readTab().style.color).not.toMatch(/b42318|dc2626|ef4444|red/i);
expect(readDrawer().hidden).toBe(true);
expect(readDrawer().style.display).toBe("none");
expect(readDrawer().style.position).toBe("fixed");
expect(readDrawer().style.right).toBe("56px");
expect(readDrawer().style.width).toBe("380px");
expect(readDrawer().getAttribute("role")).toBe("dialog");
expect(readDrawer().getAttribute("aria-label")).toBe("收藏夹");
});
test("opens, closes, and restores focus through the trigger, close control, and Escape", () => {
createHarness();
readTab().click();
expect(readDrawer().hidden).toBe(false);
expect(readDrawer().style.display).toBe("flex");
expect(readTab().getAttribute("aria-expanded")).toBe("true");
expect(document.activeElement).toBe(
document.querySelector('[data-sces-favorites-drawer="close"]')
);
click('[data-sces-favorites-drawer="close"]');
expect(readDrawer().hidden).toBe(true);
expect(readDrawer().style.display).toBe("none");
expect(document.activeElement).toBe(readTab());
readTab().click();
expect(readDrawer().style.display).toBe("flex");
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(readDrawer().hidden).toBe(true);
expect(readDrawer().style.display).toBe("none");
expect(document.activeElement).toBe(readTab());
});
test("uses an accessible icon close control and a wrapping footer for folder imports", () => {
const { controller } = createHarness();
controller.render(createState());
readTab().click();
click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`);
const close = document.querySelector(
'[data-sces-favorites-drawer="close"]'
) as HTMLButtonElement;
const footer = readDrawer().querySelector("footer") as HTMLElement;
const importFolder = document.querySelector(
'[data-sces-favorites-drawer="import-folder"]'
) as HTMLButtonElement;
const importSelected = document.querySelector(
'[data-sces-favorites-drawer="import-selected"]'
) as HTMLButtonElement;
const actionRow = importFolder.parentElement as HTMLElement;
expect(close.textContent).toBe("x");
expect(close.getAttribute("aria-label")).toBe("关闭收藏夹");
expect(close.title).toBe("关闭收藏夹");
expect(footer.style.boxSizing).toBe("border-box");
expect(footer.style.minWidth).toBe("0px");
expect(footer.style.flexWrap).toBe("wrap");
expect(actionRow.style.boxSizing).toBe("border-box");
expect(actionRow.style.minWidth).toBe("0px");
expect(actionRow.style.flexWrap).toBe("wrap");
expect(actionRow.style.width).toBe("100%");
expect(importFolder.style.boxSizing).toBe("border-box");
expect(importFolder.style.minWidth).toBe("0px");
expect(importFolder.style.flex).toBe("1 1 100%");
expect(importSelected.style.boxSizing).toBe("border-box");
expect(importSelected.style.minWidth).toBe("0px");
expect(importSelected.style.flex).toBe("1 1 100%");
});
test("deduplicates creators in all view and shows their folder affiliations", () => {
const { controller } = createHarness();
controller.render(
createState({
creators: [
createCreator("author-1", "Alpha"),
createCreator("author-1", "Alpha newer"),
createCreator("author-2", "Beta")
],
memberships: [
createMembership("author-1", firstFolder.id),
createMembership("author-1", secondFolder.id),
createMembership("author-2", secondFolder.id)
]
})
);
readTab().click();
expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(2);
expect(readDrawer().textContent).toContain("Alpha");
expect(readDrawer().textContent).toContain("合作、待联系");
expect(
document.querySelector('[data-sces-favorites-remove-author-id="author-1"]')
).toBeNull();
});
test("switches to a folder view and falls back to all when that folder is deleted", () => {
const { controller } = createHarness();
controller.render(createState());
readTab().click();
click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`);
expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1);
expect(readDrawer().textContent).toContain("Alpha");
expect(document.querySelector('[data-sces-favorites-remove-author-id="author-1"]')).not.toBeNull();
expect(
document.querySelector('[data-sces-favorites-drawer="import-folder"]')
).not.toBeNull();
controller.render(
createState({
creators: [createCreator("author-2", "Beta")],
folders: [secondFolder],
memberships: [createMembership("author-2", secondFolder.id)]
})
);
expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1);
expect(readDrawer().textContent).toContain("Beta");
expect(
document.querySelector('[data-sces-favorites-drawer="import-folder"]')
).toBeNull();
});
test.each([
{ folderId: "all", name: "全部的具体收藏夹" },
{ folderId: "", name: "空 ID 收藏夹" }
])("treats folder id $folderId as a concrete folder", ({ folderId, name }) => {
const folder = createFolder(folderId, name);
const { controller } = createHarness();
controller.render(
createState({
creators: [createCreator("111", "Alpha"), createCreator("222", "Beta")],
folders: [folder],
memberships: [createMembership("111", folderId)]
})
);
readTab().click();
click(`[data-sces-favorites-folder-id="${folderId}"]`);
expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1);
expect(readDrawer().textContent).toContain("Alpha");
expect(readDrawer().textContent).not.toContain("Beta");
expect(
document.querySelector('[data-sces-favorites-drawer="import-folder"]')
).not.toBeNull();
});
test("filters the current view by a trimmed, case-insensitive name query and shows an empty state", () => {
const { controller } = createHarness();
controller.render(createState());
readTab().click();
const search = document.querySelector(
'[data-sces-favorites-drawer="search"]'
) as HTMLInputElement;
search.value = " beta ";
search.dispatchEvent(new Event("input", { bubbles: true }));
const refocusedSearch = document.querySelector(
'[data-sces-favorites-drawer="search"]'
) as HTMLInputElement;
expect(document.activeElement).toBe(refocusedSearch);
expect(refocusedSearch.selectionStart).toBe(refocusedSearch.value.length);
expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1);
expect(readDrawer().textContent).toContain("Beta");
const nextSearch = document.querySelector(
'[data-sces-favorites-drawer="search"]'
) as HTMLInputElement;
nextSearch.value = "没有";
nextSearch.dispatchEvent(new Event("input", { bubbles: true }));
expect(readDrawer().textContent).toContain("暂无收藏达人");
});
test("imports only selected author ids and removes an author from the active folder", async () => {
const onImportSelected = vi.fn(async (_authorIds: string[]) => {});
const onRemoveCreatorFromFolder = vi.fn(
async (_authorId: string, _folderId: string) => {}
);
const { controller } = createHarness({
handlers: { onImportSelected, onRemoveCreatorFromFolder }
});
controller.render(createState());
readTab().click();
const alpha = document.querySelector(
'[data-sces-favorites-select-author-id="author-1"]'
) as HTMLInputElement;
const beta = document.querySelector(
'[data-sces-favorites-select-author-id="author-2"]'
) as HTMLInputElement;
alpha.checked = true;
alpha.dispatchEvent(new Event("change", { bubbles: true }));
const nextBeta = document.querySelector(
'[data-sces-favorites-select-author-id="author-2"]'
) as HTMLInputElement;
nextBeta.checked = true;
nextBeta.dispatchEvent(new Event("change", { bubbles: true }));
expect(readDrawer().textContent).toContain("导入已选 2 位达人");
click('[data-sces-favorites-drawer="import-selected"]');
await flush();
expect(onImportSelected).toHaveBeenCalledWith(["author-1", "author-2"]);
click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`);
click('[data-sces-favorites-remove-author-id="author-1"]');
await flush();
expect(onRemoveCreatorFromFolder).toHaveBeenCalledWith("author-1", firstFolder.id);
});
test("prunes selected creators that are outside the active folder before importing", async () => {
const onImportSelected = vi.fn(async (_authorIds: string[]) => {});
const { controller } = createHarness({ handlers: { onImportSelected } });
controller.render(
createState({
creators: [createCreator("111", "Alpha"), createCreator("222", "Beta")],
memberships: [
createMembership("111", firstFolder.id),
createMembership("222", secondFolder.id)
]
})
);
readTab().click();
const firstSelection = document.querySelector(
'[data-sces-favorites-select-author-id="111"]'
) as HTMLInputElement;
firstSelection.checked = true;
firstSelection.dispatchEvent(new Event("change", { bubbles: true }));
const secondSelection = document.querySelector(
'[data-sces-favorites-select-author-id="222"]'
) as HTMLInputElement;
secondSelection.checked = true;
secondSelection.dispatchEvent(new Event("change", { bubbles: true }));
expect(readDrawer().textContent).toContain("导入已选 2 位达人");
click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`);
expect(readDrawer().textContent).toContain("导入已选 1 位达人");
click('[data-sces-favorites-drawer="import-selected"]');
await flush();
expect(onImportSelected).toHaveBeenCalledWith(["111"]);
});
test("locks duplicate imports and overlapping folder mutations until the active handler settles", async () => {
const selectedImport = createDeferred();
const deleteFolder = createDeferred();
const onImportSelected = vi.fn(() => selectedImport.promise);
const onDeleteFolder = vi.fn(() => deleteFolder.promise);
const onRenameFolder = vi.fn(async (_folderId: string) => {});
const { controller } = createHarness({
handlers: { onDeleteFolder, onImportSelected, onRenameFolder }
});
controller.render(createState());
readTab().click();
const checkbox = document.querySelector(
'[data-sces-favorites-select-author-id="author-1"]'
) as HTMLInputElement;
checkbox.checked = true;
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
click('[data-sces-favorites-drawer="import-selected"]');
const pendingImport = document.querySelector(
'[data-sces-favorites-drawer="import-selected"]'
) as HTMLButtonElement;
pendingImport.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(onImportSelected).toHaveBeenCalledTimes(1);
expect(pendingImport.disabled).toBe(true);
selectedImport.resolve();
await flush();
click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`);
click(`[data-sces-favorites-folder-delete="${firstFolder.id}"]`);
const pendingRename = document.querySelector(
`[data-sces-favorites-folder-rename="${firstFolder.id}"]`
) as HTMLButtonElement;
pendingRename.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(onDeleteFolder).toHaveBeenCalledWith(firstFolder.id);
expect(onRenameFolder).not.toHaveBeenCalled();
expect(pendingRename.disabled).toBe(true);
deleteFolder.resolve();
await flush();
});
test("preserves focus for a folder switch and a creator checkbox render", () => {
const { controller } = createHarness();
controller.render(createState());
readTab().click();
const folder = document.querySelector(
`[data-sces-favorites-folder-id="${firstFolder.id}"]`
) as HTMLButtonElement;
folder.focus();
folder.click();
const activeFolder = document.querySelector(
`[data-sces-favorites-folder-id="${firstFolder.id}"]`
) as HTMLButtonElement;
expect(document.activeElement).toBe(activeFolder);
const checkbox = document.querySelector(
'[data-sces-favorites-select-author-id="author-1"]'
) as HTMLInputElement;
checkbox.focus();
checkbox.checked = true;
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
const activeCheckbox = document.querySelector(
'[data-sces-favorites-select-author-id="author-1"]'
) as HTMLInputElement;
expect(document.activeElement).toBe(activeCheckbox);
});
test("leaves the drawer open when an active folder-name dialog handles Escape", async () => {
let dialogResult: Promise<string | null> | undefined;
const onCreateFolder = vi.fn(async () => {
dialogResult = promptForFavoriteFolderName(document, {
title: "新建收藏夹"
});
await dialogResult;
});
const { controller } = createHarness({ handlers: { onCreateFolder } });
controller.render(createState());
readTab().click();
const search = document.querySelector(
'[data-sces-favorites-drawer="search"]'
) as HTMLInputElement;
search.value = "Alpha";
search.dispatchEvent(new Event("input", { bubbles: true }));
click('[data-sces-favorites-drawer="create-folder"]');
await flush();
expect(
document.querySelector('[data-sces-favorite-folder-dialog="root"]')
).not.toBeNull();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
await expect(dialogResult).resolves.toBeNull();
await flush();
const restoredSearch = document.querySelector(
'[data-sces-favorites-drawer="search"]'
) as HTMLInputElement;
expect(readDrawer().hidden).toBe(false);
expect(readTab().getAttribute("aria-expanded")).toBe("true");
expect(restoredSearch.value).toBe("Alpha");
expect(document.activeElement).toBe(restoredSearch);
});
test("keeps a remounted drawer open after an earlier dialog handles Escape", async () => {
const first = createHarness();
first.controller.render(createState());
readTab().click();
const dialogResult = promptForFavoriteFolderName(document, {
title: "新建收藏夹"
});
first.controller.dispose();
const remounted = createHarness();
remounted.controller.render(createState());
readTab().click();
document.dispatchEvent(
new KeyboardEvent("keydown", { cancelable: true, key: "Escape" })
);
await expect(dialogResult).resolves.toBeNull();
expect(readDrawer().hidden).toBe(false);
expect(readTab().getAttribute("aria-expanded")).toBe("true");
});
test("calls create, rename, and delete handlers with the matching folder id", async () => {
const onCreateFolder = vi.fn(async () => {});
const onRenameFolder = vi.fn(async (_folderId: string) => {});
const onDeleteFolder = vi.fn(async (_folderId: string) => {});
const { controller } = createHarness({
handlers: { onCreateFolder, onDeleteFolder, onRenameFolder }
});
controller.render(createState());
readTab().click();
click('[data-sces-favorites-drawer="create-folder"]');
await flush();
click(`[data-sces-favorites-folder-rename="${secondFolder.id}"]`);
await flush();
click(`[data-sces-favorites-folder-delete="${secondFolder.id}"]`);
await flush();
expect(onCreateFolder).toHaveBeenCalledTimes(1);
expect(onRenameFolder).toHaveBeenCalledWith(secondFolder.id);
expect(onDeleteFolder).toHaveBeenCalledWith(secondFolder.id);
});
test("confirms a deduplicated full-folder import once before importing", async () => {
const confirmImport = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true);
const onImportFolder = vi.fn(async (_folderId: string, _authorIds: string[]) => {});
const { controller } = createHarness({
confirmImport,
handlers: { onImportFolder }
});
controller.render(
createState({
memberships: [
createMembership("author-1", firstFolder.id),
createMembership("author-1", firstFolder.id),
createMembership("author-2", firstFolder.id)
]
})
);
readTab().click();
expect(
document.querySelector('[data-sces-favorites-drawer="import-folder"]')
).toBeNull();
click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`);
click('[data-sces-favorites-drawer="import-folder"]');
await flush();
expect(confirmImport).toHaveBeenLastCalledWith("将当前收藏夹的 2 位达人导入秒探吗?");
expect(onImportFolder).not.toHaveBeenCalled();
click('[data-sces-favorites-drawer="import-folder"]');
await flush();
expect(confirmImport).toHaveBeenCalledTimes(2);
expect(onImportFolder).toHaveBeenCalledWith(firstFolder.id, ["author-1", "author-2"]);
});
test("disables mutation and import controls while busy but keeps close usable", async () => {
const onCreateFolder = vi.fn(async () => {});
const { controller } = createHarness({ handlers: { onCreateFolder } });
controller.render(createState());
readTab().click();
controller.setBusy(true);
expect(
(document.querySelector(
'[data-sces-favorites-drawer="create-folder"]'
) as HTMLButtonElement).disabled
).toBe(true);
expect(
(document.querySelector(
`[data-sces-favorites-folder-rename="${firstFolder.id}"]`
) as HTMLButtonElement).disabled
).toBe(true);
expect(
(document.querySelector(
'[data-sces-favorites-drawer="import-selected"]'
) as HTMLButtonElement).disabled
).toBe(true);
expect(
(document.querySelector(
'[data-sces-favorites-drawer="close"]'
) as HTMLButtonElement).disabled
).toBe(false);
click('[data-sces-favorites-drawer="create-folder"]');
await flush();
expect(onCreateFolder).not.toHaveBeenCalled();
click('[data-sces-favorites-drawer="close"]');
expect(readDrawer().hidden).toBe(true);
});
test("renders errors and removes DOM and event behavior on disposal", async () => {
const onCreateFolder = vi.fn(async () => {});
const { controller } = createHarness({ handlers: { onCreateFolder } });
controller.render(createState());
readTab().click();
controller.setError("保存失败");
expect(
document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent
).toBe("保存失败");
const staleCreateButton = document.querySelector(
'[data-sces-favorites-drawer="create-folder"]'
) as HTMLButtonElement;
const removeEventListener = vi.spyOn(document, "removeEventListener");
controller.dispose();
controller.dispose();
expect(document.querySelector('[data-sces-favorites-tab="button"]')).toBeNull();
expect(document.querySelector('[data-sces-favorites-drawer="root"]')).toBeNull();
expect(removeEventListener).toHaveBeenCalledWith(
"keydown",
expect.any(Function),
true
);
const unrelatedButton = document.createElement("button");
unrelatedButton.type = "button";
document.body.append(unrelatedButton);
unrelatedButton.focus();
const escape = new KeyboardEvent("keydown", { cancelable: true, key: "Escape" });
document.dispatchEvent(escape);
staleCreateButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(escape.defaultPrevented).toBe(false);
expect(document.activeElement).toBe(unrelatedButton);
expect(onCreateFolder).not.toHaveBeenCalled();
removeEventListener.mockRestore();
});
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, test } from "vitest";
import { toFavoriteMarketRecords } from "../src/content/market/favorites-import";
describe("favorites-import", () => {
test("returns no records for an empty favorites list", () => {
expect(toFavoriteMarketRecords([])).toEqual([]);
});
test("preserves a creator core user id for later batch payload mapping", () => {
expect(
toFavoriteMarketRecords([
{
authorId: "author-1",
authorName: "达人A",
coreUserId: "core-1",
savedAt: "2026-07-17T00:00:00.000Z"
}
])
).toEqual([
{
authorId: "author-1",
authorName: "达人A",
coreUserId: "core-1",
status: "idle"
}
]);
});
test("keeps the first creator for each author id in first-occurrence order", () => {
expect(
toFavoriteMarketRecords([
{
authorId: "author-2",
authorName: "达人B",
savedAt: "2026-07-17T00:00:00.000Z"
},
{
authorId: "author-1",
authorName: "达人A",
savedAt: "2026-07-17T00:00:01.000Z"
},
{
authorId: "author-2",
authorName: "达人B更新",
coreUserId: "core-2",
savedAt: "2026-07-17T00:00:02.000Z"
}
])
).toEqual([
{ authorId: "author-2", authorName: "达人B", status: "idle" },
{ authorId: "author-1", authorName: "达人A", status: "idle" }
]);
});
test("excludes empty author ids while keeping valid authors in first-occurrence order", () => {
expect(
toFavoriteMarketRecords([
{
authorId: "",
authorName: "无效达人",
savedAt: "2026-07-17T00:00:00.000Z"
},
{
authorId: "author-2",
authorName: "达人B",
savedAt: "2026-07-17T00:00:01.000Z"
},
{
authorId: "author-1",
authorName: "达人A",
savedAt: "2026-07-17T00:00:02.000Z"
},
{
authorId: "author-2",
authorName: "达人B更新",
savedAt: "2026-07-17T00:00:03.000Z"
}
])
).toEqual([
{ authorId: "author-2", authorName: "达人B", status: "idle" },
{ authorId: "author-1", authorName: "达人A", status: "idle" }
]);
});
test("returns only the minimal idle market record fields", () => {
const [record] = toFavoriteMarketRecords([
{
authorId: "author-1",
authorName: "达人A",
savedAt: "2026-07-17T00:00:00.000Z"
}
]);
expect(record).toEqual({
authorId: "author-1",
authorName: "达人A",
status: "idle"
});
expect(Object.keys(record ?? {})).toEqual([
"authorId",
"authorName",
"status"
]);
});
});
+357
View File
@@ -0,0 +1,357 @@
import { describe, expect, test } from "vitest";
import {
FAVORITES_STORAGE_KEY,
createChromeFavoritesStorage,
createFavoritesRepository,
type FavoritesStateV1,
type FavoritesStorage
} from "../src/content/market/favorites-store";
function createMemoryStorage(initial: unknown = undefined): FavoritesStorage {
let value = initial;
return {
async get() {
return value;
},
async set(next) {
value = next;
}
};
}
function createRepository(storage: FavoritesStorage = createMemoryStorage()) {
let id = 0;
return {
repository: createFavoritesRepository({
storage,
now: () => "2026-07-17T00:00:00.000Z",
createId: () => `folder-${++id}`
}),
storage
};
}
describe("favorites-store", () => {
test("returns an empty versioned state when storage is empty", async () => {
const { repository } = createRepository();
await expect(repository.read()).resolves.toEqual({
creators: [],
folders: [],
memberships: [],
version: 1
});
});
test("trims folder names and rejects blank folder names", async () => {
const { repository } = createRepository();
await expect(repository.createFolder(" 工作收藏 ")).resolves.toMatchObject({
id: "folder-1",
name: "工作收藏"
});
await expect(repository.createFolder(" \n ")).rejects.toThrow("请输入收藏夹名称");
await expect(repository.renameFolder("folder-1", "")).rejects.toThrow(
"请输入收藏夹名称"
);
});
test("rejects unknown folders, blank creators, and unknown creator folder ids", async () => {
const { repository } = createRepository();
await expect(repository.renameFolder("missing", "合作")).rejects.toThrow(
"收藏夹不存在"
);
await expect(repository.deleteFolder("missing")).rejects.toThrow("收藏夹不存在");
await expect(
repository.setCreatorFolderIds({ authorId: " ", authorName: "Alice" }, [])
).rejects.toThrow("无法收藏该达人");
await expect(
repository.setCreatorFolderIds(
{ authorId: "author-1", authorName: "Alice" },
["missing"]
)
).rejects.toThrow("收藏夹不存在");
});
test("keeps multi-folder memberships unique and preserves savedAt on repeat saves", async () => {
const { repository } = createRepository();
const firstFolder = await repository.createFolder("合作");
const secondFolder = await repository.createFolder("待联系");
await repository.setCreatorFolderIds(
{ authorId: "author-1", authorName: "Alice" },
[firstFolder.id, firstFolder.id, secondFolder.id]
);
const firstSave = await repository.read();
await repository.setCreatorFolderIds(
{
authorId: "author-1",
authorName: "Alice Updated",
coreUserId: "core-1"
},
[secondFolder.id, firstFolder.id]
);
await expect(repository.read()).resolves.toMatchObject({
creators: [
{
authorId: "author-1",
authorName: "Alice Updated",
coreUserId: "core-1",
savedAt: firstSave.creators[0]?.savedAt
}
],
memberships: [
{ authorId: "author-1", folderId: firstFolder.id },
{ authorId: "author-1", folderId: secondFolder.id }
]
});
});
test("deletes only one folder membership before pruning a final creator orphan", async () => {
const { repository } = createRepository();
const firstFolder = await repository.createFolder("合作");
const secondFolder = await repository.createFolder("待联系");
await repository.setCreatorFolderIds(
{ authorId: "author-1", authorName: "Alice" },
[firstFolder.id, secondFolder.id]
);
await repository.deleteFolder(firstFolder.id);
await expect(repository.read()).resolves.toMatchObject({
creators: [{ authorId: "author-1" }],
memberships: [{ authorId: "author-1", folderId: secondFolder.id }]
});
await repository.deleteFolder(secondFolder.id);
await expect(repository.read()).resolves.toEqual({
creators: [],
folders: [],
memberships: [],
version: 1
});
});
test("rejects removal from an unknown folder without changing state", async () => {
const { repository } = createRepository();
const folder = await repository.createFolder("合作");
await repository.setCreatorFolderIds(
{ authorId: "author-1", authorName: "Alice" },
[folder.id]
);
const beforeRemoval = await repository.read();
await expect(
repository.removeCreatorFromFolder("author-1", "missing")
).rejects.toThrow("收藏夹不存在");
await expect(repository.read()).resolves.toEqual(beforeRemoval);
});
test("recovers from malformed, duplicate, and dangling stored values", async () => {
const { repository } = createRepository(
createMemoryStorage({
version: 1,
folders: [
{
id: "folder-1",
name: " 重点 ",
createdAt: "created",
updatedAt: "updated"
},
{
id: "folder-1",
name: "重复",
createdAt: "created-2",
updatedAt: "updated-2"
},
{ id: "", name: "无效", createdAt: "created", updatedAt: "updated" }
],
creators: [
{ authorId: "author-1", authorName: "Alice", savedAt: "saved" },
{
authorId: "author-1",
authorName: "",
coreUserId: "core-1",
savedAt: "newer"
},
{ authorId: "orphan", authorName: "Orphan", savedAt: "saved" }
],
memberships: [
{ authorId: "author-1", folderId: "folder-1", addedAt: "added" },
{ authorId: "author-1", folderId: "folder-1", addedAt: "duplicate" },
{ authorId: "missing", folderId: "folder-1", addedAt: "dangling" },
{ authorId: "author-1", folderId: "missing", addedAt: "dangling" },
null
]
})
);
await expect(repository.read()).resolves.toEqual({
version: 1,
folders: [
{
id: "folder-1",
name: "重点",
createdAt: "created",
updatedAt: "updated"
}
],
creators: [
{
authorId: "author-1",
authorName: "Alice",
coreUserId: "core-1",
savedAt: "saved"
}
],
memberships: [
{ authorId: "author-1", folderId: "folder-1", addedAt: "added" }
]
});
});
test("treats a future stored version as an empty V1 state", async () => {
const { repository } = createRepository(
createMemoryStorage({
version: 2,
folders: [
{
id: "future-folder",
name: "未来收藏夹",
createdAt: "future",
updatedAt: "future"
}
],
creators: [
{
authorId: "future-author",
authorName: "Future",
savedAt: "future"
}
],
memberships: [
{
folderId: "future-folder",
authorId: "future-author",
addedAt: "future"
}
],
futureOnlyField: "do not retain"
})
);
await expect(repository.read()).resolves.toEqual({
creators: [],
folders: [],
memberships: [],
version: 1
});
});
test("serializes concurrent mutations so neither folder is overwritten", async () => {
const { repository } = createRepository();
await Promise.all([
repository.createFolder("第一个"),
repository.createFolder("第二个")
]);
await expect(repository.read()).resolves.toMatchObject({
folders: [
{ id: "folder-1", name: "第一个" },
{ id: "folder-2", name: "第二个" }
]
});
});
test("recovers the mutation queue after one rejected storage write", async () => {
let writeAttempts = 0;
let storedState: FavoritesStateV1 | undefined;
const storage: FavoritesStorage = {
async get() {
return storedState;
},
async set(value) {
writeAttempts += 1;
if (writeAttempts === 1) {
throw new Error("write failed");
}
storedState = value;
}
};
const { repository } = createRepository(storage);
await expect(repository.createFolder("合作")).rejects.toThrow("write failed");
await expect(repository.createFolder("待联系")).resolves.toMatchObject({
name: "待联系"
});
await expect(repository.read()).resolves.toEqual({
creators: [],
folders: [
{
id: "folder-2",
name: "待联系",
createdAt: "2026-07-17T00:00:00.000Z",
updatedAt: "2026-07-17T00:00:00.000Z"
}
],
memberships: [],
version: 1
});
});
test("uses the exact Chrome storage key and reports unavailable storage", async () => {
const globalWithChrome = globalThis as typeof globalThis & {
chrome?: unknown;
};
const originalChrome = globalWithChrome.chrome;
const storedState: FavoritesStateV1 = {
version: 1,
folders: [],
creators: [],
memberships: []
};
const getCalls: string[] = [];
const setCalls: Record<string, FavoritesStateV1>[] = [];
try {
globalWithChrome.chrome = {
storage: {
local: {
async get(key: string) {
getCalls.push(key);
return { [key]: storedState };
},
async set(items: Record<string, FavoritesStateV1>) {
setCalls.push(items);
}
}
}
};
const storage = createChromeFavoritesStorage();
await expect(storage.get()).resolves.toBe(storedState);
await storage.set(storedState);
expect(getCalls).toEqual([FAVORITES_STORAGE_KEY]);
expect(setCalls).toEqual([{ [FAVORITES_STORAGE_KEY]: storedState }]);
delete globalWithChrome.chrome;
await expect(createChromeFavoritesStorage().get()).rejects.toThrow(
"收藏夹存储不可用"
);
await expect(createChromeFavoritesStorage().set(storedState)).rejects.toThrow(
"收藏夹存储不可用"
);
} finally {
if (originalChrome === undefined) {
delete globalWithChrome.chrome;
} else {
globalWithChrome.chrome = originalChrome;
}
}
});
});
File diff suppressed because it is too large Load Diff
+8
View File
@@ -69,6 +69,7 @@ describe("market-dom-sync", () => {
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(18);
expect(table?.headerSelectionCheckbox).not.toBeNull();
expect(table?.rows[0]?.selectionCheckbox).not.toBeNull();
expect(table?.rows[0]?.actionCell).toBeUndefined();
});
test("renders loading, success, missing, and failed states", () => {
@@ -229,6 +230,11 @@ describe("market-dom-sync", () => {
)
).toBe(350);
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
expect(table.rows.map((row) => row.actionCell?.dataset.testid)).toEqual([
"action-cell-111",
"action-cell-222"
]);
expect(table.rows[0]?.actionCell?.textContent?.trim()).toBe("下单");
renderMarketRowState(table.rows[0], {
authorId: "111",
@@ -604,6 +610,7 @@ describe("market-dom-sync", () => {
{
authorId: "111",
authorName: "达人 A",
coreUserId: "core-111",
singleVideoAfterSearchRate: "0.02%"
},
{
@@ -619,6 +626,7 @@ describe("market-dom-sync", () => {
}
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
expect(table.rows.map((row) => row.coreUserId)).toEqual(["core-111", undefined]);
expect(table.rows[0].rates).toEqual({
singleVideoAfterSearchRate: "0.02%"
});
+50
View File
@@ -155,6 +155,56 @@ describe("silent-export-controller", () => {
expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]);
});
test("starts all-result exports from page 1 even after browsing to a later page", async () => {
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
page_param: {
page: 2
}
}),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
const requestedPages: number[] = [];
const controller = createSilentExportController({
document,
fetchImpl: async (_url, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as {
page_param?: { page?: number };
};
const pageNo = body.page_param?.page ?? 0;
requestedPages.push(pageNo);
return {
json: async () => ({
authors: [
{
attribute_datas: {
nickname: `达人${pageNo}`
},
star_id: String(pageNo)
}
],
pagination: {
page: pageNo,
totalPages: 3
}
}),
ok: true
};
}
});
const records = await controller.exportRecords({ mode: "all" });
expect(requestedPages).toEqual([1, 2, 3]);
expect(records?.map((record) => record.authorId)).toEqual(["1", "2", "3"]);
});
test("keeps attribute_datas.id as the spread author id while preserving star_id as row id", async () => {
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
+121 -79
View File
@@ -1,14 +1,13 @@
import { describe, expect, test, vi } from "vitest";
import {
buildSpreadInfoConfigKey,
buildSpreadInfoColumns,
buildSpreadInfoUrl,
createSpreadInfoClient,
DEFAULT_SPREAD_INFO_CONFIGS,
matchesSpreadMetricRule,
normalizeSpreadInfoConfig,
mapSpreadInfoResponse
filterUnloadedSpreadInfoConfigs,
mapSpreadInfoResponse,
selectSpreadInfoConfigsForHeaders
} from "../src/content/market/spread-info";
describe("spread-info", () => {
@@ -90,6 +89,49 @@ describe("spread-info", () => {
]);
});
test("selects only unique request configs required by chosen columns", () => {
expect(
selectSpreadInfoConfigsForHeaders([
"内容数据-个人视频-近30天-完播率",
"内容数据-个人视频-近30天-作品平均评论数",
"内容数据-只看指派-排除营销流量-星图视频-近90天-互动率",
"效果预估-20-60s视频-预期CPM"
])
).toEqual([
{
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
},
{
flowType: 1,
onlyAssign: true,
range: 3,
type: 2
}
]);
});
test("keeps newly selected configs when other content configs are cached", () => {
const personalConfig = DEFAULT_SPREAD_INFO_CONFIGS[0];
const xingtuConfig = DEFAULT_SPREAD_INFO_CONFIGS.find(
(config) =>
config.type === 2 &&
config.onlyAssign &&
config.flowType === 1 &&
config.range === 3
);
expect(xingtuConfig).toBeDefined();
expect(
filterUnloadedSpreadInfoConfigs(
[personalConfig, xingtuConfig!],
{ "内容数据-个人视频-近30天-完播率": "28%" }
)
).toEqual([xingtuConfig]);
});
test("maps spread info response values into display values", () => {
expect(
mapSpreadInfoResponse({
@@ -169,92 +211,92 @@ describe("spread-info", () => {
);
});
test("normalizes fixed personal-video parameters before grouping", () => {
expect(
normalizeSpreadInfoConfig({
flowType: 1,
onlyAssign: true,
range: 3,
type: 1
test("requests only the explicitly selected spread configs", async () => {
const fetchImpl = vi.fn(async () => ({
json: async () => ({ play_over_rate: { value: 2824 } }),
ok: true
}));
const client = createSpreadInfoClient({ fetchImpl });
const selectedConfig = {
flowType: 1 as const,
onlyAssign: true,
range: 3 as const,
type: 2 as const
};
await client.loadAuthorSpreadMetrics("7361012802036695050", [selectedConfig]);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(fetchImpl.mock.calls[0][0]).toContain(
"type=2&flow_type=1&only_assign=true&range=3"
);
});
test("throws an HTTP error instead of returning empty metrics", async () => {
const client = createSpreadInfoClient({
fetchImpl: async () => ({
json: async () => ({}),
ok: false,
status: 429,
statusText: "Too Many Requests"
})
).toEqual({
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
});
await expect(
client.loadAuthorSpreadMetrics("7361012802036695050", [
{
flowType: 0,
onlyAssign: true,
range: 2,
type: 2
}
])
).rejects.toThrow("HTTP 429: Too Many Requests");
});
test("uses all normalized video dimensions in the config key", () => {
expect(
buildSpreadInfoConfigKey({
flowType: 1,
onlyAssign: true,
range: 3,
type: 1
test("throws an explicit error when Xingtu reports business rate limiting", async () => {
const client = createSpreadInfoClient({
fetchImpl: async () => ({
json: async () => ({
base_resp: {
status_code: 31157,
status_message: "您访问过于频繁,请24小时后重试"
}
}),
ok: true
})
).toBe(
buildSpreadInfoConfigKey({
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
})
);
});
expect(
buildSpreadInfoConfigKey({
flowType: 0,
onlyAssign: false,
range: 2,
type: 2
})
).not.toBe(
buildSpreadInfoConfigKey({
flowType: 0,
onlyAssign: false,
range: 3,
type: 2
})
await expect(
client.loadAuthorSpreadMetrics("7361012802036695050", [
{
flowType: 0,
onlyAssign: true,
range: 2,
type: 2
}
])
).rejects.toThrow(
"星图传播数据接口被限流 (status_code=31157): 您访问过于频繁,请24小时后重试"
);
});
test("matches only the metric named by one filter rule", () => {
expect(
matchesSpreadMetricRule(
{
finishRate: "28.24%",
interactionRate: "4.02%"
},
{
config: {
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
},
metric: "finishRate",
threshold: 28
}
)
).toBe(true);
test("propagates network errors instead of returning empty metrics", async () => {
const client = createSpreadInfoClient({
fetchImpl: async () => {
throw new Error("network unavailable");
}
});
expect(
matchesSpreadMetricRule(
await expect(
client.loadAuthorSpreadMetrics("7361012802036695050", [
{
finishRate: "28.24%"
},
{
config: {
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
},
metric: "interactionRate",
threshold: 1
flowType: 0,
onlyAssign: true,
range: 2,
type: 2
}
)
).toBe(false);
])
).rejects.toThrow("network unavailable");
});
});