feat(web): 任务广场公开可见(免登录) + 新增任务详情页

- services/tasks.js:listTasks/getTask 公开(免登录),myTasks/grabTask 带登录
- user/Tasks.jsx:任务广场免登录展示;「我的接单」tab 与抢单需登录(AuthGate);任务卡点击进详情
- user/TaskDetail.jsx:任务详情页(预算/分类/模式/截止/交付/人数/发包方 + 抢单),路由 #/task-detail?id=
- styles/tasks.css 补齐详情版式
This commit is contained in:
Pine
2026-08-27 18:36:33 +08:00
parent 9ae938d87d
commit 45367204e5
5 changed files with 142 additions and 28 deletions
+2
View File
@@ -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 }
];
+13 -2
View File
@@ -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 {
+24
View File
@@ -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; } }
+77
View File
@@ -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 <ContentTemplate><div className="tk-page"><p className="tk-empty">加载中</p></div></ContentTemplate>;
if (!task) return <ContentTemplate><div className="tk-page"><p className="tk-empty">任务不存在或已下线</p></div></ContentTemplate>;
return (
<ContentTemplate>
<section className="tk-page">
<a className="tk-back" href="#/tasks"><Icon name="arrow-left" size="sm" />返回任务广场</a>
<article className="tk-detail">
<div className="tk-detail-head">
<div className="tk-cat-row">
<span className="tk-cat">{task.category || '综合'}</span>
<span className="tk-mode">{MODE[task.mode] || '抢单'}</span>
{task.exclusive && <span className="tk-excl">独占</span>}
<span className="tk-status">{task.status === 'published' ? '待接单' : task.status}</span>
</div>
<h1 className="tk-detail-title">{task.title}</h1>
{task.task_code && <span className="tk-code">短码 {task.task_code}</span>}
</div>
<div className="tk-detail-grid">
<div className="tk-detail-main">
<h2 className="tk-sec">任务说明</h2>
<p className="tk-full-desc">{task.description}</p>
{task.tags && <div className="tk-tags">{String(task.tags).split(',').filter(Boolean).map((x) => <span className="tk-tag" key={x}>{x}</span>)}</div>}
</div>
<aside className="tk-detail-side">
<dl className="tk-facts">
<div className="tk-fact"><dt>预算</dt><dd className="money">{money(task.budget_min, task.budget_max)}</dd></div>
<div className="tk-fact"><dt>分类</dt><dd>{task.category}{task.sub_category ? ` · ${task.sub_category}` : ''}</dd></div>
<div className="tk-fact"><dt>接单模式</dt><dd>{MODE[task.mode] || task.mode}</dd></div>
<div className="tk-fact"><dt>截止</dt><dd>{task.deadline || '面议'}</dd></div>
<div className="tk-fact"><dt>交付时限</dt><dd>{task.delivery_days ? `${task.delivery_days}` : '面议'}</dd></div>
<div className="tk-fact"><dt>接单人数</dt><dd>{task.headcount ? `${task.headcount}` : '不限'}</dd></div>
{task.publisher_name ? <div className="tk-fact"><dt>发包方</dt><dd>{task.publisher_name}</dd></div> : null}
</dl>
<div className="tk-detail-actions">
<Button variant="cta" onClick={onGrab} loading={busy}>抢单</Button>
{err && <p className="tk-err">{err}</p>}
{!isAuthed() && <p className="tk-note">抢单需登录<a href="#/profile">去登录 / 注册</a></p>}
</div>
</aside>
</div>
</article>
</section>
</ContentTemplate>
);
}
+26 -26
View File
@@ -13,9 +13,9 @@ const money = (a, b) => {
return `¥${a || b}`;
};
function TaskCard({ t, onGrab, busy }) {
function TaskCard({ t, onGrab, busy, mine }) {
return (
<article className="tk-card">
<a className="tk-card" href={`#/task-detail?id=${t.id}`}>
<div className="tk-card-top">
<span className="tk-cat">{t.category || '综合'}</span>
<span className="tk-mode">{MODE[t.mode] || '抢单'}</span>
@@ -28,13 +28,14 @@ function TaskCard({ t, onGrab, busy }) {
<span><Icon name="sparkles" size="sm" />{money(t.budget_min, t.budget_max)}</span>
{t.deadline && <span><Icon name="calendar" size="sm" />{t.deadline}</span>}
{t.delivery_days ? <span><Icon name="clock" size="sm" />{t.delivery_days} 天交付</span> : null}
{t.headcount ? <span><Icon name="users" size="sm" />{t.headcount} </span> : null}
</div>
<div className="tk-actions">
<Button variant="ghost" onClick={onGrab} loading={busy}>抢单</Button>
<Button variant={mine ? 'dark' : 'ghost'} loading={busy} onClick={(e) => { e.preventDefault(); e.stopPropagation(); onGrab(t); }}>
{mine ? '查看详情' : '抢单'}
</Button>
<span className="tk-code" title="任务短码">{t.task_code}</span>
</div>
</article>
</a>
);
}
@@ -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 (
<ContentTemplate>
<div className="page-content" style={{ paddingTop: 40 }}>
<AuthGate title="登录后查看任务广场" subtitle="以 OPC 端身份登录,浏览并抢下适合你的平台任务。" onAuthed={() => setAuthed(true)} />
</div>
</ContentTemplate>
);
}
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 (
<ContentTemplate>
<section className="tk-page">
<header className="page-hero">
<SectionHead no="OPC" title="任务广场" en="Tasks" />
<p className="page-desc">浏览平台发布的待接单任务挑一个适合你的接下也可以查看自己的接单进度</p>
<p className="page-desc">浏览平台发布的待接单任务公开可见点击查看任务详情登录后可抢单或查看自己的接单进度</p>
</header>
<div className="tk-tabs">
<button className={`tk-tab${tab === 'pending' ? ' on' : ''}`} onClick={() => { setTab('pending'); refresh('pending'); }}>待接单 {pending.length ? `· ${pending.length}` : ''}</button>
<button className={`tk-tab${tab === 'mine' ? ' on' : ''}`} onClick={() => { setTab('mine'); refresh('mine'); }}>我的接单 {mine.length ? `· ${mine.length}` : ''}</button>
<button className={`tk-tab${tab === 'mine' ? ' on' : ''}`} onClick={() => { setTab('mine'); if (isAuthed()) refresh('mine'); }}>我的接单 {mine.length ? `· ${mine.length}` : ''}</button>
</div>
{err && <p className="tk-err">{err}</p>}
{loading ? <p className="tk-empty">加载中</p> : items.length === 0
? <p className="tk-empty">{tab === 'pending' ? '暂无待接单任务。' : '你还没有接单任务。'}</p>
: <div className="tk-grid">{items.map((t) => <TaskCard key={t.id} t={t} busy={busyId === t.id} onGrab={() => onGrab(t)} />)}</div>}
{showMine && !authed ? (
<AuthGate title="登录后查看我的接单" subtitle="以 OPC 端身份登录,查看已领取 / 进行中 / 已完成的任务。" onAuthed={() => { setAuthed(true); refresh('mine'); }} />
) : (
<>
{err && <p className="tk-err">{err}</p>}
{loading ? <p className="tk-empty">加载中</p> : items.length === 0
? <p className="tk-empty">{showMine ? '你还没有接单任务。' : '暂无待接单任务。'}</p>
: <div className="tk-grid">{items.map((t) => <TaskCard key={t.id} t={t} mine={showMine} busy={busyId === t.id} onGrab={onGrab} />)}</div>}
</>
)}
</section>
</ContentTemplate>
);