refactor: 任务中心合并任务分发(导航 9→8,任务卡进入分发明细)
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
262
app/globals.css
262
app/globals.css
@@ -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%;
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
Reference in New Issue
Block a user