fix: 完成态判据统一为曝光+阅读齐+交互断点三处修复
- 创作者凭证提交(manual_metrics/submit_creator_metrics)的完成判据从 d7_likes(全库无赋值方,永不触发)改为 exposure+views 齐,与采集侧口径 对齐;管理端手动回填后同步 refreshTaskStatus 联动任务状态 - 回收队列头部改为「已发布 N / 总数」口径,未回填链接行不再计入 - 行内更新/补填笔记链接从原生 prompt 改为弹窗表单,失败保留已输入内容 - 顶栏「获取KOC领取链接」从导航跳转改为弹窗直接列出各任务领取链接, 一键复制;弹窗内点击不冒泡误关
This commit is contained in:
@@ -548,6 +548,11 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
const [ownerLinksTask, setOwnerLinksTask] = useState<Task | null>(null);
|
const [ownerLinksTask, setOwnerLinksTask] = useState<Task | null>(null);
|
||||||
const [customerChatTask, setCustomerChatTask] = useState<Task | null>(null);
|
const [customerChatTask, setCustomerChatTask] = useState<Task | null>(null);
|
||||||
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
|
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
|
||||||
|
const [publishUrlModal, setPublishUrlModal] = useState<{
|
||||||
|
distribution: Distribution;
|
||||||
|
value: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [claimLinksOpen, setClaimLinksOpen] = useState(false);
|
||||||
const [taskForm, setTaskForm] = useState({
|
const [taskForm, setTaskForm] = useState({
|
||||||
id: "",
|
id: "",
|
||||||
platform: "小红书" as "小红书" | "抖音",
|
platform: "小红书" as "小红书" | "抖音",
|
||||||
@@ -905,27 +910,29 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
const updateDistributionPublishUrl = async (
|
const updateDistributionPublishUrl = async (
|
||||||
distribution: Distribution,
|
distribution: Distribution,
|
||||||
) => {
|
) => {
|
||||||
const publishInput = window.prompt(
|
setPublishUrlModal({ distribution, value: distribution.publish_url || "" });
|
||||||
distribution.publish_url
|
};
|
||||||
? "更新小红书笔记链接(支持长链、短链或完整分享文案)"
|
|
||||||
: "填写小红书笔记链接(支持长链、短链或完整分享文案)",
|
const submitPublishUrl = async (event: FormEvent) => {
|
||||||
distribution.publish_url || "",
|
event.preventDefault();
|
||||||
);
|
if (!publishUrlModal) return;
|
||||||
if (publishInput === null) return;
|
const publishInput = publishUrlModal.value.trim();
|
||||||
if (!publishInput.trim()) {
|
if (!publishInput) {
|
||||||
setToast("请填写笔记链接");
|
setToast("请填写笔记链接");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runAction(
|
const distribution = publishUrlModal.distribution;
|
||||||
|
const updated = await runAction(
|
||||||
{
|
{
|
||||||
action: "update_distribution_publish_url",
|
action: "update_distribution_publish_url",
|
||||||
distributionId: distribution.id,
|
distributionId: distribution.id,
|
||||||
publishUrl: publishInput.trim(),
|
publishUrl: publishInput,
|
||||||
},
|
},
|
||||||
distribution.publish_url
|
distribution.publish_url
|
||||||
? "笔记链接已更新,旧公开采集数据已清空"
|
? "笔记链接已更新,旧公开采集数据已清空"
|
||||||
: "笔记链接已补充,可开始数据采集",
|
: "笔记链接已补充,可开始数据采集",
|
||||||
);
|
);
|
||||||
|
if (updated) setPublishUrlModal(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const retryFailedMetrics = async (taskId: string, failedCount: number) => {
|
const retryFailedMetrics = async (taskId: string, failedCount: number) => {
|
||||||
@@ -1233,7 +1240,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="top-actions">
|
<div className="top-actions">
|
||||||
<span className="sync-state"><i />飞书内容表已接入</span>
|
<span className="sync-state"><i />飞书内容表已接入</span>
|
||||||
<button className="ghost-button" onClick={() => setActiveNav("tasks")}>
|
<button className="ghost-button" onClick={() => setClaimLinksOpen(true)}>
|
||||||
获取KOC领取链接
|
获取KOC领取链接
|
||||||
</button>
|
</button>
|
||||||
{isManager && (
|
{isManager && (
|
||||||
@@ -1699,6 +1706,98 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{publishUrlModal && (
|
||||||
|
<div className="modal-backdrop" role="presentation" onMouseDown={() => setPublishUrlModal(null)}>
|
||||||
|
<form className="modal-card compact" onSubmit={submitPublishUrl} onMouseDown={(event) => event.stopPropagation()}>
|
||||||
|
<div className="modal-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">发布链接</p>
|
||||||
|
<h2>{publishUrlModal.distribution.publish_url ? "更新笔记链接" : "填写笔记链接"}</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => setPublishUrlModal(null)} aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<div className="context-strip">
|
||||||
|
<strong>{publishUrlModal.distribution.account_nickname || "待识别账号"}</strong>
|
||||||
|
<span>{publishUrlModal.distribution.content_title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>笔记链接</span>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={publishUrlModal.value}
|
||||||
|
onChange={(event) =>
|
||||||
|
setPublishUrlModal({
|
||||||
|
...publishUrlModal,
|
||||||
|
value: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="支持长链、短链或完整分享文案"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="ghost-button" onClick={() => setPublishUrlModal(null)}>取消</button>
|
||||||
|
<button className="primary-button" disabled={working}>
|
||||||
|
{publishUrlModal.distribution.publish_url ? "确认更新" : "确认填写"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{claimLinksOpen && (
|
||||||
|
<div className="modal-backdrop" role="presentation" onMouseDown={() => setClaimLinksOpen(false)}>
|
||||||
|
<div
|
||||||
|
className="modal-card compact claim-links-modal"
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="modal-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">任务领取链接</p>
|
||||||
|
<h2>获取KOC领取链接</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => setClaimLinksOpen(false)} aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<div className="claim-links-list">
|
||||||
|
{data.tasks.map((task) => {
|
||||||
|
const url = task.share_token
|
||||||
|
? `${data.portal_url.replace(/\/$/, "")}/?task=${task.share_token}`
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<article key={task.id} className={url ? "" : "empty"}>
|
||||||
|
<div>
|
||||||
|
<strong>{task.name}</strong>
|
||||||
|
<span>
|
||||||
|
{task.brand}
|
||||||
|
{url ? ` · 已领取 ${task.dist_total ?? 0}/${task.quantity}` : " · 尚未生成领取链接"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{url ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(url)
|
||||||
|
.then(() => setToast("KOC领取链接已复制"))
|
||||||
|
.catch(() => setToast("复制失败,请稍后重试"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制链接
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="muted">未生成</span>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{groupPushTask && (
|
{groupPushTask && (
|
||||||
<GroupPushModal
|
<GroupPushModal
|
||||||
task={groupPushTask}
|
task={groupPushTask}
|
||||||
@@ -6490,6 +6589,9 @@ function RecoveryPage({
|
|||||||
const taskDistributions = distributions.filter(
|
const taskDistributions = distributions.filter(
|
||||||
(item) => item.task_id === selectedTask.id,
|
(item) => item.task_id === selectedTask.id,
|
||||||
);
|
);
|
||||||
|
const publishedCount = taskDistributions.filter(
|
||||||
|
(item) => item.publish_url,
|
||||||
|
).length;
|
||||||
const recoveryEnabled = Number(selectedTask.recovery_enabled ?? 1) === 1;
|
const recoveryEnabled = Number(selectedTask.recovery_enabled ?? 1) === 1;
|
||||||
const recoverySubmitted = taskDistributions.filter(
|
const recoverySubmitted = taskDistributions.filter(
|
||||||
(item) => item.result_submitted_at,
|
(item) => item.result_submitted_at,
|
||||||
@@ -6534,7 +6636,7 @@ function RecoveryPage({
|
|||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>数据回收队列</h2>
|
<h2>数据回收队列</h2>
|
||||||
<p>{taskDistributions.length} 篇已发布作品 · 展示最近一次采集结果</p>
|
<p>已发布 {publishedCount} / {taskDistributions.length} 篇 · 展示最近一次采集结果</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="recovery-panel-actions">
|
<div className="recovery-panel-actions">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type CollectionMcpBindings,
|
type CollectionMcpBindings,
|
||||||
} from "../../../lib/mcp-collection-client";
|
} from "../../../lib/mcp-collection-client";
|
||||||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||||
|
import { refreshTaskStatus } from "../../../lib/task-service";
|
||||||
import {
|
import {
|
||||||
FeishuSourceError,
|
FeishuSourceError,
|
||||||
readFeishuSource,
|
readFeishuSource,
|
||||||
@@ -663,12 +664,19 @@ export async function POST(request: Request) {
|
|||||||
exposure = ?,
|
exposure = ?,
|
||||||
views = ?,
|
views = ?,
|
||||||
ocr_status = 'manual',
|
ocr_status = 'manual',
|
||||||
status = CASE WHEN d7_likes IS NOT NULL THEN 'complete' ELSE status END,
|
status = CASE WHEN exposure IS NOT NULL AND views IS NOT NULL THEN 'complete' ELSE status END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
)
|
)
|
||||||
.bind(exposure, views, distributionId)
|
.bind(exposure, views, distributionId)
|
||||||
.run();
|
.run();
|
||||||
|
const ownerTask = await db
|
||||||
|
.prepare("SELECT task_id FROM distributions WHERE id = ?")
|
||||||
|
.bind(distributionId)
|
||||||
|
.first<{ task_id: string }>();
|
||||||
|
if (ownerTask) {
|
||||||
|
await refreshTaskStatus(ownerTask.task_id, db).catch(() => undefined);
|
||||||
|
}
|
||||||
} else if (body.action === "bind_wecom_external_id") {
|
} else if (body.action === "bind_wecom_external_id") {
|
||||||
const forbidden = managerGate();
|
const forbidden = managerGate();
|
||||||
if (forbidden) return forbidden;
|
if (forbidden) return forbidden;
|
||||||
|
|||||||
@@ -1243,7 +1243,7 @@ async function handlePost(request: Request) {
|
|||||||
exposure = ?,
|
exposure = ?,
|
||||||
views = ?,
|
views = ?,
|
||||||
ocr_status = 'manual',
|
ocr_status = 'manual',
|
||||||
status = CASE WHEN d7_likes IS NOT NULL THEN 'complete' ELSE status END,
|
status = CASE WHEN exposure IS NOT NULL AND views IS NOT NULL THEN 'complete' ELSE status END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1765,6 +1765,52 @@ a {
|
|||||||
width: min(720px, 100%);
|
width: min(720px, 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-card.claim-links-modal {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-links-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 52vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-links-list article {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgb(20 40 34 / 0.08);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-links-list article.empty {
|
||||||
|
background: rgb(20 40 34 / 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-links-list article > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-links-list article strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-links-list article span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(20 40 34 / 0.55);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.group-push-toolbar {
|
.group-push-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
105
tests/interaction-fixes.test.mjs
Normal file
105
tests/interaction-fixes.test.mjs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
// 2026-10-08 第二轮交互走查修复:
|
||||||
|
// 1) 完成态判据统一为「曝光量+阅读量齐即 complete」(原 d7_likes 条件全库无赋值方,永不触发)
|
||||||
|
// 2) 回收队列头部统计改为「已发布 N / 总数」口径,未回填行不再计入
|
||||||
|
// 3) 行内更新/补填链接从原生 prompt() 改为弹窗表单,失败保留已输入内容
|
||||||
|
// 4) 顶栏「获取KOC领取链接」从导航跳转改为直接弹出各任务领取链接列表
|
||||||
|
|
||||||
|
test("creator metrics completion follows the exposure+views criterion", async () => {
|
||||||
|
const sources = await Promise.all([
|
||||||
|
readFile(new URL("../app/api/partner/route.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
for (const source of sources) {
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/status = CASE WHEN exposure IS NOT NULL AND views IS NOT NULL THEN 'complete' ELSE status END/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 死条件必须清除:d7_likes 只被置 NULL 从不赋值,不能作为完成判据
|
||||||
|
for (const source of sources) {
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/CASE WHEN d7_likes IS NOT NULL THEN 'complete'/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("manual metrics refreshes the task status afterwards", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../app/api/action/route.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /import \{ refreshTaskStatus \} from "\.\.\/\.\.\/\.\.\/lib\/task-service";/);
|
||||||
|
const manual = source.slice(
|
||||||
|
source.indexOf('body.action === "manual_metrics"'),
|
||||||
|
source.indexOf('body.action === "bind_wecom_external_id"'),
|
||||||
|
);
|
||||||
|
assert.ok(manual.includes("manual_metrics"), "必须截取到 manual_metrics 分支");
|
||||||
|
assert.match(manual, /SELECT task_id FROM distributions WHERE id = \?/);
|
||||||
|
assert.match(manual, /refreshTaskStatus\(ownerTask\.task_id, db\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recovery queue header counts only published rows", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../app/admin-app.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/const publishedCount = taskDistributions\.filter\(/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/已发布 \{publishedCount\} \/ \{taskDistributions\.length\} 篇/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publish url editing uses a modal instead of window.prompt", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../app/admin-app.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /const \[publishUrlModal, setPublishUrlModal\]/);
|
||||||
|
assert.match(source, /onSubmit=\{submitPublishUrl\}/);
|
||||||
|
assert.match(source, /支持长链、短链或完整分享文案/);
|
||||||
|
// 提交失败保留弹窗内容(runAction 失败返回 false)
|
||||||
|
assert.match(source, /if \(updated\) setPublishUrlModal\(null\);/);
|
||||||
|
// 更新链接流程本身不再用 prompt(剪贴板复制失败兜底的 prompt 保留)
|
||||||
|
const updateFn = source.slice(
|
||||||
|
source.indexOf("const updateDistributionPublishUrl"),
|
||||||
|
source.indexOf("const submitPublishUrl"),
|
||||||
|
);
|
||||||
|
assert.ok(updateFn.includes("updateDistributionPublishUrl"), "必须截取到函数体");
|
||||||
|
assert.doesNotMatch(updateFn, /prompt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("claim links button opens a modal listing every task link", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../app/admin-app.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /const \[claimLinksOpen, setClaimLinksOpen\] = useState\(false\);/);
|
||||||
|
// 顶栏按钮不再是导航跳转
|
||||||
|
assert.match(source, /onClick=\{\(\) => setClaimLinksOpen\(true\)\}/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/onClick=\{\(\) => setActiveNav\("tasks"\)\}\s*\n\s*获取KOC领取链接/,
|
||||||
|
);
|
||||||
|
// 弹窗里每个任务给出复制按钮,无链接的任务明示未生成
|
||||||
|
assert.match(source, /claim-links-list/);
|
||||||
|
assert.match(source, /KOC领取链接已复制/);
|
||||||
|
assert.match(source, /尚未生成领取链接/);
|
||||||
|
// 弹窗内点击(含复制按钮)不能冒泡到 backdrop 导致误关闭
|
||||||
|
const modal = source.slice(
|
||||||
|
source.indexOf('className="modal-card compact claim-links-modal"'),
|
||||||
|
source.indexOf('<div className="claim-links-list">'),
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
modal,
|
||||||
|
/onMouseDown=\{\(event\) => event\.stopPropagation\(\)\}/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user