Compare commits
2 Commits
fb1551a3a4
...
5cb056507d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cb056507d | ||
|
|
e1005081fe |
@@ -204,7 +204,6 @@ type NavKey =
|
||||
| "overview"
|
||||
| "projects"
|
||||
| "tasks"
|
||||
| "distributions"
|
||||
| "resources"
|
||||
| "partners"
|
||||
| "recovery"
|
||||
@@ -215,7 +214,6 @@ const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
|
||||
{ key: "overview", label: "工作台", mark: "⌂" },
|
||||
{ key: "projects", label: "项目中心", mark: "▦" },
|
||||
{ key: "tasks", label: "任务中心", mark: "▤" },
|
||||
{ key: "distributions", label: "任务分发", mark: "↗" },
|
||||
{ key: "resources", label: "KOC资源", mark: "◎" },
|
||||
{ key: "partners", label: "合作方", mark: "Ⓒ" },
|
||||
{ key: "recovery", label: "数据回收", mark: "◫" },
|
||||
@@ -1036,12 +1034,8 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
subtitle: "按项目组织任务群与总群成员,一键把任务分发到项目各群。",
|
||||
},
|
||||
tasks: {
|
||||
title: "内容任务",
|
||||
subtitle: "从飞书同步内容,按数量开放给合作方领取。",
|
||||
},
|
||||
distributions: {
|
||||
title: "内容分发",
|
||||
subtitle: "先按任务查看进度,再进入单个任务处理每篇笔记。",
|
||||
title: "任务中心",
|
||||
subtitle: "从飞书同步内容、开放领取,并按任务跟进每篇笔记的分发进度。",
|
||||
},
|
||||
resources: {
|
||||
title: "KOC资源",
|
||||
@@ -1186,19 +1180,14 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
{activeNav === "tasks" && (
|
||||
<TasksPage
|
||||
tasks={data.tasks}
|
||||
distributions={data.distributions}
|
||||
portalUrl={data.portal_url}
|
||||
canRelease={isManager}
|
||||
working={working}
|
||||
onCreate={() => setTaskModalOpen(true)}
|
||||
onCopyShare={copyTaskShareLink}
|
||||
onPushToGroups={(task) => setGroupPushTask(task)}
|
||||
onOneClickPush={(task) => setOnePushTask(task)}
|
||||
/>
|
||||
)}
|
||||
{activeNav === "distributions" && (
|
||||
<DistributionPage
|
||||
tasks={data.tasks}
|
||||
distributions={data.distributions}
|
||||
canRelease={isManager}
|
||||
working={working}
|
||||
onRelease={async (distribution) =>
|
||||
runAction(
|
||||
{
|
||||
@@ -3945,7 +3934,7 @@ function OverviewPage({
|
||||
<h2>当前闭环</h2>
|
||||
<p>每篇内容只沿着一条记录向前走</p>
|
||||
</div>
|
||||
<button className="text-button" onClick={() => onNavigate("distributions")}>查看全部</button>
|
||||
<button className="text-button" onClick={() => onNavigate("tasks")}>查看全部</button>
|
||||
</div>
|
||||
<div className="workflow-track">
|
||||
<div className="workflow-step done">
|
||||
@@ -4016,7 +4005,7 @@ function OverviewPage({
|
||||
<section className="panel table-panel">
|
||||
<div className="panel-heading">
|
||||
<div><h2>最近分发</h2><p>领取、发布账号与回收状态一眼看清</p></div>
|
||||
<button className="text-button" onClick={() => onNavigate("distributions")}>查看全部</button>
|
||||
<button className="text-button" onClick={() => onNavigate("tasks")}>查看全部</button>
|
||||
</div>
|
||||
<DistributionTable distributions={distributions} compact />
|
||||
</section>
|
||||
@@ -4026,19 +4015,43 @@ function OverviewPage({
|
||||
|
||||
function TasksPage({
|
||||
tasks,
|
||||
distributions,
|
||||
portalUrl,
|
||||
canRelease,
|
||||
working,
|
||||
onCreate,
|
||||
onCopyShare,
|
||||
onPushToGroups,
|
||||
onOneClickPush,
|
||||
onRelease,
|
||||
}: {
|
||||
tasks: Task[];
|
||||
distributions: Distribution[];
|
||||
portalUrl: string;
|
||||
canRelease: boolean;
|
||||
working: boolean;
|
||||
onCreate: () => void;
|
||||
onCopyShare: (task: Task) => void;
|
||||
onPushToGroups: (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 (
|
||||
<div className="stack">
|
||||
<section className="task-hero">
|
||||
@@ -4078,6 +4091,12 @@ function TasksPage({
|
||||
<div className="task-date"><span>截止日期</span><strong>{formatDate(task.due_at)}</strong></div>
|
||||
<span className={`status-pill ${task.status}`}>{statusLabel(task.status)}</span>
|
||||
<div className="task-share-actions">
|
||||
<button
|
||||
className="share-link-button"
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
>
|
||||
分发明细
|
||||
</button>
|
||||
<button
|
||||
className="share-link-button"
|
||||
onClick={() => onCopyShare(task)}
|
||||
@@ -4122,12 +4141,10 @@ function TasksPage({
|
||||
function TaskScopeList({
|
||||
tasks,
|
||||
distributions,
|
||||
mode,
|
||||
onOpen,
|
||||
}: {
|
||||
tasks: Task[];
|
||||
distributions: Distribution[];
|
||||
mode: "distribution" | "recovery";
|
||||
onOpen: (taskId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -4147,9 +4164,6 @@ function TaskScopeList({
|
||||
const creatorComplete = published.filter(
|
||||
(item) => hasCreatorMetrics(item),
|
||||
).length;
|
||||
const progress = task.quantity
|
||||
? Math.round((task.claimed_quantity / task.quantity) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<article className="task-scope-card" key={task.id}>
|
||||
<div className="task-scope-head">
|
||||
@@ -4180,18 +4194,6 @@ function TaskScopeList({
|
||||
<strong>历史示例内容表</strong>
|
||||
)}
|
||||
</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">
|
||||
{isScreenshotTask ? (
|
||||
<>
|
||||
@@ -4207,9 +4209,8 @@ function TaskScopeList({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button className="task-scope-open" onClick={() => onOpen(task.id)}>
|
||||
<span>{mode === "distribution" ? "查看分发明细" : "进入回收队列"}</span>
|
||||
<span>进入回收队列</span>
|
||||
<b>→</b>
|
||||
</button>
|
||||
</article>
|
||||
@@ -4258,217 +4259,34 @@ function TaskDetailHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function DistributionPage({
|
||||
tasks,
|
||||
function TaskDistributionDetail({
|
||||
task,
|
||||
distributions,
|
||||
canRelease,
|
||||
working,
|
||||
onRelease,
|
||||
onBack,
|
||||
}: {
|
||||
tasks: Task[];
|
||||
task: Task;
|
||||
distributions: Distribution[];
|
||||
canRelease: boolean;
|
||||
working: 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 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(
|
||||
(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 submitted = taskDistributions.filter((item) => item.result_submitted_at).length;
|
||||
return (
|
||||
<div className="stack">
|
||||
<TaskDetailHeader
|
||||
task={selectedTask}
|
||||
task={task}
|
||||
label={isScreenshotTask ? "截图任务分发" : "内容分发"}
|
||||
onBack={() => setSelectedTaskId(null)}
|
||||
onBack={onBack}
|
||||
/>
|
||||
<div className="inline-summary">
|
||||
<div><span>已领取</span><strong>{taskDistributions.length}</strong></div>
|
||||
@@ -4476,7 +4294,7 @@ function DistributionPage({
|
||||
<div><span>{isScreenshotTask ? "已提交截图" : "已回填链接"}</span><strong>{isScreenshotTask ? submitted : published}</strong></div>
|
||||
<i />
|
||||
<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>
|
||||
<section className="panel table-panel">
|
||||
<div className="panel-heading">
|
||||
@@ -5933,7 +5751,6 @@ function RecoveryPage({
|
||||
<TaskScopeList
|
||||
tasks={tasks}
|
||||
distributions={distributions}
|
||||
mode="recovery"
|
||||
onOpen={setSelectedTaskId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -563,18 +563,6 @@ async function handlePost(request: Request) {
|
||||
{ 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
|
||||
? (await resolveWecomClaimant(db, wecomIdentity.externalUserId)).partnerId
|
||||
: `partner-ext-${hashText(
|
||||
@@ -585,6 +573,18 @@ async function handlePost(request: Request) {
|
||||
: (claimantIdentifier?.display ?? "");
|
||||
const claimId = uid("claim");
|
||||
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[] = [
|
||||
wecomIdentity
|
||||
? db
|
||||
@@ -660,11 +660,15 @@ async function handlePost(request: Request) {
|
||||
)
|
||||
.bind(available.results.length, partnerId),
|
||||
);
|
||||
await db.batch(statements);
|
||||
return Response.json({
|
||||
claimToken,
|
||||
claimedCount: available.results.length,
|
||||
for (const statement of statements) {
|
||||
await tx.execute(statement.sql, statement.values());
|
||||
}
|
||||
return { claimToken, claimedCount: available.results.length };
|
||||
});
|
||||
if (!claimResult) {
|
||||
return Response.json({ error: "当前任务已领完" }, { status: 409 });
|
||||
}
|
||||
return Response.json(claimResult);
|
||||
}
|
||||
|
||||
if (body.action === "create_delegation") {
|
||||
|
||||
262
app/globals.css
262
app/globals.css
@@ -2052,230 +2052,6 @@ a {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2331,10 +2107,6 @@ a {
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.task-scope-progress {
|
||||
margin-top: 13px;
|
||||
}
|
||||
|
||||
.task-scope-open {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -4952,11 +4724,6 @@ label small {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.distribution-task-count {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.workflow-track {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -5142,35 +4909,6 @@ label small {
|
||||
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-filter-search {
|
||||
width: 100%;
|
||||
|
||||
@@ -63,7 +63,11 @@ export async function runDueSoonWecomNotifications(
|
||||
JOIN tasks t ON t.id = d.task_id
|
||||
JOIN partners p ON p.id = d.partner_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 <= ?
|
||||
ORDER BY t.due_at ASC, p.name ASC`,
|
||||
@@ -199,7 +203,11 @@ async function queryOverdueUnpublished(
|
||||
FROM distributions d
|
||||
JOIN tasks t ON t.id = d.task_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 < ?
|
||||
ORDER BY t.due_at ASC, t.name ASC`,
|
||||
|
||||
@@ -23,28 +23,20 @@ test("builds the KOC LOOP product shell", async () => {
|
||||
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([
|
||||
readFile(new URL("../app/admin-app.tsx", 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(globalCss, /\.distribution-task-search\s*\{[^}]*margin:\s*0/s);
|
||||
assert.match(globalCss, /\.distribution-task-search\s*>\s*span\s*\{[^}]*flex:\s*0\s+0\s+18px/s);
|
||||
assert.match(globalCss, /\.distribution-task-filter-combobox\.brand\s*\{[^}]*flex-basis:\s*176px/s);
|
||||
assert.match(globalCss, /\.distribution-task-filter-menu\s*\{[^}]*position:\s*absolute[^}]*z-index:\s*60/s);
|
||||
assert.match(globalCss, /\.distribution-task-filter-input\s*\{[^}]*display:\s*flex[^}]*margin:\s*0/s);
|
||||
assert.match(adminApp, /aria-expanded=\{openTaskFilter === filter\.key\}/);
|
||||
assert.match(adminApp, /role="combobox"/);
|
||||
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\}/);
|
||||
assert.match(adminApp, /分发明细/);
|
||||
assert.match(adminApp, /function TaskDistributionDetail/);
|
||||
assert.match(adminApp, /选择一个数据回收任务/);
|
||||
assert.doesNotMatch(adminApp, /"distributions"/);
|
||||
assert.doesNotMatch(adminApp, /选择一个分发任务/);
|
||||
assert.doesNotMatch(adminApp, /distribution-task-/);
|
||||
assert.doesNotMatch(globalCss, /distribution-task-/);
|
||||
assert.doesNotMatch(adminApp, /mode === "distribution"/);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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, /通过作品查看主页/);
|
||||
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, /识别失败,请手动填写/);
|
||||
assert.match(adminApp, /分发明细/);
|
||||
assert.match(adminApp, /AdminImageLightbox/);
|
||||
assert.match(adminApp, /CreatorScreenshotPreview/);
|
||||
assert.match(adminApp, /admin-image-lightbox/);
|
||||
|
||||
@@ -26,6 +26,9 @@ const overdueRows = [
|
||||
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: "proj-1",
|
||||
partner_id: "p-1",
|
||||
partner_name: "合作方甲",
|
||||
@@ -35,6 +38,9 @@ const overdueRows = [
|
||||
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: "proj-1",
|
||||
partner_id: "p-2",
|
||||
partner_name: "合作方乙",
|
||||
@@ -44,6 +50,9 @@ const overdueRows = [
|
||||
task_id: "task-B",
|
||||
task_name: "无项目任务B",
|
||||
due_at: "2026-08-25",
|
||||
task_type: "content_publish",
|
||||
publish_url: null,
|
||||
result_submitted_at: null,
|
||||
project_id: "",
|
||||
partner_id: "p-3",
|
||||
partner_name: "合作方丙",
|
||||
@@ -64,6 +73,15 @@ const releasableDistribution = {
|
||||
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 } = {}) {
|
||||
return {
|
||||
prepare(sql) {
|
||||
@@ -77,8 +95,11 @@ function fakeEscalationDb({ rows = overdueRows } = {}) {
|
||||
return null;
|
||||
},
|
||||
async all() {
|
||||
if (/FROM distributions d/.test(sql) && /publish_url IS NULL/.test(sql)) {
|
||||
return { results: rows.filter((row) => row.due_at < params[0]) };
|
||||
if (
|
||||
/FROM distributions d/.test(sql) &&
|
||||
/result_submitted_at IS NULL/.test(sql)
|
||||
) {
|
||||
return { results: filterOverdue(rows, params[0]) };
|
||||
}
|
||||
return { results: [] };
|
||||
},
|
||||
@@ -117,8 +138,11 @@ function fakeReleaseDb({ rows = overdueRows, blockedIds = [] } = {}) {
|
||||
return null;
|
||||
},
|
||||
async all() {
|
||||
if (/FROM distributions d/.test(sql) && /publish_url IS NULL/.test(sql)) {
|
||||
return { results: rows.filter((row) => row.due_at < params[0]) };
|
||||
if (
|
||||
/FROM distributions d/.test(sql) &&
|
||||
/result_submitted_at IS NULL/.test(sql)
|
||||
) {
|
||||
return { results: filterOverdue(rows, params[0]) };
|
||||
}
|
||||
return { results: [] };
|
||||
},
|
||||
@@ -226,6 +250,75 @@ test("runOverdueEscalation no-ops without overdue rows", async () => {
|
||||
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 () => {
|
||||
const calls = [];
|
||||
const db = fakeReleaseDb();
|
||||
|
||||
Reference in New Issue
Block a user