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:
@@ -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 }
|
||||
];
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
export const NAV = [
|
||||
{ path: '', label: '首页' },
|
||||
{ path: 'events', label: '活动' },
|
||||
{ path: 'tasks', label: '任务' },
|
||||
{ path: 'content', label: '资讯' },
|
||||
{ path: 'opc-test', label: '测评' }
|
||||
];
|
||||
|
||||
@@ -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: '网络异常' };
|
||||
}
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user