Compare commits
46
Commits
7839380613
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d121849d3e | ||
|
|
53db9b0777 | ||
|
|
e03dedd91e | ||
|
|
e15bc0657a | ||
|
|
e062be6431 | ||
|
|
c683e1c0c2 | ||
|
|
9ed2c23230 | ||
|
|
ddf9a75e82 | ||
|
|
8ca9f95583 | ||
|
|
01efa9f847 | ||
|
|
8ebb50524c | ||
|
|
ff1decd4a7 | ||
|
|
0f1edcd181 | ||
|
|
b973e1099d | ||
|
|
ba4a1052e8 | ||
|
|
6570eb32b4 | ||
|
|
2f2a590df4 | ||
|
|
e08fc99def | ||
|
|
4c1ccb92db | ||
|
|
82e81959c0 | ||
|
|
8ba1787177 | ||
|
|
27a4fbb1aa | ||
|
|
ac8e86ab7b | ||
|
|
a7d62c7ec0 | ||
|
|
30748ddc0f | ||
|
|
8054d98b1d | ||
|
|
370cf0f5d2 | ||
|
|
4768f66c39 | ||
|
|
c45dca8a13 | ||
|
|
70c1de3a7e | ||
|
|
2e248fa9a3 | ||
|
|
ca365b485a | ||
|
|
961fb33e9e | ||
|
|
5c21f631d7 | ||
|
|
9326d676b7 | ||
|
|
b48e244a99 | ||
|
|
8eebb44551 | ||
|
|
720f5983dc | ||
|
|
370cc0170b | ||
|
|
b4440e2ca3 | ||
|
|
4c66262211 | ||
|
|
4b5515f6ec | ||
|
|
ccfb8f14fe | ||
|
|
0122e63872 | ||
|
|
9eb1fe43cc | ||
|
|
121977fd0d |
@@ -94,4 +94,4 @@ After this one-time bridge upgrade, future updates should continue using the sam
|
||||
|
||||
- Keep `.local/extension-key.pem` private and backed up internally.
|
||||
- Do not commit or share the private key with people who only need to install the extension.
|
||||
- If the batch submit backend changes away from `192.168.31.21:8083`, update `scripts/manifest.mjs` before packaging.
|
||||
- If the batch submit backend changes away from `localhost:8083`, update `scripts/manifest.mjs` before packaging.
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>星图插件工具栏改版样例</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--page: #f5f7fa;
|
||||
--panel: #ffffff;
|
||||
--line: #e7ebf0;
|
||||
--text: #20242a;
|
||||
--muted: #6b7280;
|
||||
--soft: #f8fafc;
|
||||
--brand: #ff2f6d;
|
||||
--brand-dark: #85172d;
|
||||
--brand-soft: #fff0f5;
|
||||
--blue: #2563eb;
|
||||
--green: #0f8a5f;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 1180px;
|
||||
background: var(--page);
|
||||
color: var(--text);
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 58px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 0 24px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-bottom: 1px solid var(--line);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 23px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 34px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
background:
|
||||
linear-gradient(135deg, #ff245f 0 38%, transparent 39%),
|
||||
linear-gradient(45deg, #27c7f2 0 50%, #3664ff 51%);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
color: #2f3540;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav .active {
|
||||
color: var(--brand);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav .active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: -17px;
|
||||
height: 4px;
|
||||
border-radius: 10px;
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.small-btn,
|
||||
.pink-btn {
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #d8dee8;
|
||||
background: #fff;
|
||||
color: #303744;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pink-btn {
|
||||
border-color: var(--brand);
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 18px 24px 40px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
height: 46px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.searchbar,
|
||||
.filters,
|
||||
.results {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.searchbar {
|
||||
height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
padding: 0 18px;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 440px;
|
||||
height: 36px;
|
||||
border: 1px solid #ff8ab2;
|
||||
border-radius: 6px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: 108px 1fr;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-side {
|
||||
background: #fbfcfe;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.filter-side div {
|
||||
height: 72px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.filter-main {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.filter-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-title {
|
||||
width: 72px;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chip,
|
||||
.soft-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.soft-chip {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.chip {
|
||||
color: #414854;
|
||||
}
|
||||
|
||||
.results {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 18px 24px 0;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.count strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.toolbar-shell {
|
||||
margin: 18px 24px 0;
|
||||
border: 1px solid #dfe5ee;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 58px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, #fff, #fbfcff);
|
||||
}
|
||||
|
||||
.action-cluster {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-right: 16px;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
height: 34px;
|
||||
border: 1px solid #7b1a2d;
|
||||
border-radius: 8px;
|
||||
padding: 0 14px;
|
||||
background: #7b1a2d;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.control-cluster {
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #cfe0ff;
|
||||
border-radius: 8px;
|
||||
background: #eef5ff;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.select,
|
||||
.input {
|
||||
height: 34px;
|
||||
border: 1px solid #d4dbe6;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #202938;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.select {
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.status {
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px 14px;
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.metric-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #cfe0ff;
|
||||
border-radius: 8px;
|
||||
background: #eef5ff;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-rule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d9efe7;
|
||||
border-radius: 8px;
|
||||
background: #f0fbf6;
|
||||
color: var(--green);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-rule strong {
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.metric-rule small {
|
||||
margin-left: 6px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.and {
|
||||
color: #0f8a5f;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #dbe2ec;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.metric span,
|
||||
.metric b {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric b {
|
||||
color: var(--green);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.metric input {
|
||||
min-width: 0;
|
||||
width: 58px;
|
||||
height: 26px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: #1f2937;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.native-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.native-btn {
|
||||
height: 34px;
|
||||
border: 1px solid #d8dee8;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #303744;
|
||||
padding: 0 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
height: 64px;
|
||||
padding: 0 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
height: 46px;
|
||||
background: #fbfcfe;
|
||||
color: #5d6675;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #f4b66b, #7660d6);
|
||||
}
|
||||
|
||||
.price {
|
||||
color: var(--brand);
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.metric-grid {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand"><span class="logo"></span>巨量星图</div>
|
||||
<nav class="nav">
|
||||
<span>首页</span>
|
||||
<span>我的星图</span>
|
||||
<span>找灵感</span>
|
||||
<span class="active">找达人</span>
|
||||
<span>找活动</span>
|
||||
<span>助投放</span>
|
||||
</nav>
|
||||
<div class="top-actions">
|
||||
<button class="small-btn">达人清单</button>
|
||||
<button class="pink-btn">+ 发布任务</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
<section class="banner">「精选品牌伙伴计划」优质达人合作推荐</section>
|
||||
|
||||
<section class="searchbar">
|
||||
<button class="tab">内容找人</button>
|
||||
<button class="tab active">昵称找人</button>
|
||||
<input class="search-input" value="抖音 输入达人昵称、抖音号或星图ID" />
|
||||
</section>
|
||||
|
||||
<section class="filters">
|
||||
<aside class="filter-side">
|
||||
<div>合作诉求</div>
|
||||
<div>匹配度</div>
|
||||
<div>性价比</div>
|
||||
<div>主题推荐</div>
|
||||
</aside>
|
||||
<div class="filter-main">
|
||||
<div class="filter-row">
|
||||
<span class="filter-title">合作对象</span>
|
||||
<span class="soft-chip">不限</span>
|
||||
<span class="chip">明星</span>
|
||||
<span class="soft-chip">短视频达人</span>
|
||||
<span class="chip">短剧演员</span>
|
||||
<span class="chip">短直达人</span>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<span class="filter-title">适配行业</span>
|
||||
<span class="chip">不限</span>
|
||||
<span class="soft-chip">品牌曝光</span>
|
||||
<span class="chip">破圈种草</span>
|
||||
<span class="chip">行动转化</span>
|
||||
<span class="chip">品牌5A</span>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<span class="filter-title">达人类型</span>
|
||||
<span class="soft-chip">不限</span>
|
||||
<span class="chip">美妆</span>
|
||||
<span class="chip">萌宠</span>
|
||||
<span class="chip">测评</span>
|
||||
<span class="chip">旅行</span>
|
||||
<span class="chip">母婴亲子</span>
|
||||
<span class="chip">科技数码</span>
|
||||
<span class="chip">生活家居</span>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<span class="filter-title">内容主题</span>
|
||||
<span class="soft-chip">不限</span>
|
||||
<span class="chip">妆容改造</span>
|
||||
<span class="chip">亲子育儿</span>
|
||||
<span class="chip">精彩生活</span>
|
||||
<span class="chip">手机数码</span>
|
||||
<span class="chip">萌宠养护</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="results">
|
||||
<div class="result-top">
|
||||
<div class="count">找到 <strong>10000+</strong> 个达人</div>
|
||||
</div>
|
||||
|
||||
<section class="toolbar-shell" aria-label="插件工具栏改版样例">
|
||||
<div class="toolbar-head">
|
||||
<div class="action-cluster">
|
||||
<button class="tool-btn">导出选中达人数据</button>
|
||||
<button class="tool-btn">按星图ID导出</button>
|
||||
<button class="tool-btn">选择字段</button>
|
||||
<button class="tool-btn">提交批次</button>
|
||||
</div>
|
||||
|
||||
<div class="control-cluster">
|
||||
<span class="label">视频口径</span>
|
||||
<select class="select">
|
||||
<option>星图视频</option>
|
||||
<option>个人视频</option>
|
||||
</select>
|
||||
<select class="select">
|
||||
<option>只看指派</option>
|
||||
<option>不限指派</option>
|
||||
</select>
|
||||
<select class="select">
|
||||
<option>排除营销</option>
|
||||
<option>不排除营销</option>
|
||||
</select>
|
||||
<select class="select">
|
||||
<option>近90天</option>
|
||||
<option>近30天</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<span class="status">批次提交成功</span>
|
||||
|
||||
<div class="native-actions">
|
||||
<button class="native-btn">自定义指标</button>
|
||||
<button class="native-btn">导出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-body">
|
||||
<div class="metric-title">传播指标筛选</div>
|
||||
<div class="metric-rule">全部满足<strong>AND</strong><small>每项取值 ≥ 输入值</small></div>
|
||||
<div class="metric-grid">
|
||||
<label class="metric"><span>评论</span><b>≥</b><input value="1" /></label>
|
||||
<span class="and">且</span>
|
||||
<label class="metric"><span>时长</span><b>≥</b><input value="5" /></label>
|
||||
<span class="and">且</span>
|
||||
<label class="metric"><span>点赞</span><b>≥</b><input value="10" /></label>
|
||||
<span class="and">且</span>
|
||||
<label class="metric"><span>转发</span><b>≥</b><input value="0" /></label>
|
||||
<span class="and">且</span>
|
||||
<label class="metric"><span>完播率</span><b>≥</b><input value="1" /></label>
|
||||
<span class="and">且</span>
|
||||
<label class="metric"><span>互动率</span><b>≥</b><input value="0.1" /></label>
|
||||
<span class="and">且</span>
|
||||
<label class="metric"><span>播放中位数</span><b>≥</b><input value="1000" /></label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" /> 全选</th>
|
||||
<th>达人信息</th>
|
||||
<th>代表视频</th>
|
||||
<th>达人类型</th>
|
||||
<th>内容主题</th>
|
||||
<th>粉丝数</th>
|
||||
<th>预期CPM</th>
|
||||
<th>完播率</th>
|
||||
<th>21-60s报价</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><input type="checkbox" /></td>
|
||||
<td>
|
||||
<div class="author">
|
||||
<span class="avatar"></span>
|
||||
<div>
|
||||
<strong>柯铭</strong><br />
|
||||
<span style="color: #7a8493">男 · 北京市 · 抖音精选</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>2 个视频</td>
|
||||
<td>萌宠</td>
|
||||
<td>国内旅行 / 昆虫科普</td>
|
||||
<td>1,471.4w</td>
|
||||
<td>43.5</td>
|
||||
<td>26.2%</td>
|
||||
<td class="price">¥600,000</td>
|
||||
<td><button class="pink-btn">下单</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,105 @@
|
||||
# 星图达人视频传播数据导出 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:** 导出 CSV 前按配置调用 `get_author_spread_info`,追加个人视频和星图视频传播指标列。
|
||||
|
||||
**Architecture:** 新增独立的 spread-info 模块负责参数配置、URL、响应映射和并发加载;列表解析保留 `authorId`,额外保存 `spreadAuthorId` 作为 `o_author_id`;CSV exporter 只负责把已加载的 spread metrics 输出成列。导出入口在生成 CSV 前补齐 spread metrics。
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome content script, Vitest, jsdom.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Spread Info Client And Mapping
|
||||
|
||||
**Files:**
|
||||
- Create: `src/content/market/spread-info.ts`
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- Test: `tests/spread-info.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Cover URL construction, label/header generation, response mapping, personal-video fixed params, and Xingtu-video multi-param configs.
|
||||
|
||||
- [ ] **Step 2: Run failing tests**
|
||||
|
||||
Run: `npx vitest run tests/spread-info.test.ts`
|
||||
Expected: FAIL because `spread-info.ts` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement spread-info module and types**
|
||||
|
||||
Implement typed configs, formatter helpers, response mapper, client, and limited-concurrency loader.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `npx vitest run tests/spread-info.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Preserve Spread Author ID From Search Rows
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- Modify: `src/content/market/market-list-row.ts`
|
||||
- Modify: `src/content/market/page-bridge.ts`
|
||||
- Test: `tests/market-page-bridge.test.ts`
|
||||
- Test: `tests/silent-export-controller.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Verify `attribute_datas.id` is retained as `spreadAuthorId` and preferred over top-level `star_id` for spread-info requests.
|
||||
|
||||
- [ ] **Step 2: Run failing tests**
|
||||
|
||||
Run focused tests for row parsing and silent export.
|
||||
|
||||
- [ ] **Step 3: Implement parser changes**
|
||||
|
||||
Store `spreadAuthorId` on snapshots and merge it in result store.
|
||||
|
||||
- [ ] **Step 4: Run focused tests**
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: CSV Columns
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/csv-exporter.ts`
|
||||
- Test: `tests/csv-exporter.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Verify spread headers append after backend metrics and blank cells are exported when metrics are absent.
|
||||
|
||||
- [ ] **Step 2: Implement CSV spread columns**
|
||||
|
||||
Read `record.spreadMetrics` by generated header names.
|
||||
|
||||
- [ ] **Step 3: Run focused tests**
|
||||
|
||||
Run: `npx vitest run tests/csv-exporter.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 4: Export Hydration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Modify: `src/content/market/result-store.ts`
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Verify export calls spread-info with `spreadAuthorId`, waits before CSV generation, preserves row order, and leaves blanks on failure.
|
||||
|
||||
- [ ] **Step 2: Implement hydration**
|
||||
|
||||
Inject `loadSpreadMetrics` for tests, default to spread-info loader, and hydrate records before `buildCsv`.
|
||||
|
||||
- [ ] **Step 3: Run focused tests**
|
||||
|
||||
Run focused content-entry tests.
|
||||
|
||||
### Task 5: Final Verification
|
||||
|
||||
- [ ] Run `npm test`.
|
||||
- [ ] Run `npm run build`.
|
||||
- [ ] Review `git diff`.
|
||||
@@ -0,0 +1,33 @@
|
||||
# 星图达人传播指标阈值筛选 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:** 在导出 CSV 和提交批次前,按用户选择的 spread-info 参数组合和指标阈值过滤达人。
|
||||
|
||||
**Architecture:** 工具栏负责读取筛选配置;`spread-info.ts` 提供单参数组合加载与阈值比较;`index.ts` 在 export range 收集后、CSV/批次 payload 生成前统一应用筛选。
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome MV3 content script, Vitest, jsdom.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Toolbar Filter State
|
||||
|
||||
- [ ] 增加视频类别、指派、营销流量、数据范围和 7 个阈值输入控件。
|
||||
- [ ] 增加 `readToolbarSpreadFilter` 读取并校验筛选配置。
|
||||
- [ ] 测试个人视频时固定并禁用指派/营销流量。
|
||||
|
||||
### Task 2: Spread Filter Logic
|
||||
|
||||
- [ ] 在 `spread-info.ts` 增加单配置请求与阈值比较。
|
||||
- [ ] 测试百分比显示值、秒、普通数字比较。
|
||||
|
||||
### Task 3: Export And Batch Integration
|
||||
|
||||
- [ ] 在导出和提交批次流程中调用筛选逻辑。
|
||||
- [ ] 空阈值不触发筛选请求。
|
||||
- [ ] 测试导出和提交批次都只保留满足阈值的达人。
|
||||
|
||||
### Task 4: Verification
|
||||
|
||||
- [ ] 运行 focused tests。
|
||||
- [ ] 运行 `npm run build`。
|
||||
@@ -0,0 +1,665 @@
|
||||
# Independent Spread Metric Filter 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:** Replace the single shared spread-video configuration with independently configurable completion-rate and interaction-rate rules that are combined with AND semantics.
|
||||
|
||||
**Architecture:** Represent each enabled metric as a `SpreadMetricFilterRule` containing its metric, threshold, and normalized `SpreadInfoConfig`. The toolbar owns metric selection and per-rule controls; the market controller groups rules by normalized config, loads one Xingtu snapshot per unique config and author, and evaluates every rule before export or batch submission.
|
||||
|
||||
**Tech Stack:** TypeScript 6, Chrome MV3 content scripts, DOM APIs, Vitest 4 with JSDOM, tsup build scripts.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `src/content/market/types.ts`: own shared spread config and filter rule types.
|
||||
- Modify `src/content/market/spread-info.ts`: normalize configs, build stable config keys, and compare one metric rule against a mapped snapshot.
|
||||
- Modify `src/content/market/plugin-toolbar.ts`: render metric checkboxes and per-metric dynamic rule controls, enforce personal-video constraints, and read validated rule arrays.
|
||||
- Modify `src/content/market/index.ts`: group rules by config, reuse snapshots, and apply AND filtering for export and batch submission.
|
||||
- Modify `tests/spread-info.test.ts`: cover config normalization, grouping keys, and single-rule comparison.
|
||||
- Modify `tests/market-content-entry.test.ts`: cover toolbar interaction, validation, request grouping, AND behavior, export, and batch submission.
|
||||
- Modify `docs/项目流程说明文档.md`: replace the obsolete seven-threshold/global-config description with the implemented two-metric independent-rule flow.
|
||||
|
||||
### Task 1: Shared Rule Model and Pure Spread Filter Logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/types.ts:15-35`
|
||||
- Modify: `src/content/market/spread-info.ts:1-25, 210-223`
|
||||
- Test: `tests/spread-info.test.ts:1-214`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for normalized configs, stable keys, and metric-specific matching**
|
||||
|
||||
Replace the old `matchesSpreadThresholds` test with focused tests and imports:
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildSpreadInfoConfigKey,
|
||||
matchesSpreadMetricRule,
|
||||
normalizeSpreadInfoConfig
|
||||
} from "../src/content/market/spread-info";
|
||||
|
||||
test("normalizes fixed personal-video parameters before grouping", () => {
|
||||
expect(normalizeSpreadInfoConfig({
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 3,
|
||||
type: 1
|
||||
})).toEqual({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
});
|
||||
});
|
||||
|
||||
test("uses all normalized video dimensions in the config key", () => {
|
||||
expect(buildSpreadInfoConfigKey({
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 3,
|
||||
type: 1
|
||||
})).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
|
||||
}));
|
||||
});
|
||||
|
||||
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);
|
||||
expect(matchesSpreadMetricRule(
|
||||
{ finishRate: "28.24%" },
|
||||
{
|
||||
config: { flowType: 0, onlyAssign: false, range: 2, type: 1 },
|
||||
metric: "interactionRate",
|
||||
threshold: 1
|
||||
}
|
||||
)).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the new API is missing**
|
||||
|
||||
Run: `npx vitest run tests/spread-info.test.ts`
|
||||
|
||||
Expected: FAIL because `normalizeSpreadInfoConfig`, `buildSpreadInfoConfigKey`, and `matchesSpreadMetricRule` are not exported.
|
||||
|
||||
- [ ] **Step 3: Replace the global threshold type with shared rule types**
|
||||
|
||||
In `types.ts`, remove `SpreadMetricThresholds` and define:
|
||||
|
||||
```ts
|
||||
export interface SpreadInfoConfig {
|
||||
flowType: 0 | 1;
|
||||
onlyAssign: boolean;
|
||||
range: 2 | 3;
|
||||
type: 1 | 2;
|
||||
}
|
||||
|
||||
export type SpreadFilterMetric = "finishRate" | "interactionRate";
|
||||
|
||||
export interface SpreadMetricFilterRule {
|
||||
config: SpreadInfoConfig;
|
||||
metric: SpreadFilterMetric;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export interface SpreadThresholdFilter {
|
||||
rules: SpreadMetricFilterRule[];
|
||||
}
|
||||
```
|
||||
|
||||
Move `SpreadInfoConfig` ownership out of `spread-info.ts` and import it there from `types.ts`.
|
||||
|
||||
- [ ] **Step 4: Implement pure normalization, keying, and comparison helpers**
|
||||
|
||||
In `spread-info.ts`, replace `matchesSpreadThresholds` with:
|
||||
|
||||
```ts
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the focused tests**
|
||||
|
||||
Run: `npx vitest run tests/spread-info.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit the shared rule model**
|
||||
|
||||
```bash
|
||||
git add src/content/market/types.ts src/content/market/spread-info.ts tests/spread-info.test.ts
|
||||
git commit -m "refactor: model independent spread metric rules"
|
||||
```
|
||||
|
||||
### Task 2: Dynamic Per-Metric Toolbar Rules
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/plugin-toolbar.ts:1-590, 810-1035`
|
||||
- Test: `tests/market-content-entry.test.ts:390-480, 1285-1340`
|
||||
|
||||
- [ ] **Step 1: Write failing DOM tests for the default and enabled states**
|
||||
|
||||
Update the toolbar assertions to use metric checkboxes and scoped rule rows:
|
||||
|
||||
```ts
|
||||
const finishToggle = document.querySelector(
|
||||
'[data-plugin-spread-metric="finishRate"]'
|
||||
) as HTMLInputElement;
|
||||
const interactionToggle = document.querySelector(
|
||||
'[data-plugin-spread-metric="interactionRate"]'
|
||||
) as HTMLInputElement;
|
||||
const finishRule = document.querySelector(
|
||||
'[data-plugin-spread-rule="finishRate"]'
|
||||
) as HTMLElement;
|
||||
|
||||
expect(finishToggle.checked).toBe(false);
|
||||
expect(interactionToggle.checked).toBe(false);
|
||||
expect(finishRule.hidden).toBe(true);
|
||||
|
||||
finishToggle.checked = true;
|
||||
finishToggle.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(finishRule.hidden).toBe(false);
|
||||
expect(finishRule.querySelector('[data-plugin-spread-filter="type"]')).not.toBeNull();
|
||||
expect(finishRule.querySelector('[data-plugin-spread-threshold="finishRate"]')).not.toBeNull();
|
||||
```
|
||||
|
||||
Add reusable test helpers:
|
||||
|
||||
```ts
|
||||
function enableSpreadMetric(metric: "finishRate" | "interactionRate") {
|
||||
const selector = `[data-plugin-spread-metric="${metric}"]`;
|
||||
const input = document.querySelector(selector) as HTMLInputElement;
|
||||
input.checked = true;
|
||||
dispatchChange(selector);
|
||||
}
|
||||
|
||||
function setSpreadRuleSelect(
|
||||
metric: "finishRate" | "interactionRate",
|
||||
field: "type" | "onlyAssign" | "flowType" | "range",
|
||||
value: string
|
||||
) {
|
||||
const selector =
|
||||
`[data-plugin-spread-rule="${metric}"] [data-plugin-spread-filter="${field}"]`;
|
||||
setSelectValue(selector, value);
|
||||
dispatchChange(selector);
|
||||
}
|
||||
```
|
||||
|
||||
Then verify per-rule isolation:
|
||||
|
||||
```ts
|
||||
enableSpreadMetric("finishRate");
|
||||
enableSpreadMetric("interactionRate");
|
||||
setSpreadRuleSelect("finishRate", "type", "2");
|
||||
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
|
||||
setSpreadRuleSelect("finishRate", "flowType", "1");
|
||||
setSpreadRuleSelect("interactionRate", "type", "2");
|
||||
setSpreadRuleSelect("interactionRate", "onlyAssign", "true");
|
||||
setSpreadRuleSelect("interactionRate", "flowType", "1");
|
||||
setSpreadRuleSelect("interactionRate", "type", "1");
|
||||
|
||||
const finishOnlyAssign = document.querySelector(
|
||||
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="onlyAssign"]'
|
||||
) as HTMLSelectElement;
|
||||
const interactionOnlyAssign = document.querySelector(
|
||||
'[data-plugin-spread-rule="interactionRate"] [data-plugin-spread-filter="onlyAssign"]'
|
||||
) as HTMLSelectElement;
|
||||
|
||||
expect(finishOnlyAssign.value).toBe("true");
|
||||
expect(finishOnlyAssign.disabled).toBe(false);
|
||||
expect(interactionOnlyAssign.value).toBe("false");
|
||||
expect(interactionOnlyAssign.disabled).toBe(true);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write failing validation tests for rule-array parsing**
|
||||
|
||||
Exercise `readToolbarSpreadFilter` through export button integration. The validation path is synchronous before any export work starts:
|
||||
|
||||
```ts
|
||||
enableSpreadMetric("finishRate");
|
||||
click('[data-plugin-export="button"]');
|
||||
expect(
|
||||
document.querySelector('[data-plugin-export-status="text"]')?.textContent
|
||||
).toContain("完播率");
|
||||
expect(buildCsv).not.toHaveBeenCalled();
|
||||
|
||||
setInputValue(
|
||||
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-threshold="finishRate"]',
|
||||
"30"
|
||||
);
|
||||
```
|
||||
|
||||
Import `ensurePluginToolbar` and `readToolbarSpreadFilter` in a focused test, then assert the empty and reset states directly:
|
||||
|
||||
```ts
|
||||
const { ensurePluginToolbar, readToolbarSpreadFilter } = await import(
|
||||
"../src/content/market/plugin-toolbar"
|
||||
);
|
||||
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers());
|
||||
expect(readToolbarSpreadFilter(toolbar)).toEqual({ filter: { rules: [] } });
|
||||
|
||||
enableSpreadMetric("finishRate");
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
setSpreadRuleSelect("finishRate", "type", "2");
|
||||
const toggle = document.querySelector(
|
||||
'[data-plugin-spread-metric="finishRate"]'
|
||||
) as HTMLInputElement;
|
||||
toggle.checked = false;
|
||||
dispatchChange('[data-plugin-spread-metric="finishRate"]');
|
||||
toggle.checked = true;
|
||||
dispatchChange('[data-plugin-spread-metric="finishRate"]');
|
||||
|
||||
expect(
|
||||
(document.querySelector('[data-plugin-spread-threshold="finishRate"]') as HTMLInputElement).value
|
||||
).toBe("");
|
||||
expectSelectValue(
|
||||
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="type"]',
|
||||
"1"
|
||||
);
|
||||
expectSelectValue(
|
||||
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="range"]',
|
||||
"2"
|
||||
);
|
||||
```
|
||||
|
||||
Define the no-op handlers next to the existing DOM test helpers:
|
||||
|
||||
```ts
|
||||
function createNoopToolbarHandlers() {
|
||||
return {
|
||||
onConfigureAudienceProfileFields: vi.fn(),
|
||||
onExport: vi.fn(),
|
||||
onExportAudienceProfile: vi.fn(),
|
||||
onExportAudienceProfileByIds: vi.fn(),
|
||||
onSubmitBatch: vi.fn()
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the toolbar-focused test file and verify failure**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts -t "spread"`
|
||||
|
||||
Expected: FAIL because metric checkboxes and per-rule controls do not exist.
|
||||
|
||||
- [ ] **Step 4: Replace the global toolbar DOM contract with per-rule DOM objects**
|
||||
|
||||
Define this internal shape in `plugin-toolbar.ts`:
|
||||
|
||||
```ts
|
||||
interface SpreadMetricRuleDom {
|
||||
enabledInput: HTMLInputElement;
|
||||
flowTypeSelect: HTMLSelectElement;
|
||||
onlyAssignSelect: HTMLSelectElement;
|
||||
rangeSelect: HTMLSelectElement;
|
||||
root: HTMLElement;
|
||||
thresholdInput: HTMLInputElement;
|
||||
typeSelect: HTMLSelectElement;
|
||||
}
|
||||
|
||||
type SpreadMetricRuleDomMap = Record<SpreadFilterMetric, SpreadMetricRuleDom>;
|
||||
```
|
||||
|
||||
Replace the four global config selects and two loose threshold inputs in `PluginToolbarDom` with `spreadMetricRules: SpreadMetricRuleDomMap`.
|
||||
|
||||
- [ ] **Step 5: Build metric selectors and hidden rule rows**
|
||||
|
||||
Create one checkbox and one rule row for each fixed definition:
|
||||
|
||||
```ts
|
||||
const SPREAD_FILTER_DEFINITIONS = [
|
||||
{ label: "完播率", metric: "finishRate" },
|
||||
{ label: "互动率", metric: "interactionRate" }
|
||||
] as const;
|
||||
```
|
||||
|
||||
Use these stable selectors:
|
||||
|
||||
```text
|
||||
data-plugin-spread-metric="finishRate|interactionRate"
|
||||
data-plugin-spread-rule="finishRate|interactionRate"
|
||||
data-plugin-spread-filter="type|onlyAssign|flowType|range"
|
||||
data-plugin-spread-threshold="finishRate|interactionRate"
|
||||
```
|
||||
|
||||
Keep both rule rows in the DOM but set `hidden=true` until their checkboxes are enabled. Layout the selection controls before the rule container and remove the old global “视频口径” group.
|
||||
|
||||
- [ ] **Step 6: Implement independent state synchronization and reset behavior**
|
||||
|
||||
On each metric checkbox change:
|
||||
|
||||
```ts
|
||||
function syncSpreadMetricRuleState(rule: SpreadMetricRuleDom): void {
|
||||
rule.root.hidden = !rule.enabledInput.checked;
|
||||
if (!rule.enabledInput.checked) {
|
||||
rule.thresholdInput.value = "";
|
||||
rule.typeSelect.value = "1";
|
||||
rule.onlyAssignSelect.value = "false";
|
||||
rule.flowTypeSelect.value = "0";
|
||||
rule.rangeSelect.value = "2";
|
||||
clearSpreadMetricRuleValidation(rule);
|
||||
}
|
||||
syncSpreadMetricVideoConstraints(rule);
|
||||
}
|
||||
```
|
||||
|
||||
`syncSpreadMetricVideoConstraints` must only touch the changed rule. For personal video it fixes and disables `onlyAssign` and `flowType`; for Xingtu video it enables them.
|
||||
|
||||
- [ ] **Step 7: Parse selected controls into validated rule arrays**
|
||||
|
||||
Rework `readToolbarSpreadFilter` to iterate in finish-rate then interaction-rate order:
|
||||
|
||||
```ts
|
||||
const rules: SpreadMetricFilterRule[] = [];
|
||||
for (const metric of ["finishRate", "interactionRate"] as const) {
|
||||
const ruleDom = toolbar.spreadMetricRules[metric];
|
||||
clearSpreadMetricRuleValidation(ruleDom);
|
||||
if (!ruleDom.enabledInput.checked) continue;
|
||||
|
||||
const threshold = Number(ruleDom.thresholdInput.value.trim());
|
||||
if (!ruleDom.thresholdInput.value.trim() || !Number.isFinite(threshold) || threshold < 0) {
|
||||
markSpreadMetricRuleInvalid(ruleDom);
|
||||
return { error: `请输入有效的${metric === "finishRate" ? "完播率" : "互动率"}筛选阈值` };
|
||||
}
|
||||
|
||||
rules.push({ metric, threshold, config: readSpreadMetricConfig(ruleDom) });
|
||||
}
|
||||
return { filter: { rules } };
|
||||
```
|
||||
|
||||
Update busy-state handling and native-control styling to walk every checkbox, threshold input, and select in `spreadMetricRules`.
|
||||
|
||||
- [ ] **Step 8: Run toolbar-focused tests**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts -t "toolbar|spread"`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Commit the dynamic toolbar**
|
||||
|
||||
```bash
|
||||
git add src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: add independent spread metric controls"
|
||||
```
|
||||
|
||||
### Task 3: Config-Deduplicated AND Filtering for Export and Batch Submission
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts:30-95, 801-828`
|
||||
- Test: `tests/market-content-entry.test.ts:1440-1496, 2436-2492`
|
||||
|
||||
- [ ] **Step 1: Write a failing export test for different configs and AND semantics**
|
||||
|
||||
Configure completion rate as Xingtu/assigned/30-day and interaction rate as personal/90-day. Mock snapshots by config:
|
||||
|
||||
```ts
|
||||
const loadSpreadFilterMetrics = vi.fn(async (
|
||||
spreadAuthorId: string,
|
||||
config: SpreadInfoConfig
|
||||
) => {
|
||||
if (config.type === 2) {
|
||||
return { finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%" };
|
||||
}
|
||||
return { interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%" };
|
||||
});
|
||||
```
|
||||
|
||||
Configure the controls and assert the exact result and calls:
|
||||
|
||||
```ts
|
||||
enableSpreadMetric("finishRate");
|
||||
enableSpreadMetric("interactionRate");
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
|
||||
setSpreadRuleSelect("finishRate", "type", "2");
|
||||
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
|
||||
setSpreadRuleSelect("interactionRate", "range", "3");
|
||||
click('[data-plugin-export="button"]');
|
||||
await waitForMockCall(buildCsv, 80, 50);
|
||||
|
||||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual(["a"]);
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
|
||||
flowType: 0,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
});
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write a failing export test for same-config request reuse**
|
||||
|
||||
Configure both rules as personal/30-day, return both metrics in one snapshot, and assert:
|
||||
|
||||
```ts
|
||||
enableSpreadMetric("finishRate");
|
||||
enableSpreadMetric("interactionRate");
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
|
||||
click('[data-plugin-export="button"]');
|
||||
await waitForMockCall(buildCsv, 80, 50);
|
||||
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1);
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update the batch-submit integration test to enable and apply a rule**
|
||||
|
||||
Enable both rules before submitting and preserve the exact payload assertion:
|
||||
|
||||
```ts
|
||||
enableSpreadMetric("finishRate");
|
||||
enableSpreadMetric("interactionRate");
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
|
||||
setSpreadRuleSelect("finishRate", "type", "2");
|
||||
setSpreadRuleSelect("interactionRate", "range", "3");
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
await waitForMockCall(submitBatch, 80, 50);
|
||||
|
||||
expect(submitBatch.mock.calls[0]?.[0].authors).toEqual([
|
||||
expect.objectContaining({ authorId: "a" })
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the new integration tests and verify failure**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts -t "spread threshold|independent spread|reuses"`
|
||||
|
||||
Expected: FAIL because the controller still reads one global config and threshold object.
|
||||
|
||||
- [ ] **Step 5: Update the controller dependency type**
|
||||
|
||||
Change `loadSpreadFilterMetrics` to accept `SpreadInfoConfig` and return `MappedSpreadInfoResponse`:
|
||||
|
||||
```ts
|
||||
loadSpreadFilterMetrics?: (
|
||||
spreadAuthorId: string,
|
||||
config: SpreadInfoConfig
|
||||
) => Promise<MappedSpreadInfoResponse>;
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Group normalized rules and reuse each snapshot**
|
||||
|
||||
Replace the one-request filter body with config groups:
|
||||
|
||||
```ts
|
||||
const normalizedRules = filter.rules.map((rule) => ({
|
||||
...rule,
|
||||
config: normalizeSpreadInfoConfig(rule.config)
|
||||
}));
|
||||
const groups = new Map<string, {
|
||||
config: SpreadInfoConfig;
|
||||
rules: SpreadMetricFilterRule[];
|
||||
}>();
|
||||
|
||||
for (const rule of normalizedRules) {
|
||||
const key = buildSpreadInfoConfigKey(rule.config);
|
||||
const group = groups.get(key) ?? { config: rule.config, rules: [] };
|
||||
group.rules.push(rule);
|
||||
groups.set(key, group);
|
||||
}
|
||||
```
|
||||
|
||||
For each record, load all unique groups, catch a group failure as an empty snapshot, and preserve the existing per-author isolation:
|
||||
|
||||
```ts
|
||||
const snapshots = new Map<string, MappedSpreadInfoResponse>();
|
||||
await Promise.all(Array.from(groups.entries()).map(async ([key, group]) => {
|
||||
try {
|
||||
snapshots.set(key, await loadSpreadFilterMetrics(spreadAuthorId, group.config));
|
||||
} catch {
|
||||
snapshots.set(key, {});
|
||||
}
|
||||
}));
|
||||
|
||||
const matchesAll = normalizedRules.every((rule) =>
|
||||
matchesSpreadMetricRule(
|
||||
snapshots.get(buildSpreadInfoConfigKey(rule.config)) ?? {},
|
||||
rule
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
Return records unchanged when `filter` is missing or `filter.rules.length === 0`.
|
||||
|
||||
- [ ] **Step 7: Run focused integration tests**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts -t "spread threshold|independent spread|reuses"`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Run all market content-entry tests**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Commit config-deduplicated filtering**
|
||||
|
||||
```bash
|
||||
git add src/content/market/index.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: filter spread metrics by independent configs"
|
||||
```
|
||||
|
||||
### Task 4: Process Documentation and Full Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/项目流程说明文档.md:251-269, 323, 363, 426, 490, 509, 593`
|
||||
|
||||
- [ ] **Step 1: Update the maintained process description**
|
||||
|
||||
Rewrite section `3.8 传播指标阈值筛选` to state:
|
||||
|
||||
```markdown
|
||||
- 输入内容:可选的完播率、互动率规则;每条已选规则包含非负阈值和独立的视频类别、是否只看指派、是否排除营销流量、时间范围。
|
||||
- 处理规则:
|
||||
- 默认不选择任何指标,不启用二次筛选;
|
||||
- 每个已选指标必须填写阈值,并使用自己的视频口径请求传播指标;
|
||||
- 同时选择完播率和互动率时,两条规则必须全部满足;
|
||||
- 两条规则口径相同时,同一达人复用一次接口响应;口径不同时分别请求;
|
||||
- 个人视频固定为不限指派、不排除营销流量;
|
||||
- 请求失败、缺少 ID 或缺少已选指标的达人视为不满足筛选。
|
||||
```
|
||||
|
||||
Update the interface table's concurrency note to mention unique-config reuse, and change the configuration table row from one generic “传播指标阈值” to “传播指标规则”,defaulting to no selected metric.
|
||||
|
||||
- [ ] **Step 2: Run documentation consistency checks**
|
||||
|
||||
Run: `rg -n "七个指标阈值|没有填写任何阈值|平均时长按秒|播放量、评论、点赞、转发" docs/项目流程说明文档.md`
|
||||
|
||||
Expected: no output.
|
||||
|
||||
Run: `rg -n "每个已选指标|口径相同|传播指标规则" docs/项目流程说明文档.md`
|
||||
|
||||
Expected: matches in section 3.8, the `get_author_spread_info` interface row, and the configuration table.
|
||||
|
||||
- [ ] **Step 3: Run focused feature tests**
|
||||
|
||||
Run: `npx vitest run tests/spread-info.test.ts tests/market-content-entry.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run the full test suite**
|
||||
|
||||
Run: `npm test`
|
||||
|
||||
Expected: PASS with no failed test files.
|
||||
|
||||
- [ ] **Step 5: Build the extension**
|
||||
|
||||
Run: `npm run build`
|
||||
|
||||
Expected: exit code `0` and refreshed development bundle in `dist/`.
|
||||
|
||||
- [ ] **Step 6: Review the final diff and workspace scope**
|
||||
|
||||
Run: `git diff --check`
|
||||
|
||||
Expected: no whitespace errors.
|
||||
|
||||
Run: `git status --short`
|
||||
|
||||
Expected: only the planned source, test, and process-document files are modified; `.superpowers/` remains untracked and must not be staged.
|
||||
|
||||
- [ ] **Step 7: Commit documentation and verification-complete state**
|
||||
|
||||
```bash
|
||||
git add docs/项目流程说明文档.md
|
||||
git commit -m "docs: update independent spread filter flow"
|
||||
```
|
||||
@@ -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,242 @@
|
||||
# 星图达人视频传播数据导出需求文档
|
||||
|
||||
## 目标
|
||||
|
||||
在现有星图达人 CSV 导出流程中,额外调用星图接口 `get_author_spread_info`,获取达人视频传播相关指标,并把这些指标追加到导出表格中。
|
||||
|
||||
因为同一个指标在不同参数组合下含义不同,所以导出字段名必须带上参数前缀。例如:
|
||||
|
||||
```text
|
||||
只看指派_排除营销流量_星图视频_近30天_完播率
|
||||
```
|
||||
|
||||
这个字段表示:它不是普通的“完播率”,而是在“只看指派 + 排除营销流量 + 星图视频 + 近30天”这组参数下获取到的完播率。
|
||||
|
||||
字段名前缀只体现会造成数据差异、且在当前导出中可变化的参数。固定不变的参数不用写进字段名前缀。
|
||||
|
||||
## 接口
|
||||
|
||||
调用接口:
|
||||
|
||||
```text
|
||||
GET /gw/api/data_sp/get_author_spread_info
|
||||
```
|
||||
|
||||
请求参数:
|
||||
|
||||
| 参数 | 含义 |
|
||||
| --- | --- |
|
||||
| `o_author_id` | 达人的星图 ID |
|
||||
| `platform_source` | 固定传 `1` |
|
||||
| `platform_channel` | 固定传 `1` |
|
||||
| `type` | 视频类型 |
|
||||
| `flow_type` | 是否排除营销流量 |
|
||||
| `only_assign` | 是否只看指派 |
|
||||
| `range` | 数据时间范围 |
|
||||
|
||||
请求需要带上当前星图网页登录态,所以实现时请求要使用浏览器当前 cookie,也就是 `credentials: "include"`。
|
||||
|
||||
## 星图 ID 来源
|
||||
|
||||
`o_author_id` 需要从 `search_for_author_square` 接口返回值中获取:
|
||||
|
||||
```text
|
||||
authors[i].attribute_datas.id
|
||||
```
|
||||
|
||||
如果同一行数据里同时存在顶层 `star_id` 和 `attribute_datas.id`,这个接口优先使用 `attribute_datas.id` 作为 `o_author_id`。
|
||||
|
||||
## 参数含义
|
||||
|
||||
### only_assign
|
||||
|
||||
| 值 | 含义 | 字段名前缀 |
|
||||
| --- | --- | --- |
|
||||
| `true` | 只看指派 | `只看指派` |
|
||||
| `false` | 取消“只看指派”勾选 | `不限指派` |
|
||||
|
||||
### flow_type
|
||||
|
||||
| 值 | 含义 | 字段名前缀 |
|
||||
| --- | --- | --- |
|
||||
| `1` | 排除营销流量 | `排除营销流量` |
|
||||
| `0` | 不排除营销流量 | `不排除营销流量` |
|
||||
|
||||
### range
|
||||
|
||||
| 值 | 含义 | 字段名前缀 |
|
||||
| --- | --- | --- |
|
||||
| `2` | 近 30 天 | `近30天` |
|
||||
| `3` | 近 90 天 | `近90天` |
|
||||
|
||||
### type
|
||||
|
||||
| 值 | 含义 | 字段名前缀 |
|
||||
| --- | --- | --- |
|
||||
| `1` | 个人视频 | `个人视频` |
|
||||
| `2` | 星图视频 | `星图视频` |
|
||||
|
||||
## 多组参数导出
|
||||
|
||||
第一版需要支持多组参数组合。
|
||||
|
||||
参数组合需要区分“个人视频”和“星图视频”两类处理:
|
||||
|
||||
- `type=1` 个人视频:`only_assign=false`、`flow_type=0` 固定,只允许调整 `range`。
|
||||
- `type=2` 星图视频:需要支持多组参数组合,因为 `only_assign`、`flow_type`、`range` 的不同设置会导致接口返回的数据不同。
|
||||
|
||||
个人视频固定参数:
|
||||
|
||||
```text
|
||||
type=1
|
||||
flow_type=0
|
||||
only_assign=false
|
||||
```
|
||||
|
||||
个人视频可变参数:
|
||||
|
||||
```text
|
||||
range=2 或 range=3
|
||||
```
|
||||
|
||||
因为个人视频里 `only_assign=false` 和 `flow_type=0` 是固定参数,所以它们不写入字段名前缀。个人视频字段只需要体现视频类型和时间范围,例如:
|
||||
|
||||
```text
|
||||
个人视频_近30天_完播率
|
||||
个人视频_近90天_完播率
|
||||
```
|
||||
|
||||
星图视频可以配置多组参数。每一组参数都会调用一次 `get_author_spread_info`,并为这一组参数生成 7 个导出字段。
|
||||
|
||||
例如某一组参数是:
|
||||
|
||||
```text
|
||||
only_assign=true
|
||||
flow_type=1
|
||||
type=2
|
||||
range=2
|
||||
```
|
||||
|
||||
那么这一组会生成:
|
||||
|
||||
- `只看指派_排除营销流量_星图视频_近30天_完播率`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_播放量中位数`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_互动率`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均时长`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均评论数`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均点赞数`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均转发数`
|
||||
|
||||
字段名规则固定为:
|
||||
|
||||
```text
|
||||
<会变化的参数文案>_<视频类型文案>_<时间范围文案>_<指标名>
|
||||
```
|
||||
|
||||
对星图视频来说,`only_assign`、`flow_type`、`range` 都可能变化,所以字段名要保留这些参数。对个人视频来说,只有 `range` 变化,所以字段名不需要写 `不限指派` 和 `不排除营销流量`。
|
||||
|
||||
这里必须保留会变化参数的前缀,不能把不同参数组合下的同名指标合并。例如下面两个字段都叫“完播率”,但数据含义不同,必须作为两个独立字段导出:
|
||||
|
||||
```text
|
||||
只看指派_排除营销流量_星图视频_近30天_完播率
|
||||
不限指派_不排除营销流量_星图视频_近30天_完播率
|
||||
```
|
||||
|
||||
## 需要导出的指标
|
||||
|
||||
每一组参数都要导出下面 7 个指标:
|
||||
|
||||
| 导出字段指标名 | 接口响应字段 | 示例值 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 完播率 | `play_over_rate.value` | `2824` | 按万分比理解,导出时建议显示为 `28.24%` |
|
||||
| 播放量中位数 | `play_mid`,兜底 `item_rate.play_mid.value` | `10913233` | 播放量中位数 |
|
||||
| 互动率 | `interact_rate.value` | `402` | 按万分比理解,导出时建议显示为 `4.02%` |
|
||||
| 作品平均时长 | `avg_duration` | `5600` | 按百分之一秒理解,导出时显示为秒,例如 `56` |
|
||||
| 作品平均评论数 | `comment_avg` | `7502` | 平均评论数 |
|
||||
| 作品平均点赞数 | `like_avg` | `494458` | 平均点赞数 |
|
||||
| 作品平均转发数 | `share_avg` | `188267` | 平均转发数 |
|
||||
|
||||
示例响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"avg_duration": "5600",
|
||||
"comment_avg": "7502",
|
||||
"interact_rate": {
|
||||
"overtake": 5312,
|
||||
"value": 402
|
||||
},
|
||||
"item_rate": {
|
||||
"play_mid": {
|
||||
"label": "",
|
||||
"overtake": 10000,
|
||||
"value": 10913233
|
||||
}
|
||||
},
|
||||
"like_avg": "494458",
|
||||
"play_mid": "10913233",
|
||||
"play_over_rate": {
|
||||
"overtake": 9584,
|
||||
"value": 2824
|
||||
},
|
||||
"share_avg": "188267"
|
||||
}
|
||||
```
|
||||
|
||||
## 导出流程
|
||||
|
||||
1. 当前插件仍然先从星图达人搜索页收集达人列表。
|
||||
2. 从 `search_for_author_square` 的 `authors[i].attribute_datas.id` 取出每个达人的星图 ID。
|
||||
3. 用户导出 CSV 时,先按现有逻辑确定导出范围,例如当前页、前 5 页、前 10 页、全部或自定义页数。
|
||||
4. 对导出范围内的每个达人,先按个人视频参数调用 `get_author_spread_info`:`type=1`、`flow_type=0`、`only_assign=false` 固定,`range` 按配置取值。
|
||||
5. 如果配置了星图视频参数组合,再按每一组星图视频参数分别调用 `get_author_spread_info`。
|
||||
6. 把每次接口返回值解析成 7 个指标。
|
||||
7. CSV 保留原有字段顺序,在现有字段后追加这些带参数前缀的新字段。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 如果某个达人没有 `attribute_datas.id`,这一行的视频传播指标留空。
|
||||
- 如果某个参数组合请求失败,这一组参数对应的 7 个字段留空。
|
||||
- 如果接口响应结构异常,这一组参数对应的 7 个字段留空。
|
||||
- 某个达人失败不能影响其他达人导出。
|
||||
- 某组参数失败不能影响同一个达人的其他参数组导出。
|
||||
|
||||
## 性能要求
|
||||
|
||||
这个功能会产生比较多接口请求:
|
||||
|
||||
```text
|
||||
请求数 = 导出的达人数量 * 参数组合数量
|
||||
```
|
||||
|
||||
所以实现时需要:
|
||||
|
||||
- 做并发限制,避免一次性打太多请求。
|
||||
- 保持最终 CSV 行顺序和原导出顺序一致。
|
||||
- 给每个请求设置超时时间。
|
||||
- 第一版不做激进重试,避免接口压力过大。
|
||||
|
||||
## 测试要求
|
||||
|
||||
需要补充测试覆盖:
|
||||
|
||||
- `get_author_spread_info` URL 参数构造是否正确。
|
||||
- `type=1` 生成 `个人视频` 前缀。
|
||||
- `type=2` 生成 `星图视频` 前缀。
|
||||
- 个人视频是否固定使用:`type=1`、`flow_type=0`、`only_assign=false`。
|
||||
- 个人视频是否支持切换 `range=2` 和 `range=3`。
|
||||
- 个人视频字段名前缀是否不包含固定参数 `不限指派` 和 `不排除营销流量`。
|
||||
- 星图视频是否支持多组参数组合。
|
||||
- `only_assign`、`flow_type`、`range` 前缀是否正确。
|
||||
- 是否从 `attribute_datas.id` 读取 `o_author_id`。
|
||||
- 多组参数是否分别生成 7 个字段。
|
||||
- 响应字段是否正确映射到 7 个导出指标。
|
||||
- 接口失败时是否导出空字段。
|
||||
- 多个达人并发请求完成顺序不一致时,最终 CSV 行顺序是否保持不变。
|
||||
|
||||
## 暂不做的事情
|
||||
|
||||
- 暂不新增页面上的参数配置 UI。
|
||||
- 暂不改变星图搜索页原本的筛选条件。
|
||||
- 暂不改变现有后端指标字段。
|
||||
- 暂不改变批次提交 payload。
|
||||
@@ -0,0 +1,101 @@
|
||||
# 星图达人传播指标阈值筛选需求文档
|
||||
|
||||
## 目标
|
||||
|
||||
在导出 CSV 或提交批次之前,允许用户按一组视频传播数据参数和指标阈值对达人做二次筛选。
|
||||
|
||||
只有满足筛选条件的达人,才进入最终导出或提交批次。
|
||||
|
||||
## 筛选维度
|
||||
|
||||
筛选维度对应 `get_author_spread_info` 的请求参数:
|
||||
|
||||
| UI 维度 | 接口参数 | 可选值 |
|
||||
| --- | --- | --- |
|
||||
| 视频类别 | `type` | 个人视频 / 星图视频 |
|
||||
| 是否指派 | `only_assign` | 只看指派 / 不限指派 |
|
||||
| 是否排除营销流量 | `flow_type` | 排除营销流量 / 不排除营销流量 |
|
||||
| 数据范围 | `range` | 近30天 / 近90天 |
|
||||
|
||||
个人视频的参数约束:
|
||||
|
||||
- `type=1`
|
||||
- `only_assign=false`
|
||||
- `flow_type=0`
|
||||
- `range` 可选近30天或近90天
|
||||
|
||||
星图视频的参数约束:
|
||||
|
||||
- `type=2`
|
||||
- `only_assign` 可选
|
||||
- `flow_type` 可选
|
||||
- `range` 可选近30天或近90天
|
||||
|
||||
## 指标阈值
|
||||
|
||||
支持下面 7 个指标阈值:
|
||||
|
||||
- 完播率 >=
|
||||
- 播放量中位数 >=
|
||||
- 互动率 >=
|
||||
- 作品平均时长 >=
|
||||
- 作品平均评论数 >=
|
||||
- 作品平均点赞数 >=
|
||||
- 作品平均转发数 >=
|
||||
|
||||
规则:
|
||||
|
||||
- 没填的阈值不参与筛选。
|
||||
- 填了多个阈值时,必须全部满足才保留达人。
|
||||
- 完播率和互动率使用百分数显示值,例如填 `30` 表示 `30%`。
|
||||
- 作品平均时长使用秒,例如填 `56` 表示 `56秒`。
|
||||
- 播放量、评论、点赞、转发使用普通数字。
|
||||
- 如果某个达人在所选参数组合下接口请求失败或缺少被启用的指标,则视为不满足筛选。
|
||||
|
||||
## 生效范围
|
||||
|
||||
阈值筛选同时作用于:
|
||||
|
||||
- 导出 CSV
|
||||
- 提交批次
|
||||
|
||||
处理顺序:
|
||||
|
||||
1. 先按现有导出范围收集达人,例如当前页、前5页、前10页、全部或自定义页数。
|
||||
2. 如果用户没有填写任何阈值,保持现有导出/提交行为。
|
||||
3. 如果用户填写了阈值,对收集到的每个达人按当前筛选维度调用一次 `get_author_spread_info`。
|
||||
4. 将接口响应映射为显示值。
|
||||
5. 用已填写的阈值过滤达人。
|
||||
6. 过滤后的达人进入导出 CSV 或提交批次。
|
||||
|
||||
## UI 设计
|
||||
|
||||
在现有插件操作区中增加一组紧凑控件:
|
||||
|
||||
- 视频类别下拉框
|
||||
- 指派下拉框
|
||||
- 营销流量下拉框
|
||||
- 数据范围下拉框
|
||||
- 7 个数字输入框
|
||||
|
||||
当视频类别选择“个人视频”时:
|
||||
|
||||
- 指派固定为“不限指派”
|
||||
- 营销流量固定为“不排除营销流量”
|
||||
- 对应控件禁用
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 单个达人筛选请求失败:该达人不满足筛选。
|
||||
- 全部达人都不满足:导出空 CSV 表头;提交批次时按现有空记录处理。
|
||||
- 阈值输入非法:阻止导出/提交,并提示用户修正。
|
||||
|
||||
## 测试要求
|
||||
|
||||
- 读取 toolbar 中的筛选参数和阈值。
|
||||
- 个人视频禁用指派和营销流量控件。
|
||||
- 空阈值时不触发二次筛选。
|
||||
- 有阈值时按所选参数调用 `get_author_spread_info`。
|
||||
- 百分比阈值按显示值比较。
|
||||
- 多个阈值按 AND 关系过滤。
|
||||
- 导出 CSV 和提交批次都应用二次筛选。
|
||||
@@ -0,0 +1,172 @@
|
||||
# 传播指标独立视频口径筛选设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前工具栏把视频类型、是否指派、是否排除营销流量和时间范围作为一套全局视频口径,再把完播率和互动率阈值同时应用到这套口径上。
|
||||
|
||||
正确的业务关系应为:用户先选择筛选指标,每个已选指标再分别配置阈值和一套独立视频口径。例如,完播率可以使用“星图视频、只看指派、近30天”,互动率同时使用“个人视频、近90天”。
|
||||
|
||||
## 目标
|
||||
|
||||
- 当前只提供完播率和互动率两个可选筛选指标。
|
||||
- 默认不选择任何指标,不改变现有导出和提交结果。
|
||||
- 支持同时选择两个指标。
|
||||
- 每个已选指标必须填写阈值并配置一套独立视频口径。
|
||||
- 同时启用多个指标时,达人必须满足全部指标规则才会被保留。
|
||||
- CSV 导出和提交批次复用相同的筛选结果。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不新增完播率和互动率之外的筛选指标。
|
||||
- 不修改传播数据 CSV 的可选字段和字段命名。
|
||||
- 不修改批次提交接口或批次 payload。
|
||||
- 不修改星图 `get_author_spread_info` 的接口参数和响应字段映射。
|
||||
- 不调整导出范围、选择达人、字段选择等其他工具栏能力。
|
||||
|
||||
## 前端交互
|
||||
|
||||
### 初始状态
|
||||
|
||||
“传播指标筛选”区域显示完播率、互动率两个复选框,默认均未选中。
|
||||
|
||||
未选中任何指标时:
|
||||
|
||||
- 不显示指标规则行。
|
||||
- 不调用传播指标接口。
|
||||
- 导出 CSV 和提交批次保持原行为。
|
||||
|
||||
### 动态规则行
|
||||
|
||||
勾选一个指标后,显示该指标的规则行。规则行从左到右包含:
|
||||
|
||||
1. 指标名称。
|
||||
2. `>=` 阈值输入框和 `%` 单位。
|
||||
3. 视频类型:个人视频或星图视频。
|
||||
4. 是否只看指派:不限指派或只看指派。
|
||||
5. 是否排除营销流量:不排除营销或排除营销。
|
||||
6. 时间范围:近30天或近90天。
|
||||
|
||||
新规则行沿用现有默认口径:个人视频、不限指派、不排除营销、近30天。阈值默认为空,必须由用户填写。
|
||||
|
||||
取消勾选指标后,移除并丢弃对应规则,该指标不参与筛选。重新勾选时按上述默认值创建新规则,不恢复之前输入的阈值和口径。
|
||||
|
||||
同时勾选完播率和互动率时,显示两条互相独立的规则行。界面明确提示“全部规则都达标才保留达人”。
|
||||
|
||||
### 个人视频约束
|
||||
|
||||
每条规则独立应用现有个人视频约束。选择个人视频后:
|
||||
|
||||
- `onlyAssign` 自动设为 `false`,显示“不限指派”。
|
||||
- `flowType` 自动设为 `0`,显示“不排除营销”。
|
||||
- 是否指派和营销流量两个下拉框禁用。
|
||||
- 时间范围仍可选择近30天或近90天。
|
||||
|
||||
切换回星图视频后,重新启用是否指派和营销流量下拉框。
|
||||
|
||||
### 输入校验
|
||||
|
||||
每个已选指标都必须填写一个大于或等于 `0` 的有限数值阈值。阈值为空、非数字或小于 `0` 时:
|
||||
|
||||
- 阻止导出 CSV 或提交批次。
|
||||
- 在对应指标规则行展示校验状态。
|
||||
- 工具栏状态区域给出可理解的错误提示。
|
||||
|
||||
未选中的指标不参与校验。
|
||||
|
||||
## 数据模型
|
||||
|
||||
筛选模型从“一套全局口径 + 多个可选阈值”改为规则数组:
|
||||
|
||||
```ts
|
||||
type SpreadFilterMetric = "finishRate" | "interactionRate";
|
||||
|
||||
interface SpreadMetricFilterRule {
|
||||
config: SpreadInfoConfig;
|
||||
metric: SpreadFilterMetric;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
interface SpreadThresholdFilter {
|
||||
rules: SpreadMetricFilterRule[];
|
||||
}
|
||||
```
|
||||
|
||||
每条规则完整表达“用哪套视频口径读取哪个指标,并与什么阈值比较”。同一指标最多出现一条规则。
|
||||
|
||||
`SpreadInfoConfig` 从 `spread-info.ts` 移到 `types.ts`,由工具栏、筛选模型和传播接口客户端共同引用,避免公共筛选类型反向依赖接口实现模块。
|
||||
|
||||
传播数据导出仍保留完整的 `MappedSpreadInfoResponse` 和七项传播指标映射。筛选规则类型只收窄筛选入口,不影响已有 CSV 导出能力。
|
||||
|
||||
## 筛选执行
|
||||
|
||||
### 处理顺序
|
||||
|
||||
1. 从工具栏读取已选指标,生成 `SpreadMetricFilterRule[]`。
|
||||
2. 没有规则时直接返回原达人集合。
|
||||
3. 按现有导出范围或已选达人规则收集候选达人。
|
||||
4. 对每个达人按规则中的 `config` 分组。
|
||||
5. 每个唯一 `config` 调用一次 `get_author_spread_info`。
|
||||
6. 从返回快照中读取每条规则对应的指标。
|
||||
7. 使用显示百分数值与阈值做 `>=` 比较。
|
||||
8. 对同一达人的所有规则执行 AND 汇总。
|
||||
9. 只有全部规则通过的达人进入 CSV 或批次 payload。
|
||||
|
||||
### 请求复用
|
||||
|
||||
如果完播率和互动率使用完全相同的视频口径,同一达人只请求一次接口,并从同一响应中分别读取 `play_over_rate.value` 和 `interact_rate.value`。
|
||||
|
||||
如果两条规则的视频口径不同,同一达人分别请求两次。
|
||||
|
||||
视频口径是否相同由 `type`、`onlyAssign`、`flowType` 和 `range` 四个规范化后的参数共同决定。个人视频规则必须在分组前规范化为 `onlyAssign=false`、`flowType=0`。
|
||||
|
||||
### 指标映射
|
||||
|
||||
- 完播率规则读取映射后的 `finishRate`,来源为 `data.play_over_rate.value`。
|
||||
- 互动率规则读取映射后的 `interactionRate`,来源为 `data.interact_rate.value`。
|
||||
- 两个接口值继续沿用现有基点百分比格式化逻辑,再与用户输入的显示百分数比较。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 达人缺少传播指标请求所需 ID:该达人不满足筛选。
|
||||
- 单次请求失败、超时或返回非成功状态:依赖该口径的规则不通过。
|
||||
- 响应缺少已选指标:对应规则不通过。
|
||||
- 一条规则不通过:该达人因 AND 关系被排除。
|
||||
- 单个达人失败不终止整批导出或提交,其他达人继续处理。
|
||||
- 没有达人满足规则时,保持现有空结果处理方式,不扩大本次改动范围。
|
||||
|
||||
## 组件与代码边界
|
||||
|
||||
- `plugin-toolbar.ts`:指标复选框、动态规则行、个人视频联动、逐行校验和规则读取。
|
||||
- `types.ts`:共享的 `SpreadInfoConfig`、筛选指标联合类型、单条规则类型和规则数组模型。
|
||||
- `spread-info.ts`:复用现有请求构造与响应映射;提供单指标规则比较或等价的可测试纯函数。
|
||||
- `index.ts`:按唯一口径加载快照、对每个达人执行规则 AND 判断,并让导出和提交共享该流程。
|
||||
- 现有 CSV 字段定义、批次 payload 构造和外部接口契约不变。
|
||||
|
||||
## 测试与验收
|
||||
|
||||
### 工具栏测试
|
||||
|
||||
- 默认两个指标均未选中,不显示规则行。
|
||||
- 勾选或取消完播率、互动率时,正确新增或移除对应规则行。
|
||||
- 两个规则行可以保存不同阈值和不同视频口径。
|
||||
- 个人视频自动规范化并禁用受限选项;星图视频重新启用选项。
|
||||
- 已选指标缺少合法阈值时返回逐行校验错误。
|
||||
- 未选择任何指标时读取到空规则集。
|
||||
|
||||
### 纯逻辑测试
|
||||
|
||||
- 单规则按对应指标和显示百分数正确比较。
|
||||
- 两条规则均通过时返回通过。
|
||||
- 任意一条规则不通过或缺少指标值时返回不通过。
|
||||
- 相同视频口径生成相同分组键,不同口径生成不同分组键。
|
||||
- 个人视频在分组前强制规范化固定参数。
|
||||
|
||||
### 集成测试
|
||||
|
||||
- 未选择指标时,导出和提交均不调用传播指标筛选请求。
|
||||
- 两个指标使用不同口径时,每个达人发起两个请求并按 AND 过滤。
|
||||
- 两个指标使用相同口径时,每个达人只发起一个请求并复用响应。
|
||||
- 请求失败、缺少传播 ID 或缺少指标值的达人被排除,不中断整批处理。
|
||||
- CSV 导出和批次提交得到相同的保留达人集合。
|
||||
- 现有传播数据导出、工具栏挂载和批次 payload 测试继续通过。
|
||||
- 项目类型检查、测试和构建通过。
|
||||
@@ -0,0 +1,102 @@
|
||||
# 传播指标筛选布局重设计
|
||||
|
||||
## 定位
|
||||
|
||||
本文件是 [传播指标独立视频口径筛选设计](2026-07-10-independent-spread-metric-filter-design.md)
|
||||
的界面补充。它只重做星图插件市场页工具栏中“传播指标筛选”的展示和选取方式,
|
||||
不改变已实现的独立指标口径、规则数组、筛选执行、CSV 导出或批次提交。
|
||||
|
||||
当前 `plugin-toolbar.ts` 已通过 `SPREAD_FILTER_DEFINITIONS` 提供完播率和互动率,
|
||||
并让 `SpreadMetricRuleDom` 为每个已选指标保留独立阈值和视频口径。本次继续使用这
|
||||
套状态和 DOM 规则,不重建筛选数据模型。
|
||||
|
||||
## 目标
|
||||
|
||||
避免在工具栏初始状态平铺全部可筛选指标和完整条件。用户只在需要时选择指标,主区
|
||||
只展示已经生效的指标规则。
|
||||
|
||||
即使未来 `SPREAD_FILTER_DEFINITIONS` 增加更多指标,工具栏高度也只随已选规则增加,
|
||||
不会随可选指标总数线性增长。
|
||||
|
||||
## 桌面端交互
|
||||
|
||||
“传播指标筛选”标题行由以下内容组成:
|
||||
|
||||
1. “添加筛选指标”按钮。
|
||||
2. 已选规则数量,例如“已选 2 项”。
|
||||
3. 仅在同时有两条及以上规则时显示“全部规则都达标才保留达人”。
|
||||
|
||||
点击“添加筛选指标”后,打开锚定按钮的指标目录。目录提供搜索框和分组列表;当前
|
||||
完播率、互动率归在“传播表现”分组。未选择的指标显示添加操作,已选择的指标显示
|
||||
“已添加”且不可重复选取。
|
||||
|
||||
选择一个指标后关闭目录,并在下方追加该指标现有的完整规则行。规则行继续包含:
|
||||
|
||||
1. 指标名称。
|
||||
2. `>=` 阈值输入和 `%` 单位。
|
||||
3. 视频类型。
|
||||
4. 是否只看指派。
|
||||
5. 是否排除营销流量。
|
||||
6. 时间范围。
|
||||
7. 删除按钮。
|
||||
|
||||
删除按钮取消该指标的现有 `enabledInput` 状态、移除规则行,并使指标在目录中重新
|
||||
可选。初始无选项时不显示任何规则行,只保留添加入口和简短空状态。
|
||||
|
||||
## 窄屏交互
|
||||
|
||||
窄屏时,指标目录不占用固定侧栏空间。点击“添加筛选指标”后,目录作为标题行下方
|
||||
的可收起抽屉展开;选择、搜索或关闭后恢复收起状态。规则行保留指标、阈值和视频
|
||||
类型,指派、营销和时间范围进入同一行的二级展开区,避免横向挤压或遮挡。
|
||||
|
||||
桌面端使用按钮锚定浮层,窄屏使用抽屉;两种视图读取同一组 `enabledInput` 和
|
||||
`SpreadMetricRuleDom`,不能分别保存选择状态。
|
||||
|
||||
## 代码边界
|
||||
|
||||
### 修改范围
|
||||
|
||||
- `src/content/market/plugin-toolbar.ts`
|
||||
- 用 `MetricCatalog` 替代常驻复选框式 `createSpreadMetricSelector()`。
|
||||
- 保留 `createSpreadMetricRuleDom()`、`syncSpreadMetricRuleState()`、
|
||||
`readToolbarSpreadFilter()` 和 `SpreadMetricRuleDomMap` 的规则读写职责。
|
||||
- 在添加、删除、初始化和工具栏重挂载时,同步目录中的已选状态和规则数量。
|
||||
|
||||
- `tests/market-content-entry.test.ts`
|
||||
- 覆盖初始空状态、从目录添加、重复项不可添加、删除后恢复可选、窄屏抽屉开关。
|
||||
|
||||
### 不修改范围
|
||||
|
||||
- `src/content/market/types.ts` 中的 `SpreadFilterMetric`、`SpreadMetricFilterRule`、
|
||||
`SpreadThresholdFilter` 和 `SpreadInfoConfig`。
|
||||
- `src/content/market/index.ts` 中的 `applySpreadThresholdFilter()`、规则 AND 判定、
|
||||
相同口径请求复用、CSV 导出和批次提交流程。
|
||||
- `src/content/market/spread-info.ts` 的星图接口参数、字段映射和百分比比较。
|
||||
|
||||
## 状态约束
|
||||
|
||||
1. `SPREAD_FILTER_DEFINITIONS` 是可选指标目录的唯一来源;目录不得再维护一份独立
|
||||
的指标清单。
|
||||
2. 一个 `SpreadFilterMetric` 至多有一条规则,目录已选状态由对应
|
||||
`enabledInput.checked` 推导。
|
||||
3. 从目录添加指标时,沿用既有默认口径:个人视频、不限指派、不排除营销、近 30 天;
|
||||
阈值为空,必须由用户填写。
|
||||
4. 删除后重新添加同一指标,按上述默认值创建,不恢复已删除的阈值或口径。
|
||||
5. 未选指标不参与 `readToolbarSpreadFilter()` 校验,也不能触发传播指标请求。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 初始工具栏不再显示一排常驻指标复选框和空规则行,只显示“添加筛选指标”入口。
|
||||
2. 选中完播率后,页面只增加一条完播率规则;选中互动率后才增加第二条规则。
|
||||
3. 指标目录内已添加项不可再次添加;删除后立即恢复可选。
|
||||
4. 已选数量、规则行、`enabledInput` 和 `readToolbarSpreadFilter()` 返回的规则数组
|
||||
始终一致。
|
||||
5. 已选规则的阈值校验、个人视频联动、不同口径请求、规则 AND 筛选、CSV 导出和
|
||||
批次提交结果与改版前保持一致。
|
||||
6. 窄屏打开和关闭指标抽屉时不发生内容重叠、横向溢出或大块无效留白。
|
||||
|
||||
## 验证
|
||||
|
||||
实现阶段先新增工具栏交互测试并观察其在旧布局下失败,再完成最小 UI 改造。之后运行
|
||||
相关市场页测试、项目类型检查和构建,并在桌面与窄屏实测添加、删除、重复选择和导出
|
||||
前校验。现有独立口径筛选的回归用例必须全部继续通过。
|
||||
@@ -0,0 +1,76 @@
|
||||
# 传播指标筛选与批量操作重设计
|
||||
|
||||
## 目标
|
||||
|
||||
重设计星图达人市场页插件注入的工具栏红框区域。保留全部现有导出和批量能力,将它们按任务边界排列;将传播指标筛选改成独立的条件构建区,减少操作与筛选条件混排造成的阅读负担。
|
||||
|
||||
本设计仅覆盖 `src/content/market/plugin-toolbar.ts` 生成的插件工具栏,不改变星图原生筛选区、后端接口、导出数据结构或批次提交协议。
|
||||
|
||||
## 页面结构
|
||||
|
||||
工具栏始终分为两行。
|
||||
|
||||
### 第一行:导出与批量操作
|
||||
|
||||
左侧展示结果计数,右侧可继续展示已有的导出状态。中间操作按以下顺序分成四组,组间使用细分隔线:
|
||||
|
||||
1. **列表 CSV 导出**:`导出CSV` 紧邻导出范围下拉框。范围选项保持 `当前页`、`前5页`、`前10页`、`全部`、`自定义`,默认仍为 `前5页`;选择 `自定义` 后显示页数输入框。
|
||||
2. **已选达人数据导出**:`导出选中达人数据` 与 `选择字段` 相邻。字段配置自动保存,且仅影响该类达人数据导出。
|
||||
3. **星图 ID 导出**:保留 `按星图ID导出` 独立入口,打开既有的 ID 输入流程,不依赖当前列表勾选。
|
||||
4. **批次操作**:`提交批次` 是该行唯一的高强调主按钮;其余按钮维持次级强调。
|
||||
|
||||
`导出CSV` 的范围只影响当前筛选结果的分页导出,不能影响“导出选中达人数据”或“按星图ID导出”。
|
||||
|
||||
### 第二行:传播表现筛选
|
||||
|
||||
第二行标题为“传播表现筛选”,标题旁展示已启用指标数。右侧提供“添加筛选指标”入口,打开既有指标目录;未启用的指标不显示空规则行。
|
||||
|
||||
每条已启用规则包含指标名、`≥`、阈值输入、视频类型、更多选项和删除按钮。桌面端直接显示视频类型、指派范围与时间范围;窄屏端沿用“更多选项”收起次级控件的模式。
|
||||
|
||||
当存在两条及以上规则时,显示 `AND` 关系说明:所有规则都达标才保留达人。
|
||||
|
||||
## 默认状态
|
||||
|
||||
首次加载时默认启用且仅启用一条规则:
|
||||
|
||||
| 项目 | 默认值 |
|
||||
| --- | --- |
|
||||
| 指标 | 完播率 (`finishRate`) |
|
||||
| 阈值 | 留空,用户必须填写 |
|
||||
| 视频类型 | 星图视频 (`type: 2`) |
|
||||
| 指派范围 | 只看指派 (`onlyAssign: true`) |
|
||||
| 时间范围 | 近30天 (`range: 2`) |
|
||||
| 营销过滤 | 不排除营销 (`flowType: 0`) |
|
||||
|
||||
互动率 (`interactionRate`) 初始时只出现在“添加筛选指标”目录中。添加后使用同一套独立规则结构与默认上下文;其阈值同样留空。
|
||||
|
||||
启用规则但未填写阈值时,执行依赖传播指标筛选的导出或批次操作必须显示现有的阈值校验错误,不能静默移除该规则或改用未筛选结果。
|
||||
|
||||
## 交互与状态
|
||||
|
||||
- 点击“添加筛选指标”后,已添加指标显示“已添加”且不可重复添加。
|
||||
- 删除一条规则后,清空其阈值并恢复该指标在目录中的“添加”状态。
|
||||
- 规则新增或删除后,已选计数和 `AND` 说明立即同步。
|
||||
- 每次规则重新添加时,应用本设计规定的默认上下文,但不预填阈值。
|
||||
- 工具栏忙碌时,现有导出、批次、范围、字段和筛选控件均不可操作;操作完成后恢复。
|
||||
|
||||
## 视觉原则
|
||||
|
||||
- 使用克制的白底、灰色边界与星图现有的粉色强调色;不增加新的侧栏或浮层任务区。
|
||||
- 第一行优先服务导出和批量执行,第二行优先服务条件配置,避免两个工作流争夺同一行空间。
|
||||
- `提交批次` 与“添加筛选指标”可使用强调色;删除是低强调的图标操作;其他按钮为中性描边按钮。
|
||||
- 通过文本标签与图标共同表达“选择字段”和“按星图ID导出”,不以图标替代功能名称。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 第一行完整保留 `导出CSV`、范围选择、`导出选中达人数据`、`选择字段`、`按星图ID导出` 和 `提交批次`,且导出范围仅与 `导出CSV` 相邻。
|
||||
2. 初始页面只显示一条完播率规则,选中计数为“已选 1 项”。
|
||||
3. 初始规则上下文为星图视频、只看指派、近30天;完播率阈值为空。
|
||||
4. 未填写阈值时,现有校验能阻止将该启用规则作为有效过滤条件继续执行。
|
||||
5. 互动率不会在默认界面占位,只有通过目录添加后才显示。
|
||||
6. 删除和重新添加指标后,规则默认上下文、目录状态和已选计数均正确同步。
|
||||
7. 窄屏下操作组可换行,筛选规则的次级条件不会溢出或与其他控件重叠。
|
||||
|
||||
## 验证范围
|
||||
|
||||
实现时更新工具栏 DOM 测试,覆盖默认完播率规则、默认 `SpreadInfoConfig`、空阈值校验、互动率目录添加/删除、导出范围与五个既有操作入口的保留。运行聚焦的市场工具栏测试、传播指标测试和项目构建。
|
||||
@@ -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`。
|
||||
@@ -0,0 +1,705 @@
|
||||
# 项目流程说明文档
|
||||
|
||||
## 1. 项目用途
|
||||
|
||||
本项目是公司内部使用的 Chrome 插件,用于增强巨量星图达人市场页面的达人筛选、数据导出和批次提交效率。
|
||||
|
||||
它解决的业务问题是:使用者在星图达人市场中筛选达人后,可以直接在页面上补充查看看后搜率、秒思后台指标等数据,并把选中的达人导出为 CSV,或提交为后续业务处理批次。
|
||||
|
||||
项目输入包括:
|
||||
|
||||
- 巨量星图达人市场页面中的达人列表、筛选条件和分页结果;
|
||||
- 使用者在页面上勾选的达人(可选);
|
||||
- 使用者粘贴的达人星图 ID;
|
||||
- 使用者填写的批次名称;
|
||||
- 使用者选择的导出字段;
|
||||
- 当前插件登录用户的 Logto 身份和访问令牌。
|
||||
|
||||
项目处理过程包括:
|
||||
|
||||
- 在星图达人市场页面挂载插件工具栏;
|
||||
- 读取当前页面或星图列表接口返回的达人数据;
|
||||
- 根据勾选范围和分页范围确定最终达人集合;
|
||||
- 调用星图接口补充看后搜率、画像、商业能力、传播指标等信息;
|
||||
- 调用公司后端接口补充秒思 api 指标;
|
||||
- 生成 CSV 文件,或组装批次 payload 提交到后端。
|
||||
|
||||
项目输出包括:
|
||||
|
||||
- 页面上新增的数据列和状态提示;
|
||||
- 下载到本地的 CSV 文件;
|
||||
- 提交到后端的达人批次;
|
||||
- 插件弹窗中的登录状态和更新状态。
|
||||
|
||||
项目依赖的外部平台、数据源或服务包括:
|
||||
|
||||
- 巨量星图网页和星图接口;
|
||||
- 公司 Logto 登录系统;
|
||||
- 公司 talent-search 后端服务;
|
||||
- 本地或内网批次提交后端;
|
||||
- COS 上的插件更新清单和安装包。
|
||||
|
||||
主要使用者是 AIGC 部门或相关业务同事。通常在以下场景使用:
|
||||
|
||||
- 在星图市场中筛选达人后,需要快速导出达人数据;
|
||||
- 已知一批星图 ID,需要批量补齐画像、效果预估和内容指标;
|
||||
- 需要把一批达人提交给后续业务系统继续处理;
|
||||
- 需要检查或更新内部插件版本。
|
||||
|
||||
## 2. 整体流程总览
|
||||
|
||||
完整主流程按真实使用顺序如下:
|
||||
|
||||
1. 安装或更新插件;
|
||||
2. 登录插件;
|
||||
3. 打开巨量星图达人市场页面;
|
||||
4. 插件读取登录状态并挂载工具栏;
|
||||
5. 插件读取当前星图达人列表,并补充页面展示指标;
|
||||
6. 使用者选择达人、检索并勾选导出字段,或输入星图 ID;
|
||||
7. 使用者触发导出或提交批次;
|
||||
8. 插件收集达人数据,去重并补充已选字段;
|
||||
9. 插件调用星图接口和公司后端接口补充数据;
|
||||
10. 插件下载 CSV,或把批次提交给后端;
|
||||
11. 使用者通过状态提示、下载文件或后端批次结果确认任务完成。
|
||||
|
||||
### 2.1 安装或更新插件
|
||||
|
||||
- 触发者:使用者或管理员。
|
||||
- 输入:内部 ZIP 安装包,或插件弹窗中发现的新版本安装包。
|
||||
- 处理:解压 ZIP,在 Chrome 扩展页加载 `dist` 文件夹;更新时下载新版 ZIP 后人工重新加载插件。
|
||||
- 输出:Chrome 中安装好的 `Star Chart Search Enhancer` 插件。
|
||||
- 下一步:登录插件。
|
||||
- 人工操作:需要人工解压、加载或重新加载插件。
|
||||
- 条件分支:如果旧版本无法检查更新,需要做一次手动过桥升级。
|
||||
|
||||
### 2.2 登录插件
|
||||
|
||||
- 触发者:使用者点击插件弹窗中的登录按钮。
|
||||
- 输入:公司账号登录态。
|
||||
- 处理:通过 Logto 完成 Chrome 扩展登录,并获取访问后端资源的 token。
|
||||
- 输出:插件弹窗显示已登录状态,内容脚本后续可以挂载业务工具栏。
|
||||
- 下一步:打开星图达人市场页面。
|
||||
- 人工操作:需要使用者完成登录。
|
||||
- 条件分支:未登录或登录过期时,星图页面不会进入导出/提交流程,只显示登录提示。
|
||||
|
||||
### 2.3 打开星图达人市场页面
|
||||
|
||||
- 触发者:使用者访问星图市场页面。
|
||||
- 输入:星图网页登录态、当前页面筛选条件、星图市场列表。
|
||||
- 处理:插件仅在 `xingtu.cn` 域名下的达人市场页面生效;进入页面后先安装页面桥接逻辑,再检查插件登录状态。
|
||||
- 输出:页面上出现插件工具栏和增强列。
|
||||
- 下一步:读取达人列表并补充数据。
|
||||
- 人工操作:使用者需要在星图网页中完成筛选、搜索、翻页或勾选。
|
||||
- 条件分支:如果页面不是星图达人市场页面,插件不启动主流程。
|
||||
|
||||
### 2.4 页面增强和数据补充
|
||||
|
||||
- 触发者:星图页面加载、翻页、列表变化或插件同步周期。
|
||||
- 输入:页面可见达人行、星图列表接口返回值、当前登录用户 token。
|
||||
- 处理:插件读取达人 ID、名称、地区、报价等基础数据,补充看后搜率列和秒思指标列。
|
||||
- 输出:页面上显示加载中、成功、失败或暂无数据等状态。
|
||||
- 下一步:使用者选择导出、按 ID 导出或提交批次。
|
||||
- 人工操作:无。
|
||||
- 条件分支:如果某个指标加载失败,只影响该达人对应指标,不影响页面整体使用。
|
||||
|
||||
### 2.5 导出选中达人数据
|
||||
|
||||
- 触发者:使用者点击 `导出选中达人数据`。
|
||||
- 输入:可选的当前勾选达人、当前导出范围和字段选择配置。
|
||||
- 处理:选择 `全部` 且没有勾选达人时,插件从第 1 页收集当前筛选结果的全部分页并全部导出;只要存在勾选达人,则勾选优先,只保留导出范围内已勾选的达人。其他范围仍需要勾选达人。随后按字段选择配置补充内容数据、效果预估、画像、看后搜率和秒思指标。未勾选的数据类别不会调用对应补充接口;从未保存过字段配置时保持默认全量导出。
|
||||
- 输出:CSV 文件下载到浏览器默认下载目录。
|
||||
- 下一步:使用者检查 CSV 内容。
|
||||
- 人工操作:选择 `全部` 时可直接点击按钮;选择其他范围时需要先勾选达人。
|
||||
- 条件分支:存在勾选但全部范围内没有勾选达人时,不下载 CSV 并提示;单项补充失败不阻止下载,CSV 的 `导出状态` 和 `失败原因` 会标明部分成功及失败来源。
|
||||
|
||||
### 2.6 按星图 ID 导出
|
||||
|
||||
- 触发者:使用者点击 `按星图ID导出`。
|
||||
- 输入:弹窗中粘贴的达人星图 ID。
|
||||
- 处理:插件校验 ID 格式、去重、忽略非法 token,然后逐个 ID 获取基础信息,并按字段选择配置请求看后搜率、传播指标、画像、效果预估和后端秒思指标。未勾选的数据类别不调用对应补充接口。
|
||||
- 输出:CSV 文件下载到浏览器默认下载目录。
|
||||
- 下一步:使用者检查 CSV 中每个 ID 的导出状态和失败原因。
|
||||
- 人工操作:需要使用者粘贴 ID 并确认。
|
||||
- 条件分支:如果没有有效 ID,不执行导出并提示。
|
||||
|
||||
### 2.7 提交批次
|
||||
|
||||
- 触发者:使用者点击 `提交批次`。
|
||||
- 输入:当前范围或已勾选达人、批次名称、登录用户信息。
|
||||
- 处理:插件先要求输入批次名称,再按当前范围收集达人数据。选择 `全部` 且没有勾选达人时,提交当前筛选结果的全部分页;只要存在勾选达人,则勾选优先,不会回退为全部结果。最后检查登录状态,组装批次 payload,提交到后端。
|
||||
- 输出:后端生成批次;页面显示 `批次提交成功` 或失败原因。
|
||||
- 下一步:在后端系统中继续处理批次。
|
||||
- 人工操作:需要使用者输入批次名称。
|
||||
- 条件分支:未登录、批次名为空、后端拒绝或接口失败都会导致本次提交失败。
|
||||
|
||||
## 3. 详细流程说明
|
||||
|
||||
### 3.1 插件安装与更新
|
||||
|
||||
- 步骤目的:让使用者在 Chrome 中获得可运行的内部插件。
|
||||
- 输入内容:内部发布 ZIP、安装说明 PDF、Chrome 浏览器。
|
||||
- 处理规则:
|
||||
- 首次安装需要解压 ZIP;
|
||||
- Chrome 加载的是解压后的 `dist` 文件夹;
|
||||
- 更新时仍然需要人工下载、解压并重新加载;
|
||||
- 扩展 ID 应为 `pkjopdibdnomhogjheclhnknmejccffg`。
|
||||
- 输出结果:Chrome 扩展列表中出现正确插件。
|
||||
- 外部依赖:Chrome 扩展能力;COS 更新文件。
|
||||
- 失败后如何处理:安装失败不会影响外部数据;使用者无法进入后续流程。
|
||||
- 是否影响后续步骤:影响。未安装或安装版本不正确时,后续导出和提交不可用。
|
||||
|
||||
### 3.2 插件登录
|
||||
|
||||
- 步骤目的:获得访问公司后端和受保护接口所需的用户身份。
|
||||
- 输入内容:公司登录账号、Logto 登录配置。
|
||||
- 处理规则:
|
||||
- 登录通过 Chrome identity 回调完成;
|
||||
- 插件读取用户 `sub`、用户名、资源地址和 scope;
|
||||
- 内容脚本进入星图页面时会先读取登录状态。
|
||||
- 输出结果:已登录状态、可用 access token。
|
||||
- 外部依赖:Logto 登录系统。
|
||||
- 失败后如何处理:星图页面显示登录提示;不挂载业务工具栏。
|
||||
- 是否影响后续步骤:影响。批次提交和后端指标查询都依赖 token。
|
||||
|
||||
### 3.3 星图市场页面启动
|
||||
|
||||
- 步骤目的:只在正确页面启用插件能力。
|
||||
- 输入内容:当前浏览器 URL、页面 DOM、星图页面列表请求。
|
||||
- 处理规则:
|
||||
- 只匹配巨量星图达人市场页面;
|
||||
- 进入页面后先安装桥接逻辑,用于捕获星图市场列表请求和页面列表数据;
|
||||
- 未登录时不进入业务控制流程;
|
||||
- 已登录时挂载工具栏和新增列。
|
||||
- 输出结果:插件工具栏、选择框、增强数据列。
|
||||
- 外部依赖:星图网页结构和浏览器内容脚本能力。
|
||||
- 失败后如何处理:如果页面结构变化导致无法挂载,相关功能不可用;未确认是否有统一错误上报。
|
||||
- 是否影响后续步骤:影响。工具栏未挂载时无法导出或提交。
|
||||
|
||||
### 3.4 读取达人列表
|
||||
|
||||
- 步骤目的:确定当前页面或导出范围内有哪些达人。
|
||||
- 输入内容:星图页面列表行、星图列表接口返回值、当前分页状态。
|
||||
- 处理规则:
|
||||
- 优先从星图市场列表接口返回中读取达人;
|
||||
- 关键字段包括达人 ID、达人名称、星图内部传播接口 ID、核心用户 ID、地区、报价和页面可导出字段;
|
||||
- 如果没有捕获到接口请求,则从页面 DOM 读取可见行;
|
||||
- 页面翻页导出时等待页面稳定后再读取;
|
||||
- 同一个达人重复出现时按达人 ID 合并。
|
||||
- 输出结果:标准化后的达人记录集合。
|
||||
- 外部依赖:星图市场列表接口和页面 DOM。
|
||||
- 失败后如何处理:
|
||||
- 单页加载超时会终止本次范围导出;
|
||||
- 单条达人缺少 ID 或名称会被跳过;
|
||||
- 捕获接口失败时退回页面翻页读取。
|
||||
- 是否影响后续步骤:影响。没有达人记录时,导出或提交结果为空或失败。
|
||||
|
||||
### 3.5 页面指标补充
|
||||
|
||||
- 步骤目的:在列表页直接显示补充指标,方便筛选和排序。
|
||||
- 输入内容:当前页面达人 ID。
|
||||
- 处理规则:
|
||||
- 看后搜率优先使用星图列表中已有值;
|
||||
- 如果列表值不完整,再调用星图看后搜率相关接口;
|
||||
- 秒思指标按当前页达人 ID 批量查询后端;
|
||||
- 已成功或已判定缺失的后端指标在当前页面会话中不重复查询;
|
||||
- 指标列支持页面内排序。
|
||||
- 输出结果:页面增强列显示成功值、加载中、加载失败或暂无数据。
|
||||
- 外部依赖:星图接口、公司后端指标接口。
|
||||
- 失败后如何处理:单个达人指标失败只显示失败,不阻塞其他达人。
|
||||
- 是否影响后续步骤:部分影响。导出时会复用已缓存指标,缺失时可能再次补充。
|
||||
|
||||
### 3.6 导出选中达人数据
|
||||
|
||||
- 步骤目的:把使用者选定的达人数据导出为 CSV。
|
||||
- 输入内容:可选的已勾选达人、当前导出范围和字段选择配置。
|
||||
- 处理规则:
|
||||
- 选择 `全部` 且没有勾选达人时,从第 1 页收集并导出当前筛选结果的全部分页;
|
||||
- 存在勾选达人时,勾选优先,严格保留导出范围内已勾选的达人;
|
||||
- 选择当前页、前 5 页、前 10 页或自定义页数且没有勾选时,提示并停止;
|
||||
- 只补充已选字段对应的画像、效果预估、传播指标、看后搜率和秒思指标,未选数据类别不调用对应接口;
|
||||
- 基础字段、导出状态和失败原因等固定保留;
|
||||
- 画像请求全部失败时仍下载 CSV,已成功的效果预估等字段正常写入;
|
||||
- 单个达人部分接口失败时,CSV 保留该行,并写入导出状态和失败原因。
|
||||
- 输出结果:CSV 文件。
|
||||
- 外部依赖:星图画像接口、商业能力接口、传播指标接口、公司后端指标接口、浏览器下载能力。
|
||||
- 失败后如何处理:
|
||||
- 非 `全部` 范围没有勾选达人:提示并停止;
|
||||
- 存在勾选但全部范围内无选中达人:提示并停止;
|
||||
- 单个达人部分失败:记录为部分成功或失败;
|
||||
- 画像失败:对应画像单元格留空,失败来源写入 `失败原因`,其他成功数据仍保留并下载。
|
||||
- 是否影响后续步骤:不影响外部系统写入;只影响本次下载结果。
|
||||
|
||||
### 3.7 按星图 ID 导出
|
||||
|
||||
- 步骤目的:在不依赖当前星图列表勾选的情况下,批量导出指定 ID 的达人数据。
|
||||
- 输入内容:使用者粘贴的星图 ID 文本。
|
||||
- 处理规则:
|
||||
- 支持用空格、换行、英文逗号、中文逗号、英文分号、中文分号分隔;
|
||||
- 只接受 16 到 20 位纯数字;
|
||||
- 重复 ID 会去重;
|
||||
- 非法 token 会计入提示,但不会进入导出;
|
||||
- 对有效 ID 逐个获取基础信息,只补齐已选字段对应的看后搜率、传播指标、画像、效果预估和后端秒思指标;
|
||||
- 每个 ID 生成一行 CSV,并标记成功、部分成功或失败。
|
||||
- 输出结果:按 ID 导出的 CSV 文件。
|
||||
- 外部依赖:星图基础信息接口、星图指标接口、公司后端指标接口、浏览器下载能力。
|
||||
- 失败后如何处理:
|
||||
- 没有有效 ID:提示并停止;
|
||||
- 单个 ID 的部分接口失败:保留该行并写失败原因;
|
||||
- 整体流程异常:提示按 ID 导出失败。
|
||||
- 是否影响后续步骤:不写入外部系统,不影响批次。
|
||||
|
||||
### 3.8 批次提交
|
||||
|
||||
- 步骤目的:把达人集合提交给后续业务系统。
|
||||
- 输入内容:导出范围内达人、已选达人、批次名称和登录用户信息。
|
||||
- 处理规则:
|
||||
- 点击后先输入批次名称;
|
||||
- 取消输入则停止;
|
||||
- 批次名为空则提示并停止;
|
||||
- 选择 `全部` 且没有勾选达人时,从第 1 页提交当前筛选结果的全部分页;
|
||||
- 选择 `全部` 且存在已勾选达人时,严格提交全部范围内已勾选达人;
|
||||
- 选择 `全部` 时,如果已勾选达人不在结果内,则提示并停止,不会回退为提交全部达人;
|
||||
- 前端不生成批次 ID,批次 ID 由后端生成;
|
||||
- payload 包含登录用户 ID、创建人名称、资源地址、批次名称、创建时间和达人列表;
|
||||
- 达人列表包含达人 ID、达人名称,存在核心用户 ID 时额外带上。
|
||||
- 输出结果:后端批次记录;页面提示提交成功或失败。
|
||||
- 外部依赖:Logto token、批次提交后端。
|
||||
- 失败后如何处理:
|
||||
- 未登录:提示请先登录插件;
|
||||
- token 不可用:提交失败;
|
||||
- 401 或 403:提交失败并返回未授权错误;
|
||||
- 后端返回非成功:提交失败并显示错误;
|
||||
- 网络失败:提交失败。
|
||||
- 是否影响后续步骤:影响外部后端数据。是否会重复创建批次取决于后端,当前项目前端没有确认幂等机制。
|
||||
|
||||
### 3.9 CSV 下载
|
||||
|
||||
- 步骤目的:把导出结果交付给使用者。
|
||||
- 输入内容:生成好的 CSV 字符串和文件名。
|
||||
- 处理规则:
|
||||
- 优先通过 Chrome 扩展后台下载;
|
||||
- 如果扩展下载通道不可用,则使用页面中的临时下载链接;
|
||||
- CSV 带 UTF-8 BOM,方便表格软件识别中文;
|
||||
- 选中达人导出文件名使用画像导出标识加时间戳;
|
||||
- 按 ID 导出文件名包含按 ID 导出标识。
|
||||
- 输出结果:浏览器下载列表中出现 CSV 文件。
|
||||
- 外部依赖:Chrome downloads 能力或浏览器下载能力。
|
||||
- 失败后如何处理:下载失败会提示;已生成数据不会写入外部系统。
|
||||
- 是否影响后续步骤:不影响外部系统。
|
||||
|
||||
## 4. 接口和外部服务说明
|
||||
|
||||
| 接口/服务 | 用途 | 使用环节 | 核心入参 | 核心返回 | 分页 | 限流 | 并发控制 | 超时 | 重试机制 | 重试次数 | 会重试的情况 | 不会重试的情况 | 凭证/权限 | 额度/成本 | 失败处理 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 巨量星图网页 | 提供达人市场页面和当前筛选结果 | 页面启动、读取达人列表、人工筛选 | 星图网页登录态、页面筛选条件 | 达人列表页面 | 通过页面分页 | 未确认 | 页面翻页串行执行 | 页面翻页等待约 3 秒,页面稳定等待约 12 秒 | 无自动重试 | 0 | 无 | 页面结构异常、未登录、加载失败 | 星图账号和 cookie | 未确认 | 页面不匹配或结构异常时插件功能不可用 |
|
||||
| `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 | 未确认 | 单项失败写入该行失败原因,未选择的画像不请求也不记失败 |
|
||||
| `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 | 未确认 | 指标补充失败时对应字段留空并记录失败来源 |
|
||||
| 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 不可用时不进入业务流程,或后端调用失败 |
|
||||
| COS 更新清单 | 检查插件新版本 | 插件弹窗 | `latest.json` URL | 最新版本、ZIP URL、说明 PDF URL、发布时间、更新说明 | 否 | 未确认 | 弹窗打开后单次检查 | 未确认 | 无自动重试 | 0 | 无 | 请求失败、清单格式错误、URL 非 HTTPS | 清单和安装包需公开可读 | COS 存储和流量成本未确认 | 检查失败时弹窗显示无法检查更新,不影响已安装插件主功能 |
|
||||
| Chrome downloads | 下载 CSV、更新包和说明 PDF | CSV 导出、插件更新 | 文件名、下载 URL 或 data URL | 浏览器下载任务 | 否 | 浏览器自身规则,未确认 | 由浏览器管理 | 未确认 | 无自动重试 | 0 | 无 | 下载权限不可用、浏览器拦截、URL 无效 | Chrome `downloads` 权限 | 未确认 | CSV 下载失败时回退页面链接下载;更新包下载失败时显示错误 |
|
||||
|
||||
凭证和权限说明:
|
||||
|
||||
- 星图接口依赖当前浏览器中的星图网页登录态和 cookie;
|
||||
- 公司后端指标查询和批次提交依赖 Logto access token;
|
||||
- 插件需要 Chrome `downloads`、`identity`、`storage` 权限;
|
||||
- COS 更新包需要公开可读;
|
||||
- 具体接口额度、调用成本、账号级限制均未确认。
|
||||
|
||||
## 5. 数据处理规则
|
||||
|
||||
### 5.1 数据来源
|
||||
|
||||
数据主要来自四类来源:
|
||||
|
||||
- 星图市场页面和列表接口:达人 ID、名称、地区、报价、粉丝、内容主题、预期播放、互动率、完播率等基础字段;
|
||||
- 星图详情类接口:看后搜率、画像、商业能力、传播指标;
|
||||
- 公司 talent-search 后端:秒思 api 指标;
|
||||
- 使用者输入:勾选状态、星图 ID、批次名称和字段选择。
|
||||
|
||||
### 5.2 保留规则
|
||||
|
||||
- 有有效达人 ID 和达人名称的记录会进入页面记录集合;
|
||||
- 按 ID 导出时,有效 ID 即使部分接口失败也会生成 CSV 行;
|
||||
- CSV 基础字段、导出状态、失败原因等固定字段会保留;
|
||||
- 字段选择只影响可选数据字段,不删除固定标识字段;
|
||||
- 字段弹窗左侧持续展示已选字段,右侧支持按字段名或分类检索,并实时同步勾选状态。
|
||||
|
||||
### 5.3 过滤规则
|
||||
|
||||
- 星图页面中缺少达人 ID 或达人名称的行会跳过;
|
||||
- 导出选中达人数据在选择 `全部` 且没有勾选时,保留当前筛选结果的全部分页;其他范围必须有已勾选达人;
|
||||
- 选择 `全部` 且存在勾选达人时,画像导出和提交批次都只保留全部范围内已勾选达人;
|
||||
- 选择 `全部` 时,勾选达人不在当前筛选结果内则不回退为全部达人;
|
||||
- 按 ID 导出时,非 16 到 20 位纯数字 token 会过滤。
|
||||
|
||||
### 5.4 去重规则
|
||||
|
||||
- 多页导出和后台分页导出按达人 ID 合并去重;
|
||||
- 按 ID 导出对输入 ID 去重;
|
||||
- 合并时优先保留已有的非空字段;
|
||||
- 指标字段会在有新非空值时补充。
|
||||
|
||||
### 5.5 字段补充和合并规则
|
||||
|
||||
- 星图列表数据作为基础;
|
||||
- 看后搜率优先使用列表中已有值,不完整时再请求星图指标接口;
|
||||
- 秒思指标按 star_id 从后端补充;
|
||||
- 传播指标按多组参数生成不同列,不合并同名业务指标;
|
||||
- `代表视频`可能被读取,但不会进入最终达人数据 CSV;
|
||||
- 画像和商业能力字段追加在基础字段之后;
|
||||
- 传播指标字段追加在基础字段、看后搜率和秒思 api 字段之后。
|
||||
|
||||
### 5.6 覆盖规则
|
||||
|
||||
- 当前页面会话内的内存记录会被补充和合并;
|
||||
- 项目不会把 CSV 导出结果写回星图;
|
||||
- 批次提交会向后端创建或提交数据,是否覆盖已有批次未确认;
|
||||
- 字段选择配置会保存到浏览器 localStorage,下次导出沿用。
|
||||
|
||||
### 5.7 写入位置
|
||||
|
||||
- CSV 写入浏览器下载目录;
|
||||
- 批次数据写入批次提交后端;
|
||||
- 字段选择写入浏览器 localStorage;
|
||||
- 登录状态由 Logto Chrome 扩展 SDK 管理;
|
||||
- 项目本身不维护持久任务数据库。
|
||||
|
||||
### 5.8 写入失败处理
|
||||
|
||||
- CSV 下载失败会提示或使用备用下载方式;
|
||||
- 批次提交失败会提示失败原因,本次提交不视为成功;
|
||||
- 字段选择保存失败会被忽略,后续可能恢复默认全选字段;
|
||||
- 后端指标补充失败不会阻止 CSV 生成,只会导致字段为空。
|
||||
|
||||
### 5.9 重复运行时数据变化
|
||||
|
||||
- 重复导出会重新生成新的 CSV 文件;
|
||||
- 重复按 ID 导出不会写入外部业务系统;
|
||||
- 重复提交批次可能在后端产生重复批次,是否由后端去重未确认;
|
||||
- 页面内已缓存的成功指标可能在当前会话中复用,刷新页面后会重新读取。
|
||||
|
||||
## 6. 重复执行、中断恢复和幂等性
|
||||
|
||||
- 任务可以重复执行。
|
||||
- CSV 导出重复执行会产生新的下载文件,不会覆盖星图或后端数据。
|
||||
- 按 ID 导出重复执行会重新请求接口并生成新 CSV,不会写入后端批次。
|
||||
- 字段选择重复保存会覆盖浏览器本地保存的字段选择。
|
||||
- 批次提交重复执行是否会重复创建批次:未确认。当前前端没有批次幂等键,也不生成 batchId。
|
||||
- 批次提交是否覆盖已有结果:未确认,取决于后端。
|
||||
- 任务跑到一半失败后,当前项目没有持久任务状态记录。
|
||||
- 导出中断后再次执行会从本次流程开头重新收集和请求,不会从上次中断点恢复。
|
||||
- 页面会话中的指标缓存可以减少同一页面内重复请求,但不等同于断点续跑。
|
||||
- 浏览器刷新、插件重载或页面关闭会丢失内存中的中间状态。
|
||||
- 多页导出按达人 ID 去重,重复分页读取同一达人不会在 CSV 中重复出现。
|
||||
- 按 ID 导出对输入 ID 去重,重复输入同一 ID 不会产生重复行。
|
||||
|
||||
重复执行相对安全的操作:
|
||||
|
||||
- 重新打开页面;
|
||||
- 重新导出 CSV;
|
||||
- 重新按 ID 导出;
|
||||
- 重新检查更新;
|
||||
- 重新保存字段选择。
|
||||
|
||||
重复执行可能有风险的操作:
|
||||
|
||||
- 重复点击提交批次;
|
||||
- 修改后端地址后提交批次;
|
||||
- 使用不同星图筛选条件或不同字段选择重复导出后,拿多个 CSV 混用;
|
||||
- 指标选择、视频口径或阈值变化后重复提交,可能导致提交达人集合变化。
|
||||
|
||||
未确认项:
|
||||
|
||||
- 后端批次接口是否按批次名称、用户或达人集合去重;
|
||||
- 后端批次接口是否允许空达人列表;
|
||||
- 后端是否有任务状态、失败重试或重复提交保护;
|
||||
- Logto token 刷新失败后是否有 SDK 内部重试。
|
||||
|
||||
## 7. 项目使用方式
|
||||
|
||||
### 7.1 使用前准备
|
||||
|
||||
使用前需要准备:
|
||||
|
||||
- Google Chrome 浏览器;
|
||||
- 内部发布的 `star-chart-search-enhancer-internal.zip`;
|
||||
- 可访问巨量星图的账号;
|
||||
- 可访问公司 Logto 登录系统的账号;
|
||||
- 如果要提交批次,需要批次提交后端可访问;
|
||||
- 如果要查询秒思 api 指标,需要 talent-search 后端授权可用。
|
||||
|
||||
需要的权限和凭证:
|
||||
|
||||
- Chrome 扩展加载权限;
|
||||
- 星图网页登录态;
|
||||
- Logto 登录态;
|
||||
- talent-search 后端读取权限;
|
||||
- 批次提交后端所需权限,具体 scope 是否只需当前配置未确认。
|
||||
|
||||
### 7.2 本地安装使用
|
||||
|
||||
1. 解压内部 ZIP;
|
||||
2. 打开 `chrome://extensions`;
|
||||
3. 开启开发者模式;
|
||||
4. 点击加载已解压的扩展程序;
|
||||
5. 选择解压后的 `dist` 文件夹;
|
||||
6. 确认扩展 ID 为 `pkjopdibdnomhogjheclhnknmejccffg`;
|
||||
7. 固定插件图标;
|
||||
8. 点击插件图标并登录;
|
||||
9. 打开 `https://xingtu.cn/ad/creator/market`;
|
||||
10. 等待插件工具栏出现。
|
||||
|
||||
### 7.3 执行一次完整导出任务
|
||||
|
||||
1. 在星图市场中完成筛选;
|
||||
2. 等待页面列表加载完成;
|
||||
3. 选择 `全部` 时可不勾选;选择其他范围时勾选需要导出的达人;
|
||||
4. 可选:点击 `选择字段`,通过关键词检索字段,并在已选区域确认最终字段;
|
||||
5. 点击 `导出选中达人数据`;
|
||||
6. 等待状态提示从导出中消失或浏览器下载完成;
|
||||
7. 在下载目录或 Chrome 下载列表中查看 CSV;
|
||||
8. 检查 `导出状态` 和 `失败原因` 字段。
|
||||
|
||||
### 7.4 按 ID 执行导出任务
|
||||
|
||||
1. 点击 `按星图ID导出`;
|
||||
2. 粘贴达人星图 ID,每行一个或用分隔符隔开;
|
||||
3. 点击确认;
|
||||
4. 查看识别数量、去重后数量和非法数量提示;
|
||||
5. 等待 CSV 下载;
|
||||
6. 检查每行导出状态和失败原因。
|
||||
|
||||
### 7.5 执行一次批次提交
|
||||
|
||||
1. 在星图市场中完成筛选;
|
||||
2. 可选:勾选需要提交的达人;
|
||||
3. 点击 `提交批次`;
|
||||
4. 输入批次名称;
|
||||
5. 等待页面提示 `批次提交成功`;
|
||||
6. 到后端系统确认批次是否生成。
|
||||
|
||||
### 7.6 只执行某个子流程
|
||||
|
||||
- 只登录:打开插件弹窗并登录;
|
||||
- 只检查更新:登录后打开插件弹窗查看版本更新区域;
|
||||
- 只选择字段:在星图市场页点击 `选择字段` 并保存;
|
||||
- 只按 ID 导出:不需要勾选页面达人,直接点击 `按星图ID导出`;
|
||||
- 只提交批次:不需要先导出 CSV,但需要登录并输入批次名称。
|
||||
|
||||
### 7.7 重新执行任务
|
||||
|
||||
- 重新导出:直接再次点击导出按钮;
|
||||
- 重新按 ID 导出:再次打开 ID 输入弹窗并确认;
|
||||
- 重新提交批次:再次点击提交批次并输入批次名称。注意可能创建重复批次,后端幂等未确认;
|
||||
- 页面异常后重试:刷新星图页面,等待工具栏重新出现后再执行。
|
||||
|
||||
### 7.8 确认任务完成
|
||||
|
||||
- 导出任务:浏览器下载列表出现 CSV 文件;
|
||||
- 按 ID 导出:CSV 文件名包含按 ID 导出标识,且文件中有导出状态列;
|
||||
- 批次提交:页面显示 `批次提交成功`,并在后端系统中能看到对应批次;
|
||||
- 插件更新:重新加载后插件版本显示为新版本。
|
||||
|
||||
### 7.9 危险操作
|
||||
|
||||
- 重复点击 `提交批次`;
|
||||
- 修改后端地址后未验证就发给同事使用;
|
||||
- 删除正在被 Chrome 加载的 `dist` 文件夹;
|
||||
- 随意修改 Logto 配置、后端地址、scope 或 manifest key;
|
||||
- 在未确认星图筛选条件的情况下提交全部范围达人;
|
||||
- 传播指标、视频口径或阈值选择错误,导致提交集合被大幅改变。
|
||||
|
||||
### 7.10 不能随便改的参数
|
||||
|
||||
- 固定扩展 ID 相关配置;
|
||||
- Logto appId、endpoint、resource、scope;
|
||||
- 批次提交后端地址;
|
||||
- talent-search 后端地址;
|
||||
- COS 更新清单 URL;
|
||||
- 星图接口参数含义;
|
||||
- 传播指标列名规则;
|
||||
- 批次 payload 字段。
|
||||
|
||||
### 7.11 运行和发布方式
|
||||
|
||||
本地开发运行:
|
||||
|
||||
- 安装依赖:`npm install`;
|
||||
- 运行测试:`npm test`;
|
||||
- 开发构建:`npm run build`;
|
||||
- 然后在 Chrome 中加载 `dist`。
|
||||
|
||||
内部发布构建:
|
||||
|
||||
- 运行测试;
|
||||
- 执行内部打包;
|
||||
- 生成 ZIP 和更新清单;
|
||||
- 上传或分发给同事;
|
||||
- 同事仍需人工解压和加载。
|
||||
|
||||
定时任务运行:
|
||||
|
||||
- 当前项目未确认存在服务端定时任务。
|
||||
- 插件更新发布可通过 tag 触发 Drone 发布流程。
|
||||
|
||||
## 8. 运行参数和配置说明
|
||||
|
||||
| 配置名称 | 作用 | 默认值 | 可选值 | 修改影响 | 是否需要重启/重载 | 风险 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 扩展 ID / manifest key | 固定 Chrome 扩展身份 | `pkjopdibdnomhogjheclhnknmejccffg` | 未确认 | 影响 Logto 回调、用户安装识别、更新连续性 | 需要重新构建并重新加载插件 | 改错会导致登录失败或同事装到不同插件 |
|
||||
| Logto endpoint | 登录服务地址 | `https://login-api.intelligrow.cn` | 未确认 | 影响登录和 token 获取 | 需要重新构建并重新加载插件 | 登录不可用 |
|
||||
| Logto appId | 插件登录应用 ID | `i4jkllbvih0554r4n0fd3` | 未确认 | 影响登录应用和回调校验 | 需要重新构建并重新加载插件 | 登录不可用 |
|
||||
| apiResource | token 资源地址 | `https://talent-search.intelligrow.cn` | 未确认 | 影响后端 token audience/resource | 需要重新构建并重新加载插件 | 后端接口 401/403 |
|
||||
| scopes | 登录申请权限 | `openid`、`profile`、`offline_access`、`talent-search:read` | 未确认 | 影响 token 权限 | 需要重新登录;通常也需重新构建 | 后端读写权限不足 |
|
||||
| enableDevAuthPanel | 是否显示开发调试面板 | `false` | `true` / `false` | 影响插件弹窗是否显示调试入口 | 需要重新构建并重新加载插件 | 暴露调试入口 |
|
||||
| 批次提交后端地址 | 提交达人批次的目标服务 | 当前工作区为 `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 列 | 不需要重启,会本地保存 | 漏导业务字段 |
|
||||
|
||||
## 9. 任务执行和结果确认
|
||||
|
||||
### 9.1 任务开始标志
|
||||
|
||||
- 插件登录任务:点击插件弹窗登录按钮后跳转登录;
|
||||
- 页面增强任务:星图市场页面出现插件工具栏和新增列;
|
||||
- 导出任务:状态区出现 `画像导出中`、`按ID画像导出中` 或类似导出中提示;
|
||||
- 批次提交任务:点击提交并输入批次名称后,状态区出现提交中提示;
|
||||
- 更新检查任务:插件弹窗显示正在检查更新。
|
||||
|
||||
### 9.2 执行中状态
|
||||
|
||||
- 工具栏按钮会被禁用,防止同一流程中重复点击;
|
||||
- 多页收集时会显示页码进度;
|
||||
- 画像和按 ID 导出会显示当前处理序号;
|
||||
- 页面指标列可能显示 `加载中...`;
|
||||
- 后端指标可能显示暂无数据或加载失败。
|
||||
|
||||
### 9.3 成功完成标志
|
||||
|
||||
- CSV 导出:浏览器下载列表出现 CSV 文件;
|
||||
- 按 ID 导出:下载完成,CSV 中每行有导出状态;
|
||||
- 批次提交:页面提示 `批次提交成功`;
|
||||
- 更新下载:弹窗提示已触发下载;
|
||||
- 页面增强:指标列显示具体数值或明确的暂无数据状态。
|
||||
|
||||
### 9.4 部分成功表现
|
||||
|
||||
- 单个达人部分接口失败时,CSV 中该行 `导出状态` 为部分成功或失败,并在 `失败原因` 中列明失败项;
|
||||
- 秒思 api 指标失败时,对应字段为空或页面显示失败,不一定影响 CSV 下载;
|
||||
- 传播指标某组参数失败时,对应字段为空;
|
||||
- 画像部分或全部失败时,对应单元格留空并写入失败原因,其他画像或效果预估字段仍可保留;
|
||||
- 后端指标查询不到某个达人时显示暂无数据。
|
||||
|
||||
### 9.5 失败表现
|
||||
|
||||
- 未登录:星图页面显示登录提示,不出现业务工具栏;
|
||||
- 非 `全部` 范围没勾选就导出选中达人数据:提示请先勾选;
|
||||
- 选择 `全部` 且勾选达人不在当前筛选结果内:提示全部范围内没有选中的达人;
|
||||
- 全部已选补充数据都失败:CSV 仍会下载,该行标记为部分成功或失败并列明失败原因;
|
||||
- 按 ID 没有有效 ID:提示请输入有效的达人星图 ID;
|
||||
- 批次提交失败:状态区显示接口错误或通用失败提示;
|
||||
- 已选传播指标未填写合法阈值:状态区提示具体指标,导出或提交不会开始;
|
||||
- 更新清单失败:弹窗显示暂时无法检查更新或错误信息。
|
||||
|
||||
### 9.6 最终结果查看位置
|
||||
|
||||
- CSV:浏览器默认下载目录或 Chrome 下载列表;
|
||||
- 批次:批次提交后端系统,具体查看入口未确认;
|
||||
- 插件版本:插件弹窗或 Chrome 扩展详情页;
|
||||
- 登录状态:插件弹窗;
|
||||
- 页面增强结果:星图达人市场页面新增列。
|
||||
|
||||
### 9.7 管理者确认标准
|
||||
|
||||
管理者确认一次任务是否达到预期时,应关注:
|
||||
|
||||
- 使用者是否登录了正确插件;
|
||||
- 星图筛选条件是否符合业务目标;
|
||||
- 导出的 CSV 行数是否符合已选达人或输入 ID 数量;
|
||||
- CSV 中 `导出状态` 是否大部分为成功;
|
||||
- 关键业务字段是否有值,例如内容数据、效果预估、画像、秒思 api 数据;
|
||||
- 批次提交是否在后端生成对应批次;
|
||||
- 批次名称、创建人和达人数量是否符合预期;
|
||||
- 是否存在重复提交批次。
|
||||
|
||||
## 10. 重要限制和风险
|
||||
|
||||
- 星图接口调用额度限制:未确认。
|
||||
- 星图接口限流规则:未确认。
|
||||
- 公司后端接口额度限制:未确认。
|
||||
- 批次提交接口幂等规则:未确认。
|
||||
- 项目没有持久任务状态记录,不支持真正断点续跑。
|
||||
- 导出范围过大时,会产生大量星图接口请求,运行时间会变长。
|
||||
- 当前传播指标补充和筛选存在并发请求;筛选会复用同一达人相同视频口径的响应,但是否有显式并发上限未确认,当前未看到稳定的业务级并发限制配置。
|
||||
- 星图页面结构变化可能导致工具栏挂载、列表读取或翻页失效。
|
||||
- 星图网页登录态过期会导致接口失败。
|
||||
- Logto token 不可用会导致后端指标和批次提交失败。
|
||||
- 批次提交重复执行可能产生重复批次。
|
||||
- 修改后端地址可能把数据提交到错误环境。
|
||||
- 字段选择保存到本地浏览器,换浏览器或清理数据后会恢复默认。
|
||||
- 更新包仍需人工解压和重载,下载新版本不等于插件已更新。
|
||||
- 删除或移动本地 `dist` 文件夹会导致已加载插件失效。
|
||||
- 扩展 ID、Logto 回调和 manifest key 强相关,改错会导致登录失败。
|
||||
- `http://localhost:8083` 作为批次提交默认地址时,只适合本机后端可用的场景;生产或同事环境是否适用未确认。
|
||||
- 下载 CSV 不会自动校验业务完整性,需要使用者或管理者检查导出状态和关键字段。
|
||||
- 传播指标、视频口径或阈值选错会改变导出或提交达人集合。
|
||||
|
||||
## 11. 未确认项清单
|
||||
|
||||
- 星图各接口是否存在明确限流:未确认。
|
||||
- 星图各接口账号级、IP 级或 cookie 级调用额度:未确认。
|
||||
- 星图各接口失败后是否由浏览器或服务端内部重试:未确认。
|
||||
- 公司后端 `history/talents/search` 是否有分页上限、查询数量上限或限流:未确认。
|
||||
- 公司后端 `history/talents/search` 的接口超时时间:未确认。
|
||||
- 批次提交后端的生产地址:未确认;当前工作区配置为 `http://localhost:8083`。
|
||||
- 批次提交后端是否支持幂等:未确认。
|
||||
- 批次提交后端是否允许空达人列表:未确认。
|
||||
- 批次提交后端是否会覆盖同名批次:未确认。
|
||||
- 批次提交后端生成的 7 位数字批次 ID 的具体规则:未确认。
|
||||
- 批次提交后端的查看入口和管理流程:未确认。
|
||||
- 当前 Logto scope 是否足够覆盖批次写操作:未确认。
|
||||
- COS 更新清单的权限、缓存和发布审核规则:未确认。
|
||||
- Drone 发布是否为唯一正式发布方式:未确认。
|
||||
- 插件是否有统一日志、错误上报或审计记录:未确认。
|
||||
- 页面增强指标是否有跨页面或跨浏览器持久缓存:未确认;当前仅确认有页面会话内记录。
|
||||
- 导出全部页面时最多导出多少页:后台静默导出从第 1 页开始,当前最多尝试 200 页;真实星图侧上限未确认。
|
||||
- 传播指标请求是否应该限制并发:当前筛选已按同一达人相同口径去重,但真实业务级并发上限仍未确认。
|
||||
- 后续业务系统如何消费批次:未确认。
|
||||
|
||||
## 12. 文档维护规则
|
||||
|
||||
从现在开始,任何模型或工程师在修改本项目代码时,都必须遵守以下规则:
|
||||
|
||||
1. 修改代码前,先检查本流程文档是否描述了相关流程;
|
||||
2. 如果代码改动影响流程、接口、配置、数据处理规则、使用方式、任务执行方式、结果确认方式、限流、重试、超时、并发或幂等性,必须同步更新本文档;
|
||||
3. 如果代码行为和文档描述发生冲突,必须以当前真实行为为准更新文档;
|
||||
4. 如果改动较大,但判断不需要更新文档,必须说明原因;
|
||||
5. 每次完成较大代码改动后,最终回复必须明确说明:
|
||||
- 是否检查了流程文档;
|
||||
- 是否更新了流程文档;
|
||||
- 更新了哪些部分;
|
||||
- 如果没有更新,为什么不需要更新。
|
||||
|
||||
以下情况都视为必须检查文档的较大改动:
|
||||
|
||||
- 改变整体流程顺序;
|
||||
- 新增、删除或调整流程步骤;
|
||||
- 新增、删除或替换外部接口;
|
||||
- 修改接口参数、鉴权方式、分页方式、限流方式、重试方式、超时规则或并发规则;
|
||||
- 修改数据过滤、去重、合并、覆盖、写入规则;
|
||||
- 修改任务启动方式、运行参数或配置项;
|
||||
- 修改任务成功、失败、部分成功的判断方式;
|
||||
- 修改重复执行、中断恢复或幂等性行为;
|
||||
- 修改最终结果的产出位置或格式;
|
||||
- 修改会影响项目使用者操作方式的任何内容。
|
||||
@@ -59,7 +59,7 @@ const hostPermissionsByTarget = {
|
||||
"https://*.xingtu.cn/ad/creator/market*",
|
||||
"https://login-api.intelligrow.cn/*",
|
||||
"https://talent-search.intelligrow.cn/*",
|
||||
"http://192.168.31.21:8083/*",
|
||||
"http://localhost:8083/*",
|
||||
"https://*/*"
|
||||
]
|
||||
};
|
||||
|
||||
@@ -5,16 +5,16 @@ import {
|
||||
listRateCsvHeaders,
|
||||
type CsvColumn
|
||||
} from "./csv-exporter";
|
||||
import type { MarketRecord } from "./types";
|
||||
import type {
|
||||
AudienceProfileDistributionItem,
|
||||
AudienceProfileExportRow,
|
||||
AudienceProfileKind,
|
||||
AudienceProfileResult,
|
||||
BusinessAbilityDurationKind,
|
||||
BusinessAbilityEstimateMetrics,
|
||||
BusinessAbilityVideoKind,
|
||||
BusinessAbilityVideoMetrics
|
||||
BusinessAbilityEstimateMetrics
|
||||
} from "./audience-profile-types";
|
||||
import { buildSpreadInfoColumns } from "./spread-info";
|
||||
|
||||
type AudienceProfileCsvColumn = {
|
||||
header: string;
|
||||
@@ -60,30 +60,13 @@ const CROWD_LABELS = [
|
||||
"小镇青年"
|
||||
];
|
||||
|
||||
const BUSINESS_VIDEO_LAYOUTS: Array<{
|
||||
key: BusinessAbilityVideoKind;
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: "personalVideo", label: "个人视频" },
|
||||
{ key: "xingtuVideo", label: "星图视频" }
|
||||
];
|
||||
|
||||
const BUSINESS_VIDEO_METRIC_LAYOUTS: Array<{
|
||||
key: keyof BusinessAbilityVideoMetrics;
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: "medianPlay", label: "播放量中位数" },
|
||||
{ key: "finishRate", label: "完播率" },
|
||||
{ key: "interactionRate", label: "互动率" },
|
||||
{ key: "publishedItems", label: "发布作品" },
|
||||
{ key: "averageDuration", label: "平均时长" },
|
||||
{ key: "averageLike", label: "平均点赞" },
|
||||
{ key: "averageComment", label: "平均评论" },
|
||||
{ key: "averageShare", label: "平均转发" }
|
||||
];
|
||||
|
||||
const BUSINESS_VIDEO_SECTION_LABEL = "内容数据";
|
||||
const BUSINESS_ESTIMATE_SECTION_LABEL = "效果预估";
|
||||
const FIXED_AUDIENCE_PROFILE_HEADERS = new Set([
|
||||
"达人ID",
|
||||
"达人名称",
|
||||
"导出状态",
|
||||
"失败原因"
|
||||
]);
|
||||
|
||||
const BUSINESS_ESTIMATE_LAYOUTS: Array<{
|
||||
key: BusinessAbilityDurationKind;
|
||||
@@ -109,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(",")
|
||||
@@ -135,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: "看后搜率"
|
||||
@@ -146,7 +152,7 @@ export function listAudienceProfileSelectableFieldGroups(): AudienceProfileCsvFi
|
||||
label: "秒思api数据"
|
||||
},
|
||||
{
|
||||
headers: buildBusinessVideoColumns().map((column) => column.header),
|
||||
headers: buildSpreadInfoColumns(),
|
||||
label: "内容数据"
|
||||
},
|
||||
{
|
||||
@@ -168,35 +174,67 @@ 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[] {
|
||||
return [...buildBusinessVideoColumns(), ...buildBusinessEstimateColumns()];
|
||||
}
|
||||
|
||||
function buildBusinessVideoColumns(): AudienceProfileCsvColumn[] {
|
||||
return [
|
||||
...BUSINESS_VIDEO_LAYOUTS.flatMap((videoLayout) =>
|
||||
BUSINESS_VIDEO_METRIC_LAYOUTS.map((metricLayout) => ({
|
||||
header: `${BUSINESS_VIDEO_SECTION_LABEL}-${videoLayout.label}-${metricLayout.label}`,
|
||||
readValue: (row: AudienceProfileExportRow) =>
|
||||
readBusinessVideoValue(row, videoLayout.key, metricLayout.key)
|
||||
}))
|
||||
)
|
||||
];
|
||||
return buildBusinessEstimateColumns();
|
||||
}
|
||||
|
||||
function buildBusinessEstimateColumns(): AudienceProfileCsvColumn[] {
|
||||
@@ -211,19 +249,6 @@ function buildBusinessEstimateColumns(): AudienceProfileCsvColumn[] {
|
||||
];
|
||||
}
|
||||
|
||||
function readBusinessVideoValue(
|
||||
row: AudienceProfileExportRow,
|
||||
videoKey: BusinessAbilityVideoKind,
|
||||
metricKey: keyof BusinessAbilityVideoMetrics
|
||||
): string {
|
||||
const businessAbility = row.businessAbility;
|
||||
if (!businessAbility || businessAbility.status !== "success") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return businessAbility.videos[videoKey]?.[metricKey] ?? "";
|
||||
}
|
||||
|
||||
function readBusinessEstimateValue(
|
||||
row: AudienceProfileExportRow,
|
||||
durationKey: BusinessAbilityDurationKind,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -42,21 +47,6 @@ export interface AudienceProfileExportRow {
|
||||
record: MarketRecord;
|
||||
}
|
||||
|
||||
export type BusinessAbilityVideoKind =
|
||||
| "personalVideo"
|
||||
| "xingtuVideo";
|
||||
|
||||
export interface BusinessAbilityVideoMetrics {
|
||||
averageComment: string;
|
||||
averageDuration: string;
|
||||
averageLike: string;
|
||||
averageShare: string;
|
||||
finishRate: string;
|
||||
interactionRate: string;
|
||||
medianPlay: string;
|
||||
publishedItems: string;
|
||||
}
|
||||
|
||||
export type BusinessAbilityDurationKind =
|
||||
| "oneToTwenty"
|
||||
| "twentyToSixty"
|
||||
@@ -74,7 +64,6 @@ export interface BusinessAbilitySuccess {
|
||||
Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>
|
||||
>;
|
||||
status: "success";
|
||||
videos: Partial<Record<BusinessAbilityVideoKind, BusinessAbilityVideoMetrics>>;
|
||||
}
|
||||
|
||||
export interface BusinessAbilityFailure {
|
||||
|
||||
@@ -35,6 +35,10 @@ export function createBatchPayload(options: {
|
||||
throw new Error("batch submit batch name is required");
|
||||
}
|
||||
|
||||
if (options.records.length === 0) {
|
||||
throw new Error("当前没有可提交的达人");
|
||||
}
|
||||
|
||||
return {
|
||||
authors: options.records.map((record) => ({
|
||||
authorId: record.authorId,
|
||||
|
||||
@@ -3,8 +3,7 @@ import type {
|
||||
BusinessAbilityDurationKind,
|
||||
BusinessAbilityEstimateMetrics,
|
||||
BusinessAbilityResult,
|
||||
BusinessAbilitySuccess,
|
||||
BusinessAbilityVideoMetrics
|
||||
BusinessAbilitySuccess
|
||||
} from "./audience-profile-types";
|
||||
|
||||
interface FetchResponseLike {
|
||||
@@ -23,11 +22,6 @@ interface BusinessAbilityClientOptions {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const VIDEO_TYPES = {
|
||||
personalVideo: 1,
|
||||
xingtuVideo: 2
|
||||
} as const;
|
||||
|
||||
export function createBusinessAbilityClient(
|
||||
options: BusinessAbilityClientOptions = {}
|
||||
) {
|
||||
@@ -37,33 +31,20 @@ export function createBusinessAbilityClient(
|
||||
|
||||
return {
|
||||
async loadBusinessAbility(record: MarketRecord): Promise<BusinessAbilityResult> {
|
||||
const personalVideo = await loadJson(
|
||||
buildBusinessAbilityVideoUrl(record.authorId, baseUrl, VIDEO_TYPES.personalVideo)
|
||||
);
|
||||
const xingtuVideo = await loadJson(
|
||||
buildBusinessAbilityVideoUrl(record.authorId, baseUrl, VIDEO_TYPES.xingtuVideo)
|
||||
);
|
||||
const estimates = await loadJson(
|
||||
buildBusinessAbilityEstimateUrl(record.authorId, baseUrl)
|
||||
);
|
||||
|
||||
if (!personalVideo.ok || !xingtuVideo.ok || !estimates.ok) {
|
||||
if (!estimates.ok) {
|
||||
return {
|
||||
failureReason:
|
||||
personalVideo.failureReason ??
|
||||
xingtuVideo.failureReason ??
|
||||
estimates.failureReason,
|
||||
failureReason: estimates.failureReason,
|
||||
status: "failed"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
estimates: mapBusinessAbilityEstimateResponse(estimates.payload),
|
||||
status: "success",
|
||||
videos: {
|
||||
personalVideo: mapBusinessAbilityVideoResponse(personalVideo.payload),
|
||||
xingtuVideo: mapBusinessAbilityVideoResponse(xingtuVideo.payload)
|
||||
}
|
||||
status: "success"
|
||||
} satisfies BusinessAbilitySuccess;
|
||||
}
|
||||
};
|
||||
@@ -101,22 +82,6 @@ export function createBusinessAbilityClient(
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBusinessAbilityVideoUrl(
|
||||
authorId: string,
|
||||
baseUrl: string,
|
||||
videoType: number
|
||||
): string {
|
||||
const url = new URL("/gw/api/data_sp/get_author_spread_info", baseUrl);
|
||||
url.searchParams.set("o_author_id", authorId);
|
||||
url.searchParams.set("platform_source", "1");
|
||||
url.searchParams.set("platform_channel", "1");
|
||||
url.searchParams.set("type", String(videoType));
|
||||
url.searchParams.set("flow_type", "0");
|
||||
url.searchParams.set("only_assign", "true");
|
||||
url.searchParams.set("range", "2");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function buildBusinessAbilityEstimateUrl(
|
||||
authorId: string,
|
||||
baseUrl: string
|
||||
@@ -129,25 +94,6 @@ export function buildBusinessAbilityEstimateUrl(
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function mapBusinessAbilityVideoResponse(
|
||||
payload: unknown
|
||||
): BusinessAbilityVideoMetrics {
|
||||
const data = getPayloadData(payload);
|
||||
|
||||
return {
|
||||
averageComment: formatWan(readNumber(data?.comment_avg)),
|
||||
averageDuration: formatDuration(readNumber(data?.avg_duration)),
|
||||
averageLike: formatWan(readNumber(data?.like_avg)),
|
||||
averageShare: formatWan(readNumber(data?.share_avg)),
|
||||
finishRate: formatBasisPointRate(readNestedNumber(data, "play_over_rate", "value")),
|
||||
interactionRate: formatBasisPointRate(
|
||||
readNestedNumber(data, "interact_rate", "value")
|
||||
),
|
||||
medianPlay: formatWan(readNumber(data?.play_mid)),
|
||||
publishedItems: formatPublishedItems(readNumber(data?.item_num))
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBusinessAbilityEstimateResponse(
|
||||
payload: unknown
|
||||
): Partial<Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>> {
|
||||
@@ -177,30 +123,6 @@ export function mapBusinessAbilityEstimateResponse(
|
||||
};
|
||||
}
|
||||
|
||||
function formatPublishedItems(value: number | null): string {
|
||||
if (value === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value > 0 && value < 5 ? "<5" : formatDecimal(value, 0);
|
||||
}
|
||||
|
||||
function formatDuration(value: number | null): string {
|
||||
if (value === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `${formatDecimal(value / 100, 0)}s`;
|
||||
}
|
||||
|
||||
function formatBasisPointRate(value: number | null): string {
|
||||
if (value === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `${formatDecimal(value / 100, 1)}%`;
|
||||
}
|
||||
|
||||
function formatDecimalRate(value: number | null): string {
|
||||
if (value === null) {
|
||||
return "缺失";
|
||||
@@ -238,19 +160,6 @@ function formatFixedDecimal(value: number | null, digits: number): string {
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
|
||||
function readNestedNumber(
|
||||
data: Record<string, unknown> | null,
|
||||
objectKey: string,
|
||||
valueKey: string
|
||||
): number | null {
|
||||
const objectValue = data?.[objectKey];
|
||||
if (!isRecord(objectValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return readNumber(objectValue[valueKey]);
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { normalizeRateDisplay } from "../../shared/rate-normalizer";
|
||||
import { escapeCsvCell } from "../../shared/csv";
|
||||
import { buildSpreadInfoColumns } from "./spread-info";
|
||||
import type { MarketRecord } from "./types";
|
||||
|
||||
export type CsvColumn = {
|
||||
@@ -7,6 +8,10 @@ export type CsvColumn = {
|
||||
readValue: (record: MarketRecord) => string;
|
||||
};
|
||||
|
||||
export interface MarketCsvOptions {
|
||||
selectedHeaders?: string[];
|
||||
}
|
||||
|
||||
const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
|
||||
{
|
||||
header: "达人ID",
|
||||
@@ -74,6 +79,18 @@ const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
|
||||
}
|
||||
];
|
||||
|
||||
const SPREAD_INFO_COLUMNS: CsvColumn[] = buildSpreadInfoColumns().map((header) => ({
|
||||
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);
|
||||
}
|
||||
@@ -82,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(",")
|
||||
@@ -94,7 +123,12 @@ export function buildMarketCsv(records: MarketRecord[]): string {
|
||||
|
||||
export function buildMarketCsvColumns(records: MarketRecord[]): CsvColumn[] {
|
||||
const baseColumns = buildBaseColumns(records);
|
||||
return [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS];
|
||||
return [
|
||||
...baseColumns,
|
||||
...RATE_COLUMNS,
|
||||
...BACKEND_METRIC_COLUMNS,
|
||||
...SPREAD_INFO_COLUMNS
|
||||
];
|
||||
}
|
||||
|
||||
export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
||||
@@ -122,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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,18 +76,22 @@ type MarketDataRow = {
|
||||
location?: string;
|
||||
price21To60s?: string;
|
||||
rates?: AfterSearchRates;
|
||||
spreadAuthorId?: string;
|
||||
};
|
||||
|
||||
export interface MarketRowDom {
|
||||
actionCell?: HTMLElement;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
backendMetricsCells: Record<BackendMetricField, HTMLElement>;
|
||||
coreUserId?: string;
|
||||
exportFields?: Record<string, string>;
|
||||
hasDirectRatesSource?: boolean;
|
||||
location?: string;
|
||||
personalCell: HTMLElement;
|
||||
price21To60s?: string;
|
||||
rates?: AfterSearchRates;
|
||||
spreadAuthorId?: string;
|
||||
row: HTMLElement;
|
||||
selectionCheckbox: HTMLInputElement;
|
||||
singleCell: HTMLElement;
|
||||
@@ -585,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);
|
||||
@@ -652,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,
|
||||
@@ -679,6 +686,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
row: authorCell,
|
||||
selectionCheckbox,
|
||||
singleCell,
|
||||
spreadAuthorId: fallbackMarketRow?.spreadAuthorId,
|
||||
visibilityTargets: rowCells
|
||||
} satisfies MarketRowDom
|
||||
];
|
||||
@@ -1351,7 +1359,8 @@ function readSerializedMarketRows(
|
||||
? {
|
||||
singleVideoAfterSearchRate
|
||||
}
|
||||
: undefined
|
||||
: undefined,
|
||||
spreadAuthorId: readString(record.spreadAuthorId) ?? undefined
|
||||
};
|
||||
})
|
||||
.filter((row) => Boolean(row.authorId || row.authorName));
|
||||
@@ -1673,7 +1682,11 @@ function mergeMarketDataRows(
|
||||
baseRow.price21To60s,
|
||||
preferredRow.price21To60s
|
||||
),
|
||||
rates: mergeRates(baseRow.rates, preferredRow.rates)
|
||||
rates: mergeRates(baseRow.rates, preferredRow.rates),
|
||||
spreadAuthorId: mergeNonEmptyString(
|
||||
baseRow.spreadAuthorId,
|
||||
preferredRow.spreadAuthorId
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
+759
-181
File diff suppressed because it is too large
Load Diff
@@ -68,7 +68,9 @@ export function mapMarketListRow(
|
||||
? {
|
||||
singleVideoAfterSearchRate
|
||||
}
|
||||
: undefined
|
||||
: undefined,
|
||||
spreadAuthorId:
|
||||
readString(readMarketFieldValue(row, attributeDatas, "id")) ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,8 @@ function readSerializedMarketRows() {
|
||||
authorName:
|
||||
readString(attributeDatas.nickname) ?? readString(row.nick_name) ?? "",
|
||||
coreUserId: readString(attributeDatas.core_user_id) ?? undefined,
|
||||
singleVideoAfterSearchRate
|
||||
singleVideoAfterSearchRate,
|
||||
spreadAuthorId: readString(attributeDatas.id) ?? undefined
|
||||
};
|
||||
})
|
||||
.filter((row) => Boolean(row.authorId || row.authorName));
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type {
|
||||
MarketExportScope,
|
||||
MarketExportTarget
|
||||
} from "./types";
|
||||
import type { MarketExportScope, MarketExportTarget } from "./types";
|
||||
|
||||
export interface PluginToolbarHandlers {
|
||||
onExport(): Promise<void> | void;
|
||||
onExportAudienceProfile(): Promise<void> | void;
|
||||
onExportAudienceProfileByIds(): Promise<void> | void;
|
||||
onConfigureAudienceProfileFields(): Promise<void> | void;
|
||||
@@ -16,7 +12,6 @@ export interface PluginToolbarDom {
|
||||
audienceProfileExportButton: HTMLButtonElement;
|
||||
audienceProfileFieldButton: HTMLButtonElement;
|
||||
batchSubmitButton: HTMLButtonElement;
|
||||
exportButton: HTMLButtonElement;
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
exportStatusText: HTMLElement;
|
||||
@@ -24,6 +19,11 @@ export interface PluginToolbarDom {
|
||||
}
|
||||
|
||||
const PLUGIN_ACTION_BUTTON_STYLE_ID = "sces-plugin-action-button-style";
|
||||
const TOOLBAR_DOM = Symbol("sces-toolbar-dom");
|
||||
|
||||
type ToolbarRootWithDom = HTMLElement & {
|
||||
[TOOLBAR_DOM]?: PluginToolbarDom;
|
||||
};
|
||||
|
||||
export function isPluginToolbarMounted(
|
||||
root: HTMLElement,
|
||||
@@ -41,27 +41,21 @@ export function ensurePluginToolbar(
|
||||
|
||||
const existingRoot = document.querySelector(
|
||||
"[data-plugin-toolbar='root']"
|
||||
) as HTMLElement | null;
|
||||
if (existingRoot) {
|
||||
if (
|
||||
existingRoot.querySelector(
|
||||
'[data-plugin-export-audience-profile-by-id="button"]'
|
||||
)
|
||||
) {
|
||||
ensureToolbarMounted(existingRoot, document);
|
||||
return readToolbarDom(existingRoot);
|
||||
}
|
||||
|
||||
existingRoot.remove();
|
||||
) as ToolbarRootWithDom | null;
|
||||
const existingToolbar = existingRoot?.[TOOLBAR_DOM];
|
||||
if (existingRoot && existingToolbar) {
|
||||
ensureToolbarMounted(existingRoot, document);
|
||||
return existingToolbar;
|
||||
}
|
||||
|
||||
const root = document.createElement("section");
|
||||
existingRoot?.remove();
|
||||
|
||||
const root = document.createElement("section") as ToolbarRootWithDom;
|
||||
root.dataset.pluginToolbar = "root";
|
||||
applyToolbarRootStyles(root);
|
||||
|
||||
const exportRangeSelect = document.createElement("select");
|
||||
exportRangeSelect.dataset.pluginExportRange = "select";
|
||||
exportRangeSelect.hidden = true;
|
||||
appendOption(exportRangeSelect, "current", "当前页");
|
||||
appendOption(exportRangeSelect, "first-5", "前5页");
|
||||
appendOption(exportRangeSelect, "first-10", "前10页");
|
||||
@@ -77,69 +71,93 @@ export function ensurePluginToolbar(
|
||||
exportCustomPagesInput.placeholder = "页数";
|
||||
exportCustomPagesInput.dataset.pluginExportCustomPages = "input";
|
||||
|
||||
const exportButton = document.createElement("button");
|
||||
exportButton.type = "button";
|
||||
exportButton.dataset.pluginExport = "button";
|
||||
exportButton.hidden = true;
|
||||
exportButton.tabIndex = -1;
|
||||
const audienceProfileExportButton = createActionButton(
|
||||
document,
|
||||
"导出选中达人数据",
|
||||
"pluginExportAudienceProfile"
|
||||
);
|
||||
audienceProfileExportButton.title = "仅导出已勾选达人,包含内容数据、效果预估、画像等维度";
|
||||
|
||||
const audienceProfileExportButton = document.createElement("button");
|
||||
audienceProfileExportButton.type = "button";
|
||||
audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button";
|
||||
audienceProfileExportButton.textContent = "导出选中达人数据";
|
||||
audienceProfileExportButton.title =
|
||||
"仅导出已勾选达人,包含内容数据、效果预估、画像等维度";
|
||||
const audienceProfileFieldButton = createActionButton(
|
||||
document,
|
||||
"选择字段",
|
||||
"pluginAudienceProfileFields"
|
||||
);
|
||||
audienceProfileFieldButton.title = "搜索并勾选本次CSV需要导出的字段,设置会自动保存";
|
||||
|
||||
const audienceProfileByIdExportButton = document.createElement("button");
|
||||
audienceProfileByIdExportButton.type = "button";
|
||||
audienceProfileByIdExportButton.dataset.pluginExportAudienceProfileById = "button";
|
||||
audienceProfileByIdExportButton.textContent = "按星图ID导出";
|
||||
audienceProfileByIdExportButton.title =
|
||||
"粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选";
|
||||
const audienceProfileByIdExportButton = createActionButton(
|
||||
document,
|
||||
"按星图ID导出",
|
||||
"pluginExportAudienceProfileById"
|
||||
);
|
||||
audienceProfileByIdExportButton.title = "粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选";
|
||||
|
||||
const audienceProfileFieldButton = document.createElement("button");
|
||||
audienceProfileFieldButton.type = "button";
|
||||
audienceProfileFieldButton.dataset.pluginAudienceProfileFields = "button";
|
||||
audienceProfileFieldButton.textContent = "选择字段";
|
||||
audienceProfileFieldButton.title =
|
||||
"勾选本次CSV需要导出的字段,设置会自动保存";
|
||||
|
||||
const batchSubmitButton = document.createElement("button");
|
||||
batchSubmitButton.type = "button";
|
||||
batchSubmitButton.dataset.pluginBatchSubmit = "button";
|
||||
batchSubmitButton.textContent = "提交批次";
|
||||
const batchSubmitButton = createActionButton(
|
||||
document,
|
||||
"提交批次",
|
||||
"pluginBatchSubmit"
|
||||
);
|
||||
batchSubmitButton.title = "将当前选中的达人提交到后续业务批次";
|
||||
|
||||
const exportStatusText = document.createElement("span");
|
||||
exportStatusText.dataset.pluginExportStatus = "text";
|
||||
exportStatusText.setAttribute("aria-live", "polite");
|
||||
applyStatusStyles(exportStatusText);
|
||||
|
||||
root.append(
|
||||
exportRangeSelect,
|
||||
exportCustomPagesInput,
|
||||
exportButton,
|
||||
audienceProfileExportButton,
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton,
|
||||
exportStatusText
|
||||
const panel = document.createElement("div");
|
||||
panel.dataset.pluginToolbarPanel = "root";
|
||||
applyToolbarPanelStyles(panel);
|
||||
const primaryRow = document.createElement("div");
|
||||
primaryRow.dataset.pluginToolbarRow = "primary";
|
||||
applyToolbarRowStyles(primaryRow);
|
||||
|
||||
const dataGroup = document.createElement("div");
|
||||
dataGroup.dataset.pluginToolbarGroup = "data";
|
||||
applyToolbarGroupStyles(dataGroup);
|
||||
const selectedAudienceExportGroup = createToolbarActionGroup(
|
||||
document,
|
||||
"selected-audience-export",
|
||||
[
|
||||
audienceProfileExportButton,
|
||||
audienceProfileFieldButton,
|
||||
exportRangeSelect,
|
||||
exportCustomPagesInput
|
||||
]
|
||||
);
|
||||
const idExportGroup = createToolbarActionGroup(document, "id-export", [
|
||||
audienceProfileByIdExportButton
|
||||
]);
|
||||
const batchSubmitGroup = createToolbarActionGroup(document, "batch-submit", [
|
||||
batchSubmitButton
|
||||
]);
|
||||
dataGroup.append(
|
||||
selectedAudienceExportGroup,
|
||||
createToolbarActionDivider(document),
|
||||
idExportGroup,
|
||||
createToolbarActionDivider(document),
|
||||
batchSubmitGroup
|
||||
);
|
||||
|
||||
primaryRow.append(dataGroup, exportStatusText);
|
||||
panel.append(primaryRow);
|
||||
root.append(panel);
|
||||
document.body.appendChild(root);
|
||||
applyNativeControlStyles(document, {
|
||||
audienceProfileExportButton,
|
||||
|
||||
const toolbarDom = {
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect
|
||||
});
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
root
|
||||
} satisfies PluginToolbarDom;
|
||||
root[TOOLBAR_DOM] = toolbarDom;
|
||||
|
||||
applyNativeControlStyles(document, toolbarDom);
|
||||
ensureToolbarMounted(root, document);
|
||||
|
||||
exportButton.addEventListener("click", () => {
|
||||
void handlers.onExport();
|
||||
});
|
||||
audienceProfileExportButton.addEventListener("click", () => {
|
||||
void handlers.onExportAudienceProfile();
|
||||
});
|
||||
@@ -153,74 +171,9 @@ export function ensurePluginToolbar(
|
||||
void handlers.onSubmitBatch();
|
||||
});
|
||||
exportRangeSelect.addEventListener("change", () => {
|
||||
syncCustomPagesInputVisibility({
|
||||
batchSubmitButton,
|
||||
audienceProfileFieldButton,
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileExportButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
root
|
||||
});
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
});
|
||||
|
||||
const toolbarDom = {
|
||||
audienceProfileExportButton,
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
root
|
||||
} satisfies PluginToolbarDom;
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
|
||||
return toolbarDom;
|
||||
}
|
||||
|
||||
function appendOption(
|
||||
select: HTMLSelectElement,
|
||||
value: string,
|
||||
label: string
|
||||
): void {
|
||||
const option = select.ownerDocument.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = label;
|
||||
select.appendChild(option);
|
||||
}
|
||||
|
||||
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
const toolbarDom = {
|
||||
audienceProfileByIdExportButton: root.querySelector(
|
||||
'[data-plugin-export-audience-profile-by-id="button"]'
|
||||
) as HTMLButtonElement,
|
||||
audienceProfileExportButton: root.querySelector(
|
||||
'[data-plugin-export-audience-profile="button"]'
|
||||
) as HTMLButtonElement,
|
||||
audienceProfileFieldButton: root.querySelector(
|
||||
'[data-plugin-audience-profile-fields="button"]'
|
||||
) as HTMLButtonElement,
|
||||
batchSubmitButton: root.querySelector(
|
||||
'[data-plugin-batch-submit="button"]'
|
||||
) as HTMLButtonElement,
|
||||
exportButton: root.querySelector(
|
||||
'[data-plugin-export="button"]'
|
||||
) as HTMLButtonElement,
|
||||
exportCustomPagesInput: root.querySelector(
|
||||
'[data-plugin-export-custom-pages="input"]'
|
||||
) as HTMLInputElement,
|
||||
exportRangeSelect: root.querySelector(
|
||||
'[data-plugin-export-range="select"]'
|
||||
) as HTMLSelectElement,
|
||||
exportStatusText: root.querySelector(
|
||||
'[data-plugin-export-status="text"]'
|
||||
) as HTMLElement,
|
||||
root
|
||||
} satisfies PluginToolbarDom;
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
return toolbarDom;
|
||||
}
|
||||
@@ -229,55 +182,14 @@ export function readToolbarExportTarget(
|
||||
toolbar: PluginToolbarDom
|
||||
): { error?: string; target?: MarketExportTarget } {
|
||||
const scope = toolbar.exportRangeSelect.value as MarketExportScope;
|
||||
|
||||
if (scope === "all") {
|
||||
return {
|
||||
target: {
|
||||
mode: "all"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (scope === "current") {
|
||||
return {
|
||||
target: {
|
||||
mode: "count",
|
||||
pageCount: 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (scope === "first-5") {
|
||||
return {
|
||||
target: {
|
||||
mode: "count",
|
||||
pageCount: 5
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (scope === "first-10") {
|
||||
return {
|
||||
target: {
|
||||
mode: "count",
|
||||
pageCount: 10
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (scope === "all") return { target: { mode: "all" } };
|
||||
if (scope === "current") return { target: { mode: "count", pageCount: 1 } };
|
||||
if (scope === "first-5") return { target: { mode: "count", pageCount: 5 } };
|
||||
if (scope === "first-10") return { target: { mode: "count", pageCount: 10 } };
|
||||
const pageCount = Number(toolbar.exportCustomPagesInput.value);
|
||||
if (!Number.isInteger(pageCount) || pageCount < 1) {
|
||||
return {
|
||||
error: "请输入有效页数"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
target: {
|
||||
mode: "count",
|
||||
pageCount
|
||||
}
|
||||
};
|
||||
return Number.isInteger(pageCount) && pageCount >= 1
|
||||
? { target: { mode: "count", pageCount } }
|
||||
: { error: "请输入有效页数" };
|
||||
}
|
||||
|
||||
export function setToolbarBusyState(
|
||||
@@ -289,7 +201,6 @@ export function setToolbarBusyState(
|
||||
toolbar.audienceProfileFieldButton,
|
||||
toolbar.audienceProfileByIdExportButton,
|
||||
toolbar.audienceProfileExportButton,
|
||||
toolbar.exportButton,
|
||||
toolbar.exportRangeSelect,
|
||||
toolbar.exportCustomPagesInput
|
||||
].forEach((element) => {
|
||||
@@ -304,9 +215,51 @@ export function setToolbarExportStatus(
|
||||
toolbar.exportStatusText.textContent = text;
|
||||
}
|
||||
|
||||
function createActionButton(
|
||||
document: Document,
|
||||
label: string,
|
||||
datasetKey: string
|
||||
): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.dataset[datasetKey] = "button";
|
||||
button.textContent = label;
|
||||
return button;
|
||||
}
|
||||
|
||||
function appendOption(
|
||||
select: HTMLSelectElement,
|
||||
value: string,
|
||||
label: string
|
||||
): void {
|
||||
const option = select.ownerDocument.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = label;
|
||||
select.appendChild(option);
|
||||
}
|
||||
|
||||
function createToolbarActionGroup(
|
||||
document: Document,
|
||||
groupName: "batch-submit" | "id-export" | "selected-audience-export",
|
||||
controls: HTMLElement[]
|
||||
): HTMLElement {
|
||||
const group = document.createElement("div");
|
||||
group.dataset.pluginToolbarActionGroup = groupName;
|
||||
applyToolbarActionGroupStyles(group);
|
||||
group.append(...controls);
|
||||
return group;
|
||||
}
|
||||
|
||||
function createToolbarActionDivider(document: Document): HTMLElement {
|
||||
const divider = document.createElement("span");
|
||||
divider.dataset.pluginToolbarActionDivider = "divider";
|
||||
divider.setAttribute("aria-hidden", "true");
|
||||
applyToolbarActionDividerStyles(divider);
|
||||
return divider;
|
||||
}
|
||||
|
||||
function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
|
||||
toolbar.exportRangeSelect.hidden = true;
|
||||
toolbar.exportCustomPagesInput.hidden = true;
|
||||
toolbar.exportCustomPagesInput.hidden = toolbar.exportRangeSelect.value !== "custom";
|
||||
}
|
||||
|
||||
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
||||
@@ -315,17 +268,12 @@ function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
||||
root.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const customizeButton = findNativeActionButton(actionRow, "自定义指标");
|
||||
const insertionAnchor = customizeButton
|
||||
const anchor = customizeButton
|
||||
? findDirectChildAnchor(actionRow, customizeButton)
|
||||
: null;
|
||||
if (insertionAnchor) {
|
||||
actionRow.insertBefore(root, insertionAnchor);
|
||||
} else if (root.parentElement !== actionRow) {
|
||||
actionRow.prepend(root);
|
||||
}
|
||||
|
||||
if (anchor) actionRow.insertBefore(root, anchor);
|
||||
else if (root.parentElement !== actionRow) actionRow.prepend(root);
|
||||
root.hidden = false;
|
||||
}
|
||||
|
||||
@@ -333,14 +281,11 @@ function findNativeActionRow(document: Document): HTMLElement | null {
|
||||
const customizeButton = findNativeActionButton(document, "自定义指标");
|
||||
const exportButton = findNativeActionButton(document, "导出");
|
||||
const header = findHeaderContainer(customizeButton, exportButton);
|
||||
|
||||
const sharedActionRow =
|
||||
customizeButton && exportButton
|
||||
? findSmallestSharedActionRow(customizeButton, exportButton, header)
|
||||
: null;
|
||||
if (sharedActionRow) {
|
||||
return sharedActionRow;
|
||||
}
|
||||
if (sharedActionRow) return sharedActionRow;
|
||||
|
||||
const scope = header ?? document;
|
||||
const candidates = Array.from(
|
||||
@@ -348,21 +293,22 @@ function findNativeActionRow(document: Document): HTMLElement | null {
|
||||
).filter((element): element is HTMLElement =>
|
||||
element instanceof document.defaultView!.HTMLElement
|
||||
);
|
||||
|
||||
const rankedCandidates = candidates
|
||||
.filter((candidate) =>
|
||||
isNativeActionRowCandidate(candidate, customizeButton, exportButton)
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const depthDelta = getDepthWithinAncestor(right, header) - getDepthWithinAncestor(left, header);
|
||||
if (depthDelta !== 0) {
|
||||
return depthDelta;
|
||||
}
|
||||
|
||||
return normalizeText(left.textContent).length - normalizeText(right.textContent).length;
|
||||
});
|
||||
|
||||
return rankedCandidates[0] ?? null;
|
||||
return (
|
||||
candidates
|
||||
.filter((candidate) =>
|
||||
isNativeActionRowCandidate(candidate, customizeButton, exportButton)
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const depthDelta =
|
||||
getDepthWithinAncestor(right, header) -
|
||||
getDepthWithinAncestor(left, header);
|
||||
return (
|
||||
depthDelta ||
|
||||
normalizeText(left.textContent).length -
|
||||
normalizeText(right.textContent).length
|
||||
);
|
||||
})[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function findHeaderContainer(
|
||||
@@ -381,17 +327,13 @@ function findSmallestSharedActionRow(
|
||||
boundary: HTMLElement | null
|
||||
): HTMLElement | null {
|
||||
const exportAncestors = new Set(collectAncestorChain(exportButton, boundary));
|
||||
|
||||
for (const candidate of collectAncestorChain(customizeButton, boundary)) {
|
||||
if (
|
||||
exportAncestors.has(candidate) &&
|
||||
isNativeActionRowCandidate(candidate, customizeButton, exportButton)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
collectAncestorChain(customizeButton, boundary).find(
|
||||
(candidate) =>
|
||||
exportAncestors.has(candidate) &&
|
||||
isNativeActionRowCandidate(candidate, customizeButton, exportButton)
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function collectAncestorChain(
|
||||
@@ -400,15 +342,11 @@ function collectAncestorChain(
|
||||
): HTMLElement[] {
|
||||
const ancestors: HTMLElement[] = [];
|
||||
let current: HTMLElement | null = element.parentElement;
|
||||
|
||||
while (current) {
|
||||
ancestors.push(current);
|
||||
if (current === boundary) {
|
||||
break;
|
||||
}
|
||||
if (current === boundary) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
@@ -417,28 +355,19 @@ function isNativeActionRowCandidate(
|
||||
customizeButton: HTMLElement | null,
|
||||
exportButton: HTMLElement | null
|
||||
): boolean {
|
||||
if (customizeButton && !candidate.contains(customizeButton)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (exportButton && !candidate.contains(exportButton)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const directChildLabels = Array.from(candidate.children)
|
||||
.flatMap((child) => {
|
||||
const buttons: Element[] = [];
|
||||
if (child instanceof candidate.ownerDocument.defaultView!.HTMLButtonElement) {
|
||||
buttons.push(child);
|
||||
}
|
||||
|
||||
buttons.push(...Array.from(child.querySelectorAll("button")));
|
||||
return buttons;
|
||||
})
|
||||
if (customizeButton && !candidate.contains(customizeButton)) return false;
|
||||
if (exportButton && !candidate.contains(exportButton)) return false;
|
||||
const labels = Array.from(candidate.children)
|
||||
.flatMap((child) => [
|
||||
...(child instanceof candidate.ownerDocument.defaultView!.HTMLButtonElement
|
||||
? [child]
|
||||
: []),
|
||||
...Array.from(child.querySelectorAll("button"))
|
||||
])
|
||||
.map((button) => normalizeText(button.textContent));
|
||||
return (
|
||||
directChildLabels.includes("导出") &&
|
||||
(directChildLabels.includes("自定义指标") || Boolean(customizeButton))
|
||||
labels.includes("导出") &&
|
||||
(labels.includes("自定义指标") || Boolean(customizeButton))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -448,195 +377,205 @@ function getDepthWithinAncestor(
|
||||
): number {
|
||||
let depth = 0;
|
||||
let current: HTMLElement | null = element.parentElement;
|
||||
|
||||
while (current && current !== boundary) {
|
||||
depth += 1;
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return depth;
|
||||
}
|
||||
|
||||
function findNativeActionButton(
|
||||
root: ParentNode,
|
||||
text: string
|
||||
): HTMLElement | null {
|
||||
): HTMLButtonElement | null {
|
||||
const document = root instanceof Document ? root : root.ownerDocument;
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = Array.from(root.querySelectorAll("button")).filter(
|
||||
(element): element is HTMLElement =>
|
||||
element instanceof document.defaultView!.HTMLElement
|
||||
);
|
||||
if (!document) return null;
|
||||
return (
|
||||
candidates.find((element) => normalizeText(element.textContent) === text) ?? null
|
||||
Array.from(root.querySelectorAll("button")).find(
|
||||
(element): element is HTMLButtonElement =>
|
||||
element instanceof document.defaultView!.HTMLButtonElement &&
|
||||
normalizeText(element.textContent) === text
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function findDirectChildAnchor(
|
||||
parent: HTMLElement,
|
||||
target: HTMLElement
|
||||
): Element | null {
|
||||
let current: Element | null = target;
|
||||
while (current?.parentElement && current.parentElement !== parent) {
|
||||
current = current.parentElement;
|
||||
}
|
||||
return current?.parentElement === parent ? current : null;
|
||||
}
|
||||
|
||||
function normalizeText(value: string | null): string {
|
||||
return value?.replace(/\s+/g, "").trim() ?? "";
|
||||
}
|
||||
|
||||
function applyToolbarRootStyles(root: HTMLElement): void {
|
||||
root.style.display = "inline-flex";
|
||||
root.style.alignItems = "center";
|
||||
root.style.columnGap = "8px";
|
||||
root.style.flexWrap = "wrap";
|
||||
Object.assign(root.style, {
|
||||
alignItems: "center",
|
||||
columnGap: "10px",
|
||||
display: "inline-flex",
|
||||
flex: "1 1 auto",
|
||||
flexWrap: "nowrap",
|
||||
minWidth: "0"
|
||||
});
|
||||
}
|
||||
|
||||
function applyToolbarPanelStyles(panel: HTMLElement): void {
|
||||
Object.assign(panel.style, {
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
flex: "1 1 auto",
|
||||
minWidth: "0",
|
||||
overflow: "visible",
|
||||
padding: "0"
|
||||
});
|
||||
}
|
||||
|
||||
function applyToolbarRowStyles(row: HTMLElement): void {
|
||||
Object.assign(row.style, {
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
flexWrap: "nowrap",
|
||||
gap: "8px",
|
||||
justifyContent: "flex-start",
|
||||
minHeight: "32px",
|
||||
minWidth: "0",
|
||||
width: "100%"
|
||||
});
|
||||
}
|
||||
|
||||
function applyToolbarGroupStyles(group: HTMLElement): void {
|
||||
Object.assign(group.style, {
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
flex: "0 0 auto",
|
||||
flexWrap: "nowrap",
|
||||
gap: "8px",
|
||||
minWidth: "0"
|
||||
});
|
||||
}
|
||||
|
||||
function applyToolbarActionGroupStyles(group: HTMLElement): void {
|
||||
Object.assign(group.style, {
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
flex: "0 0 auto",
|
||||
flexWrap: "nowrap",
|
||||
gap: "8px"
|
||||
});
|
||||
}
|
||||
|
||||
function applyToolbarActionDividerStyles(divider: HTMLElement): void {
|
||||
Object.assign(divider.style, {
|
||||
background: "#d0d5dd",
|
||||
flex: "0 0 auto",
|
||||
height: "20px",
|
||||
width: "1px"
|
||||
});
|
||||
}
|
||||
|
||||
function applyNativeControlStyles(
|
||||
document: Document,
|
||||
controls: {
|
||||
audienceProfileExportButton: HTMLButtonElement;
|
||||
audienceProfileByIdExportButton: HTMLButtonElement;
|
||||
audienceProfileFieldButton: HTMLButtonElement;
|
||||
batchSubmitButton: HTMLButtonElement;
|
||||
exportButton: HTMLButtonElement;
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
}
|
||||
toolbar: PluginToolbarDom
|
||||
): void {
|
||||
const primaryButton =
|
||||
findButtonContainingText(document, "发布任务") ??
|
||||
findButtonContainingText(document, "+发布任务");
|
||||
const nativeButton =
|
||||
primaryButton ??
|
||||
findNativeActionButton(document, "自定义指标") ??
|
||||
findNativeActionButton(document, "导出");
|
||||
|
||||
if (nativeButton) {
|
||||
controls.audienceProfileExportButton.className = nativeButton.className;
|
||||
controls.audienceProfileByIdExportButton.className = nativeButton.className;
|
||||
controls.audienceProfileFieldButton.className = nativeButton.className;
|
||||
controls.batchSubmitButton.className = nativeButton.className;
|
||||
}
|
||||
|
||||
[
|
||||
controls.audienceProfileExportButton,
|
||||
controls.audienceProfileByIdExportButton,
|
||||
controls.audienceProfileFieldButton,
|
||||
controls.batchSubmitButton
|
||||
toolbar.audienceProfileExportButton,
|
||||
toolbar.audienceProfileByIdExportButton,
|
||||
toolbar.audienceProfileFieldButton,
|
||||
toolbar.batchSubmitButton
|
||||
].forEach((button) => {
|
||||
applyPrimaryButtonStyles(button);
|
||||
button.style.whiteSpace = "nowrap";
|
||||
if (nativeButton) button.className = nativeButton.className;
|
||||
});
|
||||
|
||||
[controls.exportRangeSelect, controls.exportCustomPagesInput].forEach((element) => {
|
||||
element.style.height = "32px";
|
||||
element.style.border = "1px solid #d0d7de";
|
||||
element.style.borderRadius = "6px";
|
||||
element.style.padding = "0 10px";
|
||||
element.style.background = "#fff";
|
||||
element.style.color = "#1f2329";
|
||||
element.style.boxSizing = "border-box";
|
||||
});
|
||||
|
||||
controls.exportRangeSelect.style.minWidth = "104px";
|
||||
controls.exportCustomPagesInput.style.width = "72px";
|
||||
[
|
||||
toolbar.audienceProfileExportButton,
|
||||
toolbar.audienceProfileByIdExportButton,
|
||||
toolbar.audienceProfileFieldButton
|
||||
].forEach(applySecondaryButtonStyles);
|
||||
applyPrimaryButtonStyles(toolbar.batchSubmitButton);
|
||||
[toolbar.exportRangeSelect, toolbar.exportCustomPagesInput].forEach(
|
||||
applyInputStyles
|
||||
);
|
||||
toolbar.exportRangeSelect.style.minWidth = "104px";
|
||||
toolbar.exportCustomPagesInput.style.width = "72px";
|
||||
}
|
||||
|
||||
function applyPrimaryButtonStyles(
|
||||
button: HTMLButtonElement
|
||||
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
|
||||
Object.assign(button.style, {
|
||||
backgroundColor: "#7f1d2d",
|
||||
border: "1px solid #7f1d2d",
|
||||
borderRadius: "8px",
|
||||
boxSizing: "border-box",
|
||||
color: "#ffffff",
|
||||
fontWeight: "600",
|
||||
height: "32px",
|
||||
padding: "0 15px",
|
||||
whiteSpace: "nowrap"
|
||||
});
|
||||
}
|
||||
|
||||
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
|
||||
Object.assign(button.style, {
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1px solid #cbd5e1",
|
||||
borderRadius: "8px",
|
||||
boxSizing: "border-box",
|
||||
color: "#344054",
|
||||
fontWeight: "600",
|
||||
height: "32px",
|
||||
padding: "0 15px",
|
||||
whiteSpace: "nowrap"
|
||||
});
|
||||
}
|
||||
|
||||
function applyInputStyles(
|
||||
element: HTMLInputElement | HTMLSelectElement
|
||||
): void {
|
||||
button.style.backgroundColor = "#7f1d2d";
|
||||
button.style.border = "1px solid #7f1d2d";
|
||||
button.style.borderRadius = "8px";
|
||||
button.style.color = "#ffffff";
|
||||
button.style.height = "32px";
|
||||
button.style.padding = "0 15px";
|
||||
button.style.boxSizing = "border-box";
|
||||
button.style.fontWeight = "600";
|
||||
button.style.transition =
|
||||
"background-color 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease";
|
||||
Object.assign(element.style, {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #d0d7de",
|
||||
borderRadius: "6px",
|
||||
boxSizing: "border-box",
|
||||
color: "#1f2329",
|
||||
height: "32px",
|
||||
padding: "0 8px"
|
||||
});
|
||||
}
|
||||
|
||||
function applyStatusStyles(statusText: HTMLElement): void {
|
||||
statusText.style.color = "#64748b";
|
||||
statusText.style.fontSize = "12px";
|
||||
statusText.style.lineHeight = "20px";
|
||||
statusText.style.marginLeft = "4px";
|
||||
statusText.style.whiteSpace = "nowrap";
|
||||
Object.assign(statusText.style, {
|
||||
color: "#64748b",
|
||||
flex: "1 1 auto",
|
||||
fontSize: "12px",
|
||||
lineHeight: "20px",
|
||||
minWidth: "120px",
|
||||
textAlign: "center",
|
||||
whiteSpace: "nowrap"
|
||||
});
|
||||
}
|
||||
|
||||
function ensurePluginActionButtonTheme(document: Document): void {
|
||||
if (document.getElementById(PLUGIN_ACTION_BUTTON_STYLE_ID)) {
|
||||
return;
|
||||
let style = document.getElementById(
|
||||
PLUGIN_ACTION_BUTTON_STYLE_ID
|
||||
) as HTMLStyleElement | null;
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
|
||||
style.textContent = `
|
||||
[data-plugin-export-audience-profile="button"]:hover:not(:disabled),
|
||||
[data-plugin-export-audience-profile-by-id="button"]:hover:not(:disabled),
|
||||
[data-plugin-audience-profile-fields="button"]:hover:not(:disabled),
|
||||
[data-plugin-batch-submit="button"]:hover:not(:disabled) {
|
||||
background-color: #6d1627 !important;
|
||||
border-color: #6d1627 !important;
|
||||
}
|
||||
|
||||
[data-plugin-export-audience-profile="button"]:active:not(:disabled),
|
||||
[data-plugin-export-audience-profile-by-id="button"]:active:not(:disabled),
|
||||
[data-plugin-audience-profile-fields="button"]:active:not(:disabled),
|
||||
[data-plugin-batch-submit="button"]:active:not(:disabled) {
|
||||
background-color: #58111f !important;
|
||||
border-color: #58111f !important;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
[data-plugin-export-audience-profile="button"]:focus-visible,
|
||||
[data-plugin-export-audience-profile-by-id="button"]:focus-visible,
|
||||
[data-plugin-audience-profile-fields="button"]:focus-visible,
|
||||
[data-plugin-batch-submit="button"]:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important;
|
||||
}
|
||||
|
||||
[data-plugin-export-audience-profile="button"]:disabled,
|
||||
[data-plugin-export-audience-profile-by-id="button"]:disabled,
|
||||
[data-plugin-audience-profile-fields="button"]:disabled,
|
||||
[data-plugin-batch-submit="button"]:disabled {
|
||||
background-color: #c89ca4 !important;
|
||||
border-color: #c89ca4 !important;
|
||||
color: rgba(255, 255, 255, 0.95) !important;
|
||||
cursor: not-allowed !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
@media (max-width: 720px) {
|
||||
[data-plugin-toolbar='root'], [data-plugin-toolbar-panel='root'], [data-plugin-toolbar-row='primary'] { width: 100% !important; }
|
||||
[data-plugin-toolbar-row='primary'], [data-plugin-toolbar-group='data'], [data-plugin-toolbar-action-group] { align-items: flex-start !important; flex-wrap: wrap !important; }
|
||||
[data-plugin-toolbar-action-divider] { display: none !important; }
|
||||
[data-plugin-export-status='text'] { flex-basis: 100% !important; text-align: left !important; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function normalizeText(value: string | null | undefined): string {
|
||||
return value?.replace(/\s+/g, " ").trim() ?? "";
|
||||
}
|
||||
|
||||
function findButtonContainingText(
|
||||
root: ParentNode,
|
||||
text: string
|
||||
): HTMLElement | null {
|
||||
const document = root instanceof Document ? root : root.ownerDocument;
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = Array.from(root.querySelectorAll("button")).filter(
|
||||
(element): element is HTMLElement =>
|
||||
element instanceof document.defaultView!.HTMLElement
|
||||
);
|
||||
|
||||
return candidates.find((element) => normalizeText(element.textContent).includes(text)) ?? null;
|
||||
}
|
||||
|
||||
function findDirectChildAnchor(
|
||||
ancestor: HTMLElement,
|
||||
descendant: HTMLElement
|
||||
): HTMLElement | null {
|
||||
let current: HTMLElement | null = descendant;
|
||||
let previous: HTMLElement | null = null;
|
||||
|
||||
while (current && current !== ancestor) {
|
||||
previous = current;
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return current === ancestor ? previous : null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import type {
|
||||
BackendMetrics,
|
||||
MarketApiFailureReason,
|
||||
MarketRecord,
|
||||
MarketRowSnapshot
|
||||
MarketRowSnapshot,
|
||||
SpreadInfoMetrics
|
||||
} from "./types";
|
||||
import type { AfterSearchRates } from "./types";
|
||||
|
||||
@@ -46,6 +47,13 @@ export function createMarketResultStore() {
|
||||
...backendMetrics
|
||||
};
|
||||
},
|
||||
setSpreadMetricsSuccess(authorId: string, spreadMetrics: SpreadInfoMetrics) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.spreadMetrics = {
|
||||
...existingRecord.spreadMetrics,
|
||||
...spreadMetrics
|
||||
};
|
||||
},
|
||||
setAuthorSuccess(authorId: string, rates: AfterSearchRates) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.status = "success";
|
||||
@@ -73,6 +81,10 @@ export function createMarketResultStore() {
|
||||
existingRecord.price21To60s,
|
||||
row.price21To60s
|
||||
);
|
||||
existingRecord.spreadAuthorId = mergeStringValue(
|
||||
existingRecord.spreadAuthorId,
|
||||
row.spreadAuthorId
|
||||
);
|
||||
existingRecord.exportFields = mergeFieldMap(
|
||||
existingRecord.exportFields,
|
||||
row.exportFields
|
||||
@@ -81,6 +93,10 @@ export function createMarketResultStore() {
|
||||
existingRecord.backendMetrics,
|
||||
row.backendMetrics
|
||||
);
|
||||
existingRecord.spreadMetrics = mergeFieldMap(
|
||||
existingRecord.spreadMetrics,
|
||||
row.spreadMetrics
|
||||
);
|
||||
existingRecord.hasDirectRatesSource =
|
||||
existingRecord.hasDirectRatesSource || row.hasDirectRatesSource;
|
||||
existingRecord.rates = mergeFieldMap(existingRecord.rates, row.rates);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import type {
|
||||
SpreadInfoConfig,
|
||||
SpreadInfoMetrics
|
||||
} from "./types";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
interface SpreadInfoClientOptions {
|
||||
baseUrl?: string;
|
||||
configs?: SpreadInfoConfig[];
|
||||
fetchImpl?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface SpreadInfoMetricDefinition {
|
||||
key: keyof MappedSpreadInfoResponse;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface MappedSpreadInfoResponse {
|
||||
averageCommentCount?: string;
|
||||
averageDuration?: string;
|
||||
averageLikeCount?: string;
|
||||
averageShareCount?: string;
|
||||
finishRate?: string;
|
||||
interactionRate?: string;
|
||||
playMedian?: string;
|
||||
}
|
||||
|
||||
const SPREAD_INFO_METRICS: SpreadInfoMetricDefinition[] = [
|
||||
{
|
||||
key: "finishRate",
|
||||
label: "完播率"
|
||||
},
|
||||
{
|
||||
key: "playMedian",
|
||||
label: "播放量中位数"
|
||||
},
|
||||
{
|
||||
key: "interactionRate",
|
||||
label: "互动率"
|
||||
},
|
||||
{
|
||||
key: "averageDuration",
|
||||
label: "作品平均时长"
|
||||
},
|
||||
{
|
||||
key: "averageCommentCount",
|
||||
label: "作品平均评论数"
|
||||
},
|
||||
{
|
||||
key: "averageLikeCount",
|
||||
label: "作品平均点赞数"
|
||||
},
|
||||
{
|
||||
key: "averageShareCount",
|
||||
label: "作品平均转发数"
|
||||
}
|
||||
];
|
||||
|
||||
export const DEFAULT_SPREAD_INFO_CONFIGS: SpreadInfoConfig[] = [
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
},
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
},
|
||||
...buildXingtuVideoConfigs()
|
||||
];
|
||||
|
||||
export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
|
||||
const baseUrl = options.baseUrl ?? resolveBaseUrl();
|
||||
const configs = options.configs ?? DEFAULT_SPREAD_INFO_CONFIGS;
|
||||
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||
const timeoutMs = options.timeoutMs ?? 8000;
|
||||
|
||||
return {
|
||||
async loadAuthorSpreadMetrics(
|
||||
authorId: string,
|
||||
selectedConfigs: SpreadInfoConfig[] = configs
|
||||
): Promise<SpreadInfoMetrics> {
|
||||
const metrics: SpreadInfoMetrics = {};
|
||||
|
||||
for (const config of selectedConfigs) {
|
||||
const mappedResponse = await loadSpreadInfoFromUrl(
|
||||
buildSpreadInfoUrl(authorId, config, baseUrl)
|
||||
);
|
||||
Object.entries(buildSpreadInfoMetricMap(config, mappedResponse)).forEach(
|
||||
([header, value]) => {
|
||||
metrics[header] = value;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
};
|
||||
|
||||
async function loadSpreadInfoFromUrl(
|
||||
url: string
|
||||
): Promise<MappedSpreadInfoResponse> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
credentials: "include",
|
||||
method: "GET",
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`星图传播数据接口请求失败: HTTP ${response.status ?? "unknown"}${
|
||||
response.statusText ? `: ${response.statusText}` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const rateLimitMessage = readRateLimitMessage(payload);
|
||||
if (rateLimitMessage) {
|
||||
throw new Error(
|
||||
`星图传播数据接口被限流 (status_code=31157): ${rateLimitMessage}`
|
||||
);
|
||||
}
|
||||
|
||||
return mapSpreadInfoResponse(payload);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSpreadInfoUrl(
|
||||
authorId: string,
|
||||
config: SpreadInfoConfig,
|
||||
baseUrl: string
|
||||
): string {
|
||||
const url = new URL("/gw/api/data_sp/get_author_spread_info", baseUrl);
|
||||
url.searchParams.set("o_author_id", authorId);
|
||||
url.searchParams.set("platform_source", "1");
|
||||
url.searchParams.set("platform_channel", "1");
|
||||
url.searchParams.set("type", String(config.type));
|
||||
url.searchParams.set("flow_type", String(config.flowType));
|
||||
url.searchParams.set("only_assign", String(config.onlyAssign));
|
||||
url.searchParams.set("range", String(config.range));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function buildSpreadInfoColumns(
|
||||
configs: SpreadInfoConfig[] = DEFAULT_SPREAD_INFO_CONFIGS
|
||||
): string[] {
|
||||
return configs.flatMap((config) =>
|
||||
SPREAD_INFO_METRICS.map((metric) => buildSpreadInfoColumnHeader(config, metric))
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
): SpreadInfoMetrics {
|
||||
const values: SpreadInfoMetrics = {};
|
||||
|
||||
SPREAD_INFO_METRICS.forEach((metric) => {
|
||||
const value = metrics[metric.key];
|
||||
if (hasTextValue(value)) {
|
||||
values[buildSpreadInfoColumnHeader(config, metric)] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
export function mapSpreadInfoResponse(
|
||||
payload: unknown
|
||||
): MappedSpreadInfoResponse {
|
||||
const data = getPayloadData(payload);
|
||||
if (!data) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
averageCommentCount: readStringLike(data.comment_avg),
|
||||
averageDuration: formatMillisecondsAsSeconds(readNumberLike(data.avg_duration)),
|
||||
averageLikeCount: readStringLike(data.like_avg),
|
||||
averageShareCount: readStringLike(data.share_avg),
|
||||
finishRate: formatBasisPointPercent(
|
||||
readNumberLike(readNestedValue(data.play_over_rate, "value"))
|
||||
),
|
||||
interactionRate: formatBasisPointPercent(
|
||||
readNumberLike(readNestedValue(data.interact_rate, "value"))
|
||||
),
|
||||
playMedian:
|
||||
readStringLike(data.play_mid) ??
|
||||
readStringLike(readNestedValue(readNestedValue(data.item_rate, "play_mid"), "value"))
|
||||
};
|
||||
}
|
||||
|
||||
function buildSpreadInfoColumnHeader(
|
||||
config: SpreadInfoConfig,
|
||||
metric: SpreadInfoMetricDefinition
|
||||
): string {
|
||||
return ["内容数据", ...buildConfigPrefixParts(config), metric.label].join("-");
|
||||
}
|
||||
|
||||
function buildConfigPrefixParts(config: SpreadInfoConfig): string[] {
|
||||
const typeLabel = config.type === 1 ? "个人视频" : "星图视频";
|
||||
const rangeLabel = config.range === 2 ? "近30天" : "近90天";
|
||||
|
||||
if (config.type === 1) {
|
||||
return [typeLabel, rangeLabel];
|
||||
}
|
||||
|
||||
return [
|
||||
config.onlyAssign ? "只看指派" : "不限指派",
|
||||
config.flowType === 1 ? "排除营销流量" : "不排除营销流量",
|
||||
typeLabel,
|
||||
rangeLabel
|
||||
];
|
||||
}
|
||||
|
||||
function buildXingtuVideoConfigs(): SpreadInfoConfig[] {
|
||||
const configs: SpreadInfoConfig[] = [];
|
||||
[false, true].forEach((onlyAssign) => {
|
||||
([0, 1] as const).forEach((flowType) => {
|
||||
([2, 3] as const).forEach((range) => {
|
||||
configs.push({
|
||||
flowType,
|
||||
onlyAssign,
|
||||
range,
|
||||
type: 2
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return configs;
|
||||
}
|
||||
|
||||
function getPayloadData(payload: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(payload)) {
|
||||
return 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;
|
||||
}
|
||||
|
||||
function readStringLike(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNumberLike(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsedValue = Number(value);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatBasisPointPercent(value: number | null): string | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${formatDecimal(value / 100)}%`;
|
||||
}
|
||||
|
||||
function formatMillisecondsAsSeconds(value: number | null): string | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return formatDecimal(value / 100);
|
||||
}
|
||||
|
||||
function formatDecimal(value: number): string {
|
||||
return value.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function resolveBaseUrl(): string {
|
||||
if (typeof location !== "undefined" && location.origin) {
|
||||
return location.origin;
|
||||
}
|
||||
|
||||
return "https://www.xingtu.cn";
|
||||
}
|
||||
|
||||
async function defaultFetch(input: string, init?: RequestInit) {
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
function hasTextValue(value: string | undefined): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -12,6 +12,15 @@ export interface BackendMetrics {
|
||||
newA3Rate?: string;
|
||||
}
|
||||
|
||||
export type SpreadInfoMetrics = Record<string, string>;
|
||||
|
||||
export interface SpreadInfoConfig {
|
||||
flowType: 0 | 1;
|
||||
onlyAssign: boolean;
|
||||
range: 2 | 3;
|
||||
type: 1 | 2;
|
||||
}
|
||||
|
||||
export type MarketSortField =
|
||||
| keyof Required<AfterSearchRates>
|
||||
| keyof Required<BackendMetrics>;
|
||||
@@ -28,6 +37,8 @@ export interface MarketRowSnapshot {
|
||||
location?: string;
|
||||
price21To60s?: string;
|
||||
rates?: AfterSearchRates;
|
||||
spreadAuthorId?: string;
|
||||
spreadMetrics?: SpreadInfoMetrics;
|
||||
}
|
||||
|
||||
export interface MarketRecord extends MarketRowSnapshot {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://192.168.31.21:8083";
|
||||
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://localhost:8083";
|
||||
|
||||
@@ -54,29 +54,7 @@ describe("audience-profile-csv", () => {
|
||||
hotRate: "缺失"
|
||||
}
|
||||
},
|
||||
status: "success",
|
||||
videos: {
|
||||
personalVideo: {
|
||||
averageComment: "4.5w",
|
||||
averageDuration: "150s",
|
||||
averageLike: "113.2w",
|
||||
averageShare: "26.5w",
|
||||
finishRate: "15.8%",
|
||||
interactionRate: "3.9%",
|
||||
medianPlay: "3738.4w",
|
||||
publishedItems: "<5"
|
||||
},
|
||||
xingtuVideo: {
|
||||
averageComment: "5.1w",
|
||||
averageDuration: "170s",
|
||||
averageLike: "150.3w",
|
||||
averageShare: "68.4w",
|
||||
finishRate: "19.9%",
|
||||
interactionRate: "5.5%",
|
||||
medianPlay: "4059.7w",
|
||||
publishedItems: "<5"
|
||||
}
|
||||
}
|
||||
status: "success"
|
||||
},
|
||||
record: {
|
||||
authorId: "123",
|
||||
@@ -85,6 +63,10 @@ describe("audience-profile-csv", () => {
|
||||
达人信息: "达人 A",
|
||||
连接用户数: "300w"
|
||||
},
|
||||
spreadMetrics: {
|
||||
"内容数据-个人视频-近30天-播放量中位数": "10913233",
|
||||
"内容数据-只看指派-不排除营销流量-星图视频-近90天-作品平均评论数": "7502"
|
||||
},
|
||||
status: "success"
|
||||
}
|
||||
} satisfies AudienceProfileExportRow
|
||||
@@ -95,8 +77,10 @@ describe("audience-profile-csv", () => {
|
||||
expect(headerLine).toContain("达人信息,连接用户数");
|
||||
expect(headerLine).not.toContain("抓取状态");
|
||||
expect(headerLine).not.toContain("失败原因");
|
||||
expect(headerLine).toContain("内容数据-个人视频-播放量中位数");
|
||||
expect(headerLine).toContain("内容数据-星图视频-平均转发");
|
||||
expect(headerLine).toContain("内容数据-个人视频-近30天-播放量中位数");
|
||||
expect(headerLine).toContain(
|
||||
"内容数据-只看指派-不排除营销流量-星图视频-近90天-作品平均评论数"
|
||||
);
|
||||
expect(headerLine).toContain("效果预估-1-20s视频-预期CPM");
|
||||
expect(headerLine).toContain("效果预估-20-60s视频-爆文率");
|
||||
expect(headerLine).toContain("效果预估-60s以上视频-预期播放量");
|
||||
@@ -116,8 +100,15 @@ describe("audience-profile-csv", () => {
|
||||
expect(headerLine).not.toContain("兴趣TOP");
|
||||
expect(rowLine).toContain("71.7%");
|
||||
expect(rowLine).toContain("60%");
|
||||
expect(readCsvValue(csv, "内容数据-个人视频-播放量中位数")).toBe("3738.4w");
|
||||
expect(readCsvValue(csv, "内容数据-星图视频-平均转发")).toBe("68.4w");
|
||||
expect(readCsvValue(csv, "内容数据-个人视频-近30天-播放量中位数")).toBe(
|
||||
"10913233"
|
||||
);
|
||||
expect(
|
||||
readCsvValue(
|
||||
csv,
|
||||
"内容数据-只看指派-不排除营销流量-星图视频-近90天-作品平均评论数"
|
||||
)
|
||||
).toBe("7502");
|
||||
expect(readCsvValue(csv, "效果预估-1-20s视频-预期CPM")).toBe("120.0");
|
||||
expect(readCsvValue(csv, "效果预估-20-60s视频-爆文率")).toBe("缺失");
|
||||
});
|
||||
@@ -202,7 +193,7 @@ describe("audience-profile-csv", () => {
|
||||
const row = buildSuccessRow();
|
||||
const csv = buildAudienceProfileCsv([row], {
|
||||
selectedHeaders: [
|
||||
"内容数据-个人视频-播放量中位数",
|
||||
"内容数据-个人视频-近30天-播放量中位数",
|
||||
"观众画像-男性占比"
|
||||
]
|
||||
});
|
||||
@@ -210,13 +201,37 @@ describe("audience-profile-csv", () => {
|
||||
const [headerLine, rowLine] = csv.split("\n");
|
||||
|
||||
expect(headerLine).toBe(
|
||||
"达人信息,连接用户数,内容数据-个人视频-播放量中位数,观众画像-男性占比"
|
||||
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-近30天-播放量中位数,观众画像-男性占比"
|
||||
);
|
||||
expect(rowLine).toBe("达人 A,300w,3738.4w,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: {
|
||||
@@ -227,15 +242,15 @@ describe("audience-profile-csv", () => {
|
||||
}
|
||||
});
|
||||
const csv = buildAudienceProfileCsv([row], {
|
||||
selectedHeaders: ["内容数据-个人视频-播放量中位数"]
|
||||
selectedHeaders: ["内容数据-个人视频-近30天-播放量中位数"]
|
||||
});
|
||||
|
||||
const [headerLine, rowLine] = csv.split("\n");
|
||||
|
||||
expect(headerLine).toBe(
|
||||
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-播放量中位数"
|
||||
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-近30天-播放量中位数"
|
||||
);
|
||||
expect(rowLine).toBe("123,达人 A,成功,,3738.4w");
|
||||
expect(rowLine).toBe("123,达人 A,成功,,10913233");
|
||||
});
|
||||
|
||||
test("lists headers for field picker defaults", () => {
|
||||
@@ -244,7 +259,7 @@ describe("audience-profile-csv", () => {
|
||||
"达人信息",
|
||||
"连接用户数",
|
||||
"秒思api-看后搜数",
|
||||
"内容数据-个人视频-播放量中位数",
|
||||
"内容数据-个人视频-近30天-播放量中位数",
|
||||
"效果预估-20-60s视频-预期CPM",
|
||||
"观众画像-男性占比",
|
||||
"铁粉画像-小镇青年占比"
|
||||
@@ -260,7 +275,9 @@ describe("audience-profile-csv", () => {
|
||||
label: "秒思api数据"
|
||||
}),
|
||||
expect.objectContaining({
|
||||
headers: expect.arrayContaining(["内容数据-个人视频-播放量中位数"]),
|
||||
headers: expect.arrayContaining([
|
||||
"内容数据-只看指派-不排除营销流量-星图视频-近90天-作品平均评论数"
|
||||
]),
|
||||
label: "内容数据"
|
||||
}),
|
||||
expect.objectContaining({
|
||||
@@ -274,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 {
|
||||
@@ -310,12 +340,7 @@ function buildSuccessRow(
|
||||
hotRate: "缺失"
|
||||
}
|
||||
},
|
||||
status: "success",
|
||||
videos: {
|
||||
personalVideo: {
|
||||
medianPlay: "3738.4w"
|
||||
}
|
||||
}
|
||||
status: "success"
|
||||
},
|
||||
record: {
|
||||
authorId: "123",
|
||||
@@ -324,6 +349,10 @@ function buildSuccessRow(
|
||||
达人信息: "达人 A",
|
||||
连接用户数: "300w"
|
||||
},
|
||||
spreadMetrics: {
|
||||
"内容数据-个人视频-近30天-播放量中位数": "10913233",
|
||||
"内容数据-只看指派-不排除营销流量-星图视频-近90天-作品平均评论数": "7502"
|
||||
},
|
||||
status: "success",
|
||||
...overrides
|
||||
}
|
||||
|
||||
@@ -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 ?? "");
|
||||
}
|
||||
@@ -55,4 +55,22 @@ describe("batch-payload", () => {
|
||||
})
|
||||
).toThrow(/user/i);
|
||||
});
|
||||
|
||||
test("throws when no creator records are available", () => {
|
||||
expect(() =>
|
||||
createBatchPayload({
|
||||
authState: {
|
||||
isAuthenticated: true,
|
||||
resource: "https://talent-search.intelligrow.cn",
|
||||
userInfo: {
|
||||
name: "王少卿",
|
||||
sub: "p7pdhhtde8kj"
|
||||
}
|
||||
},
|
||||
batchName: "空结果批次",
|
||||
createdAt: "2026-07-03T03:05:52.432Z",
|
||||
records: []
|
||||
})
|
||||
).toThrow("当前没有可提交的达人");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createBatchSubmitClient } from "../src/shared/batch-submit-client";
|
||||
|
||||
describe("batch-submit-client", () => {
|
||||
test("exports the default batch submit base url", () => {
|
||||
expect(DEFAULT_BATCH_SUBMIT_BASE_URL).toBe("http://192.168.31.21:8083");
|
||||
expect(DEFAULT_BATCH_SUBMIT_BASE_URL).toBe("http://localhost:8083");
|
||||
});
|
||||
|
||||
test("posts the batch payload with a Bearer token", async () => {
|
||||
|
||||
@@ -2,24 +2,12 @@ import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildBusinessAbilityEstimateUrl,
|
||||
buildBusinessAbilityVideoUrl,
|
||||
createBusinessAbilityClient,
|
||||
mapBusinessAbilityEstimateResponse,
|
||||
mapBusinessAbilityVideoResponse
|
||||
mapBusinessAbilityEstimateResponse
|
||||
} from "../src/content/market/business-ability-client";
|
||||
|
||||
describe("business-ability-client", () => {
|
||||
test("builds commercial ability urls used by the Xingtu detail page", () => {
|
||||
expect(
|
||||
buildBusinessAbilityVideoUrl(
|
||||
"6724241209444794382",
|
||||
"https://www.xingtu.cn",
|
||||
2
|
||||
)
|
||||
).toBe(
|
||||
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=2&flow_type=0&only_assign=true&range=2"
|
||||
);
|
||||
|
||||
test("builds the commerce spread estimate url used by the Xingtu detail page", () => {
|
||||
expect(
|
||||
buildBusinessAbilityEstimateUrl(
|
||||
"6724241209444794382",
|
||||
@@ -30,19 +18,6 @@ describe("business-ability-client", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("maps video content metrics into page-style display values", () => {
|
||||
expect(mapBusinessAbilityVideoResponse(buildVideoPayload())).toEqual({
|
||||
averageComment: "5.1w",
|
||||
averageDuration: "170s",
|
||||
averageLike: "150.3w",
|
||||
averageShare: "68.4w",
|
||||
finishRate: "19.9%",
|
||||
interactionRate: "5.5%",
|
||||
medianPlay: "4059.7w",
|
||||
publishedItems: "<5"
|
||||
});
|
||||
});
|
||||
|
||||
test("maps duration estimates into page-style display values", () => {
|
||||
expect(mapBusinessAbilityEstimateResponse(buildEstimatePayload())).toEqual({
|
||||
oneToTwenty: {
|
||||
@@ -98,15 +73,12 @@ describe("business-ability-client", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("loads personal video, Xingtu video, and duration estimate metrics", async () => {
|
||||
test("loads duration estimate metrics without requesting legacy video content metrics", async () => {
|
||||
const requestedUrls: string[] = [];
|
||||
const fetchImpl = vi.fn(async (input: string) => {
|
||||
requestedUrls.push(input);
|
||||
return {
|
||||
json: async () =>
|
||||
input.includes("get_author_commerce_spread_info")
|
||||
? buildEstimatePayload()
|
||||
: buildVideoPayload(),
|
||||
json: async () => buildEstimatePayload(),
|
||||
ok: true
|
||||
};
|
||||
});
|
||||
@@ -126,35 +98,15 @@ describe("business-ability-client", () => {
|
||||
estimates: expect.objectContaining({
|
||||
twentyToSixty: expect.objectContaining({ expectedCpm: "212.0" })
|
||||
}),
|
||||
status: "success",
|
||||
videos: {
|
||||
personalVideo: expect.objectContaining({ medianPlay: "4059.7w" }),
|
||||
xingtuVideo: expect.objectContaining({ medianPlay: "4059.7w" })
|
||||
}
|
||||
status: "success"
|
||||
});
|
||||
|
||||
expect(requestedUrls).toEqual([
|
||||
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=1&flow_type=0&only_assign=true&range=2",
|
||||
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=2&flow_type=0&only_assign=true&range=2",
|
||||
"https://www.xingtu.cn/gw/api/aggregator/get_author_commerce_spread_info?o_author_id=6724241209444794382"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function buildVideoPayload() {
|
||||
return {
|
||||
avg_duration: 17002,
|
||||
base_resp: { status_code: 0, status_message: "" },
|
||||
comment_avg: 51404,
|
||||
interact_rate: { value: 551 },
|
||||
item_num: 2,
|
||||
like_avg: 1503028,
|
||||
play_mid: 40596960,
|
||||
play_over_rate: { value: 1991 },
|
||||
share_avg: 684318
|
||||
};
|
||||
}
|
||||
|
||||
function buildEstimatePayload() {
|
||||
return {
|
||||
base_resp: { status_code: 0, status_message: "" },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildMarketCsv } from "../src/content/market/csv-exporter";
|
||||
import { buildSpreadInfoColumns } from "../src/content/market/spread-info";
|
||||
import type { MarketRecord } from "../src/content/market/types";
|
||||
|
||||
describe("csv-exporter", () => {
|
||||
@@ -21,12 +22,13 @@ describe("csv-exporter", () => {
|
||||
"秒思api-新增A3数",
|
||||
"秒思api-新增A3率",
|
||||
"秒思api-CPA3",
|
||||
"秒思api-cp_search"
|
||||
"秒思api-cp_search",
|
||||
...buildSpreadInfoColumns()
|
||||
].join(",")
|
||||
);
|
||||
});
|
||||
|
||||
test("uses page export field order and appends the two plugin columns", () => {
|
||||
test("uses page export field order and appends the plugin columns", () => {
|
||||
const csv = buildMarketCsv([
|
||||
{
|
||||
authorId: "123",
|
||||
@@ -66,11 +68,16 @@ describe("csv-exporter", () => {
|
||||
"秒思api-新增A3数",
|
||||
"秒思api-新增A3率",
|
||||
"秒思api-CPA3",
|
||||
"秒思api-cp_search"
|
||||
"秒思api-cp_search",
|
||||
...buildSpreadInfoColumns()
|
||||
].join(",")
|
||||
);
|
||||
expect(rowLine).toBe(
|
||||
'Alice,100w,"¥450,000",0.5% - 1%,1% - 3%,0.36%,"9,689.96","78,366.22",3.44%,1.79,14.46'
|
||||
expect(rowLine).toMatch(
|
||||
new RegExp(
|
||||
`^${escapeRegExp(
|
||||
'Alice,100w,"¥450,000",0.5% - 1%,1% - 3%,0.36%,"9,689.96","78,366.22",3.44%,1.79,14.46'
|
||||
)}`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -101,10 +108,38 @@ describe("csv-exporter", () => {
|
||||
"秒思api-新增A3数",
|
||||
"秒思api-新增A3率",
|
||||
"秒思api-CPA3",
|
||||
"秒思api-cp_search"
|
||||
"秒思api-cp_search",
|
||||
...buildSpreadInfoColumns()
|
||||
].join(",")
|
||||
);
|
||||
expect(rowLine).toBe("Alice,100w,,,,,,,,");
|
||||
expect(rowLine.split(",").slice(0, 10).join(",")).toBe(
|
||||
"Alice,100w,,,,,,,,"
|
||||
);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -137,7 +172,10 @@ describe("csv-exporter", () => {
|
||||
]);
|
||||
|
||||
const [, rowLine] = csv.split("\n");
|
||||
expect(rowLine).toBe("123,Alice,,,,,,,,,,");
|
||||
expect(rowLine.split(",").slice(0, 12).join(",")).toBe(
|
||||
"123,Alice,,,,,,,,,,"
|
||||
);
|
||||
expect(rowLine.split(",").slice(12).every((cell) => cell === "")).toBe(true);
|
||||
});
|
||||
|
||||
test("uses normalized display values in export rows", () => {
|
||||
@@ -172,6 +210,53 @@ describe("csv-exporter", () => {
|
||||
]);
|
||||
|
||||
const [, rowLine] = csv.split("\n");
|
||||
expect(rowLine).toBe("123,Alice,,,0.5% - 1%,0.02% - 0.1%,,,,,,");
|
||||
expect(rowLine.split(",").slice(0, 12).join(",")).toBe(
|
||||
"123,Alice,,,0.5% - 1%,0.02% - 0.1%,,,,,,"
|
||||
);
|
||||
});
|
||||
|
||||
test("appends spread info metric columns after backend metrics", () => {
|
||||
const csv = buildMarketCsv([
|
||||
{
|
||||
authorId: "123",
|
||||
authorName: "Alice",
|
||||
spreadMetrics: {
|
||||
"内容数据-个人视频-近30天-完播率": "28.24%",
|
||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-互动率": "4.02%"
|
||||
},
|
||||
status: "success"
|
||||
} satisfies MarketRecord
|
||||
]);
|
||||
|
||||
const [headerLine, rowLine] = csv.split("\n");
|
||||
expect(headerLine).toContain(
|
||||
[
|
||||
"秒思api-cp_search",
|
||||
"内容数据-个人视频-近30天-完播率",
|
||||
"内容数据-个人视频-近30天-播放量中位数"
|
||||
].join(",")
|
||||
);
|
||||
expect(headerLine).toContain(
|
||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-互动率"
|
||||
);
|
||||
expect(rowLine).toContain("28.24%");
|
||||
expect(rowLine).toContain("4.02%");
|
||||
});
|
||||
|
||||
test("emits empty spread info cells when spread metrics are absent", () => {
|
||||
const csv = buildMarketCsv([
|
||||
{
|
||||
authorId: "123",
|
||||
authorName: "Alice",
|
||||
status: "success"
|
||||
} satisfies MarketRecord
|
||||
]);
|
||||
|
||||
const [, rowLine] = csv.split("\n");
|
||||
expect(rowLine.split(",").slice(-70).every((cell) => cell === "")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ describe("manifest", () => {
|
||||
"https://*.xingtu.cn/ad/creator/market*",
|
||||
"https://login-api.intelligrow.cn/*",
|
||||
"https://talent-search.intelligrow.cn/*",
|
||||
"http://192.168.31.21:8083/*",
|
||||
"http://localhost:8083/*",
|
||||
"https://*/*"
|
||||
]);
|
||||
expect(releaseManifest.host_permissions).not.toEqual(
|
||||
|
||||
+1286
-1376
File diff suppressed because it is too large
Load Diff
@@ -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%"
|
||||
});
|
||||
|
||||
@@ -155,6 +155,99 @@ 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",
|
||||
JSON.stringify({
|
||||
body: JSON.stringify({
|
||||
page_param: {
|
||||
page: 1
|
||||
}
|
||||
}),
|
||||
method: "POST",
|
||||
url: "https://xingtu.cn/api/mock-market-search"
|
||||
})
|
||||
);
|
||||
|
||||
const controller = createSilentExportController({
|
||||
document,
|
||||
fetchImpl: async () => ({
|
||||
json: async () => ({
|
||||
authors: [
|
||||
{
|
||||
attribute_datas: {
|
||||
id: "spread-1",
|
||||
nickname: "达人1"
|
||||
},
|
||||
star_id: "row-1"
|
||||
}
|
||||
]
|
||||
}),
|
||||
ok: true
|
||||
})
|
||||
});
|
||||
|
||||
const records = await controller.exportRecords({
|
||||
mode: "count",
|
||||
pageCount: 1
|
||||
});
|
||||
|
||||
expect(records?.[0]).toMatchObject({
|
||||
authorId: "row-1",
|
||||
spreadAuthorId: "spread-1"
|
||||
});
|
||||
});
|
||||
|
||||
test("starts from page 1 when the captured request omits an explicit page number", async () => {
|
||||
document.documentElement.setAttribute(
|
||||
"data-sces-market-request-snapshot",
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildSpreadInfoColumns,
|
||||
buildSpreadInfoUrl,
|
||||
createSpreadInfoClient,
|
||||
DEFAULT_SPREAD_INFO_CONFIGS,
|
||||
filterUnloadedSpreadInfoConfigs,
|
||||
mapSpreadInfoResponse,
|
||||
selectSpreadInfoConfigsForHeaders
|
||||
} from "../src/content/market/spread-info";
|
||||
|
||||
describe("spread-info", () => {
|
||||
test("builds the spread info url with all request parameters", () => {
|
||||
expect(
|
||||
buildSpreadInfoUrl(
|
||||
"7361012802036695050",
|
||||
{
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
},
|
||||
"https://www.xingtu.cn"
|
||||
)
|
||||
).toBe(
|
||||
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=7361012802036695050&platform_source=1&platform_channel=1&type=2&flow_type=1&only_assign=true&range=2"
|
||||
);
|
||||
});
|
||||
|
||||
test("defines personal video ranges with fixed non-prefix parameters", () => {
|
||||
const personalConfigs = DEFAULT_SPREAD_INFO_CONFIGS.filter(
|
||||
(config) => config.type === 1
|
||||
);
|
||||
|
||||
expect(personalConfigs).toEqual([
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
},
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
}
|
||||
]);
|
||||
expect(buildSpreadInfoColumns(personalConfigs).slice(0, 2)).toEqual([
|
||||
"内容数据-个人视频-近30天-完播率",
|
||||
"内容数据-个人视频-近30天-播放量中位数"
|
||||
]);
|
||||
});
|
||||
|
||||
test("defines all xingtu video assign flow and range combinations", () => {
|
||||
const xingtuConfigs = DEFAULT_SPREAD_INFO_CONFIGS.filter(
|
||||
(config) => config.type === 2
|
||||
);
|
||||
|
||||
expect(xingtuConfigs).toHaveLength(8);
|
||||
expect(xingtuConfigs).toContainEqual({
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
});
|
||||
expect(xingtuConfigs).toContainEqual({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 2
|
||||
});
|
||||
expect(buildSpreadInfoColumns([
|
||||
{
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
}
|
||||
])).toEqual([
|
||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-完播率",
|
||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-播放量中位数",
|
||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-互动率",
|
||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-作品平均时长",
|
||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-作品平均评论数",
|
||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-作品平均点赞数",
|
||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-作品平均转发数"
|
||||
]);
|
||||
});
|
||||
|
||||
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({
|
||||
avg_duration: "5600",
|
||||
comment_avg: "7502",
|
||||
interact_rate: {
|
||||
value: 402
|
||||
},
|
||||
item_rate: {
|
||||
play_mid: {
|
||||
value: 10913233
|
||||
}
|
||||
},
|
||||
like_avg: "494458",
|
||||
play_over_rate: {
|
||||
value: 2824
|
||||
},
|
||||
share_avg: "188267"
|
||||
})
|
||||
).toEqual({
|
||||
averageCommentCount: "7502",
|
||||
averageDuration: "56",
|
||||
averageLikeCount: "494458",
|
||||
averageShareCount: "188267",
|
||||
finishRate: "28.24%",
|
||||
interactionRate: "4.02%",
|
||||
playMedian: "10913233"
|
||||
});
|
||||
});
|
||||
|
||||
test("loads each configured spread metric column for one author", async () => {
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
json: async () => ({
|
||||
avg_duration: "5600",
|
||||
comment_avg: "7502",
|
||||
interact_rate: {
|
||||
value: 402
|
||||
},
|
||||
like_avg: "494458",
|
||||
play_mid: "10913233",
|
||||
play_over_rate: {
|
||||
value: 2824
|
||||
},
|
||||
share_avg: "188267"
|
||||
}),
|
||||
ok: true
|
||||
}));
|
||||
const client = createSpreadInfoClient({
|
||||
configs: [
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
}
|
||||
],
|
||||
fetchImpl
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.loadAuthorSpreadMetrics("7361012802036695050")
|
||||
).resolves.toEqual({
|
||||
"内容数据-个人视频-近30天-互动率": "4.02%",
|
||||
"内容数据-个人视频-近30天-作品平均点赞数": "494458",
|
||||
"内容数据-个人视频-近30天-作品平均评论数": "7502",
|
||||
"内容数据-个人视频-近30天-作品平均时长": "56",
|
||||
"内容数据-个人视频-近30天-作品平均转发数": "188267",
|
||||
"内容数据-个人视频-近30天-完播率": "28.24%",
|
||||
"内容数据-个人视频-近30天-播放量中位数": "10913233"
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=7361012802036695050&platform_source=1&platform_channel=1&type=1&flow_type=0&only_assign=false&range=2",
|
||||
expect.objectContaining({
|
||||
credentials: "include",
|
||||
method: "GET"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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"
|
||||
})
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.loadAuthorSpreadMetrics("7361012802036695050", [
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
}
|
||||
])
|
||||
).rejects.toThrow("HTTP 429: Too Many Requests");
|
||||
});
|
||||
|
||||
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
|
||||
})
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.loadAuthorSpreadMetrics("7361012802036695050", [
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
}
|
||||
])
|
||||
).rejects.toThrow(
|
||||
"星图传播数据接口被限流 (status_code=31157): 您访问过于频繁,请24小时后重试"
|
||||
);
|
||||
});
|
||||
|
||||
test("propagates network errors instead of returning empty metrics", async () => {
|
||||
const client = createSpreadInfoClient({
|
||||
fetchImpl: async () => {
|
||||
throw new Error("network unavailable");
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.loadAuthorSpreadMetrics("7361012802036695050", [
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
}
|
||||
])
|
||||
).rejects.toThrow("network unavailable");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user