diff --git a/website/src/App.jsx b/website/src/App.jsx index 45b5ddd..692ced8 100644 --- a/website/src/App.jsx +++ b/website/src/App.jsx @@ -32,6 +32,7 @@ import ParkTransfer from './user/ParkTransfer'; import Content from './user/Content'; import ContentDetail from './user/ContentDetail'; import Tasks from './user/Tasks'; +import TaskDetail from './user/TaskDetail'; /* 公开路由(无需登录) */ const PUBLIC_ROUTES = [ @@ -48,6 +49,7 @@ const PUBLIC_ROUTES = [ { path: 'content', Page: Content }, { path: 'content-detail', Page: ContentDetail }, { path: 'tasks', Page: Tasks }, + { path: 'task-detail', Page: TaskDetail }, { path: 'profile', Page: Profile } ]; diff --git a/website/src/services/tasks.js b/website/src/services/tasks.js index 0d0f981..2ed194e 100644 --- a/website/src/services/tasks.js +++ b/website/src/services/tasks.js @@ -8,10 +8,10 @@ function authHeaders() { return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) }; } -/** 任务广场(已发布待接单任务) */ +/** 任务广场(已发布待接单任务,公开可见,无需登录) */ export async function listTasks() { try { - const res = await fetch(`${API_BASE}/opc/tasks`, { headers: authHeaders() }); + const res = await fetch(`${API_BASE}/opc/tasks`); if (!res.ok) return { ok: false, status: res.status }; const d = await res.json().catch(() => ({})); return { ok: true, items: Array.isArray(d.items) ? d.items : [] }; @@ -20,6 +20,17 @@ export async function listTasks() { } } +/** 任务详情(公开可见) */ +export async function getTask(id) { + try { + const res = await fetch(`${API_BASE}/opc/tasks/${encodeURIComponent(id)}`); + if (!res.ok) return null; + return await res.json().catch(() => null); + } catch { + return null; + } +} + /** 我的任务看板 */ export async function myTasks() { try { diff --git a/website/src/styles/tasks.css b/website/src/styles/tasks.css index 9965be2..6772ca2 100644 --- a/website/src/styles/tasks.css +++ b/website/src/styles/tasks.css @@ -30,3 +30,27 @@ .tk-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 16px; } .tk-code { font-family: var(--font-mono); font-size: 12px; color: var(--muted); } @media (max-width: 720px) { .tk-grid { grid-template-columns: 1fr; } } + +/* ---------- 任务详情 ---------- */ +.tk-back { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 18px; color: var(--muted); font-size: 13.5px; } +.tk-back:hover { color: var(--heading); } +.tk-detail { display: grid; grid-template-columns: 1fr 320px; gap: 24px; align-items: start; } +.tk-detail-head { grid-column: 1 / -1; } +.tk-cat-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; } +.tk-status { font-size: 11px; font-weight: 700; color: var(--success); background: var(--success-soft); border-radius: 999px; padding: 3px 10px; } +.tk-detail-title { font-size: clamp(26px, 4vw, 42px); font-weight: 500; color: var(--heading); letter-spacing: -.03em; line-height: 1.15; } +.tk-detail-head .tk-code { display: inline-block; margin-top: 10px; } +.tk-detail-main { background: var(--surface); border: 1px solid var(--border); border-radius: 24px; padding: 26px; box-shadow: var(--shadow-sm-border); } +.tk-sec { font-size: 15px; font-weight: 700; color: var(--heading); margin-bottom: 12px; } +.tk-full-desc { font-size: 15px; color: var(--text); line-height: 1.8; white-space: pre-wrap; } +.tk-detail-main .tk-tags { margin-top: 16px; } +.tk-detail-side { background: var(--surface); border: 1px solid var(--border); border-radius: 24px; padding: 22px; box-shadow: var(--shadow-sm-border); position: sticky; top: 90px; } +.tk-facts { display: grid; gap: 12px; } +.tk-fact { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } +.tk-fact dt { font-size: 12.5px; color: var(--muted); } +.tk-fact dd { font-size: 14px; color: var(--heading); font-weight: 600; text-align: right; } +.tk-fact dd.money { color: var(--primary); } +.tk-detail-actions { margin-top: 20px; display: flex; flex-direction: column; gap: 10px; } +.tk-note { font-size: 12.5px; color: var(--muted); } +.tk-note a { color: var(--primary); } +@media (max-width: 900px) { .tk-detail { grid-template-columns: 1fr; } .tk-detail-side { position: static; } } diff --git a/website/src/user/TaskDetail.jsx b/website/src/user/TaskDetail.jsx new file mode 100644 index 0000000..b0e8b1c --- /dev/null +++ b/website/src/user/TaskDetail.jsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { ContentTemplate } from '@/components/templates'; +import { Button, Icon } from '@/components/atoms'; +import { isAuthed } from '@/services/auth'; +import { getTask, grabTask } from '@/services/tasks'; +import '@/styles/tasks.css'; + +const MODE = { grab: '抢单', bid: '投标', assign: '指派', recommend: '推荐' }; +const money = (a, b) => (a || b) ? (a && b && a !== b ? `¥${a}–${b}` : `¥${a || b}`) : '面议'; +const getId = () => new URLSearchParams((window.location.hash.split('?')[1] || '')).get('id'); + +export default function TaskDetail() { + const [task, setTask] = React.useState(null); + const [err, setErr] = React.useState(''); + const [loaded, setLoaded] = React.useState(false); + const [busy, setBusy] = React.useState(false); + + React.useEffect(() => { + getId() && getTask(getId()).then((t) => { setTask(t || null); setLoaded(true); }); + }, []); + + const onGrab = async () => { + if (!isAuthed()) { setErr('抢单需先登录(OPC 端身份)。'); return; } + setBusy(true); setErr(''); + const r = await grabTask(task.id); + setBusy(false); + if (r.ok) setErr('抢单成功,可在「我的接单」查看。'); + else setErr(r.error || '抢单失败'); + }; + + if (!loaded) return

加载中…

; + if (!task) return

任务不存在或已下线。

; + + return ( + +
+ 返回任务广场 +
+
+
+ {task.category || '综合'} + {MODE[task.mode] || '抢单'} + {task.exclusive && 独占} + {task.status === 'published' ? '待接单' : task.status} +
+

{task.title}

+ {task.task_code && 短码 {task.task_code}} +
+ +
+
+

任务说明

+

{task.description}

+ {task.tags &&
{String(task.tags).split(',').filter(Boolean).map((x) => {x})}
} +
+ +
+
+
+
+ ); +} diff --git a/website/src/user/Tasks.jsx b/website/src/user/Tasks.jsx index f2f557c..53a4ca3 100644 --- a/website/src/user/Tasks.jsx +++ b/website/src/user/Tasks.jsx @@ -13,9 +13,9 @@ const money = (a, b) => { return `¥${a || b}`; }; -function TaskCard({ t, onGrab, busy }) { +function TaskCard({ t, onGrab, busy, mine }) { return ( -
+
{t.category || '综合'} {MODE[t.mode] || '抢单'} @@ -28,13 +28,14 @@ function TaskCard({ t, onGrab, busy }) { {money(t.budget_min, t.budget_max)} {t.deadline && {t.deadline}} {t.delivery_days ? {t.delivery_days} 天交付 : null} - {t.headcount ? {t.headcount} 人 : null}
- + {t.task_code}
-
+ ); } @@ -54,20 +55,22 @@ export default function Tasks() { if (which === 'pending') { const r = await listTasks(); setPending(r.ok ? r.items : []); - if (!r.ok) setErr(r.status === 403 ? '需以 OPC 端登录后查看任务广场。' : r.status === 401 ? '请先登录。' : '暂时无法加载任务广场。'); + if (!r.ok) setErr('暂时无法加载任务广场。'); } else { + if (!isAuthed()) { setLoad((l) => ({ ...l, mine: false })); return; } const r = await myTasks(); setMine(r.ok ? r.items : []); - if (!r.ok) setErr(r.status === 403 ? '需以 OPC 端登录后查看我的接单。' : '暂时无法加载。'); + if (!r.ok) setErr('暂时无法加载。'); } } finally { setLoad((l) => ({ ...l, [which]: false })); } }; - React.useEffect(() => { if (authed) refresh('pending'); }, [authed]); + React.useEffect(() => { refresh('pending'); }, []); const onGrab = async (t) => { + if (!isAuthed()) { setErr('抢单需先登录(OPC 端身份)。'); return; } setBusyId(t.id); setErr(''); const r = await grabTask(t.id); setBusyId(''); @@ -75,36 +78,33 @@ export default function Tasks() { else setErr(r.error || '抢单失败'); }; - if (!authed) { - return ( - -
- setAuthed(true)} /> -
-
- ); - } - - const items = tab === 'pending' ? pending : mine; - const loading = tab === 'pending' ? load.pending : load.mine; + const showMine = tab === 'mine'; + const items = showMine ? mine : pending; + const loading = showMine ? load.mine : load.pending; return (
-

浏览平台发布的待接单任务,挑一个适合你的接下;也可以查看自己的接单进度。

+

浏览平台发布的待接单任务(公开可见),点击查看任务详情;登录后可抢单或查看自己的接单进度。

- +
- {err &&

{err}

} - {loading ?

加载中…

: items.length === 0 - ?

{tab === 'pending' ? '暂无待接单任务。' : '你还没有接单任务。'}

- :
{items.map((t) => onGrab(t)} />)}
} + {showMine && !authed ? ( + { setAuthed(true); refresh('mine'); }} /> + ) : ( + <> + {err &&

{err}

} + {loading ?

加载中…

: items.length === 0 + ?

{showMine ? '你还没有接单任务。' : '暂无待接单任务。'}

+ :
{items.map((t) => )}
} + + )}
);