diff --git a/website/src/App.jsx b/website/src/App.jsx index 82399f1..edd8438 100644 --- a/website/src/App.jsx +++ b/website/src/App.jsx @@ -34,6 +34,7 @@ import ContentDetail from './user/ContentDetail'; import Tasks from './user/Tasks'; import TaskDetail from './user/TaskDetail'; import Enterprise from './platform/Enterprise'; +import ParkCenter from './platform/ParkCenter'; /* 公开路由(无需登录) */ const PUBLIC_ROUTES = [ @@ -52,6 +53,7 @@ const PUBLIC_ROUTES = [ { path: 'tasks', Page: Tasks }, { path: 'task-detail', Page: TaskDetail }, { path: 'enterprise', Page: Enterprise }, + { path: 'park-center', Page: ParkCenter }, { path: 'profile', Page: Profile } ]; diff --git a/website/src/components/organisms/index.jsx b/website/src/components/organisms/index.jsx index 0b0d7b8..b16e06f 100644 --- a/website/src/components/organisms/index.jsx +++ b/website/src/components/organisms/index.jsx @@ -36,6 +36,7 @@ export function Header({ active }) { ) : ( <> {user && user.role === 'service' && 企业中心} + {user && user.role === 'carrier' && 园区管理} {user && {user.name}} 个人中心 diff --git a/website/src/platform/ParkCenter.jsx b/website/src/platform/ParkCenter.jsx new file mode 100644 index 0000000..3414f54 --- /dev/null +++ b/website/src/platform/ParkCenter.jsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { ContentTemplate } from '@/components/templates'; +import { SectionHead, Button, Icon } from '@/components/atoms'; +import { parkLogin, parkMe, listParkTasks, parkSetStatus, parkRelease, parkAssign } from '@/services/parkTask'; +import '@/styles/parkCenter.css'; + +const MODE = { grab: '抢单', bid: '竞标', assign: '指派', recommend: '推荐' }; +const TKEY = 'park_tenant'; + +function Login({ onOk }) { + const [u, setU] = React.useState(''); + const [p, setP] = React.useState(''); + const [err, setErr] = React.useState(''); + const [busy, setBusy] = React.useState(false); + const submit = async () => { + setBusy(true); setErr(''); + try { + const r = await parkLogin(u.trim(), p); + localStorage.setItem(TKEY, JSON.stringify(r)); + onOk(r); + } catch (e) { setErr((e && e.message) || '登录失败'); } + finally { setBusy(false); } + }; + return ( +
+ 园区端 · 任务管理 +

登录园区

+

使用园区管理员账号密码登录(该账号需绑定为园区管理员)。

+
setU(e.target.value)} placeholder="园区管理员账号" />
+
setP(e.target.value)} placeholder="密码" />
+ {err &&

{err}

} + +
+ ); +} + +export default function ParkCenter() { + const [auth, setAuth] = React.useState(() => { + try { const s = localStorage.getItem(TKEY); return s ? JSON.parse(s) : null; } catch { return null; } + }); + const [items, setItems] = React.useState([]); + const [loaded, setLoaded] = React.useState(false); + const [err, setErr] = React.useState(''); + const [expanded, setExpanded] = React.useState({}); + const [uid, setUid] = React.useState({}); + const [visSel, setVisSel] = React.useState({}); + + const token = auth && auth.token; + const refresh = async () => { + setErr(''); + try { setItems(await listParkTasks(token)); } + catch (e) { setErr((e && e.message) || '加载失败'); } + finally { setLoaded(true); } + }; + React.useEffect(() => { if (auth) refresh(); }, []); + + const act = async (id, fn) => { + setErr(''); + try { await fn(); await refresh(); } + catch (e) { setErr((e && e.message) || '操作失败'); } + }; + + const toggle = (id) => setExpanded((x) => ({ ...x, [id]: !x[id] })); + + if (!auth) { + return ( + +
{ setAuth(r); refresh(); }} />
+
+ ); + } + + return ( + +
+
+ +

管理本园区任务:上架/下架、对指派本园的任务做园区内发单(园区企业可抢)或指派给本园成员。资金由运营平台托管。

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

{err}

} + {!loaded ?

加载中…

: items.length === 0 + ?

本园区暂无任务(含指派本园)。

+ :
+ {items.map((t) => { + const open = !!expanded[t.id]; + return ( +
+
+ {t.category || '综合'} + {MODE[t.mode] || t.mode} + {t.assign_type === 'park' && !t.park_released && 待园区发单} + {t.park_released && 已发单} + {t.status === 'published' ? '待接单' : t.status} +
+

{t.title}

+

{t.description}

+
¥{t.budget_max || t.budget_min || 0}{t.deadline && {t.deadline}}{t.delivery_days ? {t.delivery_days} 天 : null}
+ + {/* 行内展开操作(不弹窗) */} +
+ {(t.assign_type === 'park' && !t.park_released) && ( + + )} + {t.status === 'published' && } + {t.status === 'cancelled' && } +
+ + {open && ( +
+
+ 园区内发单(园区企业可抢) + + +
+
+ 指派给本园成员 + setUid((x) => ({ ...x, [t.id]: e.target.value }))} /> + +
+
+ )} +
+ ); + })} +
} +
+
+ ); +} diff --git a/website/src/services/parkTask.js b/website/src/services/parkTask.js new file mode 100644 index 0000000..187ad36 --- /dev/null +++ b/website/src/services/parkTask.js @@ -0,0 +1,55 @@ +/** 园区端(carrier)任务管理:园区专用登录(租户凭证) + 本园任务列表/发单/指派/上架下架。走 /park 端点(租户token)。 */ +const API_BASE = 'https://opc.pinesound.cn'; + +function parkHeaders(token) { + return { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }; +} + +export async function parkLogin(username, password) { + const res = await fetch(`${API_BASE}/park/auth/login`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const d = await res.json().catch(() => ({})); + if (!res.ok || d.ok === false) throw new Error(d.error || '登录失败(账号/密码或未绑定园区)'); + return d; // { tenant_id, name, intro, token } +} + +export async function parkMe(token) { + const res = await fetch(`${API_BASE}/park/auth/me`, { headers: parkHeaders(token) }); + const d = await res.json().catch(() => ({})); + return res.ok && d.ok ? d : null; +} + +export async function listParkTasks(token) { + const res = await fetch(`${API_BASE}/park/api/park/tasks`, { headers: parkHeaders(token) }); + const d = await res.json().catch(() => ({})); + return res.ok ? (Array.isArray(d.items) ? d.items : []) : []; +} + +export async function parkSetStatus(token, id, status) { + const res = await fetch(`${API_BASE}/park/api/park/tasks/${encodeURIComponent(id)}/status`, { + method: 'POST', headers: parkHeaders(token), body: JSON.stringify({ status }), + }); + const d = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(d.detail || '操作失败'); + return d; +} + +export async function parkRelease(token, id, vis = 'public') { + const res = await fetch(`${API_BASE}/park/api/park/tasks/${encodeURIComponent(id)}/release`, { + method: 'POST', headers: parkHeaders(token), body: JSON.stringify({ vis }), + }); + const d = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(d.detail || '发单失败'); + return d; +} + +export async function parkAssign(token, id, userId) { + const res = await fetch(`${API_BASE}/park/api/park/tasks/${encodeURIComponent(id)}/assign`, { + method: 'POST', headers: parkHeaders(token), body: JSON.stringify({ user_id: userId }), + }); + const d = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(d.detail || '指派失败'); + return d; +} diff --git a/website/src/styles/parkCenter.css b/website/src/styles/parkCenter.css new file mode 100644 index 0000000..7c08b4d --- /dev/null +++ b/website/src/styles/parkCenter.css @@ -0,0 +1,35 @@ +/* 园区端任务管理 · 浅色 portrait 风格(token) */ +.pc-page { padding-top: clamp(24px, 4vh, 40px); } +.pc-top { display: flex; gap: 10px; margin: 6px 0 20px; } +.pc-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; } +.pc-empty { padding: 60px 0; text-align: center; color: var(--muted); font-size: 14px; } +.pc-list { display: grid; gap: 16px; } +.pc-item { background: var(--surface); border: 1px solid var(--border); border-radius: 24px; padding: 20px 22px; box-shadow: var(--shadow-sm-border); } +.pc-item-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.pc-cat { font-size: 11px; font-weight: 700; color: var(--primary); background: var(--primary-soft); border-radius: 999px; padding: 3px 10px; } +.pc-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; } +.pc-tag { font-size: 11px; font-weight: 700; color: var(--warning); background: var(--warning-soft); border-radius: 999px; padding: 3px 10px; } +.pc-tag.ok { color: var(--success); background: var(--success-soft); } +.pc-status { font-size: 11px; font-weight: 700; border-radius: 999px; padding: 3px 10px; margin-left: auto; } +.pc-status.published { color: var(--success); background: var(--success-soft); } +.pc-status.cancelled { color: var(--muted); background: var(--panel-2); } +.pc-status.claimed, .pc-status.in_progress { color: var(--primary); background: var(--primary-soft); } +.pc-status.completed { color: var(--muted); background: var(--panel-2); } +.pc-title { margin-top: 12px; font-size: 18px; font-weight: 700; color: var(--heading); } +.pc-desc { margin-top: 8px; font-size: 13.5px; color: var(--text-soft); line-height: 1.6; } +.pc-meta { display: flex; gap: 16px; margin-top: 12px; font-size: 12.5px; color: var(--muted); } +.pc-ops { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 14px; } +.pc-link { background: none; border: none; color: var(--primary); font-size: 13.5px; cursor: pointer; font-weight: 600; padding: 8px 0; } +.pc-panel { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 14px; display: grid; gap: 12px; } +.pc-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.pc-row-l { width: 150px; font-size: 13px; color: var(--text-soft); flex-shrink: 0; } +.pc-row input, .pc-row select { padding: 8px 12px; border-radius: 10px; background: var(--panel-2); border: 1px solid var(--border); color: var(--text); font-size: 13.5px; font-family: inherit; } +.pc-row input { flex: 1; min-width: 160px; } + +/* 登录 */ +.pc-login { max-width: 380px; margin: 60px auto 0; display: flex; flex-direction: column; gap: 14px; } +.pc-login-kicker { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--muted); } +.pc-login-title { font-size: 30px; font-weight: 500; color: var(--heading); letter-spacing: -.02em; } +.pc-login-sub { font-size: 14px; color: var(--muted); line-height: 1.6; margin-bottom: 8px; } +.pc-login .field label { display: block; font-size: 12.5px; color: var(--muted); margin-bottom: 6px; } +.pc-login .field input, .pc-login input { padding: 11px 13px; border-radius: 12px; background: var(--panel-2); border: 1px solid var(--border); color: var(--text); font-size: 14px; font-family: inherit; outline: none; }