任务完整详情页:阶段进度/流转时间线/里程碑/交付版本/验收/结算/沟通,路由接入

This commit is contained in:
Pine
2026-09-05 09:25:05 +08:00
parent 686fee9c69
commit 0ce33b012f
4 changed files with 335 additions and 1 deletions
@@ -75,6 +75,7 @@ const OpcHallTalentsPage = lazyImportWithRetry("../../pages/portal/Opc/HallTalen
const OpcHallJobsPage = lazyImportWithRetry("../../pages/portal/Opc/HallJobs.tsx");
const OpcHallServicesPage = lazyImportWithRetry("../../pages/portal/Opc/HallServices.tsx");
const OpcMyTasksPage = lazyImportWithRetry("../../pages/portal/Opc/MyTasks.tsx");
const OpcTaskDetailPage = lazyImportWithRetry("../../pages/portal/Opc/TaskDetailPage.tsx");
const OpcServiceMarketPage = lazyImportWithRetry("../../pages/portal/Opc/ServicesMine.tsx");
const OpcJobsMinePage = lazyImportWithRetry("../../pages/portal/Opc/JobsMine.tsx");
const OpcTalentPage = lazyImportWithRetry("../../pages/portal/Opc/Talent.tsx");
@@ -278,6 +279,7 @@ export const BUILTIN_ROUTES: Route[] = [
{ id: "core.opc.agent", path: "/opc/agent", component: OpcAgentChatPage, roles: ["opc_member"] },
{ id: "core.opc.tasks", path: "/opc/tasks", component: OpcHallTasksPage, roles: ["opc_member"] },
{ id: "core.opc.hall", path: "/opc/hall", component: OpcHallPage, roles: ["opc_member"] },
{ id: "core.opc.hall.task", path: "/opc/hall/tasks/:id", component: OpcTaskDetailPage, roles: ["opc_member"] },
{ id: "core.opc.hall.talents", path: "/opc/hall/talents", component: OpcHallTalentsPage, roles: ["opc_member"] },
{ id: "core.opc.hall.jobs", path: "/opc/hall/jobs", component: OpcHallJobsPage, roles: ["opc_member"] },
{ id: "core.opc.hall.services", path: "/opc/hall/services", component: OpcHallServicesPage, roles: ["opc_member"] },
+2 -1
View File
@@ -139,7 +139,7 @@ export function HallTasksView() {
{items.map((task) => {
const mode = MODE_TAG[task.mode ?? ""] ?? { text: task.mode ?? "", color: "default" };
return (
<div className={styles.taskCard} key={task.id} onClick={() => openDetail(task.id)} style={{ cursor: "pointer" }}>
<div className={styles.taskCard} key={task.id} onClick={() => navigate(`/opc/hall/tasks/${task.id}`)} style={{ cursor: "pointer" }}>
<div className={styles.taskHeader}>
<div className={styles.taskTitleRow}>
<div className={styles.taskTitle}>{task.title}</div>
@@ -171,6 +171,7 @@ export function HallTasksView() {
onClose={() => setDetail(null)}
title={detail?.title}
width={480}
extra={<Button size="small" type="link" onClick={() => { const id = detail?.id; setDetail(null); if (id) navigate(`/opc/hall/tasks/${id}`); }}> </Button>}
>
{detail && (
<>
+1
View File
@@ -71,6 +71,7 @@ function OpcMyTasksBody() {
<List.Item
className={styles.item}
actions={[
<Button key="v" size="small" onClick={() => nav(`/opc/hall/tasks/${task.id}`)}></Button>,
task.claimStatus === "claimed" && <Button key="s" size="small" type="primary" onClick={() => act(task.id, () => hallApi.doingTask(task.id), "已开始做单")}></Button>,
task.claimStatus === "claimed" && <Popconfirm key="w" title="退出后任务将重新开放,确认退出?" onConfirm={() => act(task.id, () => hallApi.withdrawTask(task.id), "已退出任务")}>
<Button size="small" danger>退</Button>
@@ -0,0 +1,330 @@
// 任务完整详情页:任务信息 / 阶段进度 / 流转时间线 / 里程碑 / 交付版本 / 验收记录 / 结算发票 / 沟通与操作
import { useCallback, useEffect, useState } from "react";
import {
Button, Card, Descriptions, Empty, Input, Modal, Popconfirm, Segmented, Spin, Steps, Tag, Timeline, message,
} from "antd";
import { useNavigate, useParams } from "react-router-dom";
import { hallApi, type HallTask } from "../../../api/modules/hall";
import { RequireRole } from "../../../auth/guards";
import { useStartChat } from "../../../hooks/useStartChat";
import { useCommunicationStore } from "../../../stores/communicationStore";
import styles from "./OpcList.module.less";
const MODE_TAG: Record<string, { text: string; color: string }> = {
grab: { text: "抢单", color: "orange" },
bid: { text: "投标", color: "blue" },
register: { text: "报名", color: "purple" },
assign: { text: "指派", color: "geekblue" },
recommend: { text: "推荐", color: "cyan" },
};
const STATUS_TAG: Record<string, { text: string; color: string }> = {
draft: { text: "草稿", color: "default" },
pending: { text: "待审核", color: "gold" },
review: { text: "审核中", color: "gold" },
published: { text: "招募中", color: "green" },
claimed: { text: "已接单", color: "orange" },
doing: { text: "履约中", color: "processing" },
delivered: { text: "待验收", color: "blue" },
completed: { text: "已完成", color: "success" },
cancelled: { text: "已取消", color: "default" },
};
const TL_COLOR: Record<string, string> = {
publish: "blue", claim: "orange", doing: "green", deliver: "cyan",
accept: "green", reject: "red", settle: "purple", invoice: "gold", cancel: "red",
};
const PRICE: Record<string, string> = { fixed: "一口价", hourly: "按小时", unit: "按件", revshare: "分成" };
const PAY: Record<string, string> = { escrow: "平台托管", stage: "阶段托管", offline: "线下支付" };
function formatBudget(t: HallTask): string {
const a = t.budget_min ?? 0, b = t.budget_max ?? 0;
if (!a && !b) return "面议";
if (a && b && a !== b) return `¥${a} - ¥${b}`;
return `¥${a || b}`;
}
export default function TaskDetailPage() {
return (
<RequireRole roles={["opc_member"]}>
<div className={styles.page}><TaskDetailBody /></div>
</RequireRole>
);
}
function TaskDetailBody() {
const { id } = useParams<{ id: string }>();
const nav = useNavigate();
const [task, setTask] = useState<HallTask | null>(null);
const [busy, setBusy] = useState(false);
const [note, setNote] = useState("");
const [deliverOpen, setDeliverOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectNote, setRejectNote] = useState("");
const startChat = useStartChat();
const openTaskGroup = useCommunicationStore((s) => s.openTaskGroup);
const load = useCallback(() => {
if (!id) return;
hallApi.taskDetail(id).then(setTask).catch((e) => message.error(e instanceof Error ? e.message : "加载失败"));
}, [id]);
useEffect(load, [load]);
const act = async (fn: () => Promise<unknown>, ok: string) => {
setBusy(true);
try { await fn(); message.success(ok); load(); }
catch (err) { message.error(err instanceof Error ? err.message : "操作失败"); }
finally { setBusy(false); }
};
if (!task) {
return <Spin style={{ display: "block", margin: "80px auto" }} />;
}
const status = task.status ?? "";
const myRole = (task as { myRole?: string }).myRole ?? "viewer";
const timeline = (task as { timeline?: Array<{ time: string; type: string; actor: string; text: string }> }).timeline ?? [];
const milestones = (task as { milestones?: Array<{ id: string; name: string; desc: string; days: number; payment_ratio: number }> }).milestones ?? [];
const deliveries = (task as { deliveries?: Array<{ id: string; version: number; note: string; attachments_json: string; created_at: string; milestone_id: string }> }).deliveries ?? [];
const acceptances = (task as { acceptances?: Array<{ id: string; result: string; comment: string; created_at: string; delivery_id: string }> }).acceptances ?? [];
const invoices = (task as { invoices?: Array<{ id: string; amount: number; status: string; created_at: string; issuer_name: string }> }).invoices ?? [];
const participants = (task as { participants?: { publisher?: { id: string; name: string }; claimer?: { id: string; name: string } | null } }).participants;
const claimer = participants?.claimer ?? null;
const safeParse = (s?: string, fallback: unknown = null) => {
try { return s ? JSON.parse(s) : fallback; } catch { return fallback; }
};
const support: Record<string, string> = safeParse(task.resource_support_json, {});
const materials: string[] = safeParse(task.raw_materials_json, []);
const items: string[] = safeParse(task.deliver_items, []);
const stepIndex = ["published", "claimed", "doing", "delivered", "completed"].indexOf(status);
return (
<>
<Button type="text" style={{ marginBottom: 8, padding: 0 }} onClick={() => nav(-1)}> </Button>
<div style={{ display: "flex", gap: 12, alignItems: "center", marginBottom: 12, flexWrap: "wrap" }}>
<h2 style={{ margin: 0 }}>{task.title}</h2>
<Tag color={STATUS_TAG[status]?.color}>{STATUS_TAG[status]?.text ?? status}</Tag>
<Tag color={MODE_TAG[task.mode ?? ""]?.color}>{MODE_TAG[task.mode ?? ""]?.text ?? task.mode}</Tag>
</div>
{/* 阶段进度 */}
<Card size="small" style={{ marginBottom: 12 }}>
{status === "cancelled" ? (
<div style={{ color: "var(--color-error)", padding: "8px 0" }}></div>
) : (
<Steps
size="small"
current={Math.max(stepIndex - 1, 0)}
status={status === "completed" ? "finish" : "process"}
items={[
{ title: "发布", description: task.created_at ? task.created_at.slice(0, 16).replace("T", " ") : "" },
{ title: "接单", description: task.claimed_at ? task.claimed_at.slice(0, 16).replace("T", " ") : "等待接单" },
{ title: "履约", description: task.doing_at ? task.doing_at.slice(0, 16).replace("T", " ") : "—" },
{ title: "交付", description: task.deliver_at ? task.deliver_at.slice(0, 16).replace("T", " ") : "—" },
{ title: "完成", description: task.accepted_at ? task.accepted_at.slice(0, 16).replace("T", " ") : "—" },
]}
/>
)}
</Card>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
{/* 左列:任务信息 */}
<div style={{ flex: "1 1 460px", minWidth: 0 }}>
<Card size="small" title="任务信息" style={{ marginBottom: 12 }}>
<Descriptions column={2} size="small">
<Descriptions.Item label="预算">{formatBudget(task)}</Descriptions.Item>
<Descriptions.Item label="计价">{PRICE[task.price_type ?? "fixed"] ?? task.price_type}</Descriptions.Item>
<Descriptions.Item label="支付">{PAY[task.pay_type ?? "escrow"] ?? task.pay_type}{task.escrow_ratio ? `(托管 ${task.escrow_ratio}%` : ""}</Descriptions.Item>
<Descriptions.Item label="交付周期">{task.delivery_days ? `${task.delivery_days}` : "—"}</Descriptions.Item>
<Descriptions.Item label="截止">{task.deadline || "长期"}</Descriptions.Item>
<Descriptions.Item label="分类">{task.category || "综合"}</Descriptions.Item>
<Descriptions.Item label="发包方">{task.publisher_name || "平台"}</Descriptions.Item>
<Descriptions.Item label="接单方">{claimer?.name || "—"}</Descriptions.Item>
<Descriptions.Item label="返工次数">{task.reject_count ?? 0}</Descriptions.Item>
<Descriptions.Item label="结算状态">{task.settle_status === "settled" ? `已结算${task.settled_at ? `${task.settled_at.slice(0, 16).replace("T", " ")}` : ""}` : (task.settle_status || "未结算")}</Descriptions.Item>
</Descriptions>
{task.description && <p style={{ whiteSpace: "pre-wrap", marginTop: 12 }}>{task.description}</p>}
{Object.keys(support).length > 0 && (
<div style={{ marginTop: 8 }}>
<b style={{ fontSize: 12 }}></b>
{Object.entries(support).map(([k, v]) => <div key={k} style={{ fontSize: 12 }}>· {k}{v}</div>)}
</div>
)}
{materials.length > 0 && (
<div style={{ marginTop: 8 }}>
<b style={{ fontSize: 12 }}></b>
{materials.map((m, i) => <a key={i} href={m} target="_blank" rel="noreferrer" style={{ marginLeft: 8, fontSize: 12 }}> {i + 1}</a>)}
</div>
)}
{items.length > 0 && (
<div style={{ marginTop: 8 }}>
<b style={{ fontSize: 12 }}></b>
<span style={{ fontSize: 12 }}>{items.join("、")}</span>
</div>
)}
{task.accept_standard && (
<div style={{ marginTop: 8 }}>
<b style={{ fontSize: 12 }}></b>
<span style={{ fontSize: 12 }}>{task.accept_standard}</span>
</div>
)}
</Card>
{/* 里程碑 */}
{milestones.length > 0 && (
<Card size="small" title="里程碑" style={{ marginBottom: 12 }}>
{milestones.map((m, i) => (
<div key={m.id} style={{ display: "flex", gap: 12, padding: "6px 0", borderBottom: i < milestones.length - 1 ? "1px solid rgba(0,0,0,0.06)" : "none" }}>
<Tag color={m.payment_ratio > 0 ? "geekblue" : "default"}>M{i + 1}</Tag>
<div>
<b>{m.name}</b>
{m.desc && <div style={{ fontSize: 12 }}>{m.desc}</div>}
<div style={{ fontSize: 12, color: "rgba(0,0,0,0.45)" }}>{m.days ? `${m.days}` : ""}{m.payment_ratio ? ` · 付款 ${Math.round(m.payment_ratio * 100)}%` : ""}</div>
</div>
</div>
))}
</Card>
)}
{/* 交付版本 */}
{deliveries.length > 0 && (
<Card size="small" title={`交付版本(${deliveries.length}`} style={{ marginBottom: 12 }}>
{deliveries.map((d) => (
<div key={d.id} style={{ padding: "6px 0", borderBottom: "1px solid rgba(0,0,0,0.06)" }}>
<Tag color="cyan">v{d.version}</Tag>
{d.note && <span style={{ fontSize: 13 }}>{d.note}</span>}
<div style={{ fontSize: 12, color: "rgba(0,0,0,0.45)" }}>
{d.created_at ? d.created_at.slice(0, 16).replace("T", " ") : ""}
{d.milestone_id ? " · 里程碑交付" : " · 整体交付"}
</div>
</div>
))}
</Card>
)}
{/* 验收记录 */}
{acceptances.length > 0 && (
<Card size="small" title={`验收记录(${acceptances.length}`} style={{ marginBottom: 12 }}>
{acceptances.map((a) => (
<div key={a.id} style={{ padding: "6px 0", borderBottom: "1px solid rgba(0,0,0,0.06)" }}>
<Tag color={a.result === "pass" ? "green" : "red"}>{a.result === "pass" ? "验收通过" : "退回返工"}</Tag>
{a.comment && <span style={{ fontSize: 13 }}>{a.comment}</span>}
<div style={{ fontSize: 12, color: "rgba(0,0,0,0.45)" }}>{a.created_at ? a.created_at.slice(0, 16).replace("T", " ") : ""}</div>
</div>
))}
</Card>
)}
</div>
{/* 右列:流转时间线 + 操作 + 沟通 */}
<div style={{ flex: "1 1 340px", minWidth: 0 }}>
{/* 操作区 */}
<Card size="small" title="我的操作" style={{ marginBottom: 12 }}>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{myRole === "claimer" && status === "claimed" && (
<>
<Button type="primary" loading={busy} onClick={() => act(() => hallApi.doingTask(task.id), "已开始做单")}></Button>
<Popconfirm title="退出后任务将重新开放,确认退出?" onConfirm={() => act(() => hallApi.withdrawTask(task.id), "已退出任务")}>
<Button danger loading={busy}>退</Button>
</Popconfirm>
</>
)}
{myRole === "claimer" && status === "doing" && (
<Button type="primary" loading={busy} onClick={() => setDeliverOpen(true)}></Button>
)}
{myRole === "claimer" && status === "delivered" && (
<span style={{ color: "rgba(0,0,0,0.45)" }}></span>
)}
{myRole === "publisher" && status === "delivered" && (
<>
<Button type="primary" loading={busy} onClick={() => act(() => hallApi.acceptTask(task.id, { comment: "验收通过" }), "验收通过,任务完成")}></Button>
<Button danger loading={busy} onClick={() => { setRejectNote(""); setRejectOpen(true); }}>退</Button>
{task.settle_status !== "settled" && (
<Button loading={busy} onClick={() => act(() => hallApi.taskSettle(task.id), "已发起结算")}></Button>
)}
</>
)}
{myRole === "publisher" && status === "doing" && (
<span style={{ color: "rgba(0,0,0,0.45)" }}></span>
)}
{myRole === "publisher" && ["published", "claimed"].includes(status) && (
<Popconfirm title="确认取消任务?" onConfirm={() => act(() => hallApi.taskCancel(task.id), "任务已取消")}>
<Button danger loading={busy}></Button>
</Popconfirm>
)}
{myRole === "viewer" && ["published", "claimed", "doing"].includes(status) && (
<>
{status === "published" && task.mode === "grab" && (
<Button type="primary" loading={busy} onClick={() => act(() => hallApi.grabTask(task.id), "抢单成功")}></Button>
)}
{status === "published" && task.mode === "register" && (
<Button type="primary" loading={busy} disabled={!!task.myClaim}
onClick={() => act(() => hallApi.registerTask(task.id), "报名成功")}>
{task.myClaim ? "已报名" : "立即报名"}
</Button>
)}
{status === "published" && task.mode === "bid" && (
<span style={{ color: "rgba(0,0,0,0.45)" }}></span>
)}
</>
)}
{task.can_accept === false && Array.isArray(task.accept_reasons) && (
<div style={{ width: "100%", color: "var(--color-warning)", fontSize: 12 }}>
{task.accept_reasons.join("")}
</div>
)}
</div>
</Card>
{/* 沟通区 */}
<Card size="small" title="沟通" style={{ marginBottom: 12 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Button onClick={() => void (async () => {
const conv = await openTaskGroup(task.id, task.title);
if (conv) nav("/communication");
})()}></Button>
{participants?.publisher?.id && participants.publisher.id !== (task as { myUserId?: string }).myUserId && (
<Button onClick={() => void startChat(participants.publisher.id)}>{participants.publisher.name}</Button>
)}
{claimer?.id && (
<Button onClick={() => void startChat(claimer.id)}>{claimer.name}</Button>
)}
</div>
</Card>
{/* 流转时间线 */}
<Card size="small" title="阶段流转">
{timeline.length === 0 ? <Empty description="暂无流转记录" /> : (
<Timeline
items={timeline.map((e) => ({
color: TL_COLOR[e.type] ?? "gray",
children: (
<div>
<div style={{ fontSize: 13 }}>{e.text}</div>
<div style={{ fontSize: 12, color: "rgba(0,0,0,0.45)" }}>
{e.actor} · {e.time ? e.time.slice(0, 16).replace("T", " ") : ""}
</div>
</div>
),
}))}
/>
)}
</Card>
</div>
</div>
{/* 交付弹窗 */}
<Modal title="交付成果" open={deliverOpen} onCancel={() => setDeliverOpen(false)}
onOk={() => { act(() => hallApi.deliverTask(task.id, { note }), "已交付,等待发包方验收"); setDeliverOpen(false); }}
okText="确认交付">
<Input.TextArea rows={4} value={note} onChange={(e) => setNote(e.target.value)}
placeholder="交付说明:交付了哪些内容、如何验收(附件链接可写在说明中)" />
</Modal>
{/* 返工弹窗 */}
<Modal title="退回返工" open={rejectOpen} onCancel={() => setRejectOpen(false)}
onOk={() => { act(() => hallApi.rejectTask(task.id, rejectNote || "不符合验收标准"), "已退回返工"); setRejectOpen(false); }}
okText="确认退回">
<Input.TextArea rows={3} value={rejectNote} onChange={(e) => setRejectNote(e.target.value)}
placeholder="返工要求:说明需要修改的地方" />
</Modal>
</>
);
}