feat(web): 新增「任务」菜单 + 任务广场页(接入任务系统,展示待接单)

- services/tasks.js:/opc/tasks(待接单广场) /opc/my-tasks(我的接单) /opc/tasks/{id}/grab(抢单),带 Bearer
- user/Tasks.jsx:OPC 端登录墙 + 待接单/我的接单 两个 tab;任务卡(分类/mode/预算/截止/标签/交付天数/人数) + 抢单
- App.jsx 加路由 'tasks';site.js NAV 加「任务」项
- styles/tasks.css 浅色 token 卡片(圆角24/软阴影/悬浮上移)
This commit is contained in:
Pine
2026-08-27 18:14:56 +08:00
parent 56f10e65b9
commit 9ae938d87d
5 changed files with 193 additions and 0 deletions
+2
View File
@@ -31,6 +31,7 @@ import CertApply from './user/CertApply';
import ParkTransfer from './user/ParkTransfer';
import Content from './user/Content';
import ContentDetail from './user/ContentDetail';
import Tasks from './user/Tasks';
/* 公开路由(无需登录) */
const PUBLIC_ROUTES = [
@@ -46,6 +47,7 @@ const PUBLIC_ROUTES = [
{ path: 'park-transfer', Page: ParkTransfer },
{ path: 'content', Page: Content },
{ path: 'content-detail', Page: ContentDetail },
{ path: 'tasks', Page: Tasks },
{ path: 'profile', Page: Profile }
];
+1
View File
@@ -3,6 +3,7 @@
export const NAV = [
{ path: '', label: '首页' },
{ path: 'events', label: '活动' },
{ path: 'tasks', label: '任务' },
{ path: 'content', label: '资讯' },
{ path: 'opc-test', label: '测评' }
];
+47
View File
@@ -0,0 +1,47 @@
/** 任务系统(OPC 端):任务广场(待接单) / 我的接单 / 抢单。走平台 /opc 端点,需登录(opc_member)。 */
import { getToken } from '@/services/auth';
const API_BASE = 'https://opc.pinesound.cn';
function authHeaders() {
const t = getToken();
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
}
/** 任务广场(已发布待接单任务) */
export async function listTasks() {
try {
const res = await fetch(`${API_BASE}/opc/tasks`, { headers: authHeaders() });
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 : [] };
} catch {
return { ok: false, status: 0 };
}
}
/** 我的任务看板 */
export async function myTasks() {
try {
const res = await fetch(`${API_BASE}/opc/my-tasks`, { headers: authHeaders() });
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 : [] };
} catch {
return { ok: false, status: 0 };
}
}
/** 抢单 */
export async function grabTask(id) {
try {
const res = await fetch(`${API_BASE}/opc/tasks/${encodeURIComponent(id)}/grab`, {
method: 'POST', headers: authHeaders(),
});
const d = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, error: d.detail || d.message || '抢单失败' };
return { ok: true, task: d };
} catch {
return { ok: false, error: '网络异常' };
}
}
+32
View File
@@ -0,0 +1,32 @@
/* 任务广场 · 浅色 portrait 风格(token 驱动) */
.tk-page { padding-top: clamp(24px, 4vh, 40px); }
.tk-tabs { display: flex; gap: 8px; margin-bottom: 22px; }
.tk-tab {
padding: 9px 18px; border-radius: 999px; border: 1px solid var(--line);
background: transparent; color: var(--muted); font-family: inherit; font-size: 14px; font-weight: 600; cursor: pointer;
transition: background .2s ease, color .2s ease, border-color .2s ease;
}
.tk-tab.on { background: var(--heading); color: var(--bg); border-color: transparent; }
.tk-err { margin: 0 0 16px; padding: 10px 14px; border-radius: 12px; background: var(--danger-soft); border: 1px solid rgba(237,21,27,.4); color: var(--danger); font-size: 13px; }
.tk-empty { padding: 60px 0; text-align: center; color: var(--muted); font-size: 14px; }
.tk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.tk-card {
display: flex; flex-direction: column;
background: var(--surface); border: 1px solid var(--border); border-radius: 24px;
padding: 22px; box-shadow: var(--shadow-sm-border); transition: transform .25s var(--ease), box-shadow .25s ease;
}
.tk-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); }
.tk-card-top { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.tk-cat { font-size: 11px; font-weight: 700; color: var(--primary); background: var(--primary-soft); border-radius: 999px; padding: 3px 10px; }
.tk-mode { font-size: 11px; font-weight: 600; color: var(--on-surface); background: var(--surface); border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; }
.tk-excl { font-size: 11px; font-weight: 600; color: var(--warning); background: var(--warning-soft); border-radius: 999px; padding: 3px 10px; }
.tk-title { font-size: 17px; font-weight: 700; color: var(--heading); line-height: 1.4; }
.tk-desc { margin-top: 8px; font-size: 13.5px; color: var(--text-soft); line-height: 1.65; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.tk-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.tk-tag { font-size: 11px; color: var(--muted); background: var(--panel-2); border-radius: 999px; padding: 2px 9px; }
.tk-meta { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line); }
.tk-meta span { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--muted); }
.tk-meta .icon { color: var(--primary); }
.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; } }
+111
View File
@@ -0,0 +1,111 @@
import React from 'react';
import { ContentTemplate } from '@/components/templates';
import { SectionHead, Button, Icon } from '@/components/atoms';
import AuthGate from '@/components/AuthGate';
import { isAuthed } from '@/services/auth';
import { listTasks, myTasks, grabTask } from '@/services/tasks';
import '@/styles/tasks.css';
const MODE = { grab: '抢单', bid: '投标', assign: '指派', recommend: '推荐' };
const money = (a, b) => {
if (!a && !b) return '面议';
if (a && b && a !== b) return `¥${a}${b}`;
return `¥${a || b}`;
};
function TaskCard({ t, onGrab, busy }) {
return (
<article className="tk-card">
<div className="tk-card-top">
<span className="tk-cat">{t.category || '综合'}</span>
<span className="tk-mode">{MODE[t.mode] || '抢单'}</span>
{t.exclusive && <span className="tk-excl">独占</span>}
</div>
<h3 className="tk-title">{t.title}</h3>
<p className="tk-desc">{t.description}</p>
{t.tags && <div className="tk-tags">{String(t.tags).split(',').filter(Boolean).map((x) => <span className="tk-tag" key={x}>{x}</span>)}</div>}
<div className="tk-meta">
<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>
<span className="tk-code" title="任务短码">{t.task_code}</span>
</div>
</article>
);
}
export default function Tasks() {
const [authed, setAuthed] = React.useState(isAuthed());
const [tab, setTab] = React.useState('pending');
const [pending, setPending] = React.useState([]);
const [mine, setMine] = React.useState([]);
const [load, setLoad] = React.useState({ pending: false, mine: false });
const [err, setErr] = React.useState('');
const [busyId, setBusyId] = React.useState('');
const refresh = async (which) => {
setLoad((l) => ({ ...l, [which]: true }));
setErr('');
try {
if (which === 'pending') {
const r = await listTasks();
setPending(r.ok ? r.items : []);
if (!r.ok) setErr(r.status === 403 ? '需以 OPC 端登录后查看任务广场。' : r.status === 401 ? '请先登录。' : '暂时无法加载任务广场。');
} else {
const r = await myTasks();
setMine(r.ok ? r.items : []);
if (!r.ok) setErr(r.status === 403 ? '需以 OPC 端登录后查看我的接单。' : '暂时无法加载。');
}
} finally {
setLoad((l) => ({ ...l, [which]: false }));
}
};
React.useEffect(() => { if (authed) refresh('pending'); }, [authed]);
const onGrab = async (t) => {
setBusyId(t.id); setErr('');
const r = await grabTask(t.id);
setBusyId('');
if (r.ok) { refresh('pending'); refresh('mine'); }
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;
return (
<ContentTemplate>
<section className="tk-page">
<header className="page-hero">
<SectionHead no="OPC" title="任务广场" en="Tasks" />
<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>
</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>}
</section>
</ContentTemplate>
);
}