diff --git a/console/src/layouts/registry/builtinRoutes.tsx b/console/src/layouts/registry/builtinRoutes.tsx
index 6ff8e43..539cb04 100644
--- a/console/src/layouts/registry/builtinRoutes.tsx
+++ b/console/src/layouts/registry/builtinRoutes.tsx
@@ -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"] },
diff --git a/console/src/pages/portal/Opc/HallTasks.tsx b/console/src/pages/portal/Opc/HallTasks.tsx
index 11119ef..5659f9c 100644
--- a/console/src/pages/portal/Opc/HallTasks.tsx
+++ b/console/src/pages/portal/Opc/HallTasks.tsx
@@ -139,7 +139,7 @@ export function HallTasksView() {
{items.map((task) => {
const mode = MODE_TAG[task.mode ?? ""] ?? { text: task.mode ?? "", color: "default" };
return (
-
openDetail(task.id)} style={{ cursor: "pointer" }}>
+
navigate(`/opc/hall/tasks/${task.id}`)} style={{ cursor: "pointer" }}>
{task.title}
@@ -171,6 +171,7 @@ export function HallTasksView() {
onClose={() => setDetail(null)}
title={detail?.title}
width={480}
+ extra={
}
>
{detail && (
<>
diff --git a/console/src/pages/portal/Opc/MyTasks.tsx b/console/src/pages/portal/Opc/MyTasks.tsx
index da8e30a..78a9119 100644
--- a/console/src/pages/portal/Opc/MyTasks.tsx
+++ b/console/src/pages/portal/Opc/MyTasks.tsx
@@ -71,6 +71,7 @@ function OpcMyTasksBody() {
nav(`/opc/hall/tasks/${task.id}`)}>详情,
task.claimStatus === "claimed" && ,
task.claimStatus === "claimed" && act(task.id, () => hallApi.withdrawTask(task.id), "已退出任务")}>
diff --git a/console/src/pages/portal/Opc/TaskDetailPage.tsx b/console/src/pages/portal/Opc/TaskDetailPage.tsx
new file mode 100644
index 0000000..45dff95
--- /dev/null
+++ b/console/src/pages/portal/Opc/TaskDetailPage.tsx
@@ -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 = {
+ grab: { text: "抢单", color: "orange" },
+ bid: { text: "投标", color: "blue" },
+ register: { text: "报名", color: "purple" },
+ assign: { text: "指派", color: "geekblue" },
+ recommend: { text: "推荐", color: "cyan" },
+};
+const STATUS_TAG: Record = {
+ 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 = {
+ publish: "blue", claim: "orange", doing: "green", deliver: "cyan",
+ accept: "green", reject: "red", settle: "purple", invoice: "gold", cancel: "red",
+};
+const PRICE: Record = { fixed: "一口价", hourly: "按小时", unit: "按件", revshare: "分成" };
+const PAY: Record = { 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 (
+
+
+
+ );
+}
+
+function TaskDetailBody() {
+ const { id } = useParams<{ id: string }>();
+ const nav = useNavigate();
+ const [task, setTask] = useState(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, 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 ;
+ }
+
+ 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 = 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 (
+ <>
+
+
+
{task.title}
+ {STATUS_TAG[status]?.text ?? status}
+ {MODE_TAG[task.mode ?? ""]?.text ?? task.mode}
+
+
+ {/* 阶段进度 */}
+
+ {status === "cancelled" ? (
+ 任务已取消,流程终止
+ ) : (
+
+ )}
+
+
+
+ {/* 左列:任务信息 */}
+
+
+
+ {formatBudget(task)}
+ {PRICE[task.price_type ?? "fixed"] ?? task.price_type}
+ {PAY[task.pay_type ?? "escrow"] ?? task.pay_type}{task.escrow_ratio ? `(托管 ${task.escrow_ratio}%)` : ""}
+ {task.delivery_days ? `${task.delivery_days} 天` : "—"}
+ {task.deadline || "长期"}
+ {task.category || "综合"}
+ {task.publisher_name || "平台"}
+ {claimer?.name || "—"}
+ {task.reject_count ?? 0}
+ {task.settle_status === "settled" ? `已结算${task.settled_at ? `(${task.settled_at.slice(0, 16).replace("T", " ")})` : ""}` : (task.settle_status || "未结算")}
+
+ {task.description && {task.description}
}
+ {Object.keys(support).length > 0 && (
+
+
提供的支持:
+ {Object.entries(support).map(([k, v]) =>
· {k}:{v}
)}
+
+ )}
+ {materials.length > 0 && (
+
+ )}
+ {items.length > 0 && (
+
+ 交付内容:
+ {items.join("、")}
+
+ )}
+ {task.accept_standard && (
+
+ 验收标准:
+ {task.accept_standard}
+
+ )}
+
+
+ {/* 里程碑 */}
+ {milestones.length > 0 && (
+
+ {milestones.map((m, i) => (
+
+
0 ? "geekblue" : "default"}>M{i + 1}
+
+
{m.name}
+ {m.desc &&
{m.desc}
}
+
{m.days ? `${m.days} 天` : ""}{m.payment_ratio ? ` · 付款 ${Math.round(m.payment_ratio * 100)}%` : ""}
+
+
+ ))}
+
+ )}
+
+ {/* 交付版本 */}
+ {deliveries.length > 0 && (
+
+ {deliveries.map((d) => (
+
+
v{d.version}
+ {d.note &&
{d.note}}
+
+ {d.created_at ? d.created_at.slice(0, 16).replace("T", " ") : ""}
+ {d.milestone_id ? " · 里程碑交付" : " · 整体交付"}
+
+
+ ))}
+
+ )}
+
+ {/* 验收记录 */}
+ {acceptances.length > 0 && (
+
+ {acceptances.map((a) => (
+
+
{a.result === "pass" ? "验收通过" : "退回返工"}
+ {a.comment &&
{a.comment}}
+
{a.created_at ? a.created_at.slice(0, 16).replace("T", " ") : ""}
+
+ ))}
+
+ )}
+
+
+ {/* 右列:流转时间线 + 操作 + 沟通 */}
+
+ {/* 操作区 */}
+
+
+ {myRole === "claimer" && status === "claimed" && (
+ <>
+
+
act(() => hallApi.withdrawTask(task.id), "已退出任务")}>
+
+
+ >
+ )}
+ {myRole === "claimer" && status === "doing" && (
+
+ )}
+ {myRole === "claimer" && status === "delivered" && (
+
已交付,等待发包方验收
+ )}
+ {myRole === "publisher" && status === "delivered" && (
+ <>
+
+
+ {task.settle_status !== "settled" && (
+
+ )}
+ >
+ )}
+ {myRole === "publisher" && status === "doing" && (
+
履约中,等待接单方交付
+ )}
+ {myRole === "publisher" && ["published", "claimed"].includes(status) && (
+
act(() => hallApi.taskCancel(task.id), "任务已取消")}>
+
+
+ )}
+ {myRole === "viewer" && ["published", "claimed", "doing"].includes(status) && (
+ <>
+ {status === "published" && task.mode === "grab" && (
+
+ )}
+ {status === "published" && task.mode === "register" && (
+
+ )}
+ {status === "published" && task.mode === "bid" && (
+
请在任务大厅投标
+ )}
+ >
+ )}
+ {task.can_accept === false && Array.isArray(task.accept_reasons) && (
+
+ 条件不符:{task.accept_reasons.join(";")}
+
+ )}
+
+
+
+ {/* 沟通区 */}
+
+
+
+ {participants?.publisher?.id && participants.publisher.id !== (task as { myUserId?: string }).myUserId && (
+
+ )}
+ {claimer?.id && (
+
+ )}
+
+
+
+ {/* 流转时间线 */}
+
+ {timeline.length === 0 ? : (
+ ({
+ color: TL_COLOR[e.type] ?? "gray",
+ children: (
+
+
{e.text}
+
+ {e.actor} · {e.time ? e.time.slice(0, 16).replace("T", " ") : ""}
+
+
+ ),
+ }))}
+ />
+ )}
+
+
+
+
+ {/* 交付弹窗 */}
+ setDeliverOpen(false)}
+ onOk={() => { act(() => hallApi.deliverTask(task.id, { note }), "已交付,等待发包方验收"); setDeliverOpen(false); }}
+ okText="确认交付">
+ setNote(e.target.value)}
+ placeholder="交付说明:交付了哪些内容、如何验收(附件链接可写在说明中)" />
+
+ {/* 返工弹窗 */}
+ setRejectOpen(false)}
+ onOk={() => { act(() => hallApi.rejectTask(task.id, rejectNote || "不符合验收标准"), "已退回返工"); setRejectOpen(false); }}
+ okText="确认退回">
+ setRejectNote(e.target.value)}
+ placeholder="返工要求:说明需要修改的地方" />
+
+ >
+ );
+}