refactor: 项目中心合并进任务中心(导航 8→7,分组/平铺双视图)
manager 默认按项目分组(项目区块含总群成员/任务群关联/推送,末尾未分组任务), 可切换平铺;普通用户仅平铺列表无管理入口。任务卡抽为 TaskRowCard 双视图共用, 项目编辑/删除弹窗上提为受控组件,详情内任务表与区块任务卡去重(单任务推送 改走推送弹层任务下拉)。
This commit is contained in:
@@ -202,7 +202,6 @@ type FeishuPreview = {
|
||||
|
||||
type NavKey =
|
||||
| "overview"
|
||||
| "projects"
|
||||
| "tasks"
|
||||
| "resources"
|
||||
| "partners"
|
||||
@@ -212,7 +211,6 @@ type NavKey =
|
||||
|
||||
const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
|
||||
{ key: "overview", label: "工作台", mark: "⌂" },
|
||||
{ key: "projects", label: "项目中心", mark: "▦" },
|
||||
{ key: "tasks", label: "任务中心", mark: "▤" },
|
||||
{ key: "resources", label: "KOC资源", mark: "◎" },
|
||||
{ key: "partners", label: "合作方", mark: "Ⓒ" },
|
||||
@@ -559,7 +557,6 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
const visibleNavItems = NAV_ITEMS.filter((item) => {
|
||||
if (item.key === "resources" && currentUser.role === "user") return false;
|
||||
if (item.key === "users" && !isManager) return false;
|
||||
if (item.key === "projects" && !isManager) return false;
|
||||
if (item.key === "reports" && !isManager) return false;
|
||||
return true;
|
||||
});
|
||||
@@ -668,7 +665,6 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
const navigate = (key: NavKey) => {
|
||||
if (key === "resources" && currentUser.role === "user") return;
|
||||
if (key === "users" && !isManager) return;
|
||||
if (key === "projects" && !isManager) return;
|
||||
if (key === "reports" && !isManager) return;
|
||||
setActiveNav(key);
|
||||
setMenuOpen(false);
|
||||
@@ -1029,13 +1025,9 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
title: "分发工作台",
|
||||
subtitle: "用最少动作,跑完内容领取、发布与数据回收。",
|
||||
},
|
||||
projects: {
|
||||
title: "项目中心",
|
||||
subtitle: "按项目组织任务群与总群成员,一键把任务分发到项目各群。",
|
||||
},
|
||||
tasks: {
|
||||
title: "任务中心",
|
||||
subtitle: "从飞书同步内容、开放领取,并按任务跟进每篇笔记的分发进度。",
|
||||
subtitle: "按项目组织任务群与总群成员,从飞书同步内容、开放领取并跟进分发进度。",
|
||||
},
|
||||
resources: {
|
||||
title: "KOC资源",
|
||||
@@ -1169,21 +1161,16 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
showResources={currentUser.role !== "user"}
|
||||
/>
|
||||
)}
|
||||
{activeNav === "projects" && isManager && (
|
||||
<ProjectsPage
|
||||
portalUrl={data.portal_url}
|
||||
setToast={setToast}
|
||||
onTasksChanged={loadData}
|
||||
onCreateTask={() => setTaskModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{activeNav === "tasks" && (
|
||||
<TasksPage
|
||||
tasks={data.tasks}
|
||||
distributions={data.distributions}
|
||||
portalUrl={data.portal_url}
|
||||
canRelease={isManager}
|
||||
isManager={isManager}
|
||||
working={working}
|
||||
setToast={setToast}
|
||||
onTasksChanged={loadData}
|
||||
onCreate={() => setTaskModalOpen(true)}
|
||||
onCopyShare={copyTaskShareLink}
|
||||
onPushToGroups={(task) => setGroupPushTask(task)}
|
||||
@@ -1461,7 +1448,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>挂在项目下的任务可通过「项目中心」直接推送到项目总群与任务群。</small>
|
||||
<small>挂在项目下的任务可在「任务中心」的项目分组中直接推送到项目总群与任务群。</small>
|
||||
</label>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="ghost-button" onClick={() => setTaskModalOpen(false)}>取消</button>
|
||||
@@ -2156,7 +2143,7 @@ function PartnersPage({
|
||||
</div>
|
||||
<p className="project-chat-hint">
|
||||
userid 在企业微信管理后台「通讯录」成员详情中查看。群主合作方是内部员工,
|
||||
用于在项目中心把任务群映射到负责人。
|
||||
用于在任务中心的项目分组中把任务群映射到负责人。
|
||||
</p>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="ghost-button" onClick={() => setCreateOpen(false)} disabled={createSaving}>取消</button>
|
||||
@@ -2539,42 +2526,7 @@ type GroupOwnerOption = {
|
||||
wecom_userid: string;
|
||||
};
|
||||
|
||||
function ProjectsPage({
|
||||
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) => {
|
||||
async function postAction<T>(payload: Record<string, unknown>, fallback: string) {
|
||||
const response = await fetch("/api/action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -2583,122 +2535,78 @@ function ProjectsPage({
|
||||
const parsed = await readApiResponse<T & { error?: string }>(response, fallback);
|
||||
if (!response.ok) throw new Error(parsed.error || fallback);
|
||||
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]);
|
||||
type ProjectEditorDraft = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
members: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void loadProjects();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loadProjects]);
|
||||
const EMPTY_PROJECT_DRAFT: ProjectEditorDraft = { id: "", name: "", brand: "", members: "", note: "" };
|
||||
|
||||
const refreshDetail = useCallback(
|
||||
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 {
|
||||
const parsed = await postAction<ProjectDetailData>(
|
||||
{ action: "project_detail", projectId },
|
||||
{ action: "project_detail", projectId: project.id },
|
||||
"读取项目详情失败",
|
||||
);
|
||||
setDetail(parsed);
|
||||
} catch (reason) {
|
||||
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 {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
}, [project.id, setToast]);
|
||||
|
||||
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();
|
||||
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);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
void loadDetail();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [expanded, detailVersion, loadDetail]);
|
||||
|
||||
const openChatManager = async () => {
|
||||
if (!detail) return;
|
||||
@@ -2758,7 +2666,7 @@ function ProjectsPage({
|
||||
setDetail(parsed);
|
||||
setChatManaging(false);
|
||||
setToast("项目任务群已更新");
|
||||
await loadProjects();
|
||||
await onProjectsChanged();
|
||||
} catch (reason) {
|
||||
setToast(reason instanceof Error ? reason.message : "保存任务群关联失败");
|
||||
} finally {
|
||||
@@ -2767,34 +2675,7 @@ function ProjectsPage({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<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}>
|
||||
<section className="panel project-panel">
|
||||
<div className="project-card-head">
|
||||
<div className="project-card-title">
|
||||
<div className={`avatar large ${avatarColor(project.name)}`}>
|
||||
@@ -2808,15 +2689,34 @@ function ProjectsPage({
|
||||
</div>
|
||||
</div>
|
||||
<div className="project-card-actions">
|
||||
<button className="share-link-button" onClick={() => void toggleDetail(project)}>
|
||||
{expandedId === project.id ? "收起详情" : "查看详情"}
|
||||
<button className="share-link-button" onClick={() => setExpanded((previous) => !previous)}>
|
||||
{expanded ? "收起详情" : "查看详情"}
|
||||
</button>
|
||||
<button className="share-link-button" onClick={() => openEditor(project)}>编辑</button>
|
||||
<button className="share-link-button danger" onClick={() => setDeleteTarget(project)}>删除</button>
|
||||
<button className="share-link-button" onClick={() => onEditProject(project)}>编辑</button>
|
||||
<button className="share-link-button danger" onClick={() => onDeleteProject(project)}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
{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">
|
||||
{detailLoading || !detail || detail.project.id !== project.id ? (
|
||||
<div className="wecom-picker-empty">正在读取项目详情…</div>
|
||||
@@ -2864,39 +2764,6 @@ function ProjectsPage({
|
||||
)}
|
||||
</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">
|
||||
<button
|
||||
className="primary-button"
|
||||
@@ -2905,97 +2772,16 @@ function ProjectsPage({
|
||||
>
|
||||
推送任务到项目群
|
||||
</button>
|
||||
{detail.tasks.length === 0 && <small>项目下没有可推送的任务</small>}
|
||||
{detail.tasks.length === 0 ? (
|
||||
<small>项目下没有可推送的任务</small>
|
||||
) : (
|
||||
<small>可在推送弹层中选择项目下的具体任务</small>
|
||||
)}
|
||||
</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 && (
|
||||
<div className="modal-backdrop" role="presentation" onMouseDown={() => setChatManaging(false)}>
|
||||
@@ -3078,6 +2864,114 @@ function ProjectsPage({
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -4013,64 +3907,24 @@ function OverviewPage({
|
||||
);
|
||||
}
|
||||
|
||||
function TasksPage({
|
||||
tasks,
|
||||
distributions,
|
||||
function TaskRowCard({
|
||||
task,
|
||||
portalUrl,
|
||||
canRelease,
|
||||
working,
|
||||
onCreate,
|
||||
onDetail,
|
||||
onCopyShare,
|
||||
onPushToGroups,
|
||||
onOneClickPush,
|
||||
onRelease,
|
||||
}: {
|
||||
tasks: Task[];
|
||||
distributions: Distribution[];
|
||||
task: Task;
|
||||
portalUrl: string;
|
||||
canRelease: boolean;
|
||||
working: boolean;
|
||||
onCreate: () => void;
|
||||
onDetail: () => 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">
|
||||
<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);
|
||||
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="task-row-main">
|
||||
<div><h3>{task.name}</h3><span>{task.brand}</span></div>
|
||||
@@ -4093,7 +3947,7 @@ function TasksPage({
|
||||
<div className="task-share-actions">
|
||||
<button
|
||||
className="share-link-button"
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
onClick={onDetail}
|
||||
>
|
||||
分发明细
|
||||
</button>
|
||||
@@ -4131,9 +3985,267 @@ function TasksPage({
|
||||
</div>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5123,6 +5123,14 @@ label small {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.ungrouped-panel {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.project-task-list {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.project-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user