From 9ae938d87dfb9f7ba9ccc2eeec2d240699d1f1f8 Mon Sep 17 00:00:00 2001 From: Pine Date: Thu, 27 Aug 2026 18:14:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E3=80=8D=E8=8F=9C=E5=8D=95=20+=20=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=B9=BF=E5=9C=BA=E9=A1=B5(=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=B3=BB=E7=BB=9F=EF=BC=8C=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E5=BE=85=E6=8E=A5=E5=8D=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/软阴影/悬浮上移) --- website/src/App.jsx | 2 + website/src/data/site.js | 1 + website/src/services/tasks.js | 47 ++++++++++++++ website/src/styles/tasks.css | 32 ++++++++++ website/src/user/Tasks.jsx | 111 ++++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+) create mode 100644 website/src/services/tasks.js create mode 100644 website/src/styles/tasks.css create mode 100644 website/src/user/Tasks.jsx diff --git a/website/src/App.jsx b/website/src/App.jsx index fd2229f..45b5ddd 100644 --- a/website/src/App.jsx +++ b/website/src/App.jsx @@ -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 } ]; diff --git a/website/src/data/site.js b/website/src/data/site.js index 6a5b31c..833908d 100644 --- a/website/src/data/site.js +++ b/website/src/data/site.js @@ -3,6 +3,7 @@ export const NAV = [ { path: '', label: '首页' }, { path: 'events', label: '活动' }, + { path: 'tasks', label: '任务' }, { path: 'content', label: '资讯' }, { path: 'opc-test', label: '测评' } ]; diff --git a/website/src/services/tasks.js b/website/src/services/tasks.js new file mode 100644 index 0000000..0d0f981 --- /dev/null +++ b/website/src/services/tasks.js @@ -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: '网络异常' }; + } +} diff --git a/website/src/styles/tasks.css b/website/src/styles/tasks.css new file mode 100644 index 0000000..9965be2 --- /dev/null +++ b/website/src/styles/tasks.css @@ -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; } } diff --git a/website/src/user/Tasks.jsx b/website/src/user/Tasks.jsx new file mode 100644 index 0000000..f2f557c --- /dev/null +++ b/website/src/user/Tasks.jsx @@ -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 ( +
+
+ {t.category || '综合'} + {MODE[t.mode] || '抢单'} + {t.exclusive && 独占} +
+

{t.title}

+

{t.description}

+ {t.tags &&
{String(t.tags).split(',').filter(Boolean).map((x) => {x})}
} +
+ {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} +
+
+ ); +} + +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 ( + +
+ setAuthed(true)} /> +
+
+ ); + } + + const items = tab === 'pending' ? pending : mine; + const loading = tab === 'pending' ? load.pending : load.mine; + + return ( + +
+
+ +

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

+
+ +
+ + +
+ + {err &&

{err}

} + {loading ?

加载中…

: items.length === 0 + ?

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

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