refactor: 项目中心合并进任务中心(导航 8→7,分组/平铺双视图)
manager 默认按项目分组(项目区块含总群成员/任务群关联/推送,末尾未分组任务), 可切换平铺;普通用户仅平铺列表无管理入口。任务卡抽为 TaskRowCard 双视图共用, 项目编辑/删除弹窗上提为受控组件,详情内任务表与区块任务卡去重(单任务推送 改走推送弹层任务下拉)。
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user