refactor: 项目中心合并进任务中心(导航 8→7,分组/平铺双视图)

manager 默认按项目分组(项目区块含总群成员/任务群关联/推送,末尾未分组任务),
可切换平铺;普通用户仅平铺列表无管理入口。任务卡抽为 TaskRowCard 双视图共用,
项目编辑/删除弹窗上提为受控组件,详情内任务表与区块任务卡去重(单任务推送
改走推送弹层任务下拉)。
This commit is contained in:
ABAPPLO
2026-09-02 17:32:42 +08:00
parent 7ebf8d4902
commit 5d2a17f5b5
2 changed files with 567 additions and 447 deletions

View File

@@ -202,7 +202,6 @@ type FeishuPreview = {
type NavKey = type NavKey =
| "overview" | "overview"
| "projects"
| "tasks" | "tasks"
| "resources" | "resources"
| "partners" | "partners"
@@ -212,7 +211,6 @@ type NavKey =
const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [ const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
{ key: "overview", label: "工作台", mark: "⌂" }, { key: "overview", label: "工作台", mark: "⌂" },
{ key: "projects", label: "项目中心", mark: "▦" },
{ key: "tasks", label: "任务中心", mark: "▤" }, { key: "tasks", label: "任务中心", mark: "▤" },
{ key: "resources", label: "KOC资源", mark: "◎" }, { key: "resources", label: "KOC资源", mark: "◎" },
{ key: "partners", label: "合作方", mark: "Ⓒ" }, { key: "partners", label: "合作方", mark: "Ⓒ" },
@@ -559,7 +557,6 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
const visibleNavItems = NAV_ITEMS.filter((item) => { const visibleNavItems = NAV_ITEMS.filter((item) => {
if (item.key === "resources" && currentUser.role === "user") return false; if (item.key === "resources" && currentUser.role === "user") return false;
if (item.key === "users" && !isManager) return false; if (item.key === "users" && !isManager) return false;
if (item.key === "projects" && !isManager) return false;
if (item.key === "reports" && !isManager) return false; if (item.key === "reports" && !isManager) return false;
return true; return true;
}); });
@@ -668,7 +665,6 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
const navigate = (key: NavKey) => { const navigate = (key: NavKey) => {
if (key === "resources" && currentUser.role === "user") return; if (key === "resources" && currentUser.role === "user") return;
if (key === "users" && !isManager) return; if (key === "users" && !isManager) return;
if (key === "projects" && !isManager) return;
if (key === "reports" && !isManager) return; if (key === "reports" && !isManager) return;
setActiveNav(key); setActiveNav(key);
setMenuOpen(false); setMenuOpen(false);
@@ -1029,13 +1025,9 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
title: "分发工作台", title: "分发工作台",
subtitle: "用最少动作,跑完内容领取、发布与数据回收。", subtitle: "用最少动作,跑完内容领取、发布与数据回收。",
}, },
projects: {
title: "项目中心",
subtitle: "按项目组织任务群与总群成员,一键把任务分发到项目各群。",
},
tasks: { tasks: {
title: "任务中心", title: "任务中心",
subtitle: "从飞书同步内容、开放领取,并按任务跟进每篇笔记的分发进度。", subtitle: "按项目组织任务群与总群成员,从飞书同步内容、开放领取并跟进分发进度。",
}, },
resources: { resources: {
title: "KOC资源", title: "KOC资源",
@@ -1169,21 +1161,16 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
showResources={currentUser.role !== "user"} showResources={currentUser.role !== "user"}
/> />
)} )}
{activeNav === "projects" && isManager && (
<ProjectsPage
portalUrl={data.portal_url}
setToast={setToast}
onTasksChanged={loadData}
onCreateTask={() => setTaskModalOpen(true)}
/>
)}
{activeNav === "tasks" && ( {activeNav === "tasks" && (
<TasksPage <TasksPage
tasks={data.tasks} tasks={data.tasks}
distributions={data.distributions} distributions={data.distributions}
portalUrl={data.portal_url} portalUrl={data.portal_url}
canRelease={isManager} canRelease={isManager}
isManager={isManager}
working={working} working={working}
setToast={setToast}
onTasksChanged={loadData}
onCreate={() => setTaskModalOpen(true)} onCreate={() => setTaskModalOpen(true)}
onCopyShare={copyTaskShareLink} onCopyShare={copyTaskShareLink}
onPushToGroups={(task) => setGroupPushTask(task)} onPushToGroups={(task) => setGroupPushTask(task)}
@@ -1461,7 +1448,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
</option> </option>
))} ))}
</select> </select>
<small>挂在项目下的任务可通过「项目中心」直接推送到项目总群与任务群。</small> <small>挂在项目下的任务可在「任务中心」的项目分组中直接推送到项目总群与任务群。</small>
</label> </label>
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setTaskModalOpen(false)}>取消</button> <button type="button" className="ghost-button" onClick={() => setTaskModalOpen(false)}>取消</button>
@@ -2156,7 +2143,7 @@ function PartnersPage({
</div> </div>
<p className="project-chat-hint"> <p className="project-chat-hint">
userid 在企业微信管理后台「通讯录」成员详情中查看。群主合作方是内部员工, userid 在企业微信管理后台「通讯录」成员详情中查看。群主合作方是内部员工,
用于在项目中心把任务群映射到负责人。 用于在任务中心的项目分组中把任务群映射到负责人。
</p> </p>
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setCreateOpen(false)} disabled={createSaving}>取消</button> <button type="button" className="ghost-button" onClick={() => setCreateOpen(false)} disabled={createSaving}>取消</button>
@@ -2539,42 +2526,7 @@ type GroupOwnerOption = {
wecom_userid: string; wecom_userid: string;
}; };
function ProjectsPage({ async function postAction<T>(payload: Record<string, unknown>, fallback: string) {
portalUrl,
setToast,
onTasksChanged,
onCreateTask,
}: {
portalUrl: string;
setToast: (message: string) => void;
onTasksChanged: () => Promise<void>;
onCreateTask: () => void;
}) {
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [detail, setDetail] = useState<ProjectDetailData | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [editorSaving, setEditorSaving] = useState(false);
const [editorDraft, setEditorDraft] = useState({
id: "",
name: "",
brand: "",
members: "",
note: "",
});
const [chatManaging, setChatManaging] = useState(false);
const [chatSaving, setChatSaving] = useState(false);
const [chatLoading, setChatLoading] = useState(false);
const [chatGroups, setChatGroups] = useState<WecomGroupChatRow[]>([]);
const [chatOwners, setChatOwners] = useState<GroupOwnerOption[]>([]);
const [chatDraft, setChatDraft] = useState<Array<{ chatId: string; partnerId: string }>>([]);
const [deleteTarget, setDeleteTarget] = useState<ProjectSummary | null>(null);
const [deleteWorking, setDeleteWorking] = useState(false);
const [pushProject, setPushProject] = useState<ProjectDetailData | null>(null);
const postAction = useCallback(async <T,>(payload: Record<string, unknown>, fallback: string) => {
const response = await fetch("/api/action", { const response = await fetch("/api/action", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -2583,122 +2535,78 @@ function ProjectsPage({
const parsed = await readApiResponse<T & { error?: string }>(response, fallback); const parsed = await readApiResponse<T & { error?: string }>(response, fallback);
if (!response.ok) throw new Error(parsed.error || fallback); if (!response.ok) throw new Error(parsed.error || fallback);
return parsed; return parsed;
}, []);
const loadProjects = useCallback(async () => {
try {
const parsed = await postAction<{ projects?: ProjectSummary[] }>(
{ action: "project_list" },
"读取项目列表失败",
);
setProjects(parsed.projects ?? []);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "读取项目列表失败");
} finally {
setLoading(false);
} }
}, [postAction, setToast]);
useEffect(() => { type ProjectEditorDraft = {
const timer = window.setTimeout(() => { id: string;
void loadProjects(); name: string;
}, 0); brand: string;
return () => window.clearTimeout(timer); members: string;
}, [loadProjects]); note: string;
};
const refreshDetail = useCallback( const EMPTY_PROJECT_DRAFT: ProjectEditorDraft = { id: "", name: "", brand: "", members: "", note: "" };
async (projectId: string) => {
function ProjectSection({
project,
tasks,
detailVersion,
portalUrl,
setToast,
onProjectsChanged,
onEditProject,
onDeleteProject,
onOpenTaskDetail,
onCopyShare,
onPushToGroups,
onOneClickPush,
}: {
project: ProjectSummary;
tasks: Task[];
detailVersion: number;
portalUrl: string;
setToast: (message: string) => void;
onProjectsChanged: () => Promise<void>;
onEditProject: (project: ProjectSummary) => void;
onDeleteProject: (project: ProjectSummary) => void;
onOpenTaskDetail: (taskId: string) => void;
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void;
}) {
const [expanded, setExpanded] = useState(false);
const [detail, setDetail] = useState<ProjectDetailData | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [chatManaging, setChatManaging] = useState(false);
const [chatSaving, setChatSaving] = useState(false);
const [chatLoading, setChatLoading] = useState(false);
const [chatGroups, setChatGroups] = useState<WecomGroupChatRow[]>([]);
const [chatOwners, setChatOwners] = useState<GroupOwnerOption[]>([]);
const [chatDraft, setChatDraft] = useState<Array<{ chatId: string; partnerId: string }>>([]);
const [pushProject, setPushProject] = useState<ProjectDetailData | null>(null);
const loadDetail = useCallback(async () => {
setDetailLoading(true);
try { try {
const parsed = await postAction<ProjectDetailData>( const parsed = await postAction<ProjectDetailData>(
{ action: "project_detail", projectId }, { action: "project_detail", projectId: project.id },
"读取项目详情失败", "读取项目详情失败",
); );
setDetail(parsed); setDetail(parsed);
} catch (reason) { } catch (reason) {
setToast(reason instanceof Error ? reason.message : "读取项目详情失败"); setToast(reason instanceof Error ? reason.message : "读取项目详情失败");
}
},
[postAction, setToast],
);
const toggleDetail = async (project: ProjectSummary) => {
if (expandedId === project.id) {
setExpandedId(null);
setDetail(null);
return;
}
setExpandedId(project.id);
setDetail(null);
setDetailLoading(true);
try {
await refreshDetail(project.id);
} finally { } finally {
setDetailLoading(false); setDetailLoading(false);
} }
}; }, [project.id, setToast]);
const openEditor = (project?: ProjectSummary) => { useEffect(() => {
setEditorDraft({ if (!expanded) return;
id: project?.id ?? "", const timer = window.setTimeout(() => {
name: project?.name ?? "", void loadDetail();
brand: project?.brand ?? "", }, 0);
members: (project?.mainGroupMemberIds ?? []).join("\n"), return () => window.clearTimeout(timer);
note: project?.note ?? "", }, [expanded, detailVersion, loadDetail]);
});
setEditorOpen(true);
};
const submitEditor = async () => {
if (!editorDraft.name.trim()) {
setToast("请填写项目名称");
return;
}
setEditorSaving(true);
try {
await postAction(
{
action: "project_save",
id: editorDraft.id,
name: editorDraft.name,
brand: editorDraft.brand,
mainGroupMemberIds: editorDraft.members,
note: editorDraft.note,
},
"保存项目失败",
);
setEditorOpen(false);
setToast(editorDraft.id ? "项目已更新" : "项目已创建");
await loadProjects();
if (editorDraft.id && expandedId === editorDraft.id) {
await refreshDetail(editorDraft.id);
}
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "保存项目失败");
} finally {
setEditorSaving(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget) return;
setDeleteWorking(true);
try {
await postAction(
{ action: "project_delete", projectId: deleteTarget.id },
"删除项目失败",
);
setToast(`项目「${deleteTarget.name}」已删除,任务已解除关联`);
setDeleteTarget(null);
setExpandedId(null);
setDetail(null);
await loadProjects();
await onTasksChanged();
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "删除项目失败");
} finally {
setDeleteWorking(false);
}
};
const openChatManager = async () => { const openChatManager = async () => {
if (!detail) return; if (!detail) return;
@@ -2758,7 +2666,7 @@ function ProjectsPage({
setDetail(parsed); setDetail(parsed);
setChatManaging(false); setChatManaging(false);
setToast("项目任务群已更新"); setToast("项目任务群已更新");
await loadProjects(); await onProjectsChanged();
} catch (reason) { } catch (reason) {
setToast(reason instanceof Error ? reason.message : "保存任务群关联失败"); setToast(reason instanceof Error ? reason.message : "保存任务群关联失败");
} finally { } finally {
@@ -2767,34 +2675,7 @@ function ProjectsPage({
}; };
return ( return (
<div className="stack"> <section className="panel project-panel">
<section className="task-hero">
<div>
<p className="eyebrow">项目 = 任务 + 任务群 + 总群</p>
<h2>按项目组织分发链路</h2>
<p>项目总群成员收到应用消息后,各自把任务群发到自己负责的客户群。</p>
</div>
<div className="project-hero-actions">
<button className="ghost-button" onClick={onCreateTask}>+ 新建任务</button>
<button className="primary-button light" onClick={() => openEditor()}>+ 新建项目</button>
</div>
</section>
{loading ? (
<div className="empty-state">
<div className="empty-icon">…</div>
<h3>正在读取项目</h3>
</div>
) : projects.length === 0 ? (
<div className="empty-state">
<div className="empty-icon">▦</div>
<h3>还没有项目</h3>
<p>新建项目后,把任务挂到项目下,即可一次推送到项目总群与各任务群。</p>
<button className="primary-button" onClick={() => openEditor()}>+ 新建项目</button>
</div>
) : (
projects.map((project) => (
<section className="panel project-panel" key={project.id}>
<div className="project-card-head"> <div className="project-card-head">
<div className="project-card-title"> <div className="project-card-title">
<div className={`avatar large ${avatarColor(project.name)}`}> <div className={`avatar large ${avatarColor(project.name)}`}>
@@ -2808,15 +2689,34 @@ function ProjectsPage({
</div> </div>
</div> </div>
<div className="project-card-actions"> <div className="project-card-actions">
<button className="share-link-button" onClick={() => void toggleDetail(project)}> <button className="share-link-button" onClick={() => setExpanded((previous) => !previous)}>
{expandedId === project.id ? "收起详情" : "查看详情"} {expanded ? "收起详情" : "查看详情"}
</button> </button>
<button className="share-link-button" onClick={() => openEditor(project)}>编辑</button> <button className="share-link-button" onClick={() => onEditProject(project)}>编辑</button>
<button className="share-link-button danger" onClick={() => setDeleteTarget(project)}>删除</button> <button className="share-link-button danger" onClick={() => onDeleteProject(project)}>删除</button>
</div> </div>
</div> </div>
{project.note && <p className="project-note">{project.note}</p>} {project.note && <p className="project-note">{project.note}</p>}
{expandedId === project.id && (
{tasks.length === 0 ? (
<p className="project-empty-line">项目下还没有任务,新建任务时选择所属项目即可挂入。</p>
) : (
<div className="task-list project-task-list">
{tasks.map((task) => (
<TaskRowCard
key={task.id}
task={task}
portalUrl={portalUrl}
onDetail={() => onOpenTaskDetail(task.id)}
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
/>
))}
</div>
)}
{expanded && (
<div className="project-detail"> <div className="project-detail">
{detailLoading || !detail || detail.project.id !== project.id ? ( {detailLoading || !detail || detail.project.id !== project.id ? (
<div className="wecom-picker-empty">正在读取项目详情…</div> <div className="wecom-picker-empty">正在读取项目详情…</div>
@@ -2864,39 +2764,6 @@ function ProjectsPage({
)} )}
</div> </div>
</div> </div>
<div className="project-detail-block">
<div className="project-detail-block-head">
<h4>项目任务({detail.tasks.length})</h4>
<small>在「任务中心」创建任务时选择所属项目</small>
</div>
{detail.tasks.length === 0 ? (
<p className="project-empty-line">项目下还没有任务</p>
) : (
<table className="data-table project-task-table">
<thead>
<tr><th>任务名称</th><th>数量</th><th>截止</th><th>状态</th><th></th></tr>
</thead>
<tbody>
{detail.tasks.map((task) => (
<tr key={task.id}>
<td>{task.name}</td>
<td>{task.claimed_quantity}/{task.quantity}</td>
<td>{formatDate(task.due_at)}</td>
<td><span className={`status-pill ${task.status}`}>{statusLabel(task.status)}</span></td>
<td>
<button
className="share-link-button"
onClick={() => setPushProject({ ...detail, tasks: detail.tasks.filter((item) => item.id === task.id) })}
>
推送该任务
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="project-push-bar"> <div className="project-push-bar">
<button <button
className="primary-button" className="primary-button"
@@ -2905,97 +2772,16 @@ function ProjectsPage({
> >
推送任务到项目群 推送任务到项目群
</button> </button>
{detail.tasks.length === 0 && <small>项目下没有可推送的任务</small>} {detail.tasks.length === 0 ? (
<small>项目下没有可推送的任务</small>
) : (
<small>可在推送弹层中选择项目下的具体任务</small>
)}
</div> </div>
</> </>
)} )}
</div> </div>
)} )}
</section>
))
)}
{editorOpen && (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setEditorOpen(false)}>
<div className="modal-card" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">项目中心</p>
<h2>{editorDraft.id ? "编辑项目" : "新建项目"}</h2>
</div>
<button type="button" onClick={() => setEditorOpen(false)} aria-label="关闭">×</button>
</div>
<div className="form-grid two">
<label>
<span>项目名称 *</span>
<input
value={editorDraft.name}
onChange={(event) => setEditorDraft({ ...editorDraft, name: event.target.value })}
placeholder="例如:暑期焕新计划"
required
/>
</label>
<label>
<span>品牌</span>
<input
value={editorDraft.brand}
onChange={(event) => setEditorDraft({ ...editorDraft, brand: event.target.value })}
placeholder="例如:青柠实验室"
/>
</label>
</div>
<label>
<span>项目总群成员 userid(一行一个)</span>
<textarea
rows={5}
value={editorDraft.members}
onChange={(event) => setEditorDraft({ ...editorDraft, members: event.target.value })}
placeholder={"zhangsan\nlisi\nwangwu"}
/>
<small>userid 在企业微信管理后台「通讯录」成员详情中查看;推送任务时会逐人发送应用消息。</small>
</label>
<label>
<span>备注</span>
<textarea
rows={2}
value={editorDraft.note}
onChange={(event) => setEditorDraft({ ...editorDraft, note: event.target.value })}
placeholder="项目背景、投放节奏等"
/>
</label>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setEditorOpen(false)} disabled={editorSaving}>取消</button>
<button type="button" className="primary-button" onClick={() => void submitEditor()} disabled={editorSaving}>
{editorSaving ? "保存中…" : "保存项目"}
</button>
</div>
</div>
</div>
)}
{deleteTarget && (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setDeleteTarget(null)}>
<div className="modal-card" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow danger">删除项目</p>
<h2>确认删除「{deleteTarget.name}」?</h2>
</div>
<button type="button" onClick={() => setDeleteTarget(null)} aria-label="关闭">×</button>
</div>
<p className="project-delete-warning">
删除后项目下的 {deleteTarget.taskCount} 个任务会解除项目关联(任务本身保留),
任务群关联记录会被移除。此操作不可撤销。
</p>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setDeleteTarget(null)} disabled={deleteWorking}>取消</button>
<button type="button" className="danger-button" onClick={() => void confirmDelete()} disabled={deleteWorking}>
{deleteWorking ? "删除中…" : "确认删除"}
</button>
</div>
</div>
</div>
)}
{chatManaging && detail && ( {chatManaging && detail && (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setChatManaging(false)}> <div className="modal-backdrop" role="presentation" onMouseDown={() => setChatManaging(false)}>
@@ -3078,6 +2864,114 @@ function ProjectsPage({
setToast={setToast} setToast={setToast}
/> />
)} )}
</section>
);
}
function ProjectEditorModal({
draft,
saving,
onChange,
onClose,
onSave,
}: {
draft: ProjectEditorDraft;
saving: boolean;
onChange: (draft: ProjectEditorDraft) => void;
onClose: () => void;
onSave: () => void;
}) {
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-card" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">任务中心 · 项目分组</p>
<h2>{draft.id ? "编辑项目" : "新建项目"}</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
<div className="form-grid two">
<label>
<span>项目名称 *</span>
<input
value={draft.name}
onChange={(event) => onChange({ ...draft, name: event.target.value })}
placeholder="例如:暑期焕新计划"
required
/>
</label>
<label>
<span>品牌</span>
<input
value={draft.brand}
onChange={(event) => onChange({ ...draft, brand: event.target.value })}
placeholder="例如:青柠实验室"
/>
</label>
</div>
<label>
<span>项目总群成员 userid(一行一个)</span>
<textarea
rows={5}
value={draft.members}
onChange={(event) => onChange({ ...draft, members: event.target.value })}
placeholder={"zhangsan\nlisi\nwangwu"}
/>
<small>userid 在企业微信管理后台「通讯录」成员详情中查看;推送任务时会逐人发送应用消息。</small>
</label>
<label>
<span>备注</span>
<textarea
rows={2}
value={draft.note}
onChange={(event) => onChange({ ...draft, note: event.target.value })}
placeholder="项目背景、投放节奏等"
/>
</label>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose} disabled={saving}>取消</button>
<button type="button" className="primary-button" onClick={onSave} disabled={saving}>
{saving ? "保存中…" : "保存项目"}
</button>
</div>
</div>
</div>
);
}
function ProjectDeleteModal({
target,
working,
onClose,
onConfirm,
}: {
target: ProjectSummary;
working: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-card" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow danger">删除项目</p>
<h2>确认删除「{target.name}」?</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
<p className="project-delete-warning">
删除后项目下的 {target.taskCount} 个任务会解除项目关联(任务本身保留),
任务群关联记录会被移除。此操作不可撤销。
</p>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose} disabled={working}>取消</button>
<button type="button" className="danger-button" onClick={onConfirm} disabled={working}>
{working ? "删除中…" : "确认删除"}
</button>
</div>
</div>
</div> </div>
); );
} }
@@ -4013,64 +3907,24 @@ function OverviewPage({
); );
} }
function TasksPage({ function TaskRowCard({
tasks, task,
distributions,
portalUrl, portalUrl,
canRelease, onDetail,
working,
onCreate,
onCopyShare, onCopyShare,
onPushToGroups, onPushToGroups,
onOneClickPush, onOneClickPush,
onRelease,
}: { }: {
tasks: Task[]; task: Task;
distributions: Distribution[];
portalUrl: string; portalUrl: string;
canRelease: boolean; onDetail: () => void;
working: boolean;
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 (
<div className="stack">
<section className="task-hero">
<div>
<p className="eyebrow">两种固定任务类型</p>
<h2>发布笔记,或回收任务截图</h2>
<p>内容发布走飞书笔记链路;截图回收只下发关键词和要求,不进入发布数据采集。</p>
</div>
<button className="primary-button light" onClick={onCreate}>+ 新建任务</button>
</section>
<section className="panel table-panel">
<div className="panel-heading">
<div><h2>任务列表</h2><p>{tasks.length} 个任务</p></div>
</div>
<div className="task-list">
{tasks.map((task) => {
const progress = Math.round((task.claimed_quantity / task.quantity) * 100); const progress = Math.round((task.claimed_quantity / task.quantity) * 100);
return ( return (
<article className="task-row" key={task.id}> <article className="task-row">
<div className={`avatar large ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div> <div className={`avatar large ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
<div className="task-row-main"> <div className="task-row-main">
<div><h3>{task.name}</h3><span>{task.brand}</span></div> <div><h3>{task.name}</h3><span>{task.brand}</span></div>
@@ -4093,7 +3947,7 @@ function TasksPage({
<div className="task-share-actions"> <div className="task-share-actions">
<button <button
className="share-link-button" className="share-link-button"
onClick={() => setSelectedTaskId(task.id)} onClick={onDetail}
> >
分发明细 分发明细
</button> </button>
@@ -4131,9 +3985,267 @@ function TasksPage({
</div> </div>
</article> </article>
); );
})} }
function TasksPage({
tasks,
distributions,
portalUrl,
canRelease,
isManager,
working,
setToast,
onTasksChanged,
onCreate,
onCopyShare,
onPushToGroups,
onOneClickPush,
onRelease,
}: {
tasks: Task[];
distributions: Distribution[];
portalUrl: string;
canRelease: boolean;
isManager: boolean;
working: boolean;
setToast: (message: string) => void;
onTasksChanged: () => Promise<void>;
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 [viewMode, setViewMode] = useState<"grouped" | "flat">(isManager ? "grouped" : "flat");
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [detailVersion, setDetailVersion] = useState(0);
const [editorOpen, setEditorOpen] = useState(false);
const [editorSaving, setEditorSaving] = useState(false);
const [editorDraft, setEditorDraft] = useState<ProjectEditorDraft>(EMPTY_PROJECT_DRAFT);
const [deleteTarget, setDeleteTarget] = useState<ProjectSummary | null>(null);
const [deleteWorking, setDeleteWorking] = useState(false);
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
const loadProjects = useCallback(async () => {
setProjectsLoading(true);
try {
const parsed = await postAction<{ projects?: ProjectSummary[] }>(
{ action: "project_list" },
"读取项目列表失败",
);
setProjects(parsed.projects ?? []);
} catch (reason) {
setProjects([]);
setToast(reason instanceof Error ? reason.message : "读取项目列表失败");
} finally {
setProjectsLoading(false);
}
}, [setToast]);
useEffect(() => {
if (!isManager || viewMode !== "grouped") return;
const timer = window.setTimeout(() => {
void loadProjects();
}, 0);
return () => window.clearTimeout(timer);
}, [isManager, viewMode, loadProjects]);
const openEditor = (project?: ProjectSummary) => {
setEditorDraft({
id: project?.id ?? "",
name: project?.name ?? "",
brand: project?.brand ?? "",
members: (project?.mainGroupMemberIds ?? []).join("\n"),
note: project?.note ?? "",
});
setEditorOpen(true);
};
const submitEditor = async () => {
if (!editorDraft.name.trim()) {
setToast("请填写项目名称");
return;
}
setEditorSaving(true);
try {
await postAction(
{
action: "project_save",
id: editorDraft.id,
name: editorDraft.name,
brand: editorDraft.brand,
mainGroupMemberIds: editorDraft.members,
note: editorDraft.note,
},
"保存项目失败",
);
setEditorOpen(false);
setToast(editorDraft.id ? "项目已更新" : "项目已创建");
await loadProjects();
setDetailVersion((version) => version + 1);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "保存项目失败");
} finally {
setEditorSaving(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget) return;
setDeleteWorking(true);
try {
await postAction(
{ action: "project_delete", projectId: deleteTarget.id },
"删除项目失败",
);
setToast(`项目「${deleteTarget.name}」已删除,任务已解除关联`);
setDeleteTarget(null);
await loadProjects();
await onTasksChanged();
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "删除项目失败");
} finally {
setDeleteWorking(false);
}
};
if (selectedTask) {
return (
<TaskDistributionDetail
task={selectedTask}
distributions={distributions}
canRelease={canRelease}
working={working}
onRelease={onRelease}
onBack={() => setSelectedTaskId(null)}
/>
);
}
const ungroupedTasks = tasks.filter((task) => !task.project_id);
const showGrouped = isManager && viewMode === "grouped";
return (
<div className="stack">
<section className="task-hero">
<div>
<p className="eyebrow">两种固定任务类型 · 项目化分发</p>
<h2>发布笔记,或回收任务截图</h2>
<p>内容发布走飞书笔记链路;截图回收只下发关键词和要求,不进入发布数据采集。</p>
</div>
<div className="project-hero-actions">
{isManager && <button className="ghost-button" onClick={() => openEditor()}>+ 新建项目</button>}
<button className="primary-button light" onClick={onCreate}>+ 新建任务</button>
</div> </div>
</section> </section>
{isManager && (
<div className="partner-type-tabs" role="group" aria-label="任务视图切换">
<button type="button" className={viewMode === "grouped" ? "active" : ""} onClick={() => setViewMode("grouped")}>
按项目分组
</button>
<button type="button" className={viewMode === "flat" ? "active" : ""} onClick={() => setViewMode("flat")}>
全部任务({tasks.length})
</button>
</div>
)}
{showGrouped ? (
<>
{projectsLoading && projects.length === 0 ? (
<div className="empty-state">
<div className="empty-icon">…</div>
<h3>正在读取项目</h3>
</div>
) : projects.length === 0 ? (
<div className="empty-state">
<div className="empty-icon">▦</div>
<h3>还没有项目</h3>
<p>新建项目后,把任务挂到项目下,即可一次推送到项目总群与各任务群。</p>
<button className="primary-button" onClick={() => openEditor()}>+ 新建项目</button>
</div>
) : (
projects.map((project) => (
<ProjectSection
key={project.id}
project={project}
tasks={tasks.filter((task) => task.project_id === project.id)}
detailVersion={detailVersion}
portalUrl={portalUrl}
setToast={setToast}
onProjectsChanged={loadProjects}
onEditProject={openEditor}
onDeleteProject={setDeleteTarget}
onOpenTaskDetail={setSelectedTaskId}
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
/>
))
)}
<section className="panel ungrouped-panel">
<div className="panel-heading">
<div><h2>未分组任务</h2><p>{ungroupedTasks.length} 个任务未挂项目</p></div>
</div>
{ungroupedTasks.length === 0 ? (
<p className="project-empty-line">所有任务都已挂在项目下</p>
) : (
<div className="task-list">
{ungroupedTasks.map((task) => (
<TaskRowCard
key={task.id}
task={task}
portalUrl={portalUrl}
onDetail={() => setSelectedTaskId(task.id)}
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
/>
))}
</div>
)}
</section>
</>
) : (
<section className="panel table-panel">
<div className="panel-heading">
<div><h2>任务列表</h2><p>{tasks.length} 个任务</p></div>
</div>
<div className="task-list">
{tasks.map((task) => (
<TaskRowCard
key={task.id}
task={task}
portalUrl={portalUrl}
onDetail={() => setSelectedTaskId(task.id)}
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
/>
))}
</div>
</section>
)}
{editorOpen && (
<ProjectEditorModal
draft={editorDraft}
saving={editorSaving}
onChange={setEditorDraft}
onClose={() => setEditorOpen(false)}
onSave={() => void submitEditor()}
/>
)}
{deleteTarget && (
<ProjectDeleteModal
target={deleteTarget}
working={deleteWorking}
onClose={() => setDeleteTarget(null)}
onConfirm={() => void confirmDelete()}
/>
)}
</div> </div>
); );
} }

View File

@@ -5123,6 +5123,14 @@ label small {
padding: 18px 20px; padding: 18px 20px;
} }
.ungrouped-panel {
padding: 18px 20px;
}
.project-task-list {
margin-top: 12px;
}
.project-card-head { .project-card-head {
display: flex; display: flex;
align-items: center; align-items: center;