2 Commits

Author SHA1 Message Date
ABAPPLO
5cb056507d fix: 催办按任务类型分流 + 领取事务加锁防超发
- 临期/超期/自动释放查询按 task_type 区分完成口径:内容任务看 publish_url,截图任务看 result_submitted_at,截图任务不再被永久误判为未发布
- portal 领取改为事务内 SELECT ... FOR UPDATE 锁定可用内容,消除并发领取超发窗口
2026-09-02 13:56:07 +08:00
ABAPPLO
e1005081fe refactor: 任务中心合并任务分发(导航 9→8,任务卡进入分发明细) 2026-09-02 11:25:16 +08:00
6 changed files with 265 additions and 617 deletions

View File

@@ -204,7 +204,6 @@ type NavKey =
| "overview" | "overview"
| "projects" | "projects"
| "tasks" | "tasks"
| "distributions"
| "resources" | "resources"
| "partners" | "partners"
| "recovery" | "recovery"
@@ -215,7 +214,6 @@ const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
{ key: "overview", label: "工作台", mark: "⌂" }, { key: "overview", label: "工作台", mark: "⌂" },
{ key: "projects", label: "项目中心", mark: "▦" }, { key: "projects", label: "项目中心", mark: "▦" },
{ key: "tasks", label: "任务中心", mark: "▤" }, { key: "tasks", label: "任务中心", mark: "▤" },
{ key: "distributions", label: "任务分发", mark: "↗" },
{ key: "resources", label: "KOC资源", mark: "◎" }, { key: "resources", label: "KOC资源", mark: "◎" },
{ key: "partners", label: "合作方", mark: "Ⓒ" }, { key: "partners", label: "合作方", mark: "Ⓒ" },
{ key: "recovery", label: "数据回收", mark: "◫" }, { key: "recovery", label: "数据回收", mark: "◫" },
@@ -1036,12 +1034,8 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
subtitle: "按项目组织任务群与总群成员,一键把任务分发到项目各群。", subtitle: "按项目组织任务群与总群成员,一键把任务分发到项目各群。",
}, },
tasks: { tasks: {
title: "内容任务", title: "任务中心",
subtitle: "从飞书同步内容,按数量开放给合作方领取。", subtitle: "从飞书同步内容、开放领取,并按任务跟进每篇笔记的分发进度。",
},
distributions: {
title: "内容分发",
subtitle: "先按任务查看进度,再进入单个任务处理每篇笔记。",
}, },
resources: { resources: {
title: "KOC资源", title: "KOC资源",
@@ -1186,19 +1180,14 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
{activeNav === "tasks" && ( {activeNav === "tasks" && (
<TasksPage <TasksPage
tasks={data.tasks} tasks={data.tasks}
distributions={data.distributions}
portalUrl={data.portal_url} portalUrl={data.portal_url}
canRelease={isManager}
working={working}
onCreate={() => setTaskModalOpen(true)} onCreate={() => setTaskModalOpen(true)}
onCopyShare={copyTaskShareLink} onCopyShare={copyTaskShareLink}
onPushToGroups={(task) => setGroupPushTask(task)} onPushToGroups={(task) => setGroupPushTask(task)}
onOneClickPush={(task) => setOnePushTask(task)} onOneClickPush={(task) => setOnePushTask(task)}
/>
)}
{activeNav === "distributions" && (
<DistributionPage
tasks={data.tasks}
distributions={data.distributions}
canRelease={isManager}
working={working}
onRelease={async (distribution) => onRelease={async (distribution) =>
runAction( runAction(
{ {
@@ -3945,7 +3934,7 @@ function OverviewPage({
<h2>当前闭环</h2> <h2>当前闭环</h2>
<p>每篇内容只沿着一条记录向前走</p> <p>每篇内容只沿着一条记录向前走</p>
</div> </div>
<button className="text-button" onClick={() => onNavigate("distributions")}>查看全部</button> <button className="text-button" onClick={() => onNavigate("tasks")}>查看全部</button>
</div> </div>
<div className="workflow-track"> <div className="workflow-track">
<div className="workflow-step done"> <div className="workflow-step done">
@@ -4016,7 +4005,7 @@ function OverviewPage({
<section className="panel table-panel"> <section className="panel table-panel">
<div className="panel-heading"> <div className="panel-heading">
<div><h2>最近分发</h2><p>领取、发布账号与回收状态一眼看清</p></div> <div><h2>最近分发</h2><p>领取、发布账号与回收状态一眼看清</p></div>
<button className="text-button" onClick={() => onNavigate("distributions")}>查看全部</button> <button className="text-button" onClick={() => onNavigate("tasks")}>查看全部</button>
</div> </div>
<DistributionTable distributions={distributions} compact /> <DistributionTable distributions={distributions} compact />
</section> </section>
@@ -4026,19 +4015,43 @@ function OverviewPage({
function TasksPage({ function TasksPage({
tasks, tasks,
distributions,
portalUrl, portalUrl,
canRelease,
working,
onCreate, onCreate,
onCopyShare, onCopyShare,
onPushToGroups, onPushToGroups,
onOneClickPush, onOneClickPush,
onRelease,
}: { }: {
tasks: Task[]; tasks: Task[];
distributions: Distribution[];
portalUrl: string; portalUrl: string;
canRelease: boolean;
working: boolean;
onCreate: () => void; onCreate: () => void;
onCopyShare: (task: Task) => void; onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void; onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void; onOneClickPush: (task: Task) => void;
onRelease: (distribution: Distribution) => Promise<boolean>;
}) { }) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
if (selectedTask) {
return (
<TaskDistributionDetail
task={selectedTask}
distributions={distributions}
canRelease={canRelease}
working={working}
onRelease={onRelease}
onBack={() => setSelectedTaskId(null)}
/>
);
}
return ( return (
<div className="stack"> <div className="stack">
<section className="task-hero"> <section className="task-hero">
@@ -4078,6 +4091,12 @@ function TasksPage({
<div className="task-date"><span>截止日期</span><strong>{formatDate(task.due_at)}</strong></div> <div className="task-date"><span>截止日期</span><strong>{formatDate(task.due_at)}</strong></div>
<span className={`status-pill ${task.status}`}>{statusLabel(task.status)}</span> <span className={`status-pill ${task.status}`}>{statusLabel(task.status)}</span>
<div className="task-share-actions"> <div className="task-share-actions">
<button
className="share-link-button"
onClick={() => setSelectedTaskId(task.id)}
>
分发明细
</button>
<button <button
className="share-link-button" className="share-link-button"
onClick={() => onCopyShare(task)} onClick={() => onCopyShare(task)}
@@ -4122,12 +4141,10 @@ function TasksPage({
function TaskScopeList({ function TaskScopeList({
tasks, tasks,
distributions, distributions,
mode,
onOpen, onOpen,
}: { }: {
tasks: Task[]; tasks: Task[];
distributions: Distribution[]; distributions: Distribution[];
mode: "distribution" | "recovery";
onOpen: (taskId: string) => void; onOpen: (taskId: string) => void;
}) { }) {
return ( return (
@@ -4147,9 +4164,6 @@ function TaskScopeList({
const creatorComplete = published.filter( const creatorComplete = published.filter(
(item) => hasCreatorMetrics(item), (item) => hasCreatorMetrics(item),
).length; ).length;
const progress = task.quantity
? Math.round((task.claimed_quantity / task.quantity) * 100)
: 0;
return ( return (
<article className="task-scope-card" key={task.id}> <article className="task-scope-card" key={task.id}>
<div className="task-scope-head"> <div className="task-scope-head">
@@ -4180,18 +4194,6 @@ function TaskScopeList({
<strong>历史示例内容表</strong> <strong>历史示例内容表</strong>
)} )}
</div> </div>
{mode === "distribution" ? (
<>
<div className="task-scope-metrics">
<div><span>{isScreenshotTask ? "任务总量" : "内容总量"}</span><strong>{task.quantity}</strong></div>
<div><span>已领取</span><strong>{task.claimed_quantity}</strong></div>
<div><span>{isScreenshotTask ? "已提交" : "已发布"}</span><strong>{isScreenshotTask ? submitted.length : published.length}</strong></div>
</div>
<div className="progress task-scope-progress">
<span style={{ width: `${progress}%` }} />
</div>
</>
) : (
<div className="task-scope-metrics"> <div className="task-scope-metrics">
{isScreenshotTask ? ( {isScreenshotTask ? (
<> <>
@@ -4207,9 +4209,8 @@ function TaskScopeList({
</> </>
)} )}
</div> </div>
)}
<button className="task-scope-open" onClick={() => onOpen(task.id)}> <button className="task-scope-open" onClick={() => onOpen(task.id)}>
<span>{mode === "distribution" ? "查看分发明细" : "进入回收队列"}</span> <span>进入回收队列</span>
<b>→</b> <b>→</b>
</button> </button>
</article> </article>
@@ -4258,217 +4259,34 @@ function TaskDetailHeader({
); );
} }
function DistributionPage({ function TaskDistributionDetail({
tasks, task,
distributions, distributions,
canRelease, canRelease,
working, working,
onRelease, onRelease,
onBack,
}: { }: {
tasks: Task[]; task: Task;
distributions: Distribution[]; distributions: Distribution[];
canRelease: boolean; canRelease: boolean;
working: boolean; working: boolean;
onRelease: (distribution: Distribution) => Promise<boolean>; onRelease: (distribution: Distribution) => Promise<boolean>;
onBack: () => void;
}) { }) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [taskQuery, setTaskQuery] = useState("");
const [brandFilter, setBrandFilter] = useState("");
const [contentTypeFilter, setContentTypeFilter] = useState("");
const [platformFilter, setPlatformFilter] = useState("");
const [openTaskFilter, setOpenTaskFilter] = useState<"brand" | "content" | "platform" | null>(null);
const taskFiltersRef = useRef<HTMLDivElement | null>(null);
const brandOptions = useMemo(
() => Array.from(new Set(tasks.map((task) => task.brand).filter(Boolean))).sort((a, b) => a.localeCompare(b, "zh-CN")),
[tasks],
);
const brandFilterOptions = useMemo(
() => [{ value: "", label: "全部品牌/项目" }, ...brandOptions.map((brand) => ({ value: brand, label: brand }))],
[brandOptions],
);
const contentTypeOptions = [
{ value: "", label: "全部内容类型" },
{ value: "图文", label: "图文" },
{ value: "视频", label: "视频" },
{ value: "截图回收", label: "截图回收" },
];
const platformOptions = [
{ value: "", label: "全部平台" },
{ value: "小红书", label: "小红书" },
{ value: "抖音", label: "抖音" },
];
const taskFilterControls = [
{ key: "brand" as const, label: "品牌/项目", value: brandFilter, options: brandFilterOptions },
{ key: "content" as const, label: "内容类型", value: contentTypeFilter, options: contentTypeOptions },
{ key: "platform" as const, label: "平台", value: platformFilter, options: platformOptions },
];
useEffect(() => {
const closeOnOutsideClick = (event: MouseEvent) => {
if (!taskFiltersRef.current?.contains(event.target as Node)) {
setOpenTaskFilter(null);
}
};
document.addEventListener("mousedown", closeOnOutsideClick);
return () => document.removeEventListener("mousedown", closeOnOutsideClick);
}, []);
const setTaskFilterValue = (key: "brand" | "content" | "platform", value: string) => {
if (key === "brand") setBrandFilter(value);
if (key === "content") setContentTypeFilter(value);
if (key === "platform") setPlatformFilter(value);
};
const selectTaskFilter = (key: "brand" | "content" | "platform", value: string) => {
setTaskFilterValue(key, value);
setOpenTaskFilter(null);
};
const filteredTasks = useMemo(() => {
const query = taskQuery.trim().toLocaleLowerCase("zh-CN");
const brandQuery = brandFilter.trim().toLocaleLowerCase("zh-CN");
const contentTypeQuery = contentTypeFilter.trim().toLocaleLowerCase("zh-CN");
const platformQuery = platformFilter.trim().toLocaleLowerCase("zh-CN");
return tasks.filter((task) => {
const taskContentType = task.task_type === "screenshot_collect"
? "截图回收"
: task.content_format === "video" ? "视频" : "图文";
return (
(!query || task.name.toLocaleLowerCase("zh-CN").includes(query)) &&
(!brandQuery || task.brand.toLocaleLowerCase("zh-CN").includes(brandQuery)) &&
(!contentTypeQuery || taskContentType.toLocaleLowerCase("zh-CN").includes(contentTypeQuery)) &&
(!platformQuery || task.platform.toLocaleLowerCase("zh-CN").includes(platformQuery))
);
});
}, [brandFilter, contentTypeFilter, platformFilter, taskQuery, tasks]);
const hasTaskFilters = Boolean(
taskQuery.trim() || brandFilter.trim() || contentTypeFilter.trim() || platformFilter.trim(),
);
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null); const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
if (!selectedTask) {
return (
<div className="stack">
<div className="section-intro">
<div><p className="eyebrow">按任务管理</p><h2>选择一个分发任务</h2></div>
<p>每个任务独立查看领取、发布和账号识别进度,不混排不同项目的笔记。</p>
</div>
<section className="distribution-task-toolbar" aria-label="筛选分发任务">
<label className="distribution-task-search">
<span aria-hidden="true">⌕</span>
<input
type="search"
value={taskQuery}
onChange={(event) => setTaskQuery(event.target.value)}
placeholder="搜索任务名称"
aria-label="按任务名称模糊搜索"
/>
</label>
<div className="distribution-task-filter-group" ref={taskFiltersRef}>
{taskFilterControls.map((filter) => {
const normalizedFilterValue = filter.value.trim().toLocaleLowerCase("zh-CN");
const visibleOptions = filter.options.filter((option) => (
!normalizedFilterValue ||
option.label.toLocaleLowerCase("zh-CN").includes(normalizedFilterValue)
));
return (
<div
className={`distribution-task-filter-combobox ${filter.key === "brand" ? "brand" : ""}`}
key={filter.key}
>
<div className="distribution-task-filter-input">
<span aria-hidden="true">⌕</span>
<input
type="search"
role="combobox"
aria-autocomplete="list"
aria-expanded={openTaskFilter === filter.key}
aria-controls={`distribution-task-${filter.key}-menu`}
value={filter.value}
placeholder={`搜索${filter.label}`}
aria-label={`模糊搜索${filter.label}`}
onFocus={() => setOpenTaskFilter(filter.key)}
onChange={(event) => {
setTaskFilterValue(filter.key, event.target.value);
setOpenTaskFilter(filter.key);
}}
onKeyDown={(event) => {
if (event.key === "Escape") setOpenTaskFilter(null);
}}
/>
</div>
{openTaskFilter === filter.key ? (
<div
id={`distribution-task-${filter.key}-menu`}
className="distribution-task-filter-menu"
role="listbox"
aria-label={`选择${filter.label}`}
>
<div className="distribution-task-filter-menu-options">
{visibleOptions.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={filter.value === option.value}
className={filter.value === option.value ? "selected" : ""}
onClick={() => selectTaskFilter(filter.key, option.value)}
>
<span>{option.label}</span>
{filter.value === option.value ? <small>✓</small> : null}
</button>
))}
{visibleOptions.length === 0 ? (
<span className="distribution-task-filter-empty">没有匹配项</span>
) : null}
</div>
</div>
) : null}
</div>
);
})}
</div>
<button
type="button"
className="distribution-task-clear"
disabled={!hasTaskFilters}
onClick={() => {
setTaskQuery("");
setBrandFilter("");
setContentTypeFilter("");
setPlatformFilter("");
setOpenTaskFilter(null);
}}
>
清空筛选
</button>
<span className="distribution-task-count">共 <b>{filteredTasks.length}</b> 个任务</span>
</section>
{filteredTasks.length > 0 ? (
<TaskScopeList
tasks={filteredTasks}
distributions={distributions}
mode="distribution"
onOpen={setSelectedTaskId}
/>
) : (
<section className="distribution-task-empty">
<strong>没有符合条件的任务</strong>
<span>可以调整任务名称或清空筛选条件</span>
</section>
)}
</div>
);
}
const taskDistributions = distributions.filter( const taskDistributions = distributions.filter(
(item) => item.task_id === selectedTask.id, (item) => item.task_id === task.id,
); );
const isScreenshotTask = selectedTask.task_type === "screenshot_collect"; const isScreenshotTask = task.task_type === "screenshot_collect";
const published = taskDistributions.filter((item) => item.publish_url).length; const published = taskDistributions.filter((item) => item.publish_url).length;
const submitted = taskDistributions.filter((item) => item.result_submitted_at).length; const submitted = taskDistributions.filter((item) => item.result_submitted_at).length;
return ( return (
<div className="stack"> <div className="stack">
<TaskDetailHeader <TaskDetailHeader
task={selectedTask} task={task}
label={isScreenshotTask ? "截图任务分发" : "内容分发"} label={isScreenshotTask ? "截图任务分发" : "内容分发"}
onBack={() => setSelectedTaskId(null)} onBack={onBack}
/> />
<div className="inline-summary"> <div className="inline-summary">
<div><span>已领取</span><strong>{taskDistributions.length}</strong></div> <div><span>已领取</span><strong>{taskDistributions.length}</strong></div>
@@ -4476,7 +4294,7 @@ function DistributionPage({
<div><span>{isScreenshotTask ? "已提交截图" : "已回填链接"}</span><strong>{isScreenshotTask ? submitted : published}</strong></div> <div><span>{isScreenshotTask ? "已提交截图" : "已回填链接"}</span><strong>{isScreenshotTask ? submitted : published}</strong></div>
<i /> <i />
<div><span>{isScreenshotTask ? "待提交" : "待发布"}</span><strong>{taskDistributions.length - (isScreenshotTask ? submitted : published)}</strong></div> <div><span>{isScreenshotTask ? "待提交" : "待发布"}</span><strong>{taskDistributions.length - (isScreenshotTask ? submitted : published)}</strong></div>
<div className="summary-note"><span className="pulse-dot" />当前仅展示“{selectedTask.name}”</div> <div className="summary-note"><span className="pulse-dot" />当前仅展示“{task.name}”</div>
</div> </div>
<section className="panel table-panel"> <section className="panel table-panel">
<div className="panel-heading"> <div className="panel-heading">
@@ -5933,7 +5751,6 @@ function RecoveryPage({
<TaskScopeList <TaskScopeList
tasks={tasks} tasks={tasks}
distributions={distributions} distributions={distributions}
mode="recovery"
onOpen={setSelectedTaskId} onOpen={setSelectedTaskId}
/> />
</div> </div>

View File

@@ -563,18 +563,6 @@ async function handlePost(request: Request) {
{ status: 400 }, { status: 400 },
); );
} }
const available = await db
.prepare(
`SELECT id FROM contents
WHERE task_id = ? AND status = 'available'
ORDER BY COALESCE(source_row, 999999), created_at, id
LIMIT ${quantity}`,
)
.bind(task.id)
.all<{ id: string }>();
if (available.results.length === 0) {
return Response.json({ error: "当前任务已领完" }, { status: 409 });
}
const partnerId = wecomIdentity const partnerId = wecomIdentity
? (await resolveWecomClaimant(db, wecomIdentity.externalUserId)).partnerId ? (await resolveWecomClaimant(db, wecomIdentity.externalUserId)).partnerId
: `partner-ext-${hashText( : `partner-ext-${hashText(
@@ -585,6 +573,18 @@ async function handlePost(request: Request) {
: (claimantIdentifier?.display ?? ""); : (claimantIdentifier?.display ?? "");
const claimId = uid("claim"); const claimId = uid("claim");
const claimToken = crypto.randomUUID().replaceAll("-", ""); const claimToken = crypto.randomUUID().replaceAll("-", "");
const claimResult = await db.transaction(async (tx) => {
const available = await tx
.prepare(
`SELECT id FROM contents
WHERE task_id = ? AND status = 'available'
ORDER BY COALESCE(source_row, 999999), created_at, id
LIMIT ${quantity}
FOR UPDATE`,
)
.bind(task.id)
.all<{ id: string }>();
if (available.results.length === 0) return null;
const statements: DatabaseStatement[] = [ const statements: DatabaseStatement[] = [
wecomIdentity wecomIdentity
? db ? db
@@ -660,11 +660,15 @@ async function handlePost(request: Request) {
) )
.bind(available.results.length, partnerId), .bind(available.results.length, partnerId),
); );
await db.batch(statements); for (const statement of statements) {
return Response.json({ await tx.execute(statement.sql, statement.values());
claimToken, }
claimedCount: available.results.length, return { claimToken, claimedCount: available.results.length };
}); });
if (!claimResult) {
return Response.json({ error: "当前任务已领完" }, { status: 409 });
}
return Response.json(claimResult);
} }
if (body.action === "create_delegation") { if (body.action === "create_delegation") {

View File

@@ -2052,230 +2052,6 @@ a {
background: #eef7f9; background: #eef7f9;
} }
.distribution-task-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 9px;
padding: 13px;
border: 1px solid #e5ebe8;
border-radius: 12px;
background: #f8faf8;
}
.distribution-task-search {
display: flex;
flex-direction: row;
min-width: 240px;
height: 38px;
flex: 1 1 320px;
align-items: center;
gap: 8px;
margin: 0;
padding: 0 12px;
border: 1px solid #dfe6e2;
border-radius: 9px;
background: white;
}
.distribution-task-search:focus-within {
border-color: #74b79f;
box-shadow: 0 0 0 3px rgb(31 143 107 / 0.08);
}
.distribution-task-search > span {
display: grid;
width: 18px;
flex: 0 0 18px;
place-items: center;
color: #83918c;
font-size: 15px;
line-height: 1;
}
.distribution-task-search input {
min-width: 0;
width: 100%;
height: 100%;
padding: 0;
border: 0;
outline: 0;
background: transparent;
font-size: 10px;
}
.distribution-task-filter-group {
display: flex;
flex: 0 1 auto;
align-items: center;
gap: 9px;
}
.distribution-task-filter-combobox {
position: relative;
width: 150px;
flex: 0 1 150px;
}
.distribution-task-filter-combobox.brand {
width: 176px;
flex-basis: 176px;
}
.distribution-task-filter-input {
display: flex;
width: 100%;
height: 38px;
flex: none;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 0 11px;
border: 1px solid #dfe6e2;
border-radius: 9px;
outline: 0;
color: #28332f;
background: white;
font-size: 9px;
margin: 0;
}
.distribution-task-filter-input > span {
display: grid;
width: 16px;
flex: 0 0 16px;
place-items: center;
color: #83918c;
font-size: 13px;
line-height: 1;
}
.distribution-task-filter-input input {
display: block;
min-width: 0;
width: 100%;
height: 100%;
flex: 1 1 auto;
margin: 0;
padding: 0;
border: 0;
outline: 0;
color: #28332f;
background: transparent;
font-size: 9px;
}
.distribution-task-filter-input:hover,
.distribution-task-filter-input:focus-within {
border-color: #74b79f;
box-shadow: 0 0 0 3px rgb(31 143 107 / 0.08);
}
.distribution-task-filter-menu {
position: absolute;
z-index: 60;
top: calc(100% + 7px);
left: 0;
display: grid;
width: max(100%, 230px);
padding: 8px;
border: 1px solid #dce8e3;
border-radius: 12px;
background: white;
box-shadow: 0 16px 36px rgb(20 45 37 / 0.16);
}
.distribution-task-filter-menu-options {
display: grid;
max-height: 240px;
gap: 3px;
overflow-y: auto;
}
.distribution-task-filter-menu-options button {
display: flex;
min-height: 34px;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 10px;
border: 0;
border-radius: 8px;
color: #34423d;
background: transparent;
font-size: 9px;
text-align: left;
}
.distribution-task-filter-menu-options button:hover,
.distribution-task-filter-menu-options button:focus-visible {
outline: 0;
background: #f3f9f6;
}
.distribution-task-filter-menu-options button.selected {
color: var(--green-deep);
background: #eaf6f1;
font-weight: 650;
}
.distribution-task-filter-menu-options button small {
color: #6f9d8d;
font-size: 8px;
}
.distribution-task-filter-empty {
padding: 12px 10px;
color: #98a39f;
font-size: 9px;
text-align: center;
}
.distribution-task-clear {
height: 38px;
padding: 0 6px;
border: 0;
color: var(--green-deep);
background: transparent;
font-size: 9px;
font-weight: 650;
}
.distribution-task-count {
margin-left: auto;
color: #8c9894;
font-size: 9px;
white-space: nowrap;
}
.distribution-task-count b {
color: var(--ink);
font-size: 11px;
}
.distribution-task-empty {
display: grid;
min-height: 190px;
place-content: center;
gap: 7px;
border: 1px dashed #dce5e1;
border-radius: 15px;
color: #8d9894;
background: #fbfcfb;
text-align: center;
}
.distribution-task-empty strong {
color: var(--ink);
font-size: 12px;
}
.distribution-task-empty span {
font-size: 9px;
}
.task-source-line { .task-source-line {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2331,10 +2107,6 @@ a {
letter-spacing: -0.03em; letter-spacing: -0.03em;
} }
.task-scope-progress {
margin-top: 13px;
}
.task-scope-open { .task-scope-open {
display: flex; display: flex;
width: 100%; width: 100%;
@@ -4952,11 +4724,6 @@ label small {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.distribution-task-count {
width: 100%;
text-align: right;
}
.workflow-track { .workflow-track {
overflow-x: auto; overflow-x: auto;
} }
@@ -5142,35 +4909,6 @@ label small {
align-items: stretch; align-items: stretch;
} }
.distribution-task-toolbar {
align-items: stretch;
}
.distribution-task-search,
.distribution-task-filter-group,
.distribution-task-filter-combobox,
.distribution-task-filter-combobox.brand {
width: 100%;
flex-basis: 100%;
}
.distribution-task-filter-group {
grid-template-columns: 1fr;
display: grid;
gap: 8px;
}
.distribution-task-clear {
text-align: left;
}
.distribution-task-count {
display: flex;
width: auto;
align-items: center;
margin-left: auto;
}
.resource-search, .resource-search,
.resource-filter-search { .resource-filter-search {
width: 100%; width: 100%;

View File

@@ -63,7 +63,11 @@ export async function runDueSoonWecomNotifications(
JOIN tasks t ON t.id = d.task_id JOIN tasks t ON t.id = d.task_id
JOIN partners p ON p.id = d.partner_id JOIN partners p ON p.id = d.partner_id
JOIN contents c ON c.id = d.content_id JOIN contents c ON c.id = d.content_id
WHERE (d.publish_url IS NULL OR d.publish_url = '') WHERE (
(t.task_type = 'screenshot_collect' AND d.result_submitted_at IS NULL)
OR (t.task_type != 'screenshot_collect'
AND (d.publish_url IS NULL OR d.publish_url = ''))
)
AND t.due_at IS NOT NULL AND t.due_at != '' AND t.due_at IS NOT NULL AND t.due_at != ''
AND t.due_at <= ? AND t.due_at <= ?
ORDER BY t.due_at ASC, p.name ASC`, ORDER BY t.due_at ASC, p.name ASC`,
@@ -199,7 +203,11 @@ async function queryOverdueUnpublished(
FROM distributions d FROM distributions d
JOIN tasks t ON t.id = d.task_id JOIN tasks t ON t.id = d.task_id
JOIN partners p ON p.id = d.partner_id JOIN partners p ON p.id = d.partner_id
WHERE (d.publish_url IS NULL OR d.publish_url = '') WHERE (
(t.task_type = 'screenshot_collect' AND d.result_submitted_at IS NULL)
OR (t.task_type != 'screenshot_collect'
AND (d.publish_url IS NULL OR d.publish_url = ''))
)
AND t.due_at IS NOT NULL AND t.due_at != '' AND t.due_at IS NOT NULL AND t.due_at != ''
AND t.due_at < ? AND t.due_at < ?
ORDER BY t.due_at ASC, t.name ASC`, ORDER BY t.due_at ASC, t.name ASC`,

View File

@@ -23,28 +23,20 @@ test("builds the KOC LOOP product shell", async () => {
await access(new URL("../.next/static", import.meta.url)); await access(new URL("../.next/static", import.meta.url));
}); });
test("keeps distribution filters as always-visible fuzzy search fields", async () => { test("merges distribution detail into the task center", async () => {
const [adminApp, globalCss] = await Promise.all([ const [adminApp, globalCss] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"), readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"), readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
]); ]);
assert.match(globalCss, /\.distribution-task-search\s*\{[^}]*flex-direction:\s*row/s); assert.match(adminApp, /分发明细/);
assert.match(globalCss, /\.distribution-task-search\s*\{[^}]*margin:\s*0/s); assert.match(adminApp, /function TaskDistributionDetail/);
assert.match(globalCss, /\.distribution-task-search\s*>\s*span\s*\{[^}]*flex:\s*0\s+0\s+18px/s); assert.match(adminApp, /选择一个数据回收任务/);
assert.match(globalCss, /\.distribution-task-filter-combobox\.brand\s*\{[^}]*flex-basis:\s*176px/s); assert.doesNotMatch(adminApp, /"distributions"/);
assert.match(globalCss, /\.distribution-task-filter-menu\s*\{[^}]*position:\s*absolute[^}]*z-index:\s*60/s); assert.doesNotMatch(adminApp, /选择一个分发任务/);
assert.match(globalCss, /\.distribution-task-filter-input\s*\{[^}]*display:\s*flex[^}]*margin:\s*0/s); assert.doesNotMatch(adminApp, /distribution-task-/);
assert.match(adminApp, /aria-expanded=\{openTaskFilter === filter\.key\}/); assert.doesNotMatch(globalCss, /distribution-task-/);
assert.match(adminApp, /role="combobox"/); assert.doesNotMatch(adminApp, /mode === "distribution"/);
assert.match(adminApp, /role="listbox"/);
assert.match(adminApp, /placeholder=\{`搜索\$\{filter\.label\}`\}/);
assert.match(adminApp, /setTaskFilterValue\(filter\.key, event\.target\.value\)/);
assert.doesNotMatch(adminApp, /distribution-task-filter-trigger/);
assert.doesNotMatch(adminApp, /distribution-task-filter-menu-search/);
assert.doesNotMatch(adminApp, /taskFilterQuery/);
assert.doesNotMatch(adminApp, /distribution-task-option-panel/);
assert.doesNotMatch(adminApp, /<select value=\{contentTypeFilter\}/);
}); });
test("stacks user management and securely removes departed accounts", async () => { test("stacks user management and securely removes departed accounts", async () => {
@@ -95,7 +87,6 @@ test("organizes distribution and recovery by task and imports the verified Feish
]); ]);
const snapshot = JSON.parse(snapshotText); const snapshot = JSON.parse(snapshotText);
assert.match(adminApp, /选择一个分发任务/);
assert.match(adminApp, /选择一个数据回收任务/); assert.match(adminApp, /选择一个数据回收任务/);
assert.match(adminApp, /读取表格/); assert.match(adminApp, /读取表格/);
assert.match(adminApp, /当前仅展示/); assert.match(adminApp, /当前仅展示/);
@@ -219,12 +210,9 @@ test("issues external task links and supports one-to-one note submissions", asyn
assert.match(adminApp, /latest_publish_url/); assert.match(adminApp, /latest_publish_url/);
assert.match(adminApp, /通过作品查看主页/); assert.match(adminApp, /通过作品查看主页/);
assert.match(adminApp, /platform-logo/); assert.match(adminApp, /platform-logo/);
assert.match(adminApp, /按任务名称模糊搜索/);
assert.match(adminApp, /全部品牌\/项目/);
assert.match(adminApp, /全部内容类型/);
assert.match(adminApp, /全部平台/);
assert.match(adminApp, /task-scope-subline/); assert.match(adminApp, /task-scope-subline/);
assert.match(adminApp, /识别失败,请手动填写/); assert.match(adminApp, /识别失败,请手动填写/);
assert.match(adminApp, /分发明细/);
assert.match(adminApp, /AdminImageLightbox/); assert.match(adminApp, /AdminImageLightbox/);
assert.match(adminApp, /CreatorScreenshotPreview/); assert.match(adminApp, /CreatorScreenshotPreview/);
assert.match(adminApp, /admin-image-lightbox/); assert.match(adminApp, /admin-image-lightbox/);

View File

@@ -26,6 +26,9 @@ const overdueRows = [
task_id: "task-A", task_id: "task-A",
task_name: "超期任务A", task_name: "超期任务A",
due_at: "2026-08-20", due_at: "2026-08-20",
task_type: "content_publish",
publish_url: null,
result_submitted_at: null,
project_id: "proj-1", project_id: "proj-1",
partner_id: "p-1", partner_id: "p-1",
partner_name: "合作方甲", partner_name: "合作方甲",
@@ -35,6 +38,9 @@ const overdueRows = [
task_id: "task-A", task_id: "task-A",
task_name: "超期任务A", task_name: "超期任务A",
due_at: "2026-08-20", due_at: "2026-08-20",
task_type: "content_publish",
publish_url: null,
result_submitted_at: null,
project_id: "proj-1", project_id: "proj-1",
partner_id: "p-2", partner_id: "p-2",
partner_name: "合作方乙", partner_name: "合作方乙",
@@ -44,6 +50,9 @@ const overdueRows = [
task_id: "task-B", task_id: "task-B",
task_name: "无项目任务B", task_name: "无项目任务B",
due_at: "2026-08-25", due_at: "2026-08-25",
task_type: "content_publish",
publish_url: null,
result_submitted_at: null,
project_id: "", project_id: "",
partner_id: "p-3", partner_id: "p-3",
partner_name: "合作方丙", partner_name: "合作方丙",
@@ -64,6 +73,15 @@ const releasableDistribution = {
result_submitted_at: null, result_submitted_at: null,
}; };
function filterOverdue(rows, cutoff) {
return rows.filter((row) => {
if (!(row.due_at && row.due_at < cutoff)) return false;
return row.task_type === "screenshot_collect"
? !row.result_submitted_at
: !row.publish_url;
});
}
function fakeEscalationDb({ rows = overdueRows } = {}) { function fakeEscalationDb({ rows = overdueRows } = {}) {
return { return {
prepare(sql) { prepare(sql) {
@@ -77,8 +95,11 @@ function fakeEscalationDb({ rows = overdueRows } = {}) {
return null; return null;
}, },
async all() { async all() {
if (/FROM distributions d/.test(sql) && /publish_url IS NULL/.test(sql)) { if (
return { results: rows.filter((row) => row.due_at < params[0]) }; /FROM distributions d/.test(sql) &&
/result_submitted_at IS NULL/.test(sql)
) {
return { results: filterOverdue(rows, params[0]) };
} }
return { results: [] }; return { results: [] };
}, },
@@ -117,8 +138,11 @@ function fakeReleaseDb({ rows = overdueRows, blockedIds = [] } = {}) {
return null; return null;
}, },
async all() { async all() {
if (/FROM distributions d/.test(sql) && /publish_url IS NULL/.test(sql)) { if (
return { results: rows.filter((row) => row.due_at < params[0]) }; /FROM distributions d/.test(sql) &&
/result_submitted_at IS NULL/.test(sql)
) {
return { results: filterOverdue(rows, params[0]) };
} }
return { results: [] }; return { results: [] };
}, },
@@ -226,6 +250,75 @@ test("runOverdueEscalation no-ops without overdue rows", async () => {
assert.equal(calls.length, 0); assert.equal(calls.length, 0);
}); });
test("runOverdueEscalation distinguishes screenshot tasks from content tasks", async () => {
const rows = [
{
id: "d-s1",
task_id: "task-S",
task_name: "截图任务S",
due_at: "2026-08-20",
task_type: "screenshot_collect",
publish_url: null,
result_submitted_at: null,
project_id: "",
partner_id: "p-1",
partner_name: "合作方甲",
},
{
id: "d-s2",
task_id: "task-S",
task_name: "截图任务S",
due_at: "2026-08-20",
task_type: "screenshot_collect",
publish_url: null,
result_submitted_at: "2026-08-21 09:00:00",
project_id: "",
partner_id: "p-2",
partner_name: "合作方乙",
},
{
id: "d-c1",
task_id: "task-A",
task_name: "内容任务A",
due_at: "2026-08-20",
task_type: "content_publish",
publish_url: "https://www.xiaohongshu.com/note/1",
result_submitted_at: null,
project_id: "",
partner_id: "p-3",
partner_name: "合作方丙",
},
{
id: "d-c2",
task_id: "task-A",
task_name: "内容任务A",
due_at: "2026-08-20",
task_type: "content_publish",
publish_url: null,
result_submitted_at: null,
project_id: "",
partner_id: "p-4",
partner_name: "合作方丁",
},
];
const calls = [];
const summary = await runOverdueEscalation(
fakeEscalationDb({ rows }),
config,
wecomFetch(calls),
NOW,
);
// 已提交截图的 d-s2 与已回填链接的 d-c1 不算超期
assert.equal(summary.overdueTasks, 2);
assert.equal(summary.overdueDistributions, 2);
const robotBodies = calls
.filter((call) => call.url.host === "qyapi.example.com")
.map((call) => JSON.parse(String(call.init.body)));
assert.equal(robotBodies.length, 1);
assert.match(robotBodies[0].text.content, /截图任务S(截止 2026-08-20):1 条/);
assert.match(robotBodies[0].text.content, /内容任务A(截止 2026-08-20):1 条/);
});
test("runOverdueAutoRelease is disabled without env switch", async () => { test("runOverdueAutoRelease is disabled without env switch", async () => {
const calls = []; const calls = [];
const db = fakeReleaseDb(); const db = fakeReleaseDb();