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; }