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 && ( +
+ 原始资料: + {materials.map((m, i) => 资料 {i + 1})} +
+ )} + {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="返工要求:说明需要修改的地方" /> + + + ); +}