@@ -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 >
< / >
) ;
}