diff --git a/app/admin-app.tsx b/app/admin-app.tsx index 62f5164..214bffc 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -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" && ( setTaskModalOpen(true)} onCopyShare={copyTaskShareLink} onPushToGroups={(task) => setGroupPushTask(task)} onOneClickPush={(task) => setOnePushTask(task)} - /> - )} - {activeNav === "distributions" && ( - runAction( { @@ -3945,7 +3934,7 @@ function OverviewPage({

当前闭环

每篇内容只沿着一条记录向前走

- +
@@ -4016,7 +4005,7 @@ function OverviewPage({

最近分发

领取、发布账号与回收状态一眼看清

- +
@@ -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; }) { + const [selectedTaskId, setSelectedTaskId] = useState(null); + const selectedTask = tasks.find((task) => task.id === selectedTaskId); + + if (selectedTask) { + return ( + setSelectedTaskId(null)} + /> + ); + } + return (
@@ -4078,6 +4091,12 @@ function TasksPage({
截止日期{formatDate(task.due_at)}
{statusLabel(task.status)}
+ @@ -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; + onBack: () => void; }) { - const [selectedTaskId, setSelectedTaskId] = useState(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(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(null); - const selectedTask = tasks.find((task) => task.id === selectedTaskId); - if (!selectedTask) { - return ( -
-
-

按任务管理

选择一个分发任务

-

每个任务独立查看领取、发布和账号识别进度,不混排不同项目的笔记。

-
-
- -
- {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 ( -
-
- - setOpenTaskFilter(filter.key)} - onChange={(event) => { - setTaskFilterValue(filter.key, event.target.value); - setOpenTaskFilter(filter.key); - }} - onKeyDown={(event) => { - if (event.key === "Escape") setOpenTaskFilter(null); - }} - /> -
- {openTaskFilter === filter.key ? ( -
-
- {visibleOptions.map((option) => ( - - ))} - {visibleOptions.length === 0 ? ( - 没有匹配项 - ) : null} -
-
- ) : null} -
- ); - })} -
- - 共 {filteredTasks.length} 个任务 -
- {filteredTasks.length > 0 ? ( - - ) : ( -
- 没有符合条件的任务 - 可以调整任务名称或清空筛选条件 -
- )} -
- ); - } - 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 (
setSelectedTaskId(null)} + onBack={onBack} />
已领取{taskDistributions.length}
@@ -4476,7 +4294,7 @@ function DistributionPage({
{isScreenshotTask ? "已提交截图" : "已回填链接"}{isScreenshotTask ? submitted : published}
{isScreenshotTask ? "待提交" : "待发布"}{taskDistributions.length - (isScreenshotTask ? submitted : published)}
-
当前仅展示“{selectedTask.name}”
+
当前仅展示“{task.name}”
@@ -5933,7 +5751,6 @@ function RecoveryPage({
diff --git a/app/globals.css b/app/globals.css index 357ad95..15ef0bc 100644 --- a/app/globals.css +++ b/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%; diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs index f4683ae..0cd10e0 100644 --- a/tests/rendered-html.test.mjs +++ b/tests/rendered-html.test.mjs @@ -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, /