refactor: 任务中心合并任务分发(导航 9→8,任务卡进入分发明细)

This commit is contained in:
ABAPPLO
2026-09-02 11:25:16 +08:00
parent fb1551a3a4
commit e1005081fe
3 changed files with 73 additions and 530 deletions

View File

@@ -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,36 +4194,23 @@ 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 ? (
<>
<div><span>已领取</span><strong>{taskDistributions.length}</strong></div>
<div><span>已提交</span><strong>{submitted.length}</strong></div>
<div><span>待提交</span><strong>{taskDistributions.length - submitted.length}</strong></div>
</>
) : (
<>
<div><span>已发布</span><strong>{published.length}</strong></div>
<div><span>公开数据</span><strong>{publicDataComplete}</strong></div>
<div><span>创作者数据</span><strong>{creatorComplete}</strong></div>
</>
)}
</div>
)}
<div className="task-scope-metrics">
{isScreenshotTask ? (
<>
<div><span>已领取</span><strong>{taskDistributions.length}</strong></div>
<div><span>已提交</span><strong>{submitted.length}</strong></div>
<div><span>待提交</span><strong>{taskDistributions.length - submitted.length}</strong></div>
</>
) : (
<>
<div><span>已发布</span><strong>{published.length}</strong></div>
<div><span>公开数据</span><strong>{publicDataComplete}</strong></div>
<div><span>创作者数据</span><strong>{creatorComplete}</strong></div>
</>
)}
</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>

View File

@@ -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%;