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:
@@ -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 }
|
||||
];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user