feat(园区端): 独立任务管理页(不弹窗) —— 登录/本园任务/发单/指派/上下架
- services/parkTask.js:园区租户登录(/park/auth/login) + 本园任务列表/发单/指派/上下架(租户token) - platform/ParkCenter.jsx:园区独立管理页(登录墙→任务列表),行内展开做发单(可见性)/指派(成员user_id),上下架按钮——不使用弹窗 - 路由 #/park-center;页眉对 carrier 角色显示「园区管理」
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
Reference in New Issue
Block a user