feat(园区端): 独立任务管理页(不弹窗) —— 登录/本园任务/发单/指派/上下架

- services/parkTask.js:园区租户登录(/park/auth/login) + 本园任务列表/发单/指派/上下架(租户token)
- platform/ParkCenter.jsx:园区独立管理页(登录墙→任务列表),行内展开做发单(可见性)/指派(成员user_id),上下架按钮——不使用弹窗
- 路由 #/park-center;页眉对 carrier 角色显示「园区管理」
This commit is contained in:
Pine
2026-08-27 19:49:04 +08:00
parent 813c7ee1aa
commit 0d0969c91d
5 changed files with 230 additions and 0 deletions
+2
View File
@@ -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 }
];
@@ -36,6 +36,7 @@ export function Header({ active }) {
) : (
<>
{user && user.role === 'service' && <a className="btn-sign" href="#/enterprise">企业中心</a>}
{user && user.role === 'carrier' && <a className="btn-sign" href="#/park-center">园区管理</a>}
{user && <a className="user-nick" href="#/profile" title={user.username}>{user.name}</a>}
<a className="btn-sign" href="#/profile">个人中心</a>
</>
+137
View File
@@ -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 (
<div className="pc-login">
<span className="pc-login-kicker"><Icon name="home" size="sm" />园区端 · 任务管理</span>
<h1 className="pc-login-title">登录园区</h1>
<p className="pc-login-sub">使用园区管理员账号密码登录该账号需绑定为园区管理员</p>
<div className="field"><label>账号</label><input value={u} onChange={(e) => setU(e.target.value)} placeholder="园区管理员账号" /></div>
<div className="field"><label>密码</label><input type="password" value={p} onChange={(e) => setP(e.target.value)} placeholder="密码" /></div>
{err && <p className="pc-err">{err}</p>}
<button className="btn-dark2" onClick={submit} disabled={busy}>登录</button>
</div>
);
}
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 (
<ContentTemplate>
<div className="pc-page"><Login onOk={(r) => { setAuth(r); refresh(); }} /></div>
</ContentTemplate>
);
}
return (
<ContentTemplate>
<section className="pc-page">
<header className="page-hero">
<SectionHead no="PARK" title={`园区任务管理 · ${auth.name || ''}`} en="Carrier · Tasks" />
<p className="page-desc">管理本园区任务上架/下架对指派本园的任务做园区内发单园区企业可抢或指派给本园成员资金由运营平台托管</p>
</header>
<div className="pc-top">
<Button variant="ghost" onClick={refresh}>刷新</Button>
<Button variant="ghost" onClick={() => { localStorage.removeItem(TKEY); setAuth(null); }}>退出</Button>
</div>
{err && <p className="pc-err">{err}</p>}
{!loaded ? <p className="pc-empty">加载中</p> : items.length === 0
? <p className="pc-empty">本园区暂无任务含指派本园</p>
: <div className="pc-list">
{items.map((t) => {
const open = !!expanded[t.id];
return (
<div className="pc-item" key={t.id}>
<div className="pc-item-head">
<span className="pc-cat">{t.category || '综合'}</span>
<span className="pc-mode">{MODE[t.mode] || t.mode}</span>
{t.assign_type === 'park' && !t.park_released && <span className="pc-tag">待园区发单</span>}
{t.park_released && <span className="pc-tag ok">已发单</span>}
<span className={`pc-status ${t.status}`}>{t.status === 'published' ? '待接单' : t.status}</span>
</div>
<h3 className="pc-title">{t.title}</h3>
<p className="pc-desc">{t.description}</p>
<div className="pc-meta"><span>¥{t.budget_max || t.budget_min || 0}</span>{t.deadline && <span>{t.deadline}</span>}{t.delivery_days ? <span>{t.delivery_days} </span> : null}</div>
{/* 行内展开操作(不弹窗) */}
<div className="pc-ops">
{(t.assign_type === 'park' && !t.park_released) && (
<button className="pc-link" onClick={() => toggle(t.id)}>发单 / 指派</button>
)}
{t.status === 'published' && <Button variant="ghost" onClick={() => act(t.id, () => parkSetStatus(token, t.id, 'cancelled'))}>下架</Button>}
{t.status === 'cancelled' && <Button variant="ghost" onClick={() => act(t.id, () => parkSetStatus(token, t.id, 'published'))}>上架</Button>}
</div>
{open && (
<div className="pc-panel">
<div className="pc-row">
<span className="pc-row-l">园区内发单园区企业可抢</span>
<select value={visSel[t.id] || 'public'} onChange={(e) => setVisSel((x) => ({ ...x, [t.id]: e.target.value }))}>
<option value="public">公开</option><option value="c_visible">C端可见</option>
</select>
<Button variant="ghost" onClick={() => act(t.id, () => parkRelease(token, t.id, visSel[t.id] || 'public'))}>发单</Button>
</div>
<div className="pc-row">
<span className="pc-row-l">指派给本园成员</span>
<input value={uid[t.id] || ''} placeholder="成员 user_id" onChange={(e) => setUid((x) => ({ ...x, [t.id]: e.target.value }))} />
<Button variant="ghost" onClick={() => act(t.id, () => parkAssign(token, t.id, uid[t.id]))}>指派</Button>
</div>
</div>
)}
</div>
);
})}
</div>}
</section>
</ContentTemplate>
);
}
+55
View File
@@ -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;
}
+35
View File
@@ -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; }