feat(web): 企业端门户(甲方/service) —— 发布任务/竞标评审/验收结算

- services/enterprise.js:/enterprise/tasks* (发标/提交发布/竞标列表/评标中标/验收)
- platform/Enterprise.jsx:service 角色门户(登录墙+角色校验)
  · 发标表单:模式/可见性/预算/名额/竞标报名·中标数/接单条件JSON/托管方式 + C端可见/公有/独占
  · 我的任务列表 + 竞标查看/评标中标 + 验收通过(结算)/驳回 + 重新上架
- 路由 #/enterprise;浅色 token 样式
This commit is contained in:
Pine
2026-08-27 19:43:05 +08:00
parent 384b957617
commit 2dae55741e
4 changed files with 279 additions and 0 deletions
+2
View File
@@ -33,6 +33,7 @@ import Content from './user/Content';
import ContentDetail from './user/ContentDetail';
import Tasks from './user/Tasks';
import TaskDetail from './user/TaskDetail';
import Enterprise from './platform/Enterprise';
/* 公开路由(无需登录) */
const PUBLIC_ROUTES = [
@@ -50,6 +51,7 @@ const PUBLIC_ROUTES = [
{ path: 'content-detail', Page: ContentDetail },
{ path: 'tasks', Page: Tasks },
{ path: 'task-detail', Page: TaskDetail },
{ path: 'enterprise', Page: Enterprise },
{ path: 'profile', Page: Profile }
];
+194
View File
@@ -0,0 +1,194 @@
import React from 'react';
import { ContentTemplate } from '@/components/templates';
import { SectionHead, Button, Icon } from '@/components/atoms';
import AuthGate from '@/components/AuthGate';
import { isAuthed, getUser } from '@/services/auth';
import { listTasks, createTask, submitTask, listBids, winBid, reviewTask } from '@/services/enterprise';
import '@/styles/enterprise.css';
const MODE = { grab: '抢单', bid: '竞标', assign: '指派', recommend: '推荐' };
const MODE_OPT = [
['grab', '抢单(可设名额)'],
['bid', '竞标(报名/中标数)'],
['assign', '指派(给OPC或园区)'],
];
const VIS_OPT = [['public', '公开'], ['assigned_only', '仅被指派'], ['c_visible', 'C端可见']];
const EMPTY = () => ({
title: '', category: '', sub_category: '', description: '', tags: '',
mode: 'grab', budget_min: '0', budget_max: '0', deadline: '', delivery_days: '0',
headcount: '0', exclusive: false, publisher_name: '', publisher_role: 'enterprise',
visibility: 'public', c_visible: true, park_public: false, assign_type: '',
assign_park_id: '', assign_opc_id: '', bid_quota: '0', win_quota: '0',
eligibility: '{}', settle_mode: 'operator_escrow',
});
export default function Enterprise() {
const [authed, setAuthed] = React.useState(isAuthed());
const [items, setItems] = React.useState([]);
const [loaded, setLoaded] = React.useState(false);
const [err, setErr] = React.useState('');
const [editing, setEditing] = React.useState(false);
const [form, setForm] = React.useState(EMPTY());
const [busy, setBusy] = React.useState(false);
const [bids, setBids] = React.useState({});
const refresh = async () => {
try { setItems(await listTasks()); } catch { setItems([]); }
finally { setLoaded(true); setErr(''); }
};
React.useEffect(() => { if (authed) refresh(); }, [authed]);
const role = (getUser() || {}).role;
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }));
const setSel = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const save = async () => {
setBusy(true); setErr('');
const body = {
...form,
budget_min: Number(form.budget_min) || 0, budget_max: Number(form.budget_max) || 0,
delivery_days: Number(form.delivery_days) || 0, headcount: Number(form.headcount) || 0,
bid_quota: Number(form.bid_quota) || 0, win_quota: Number(form.win_quota) || 0,
exclusive: !!form.exclusive,
};
try {
const t = await createTask(body);
setEditing(false); setForm(EMPTY()); setErr('');
if (t && t.id) await submitTask(t.id);
await refresh();
} catch (e) { setErr((e && e.message) || '发布失败'); }
finally { setBusy(false); }
};
const openBids = async (t) => {
const list = await listBids(t.id);
setBids((b) => ({ ...b, [t.id]: list }));
};
const doWin = async (t, bidId) => {
try { await winBid(t.id, bidId); await refresh(); }
catch (e) { setErr((e && e.message) || '评标失败'); }
};
const doReview = async (t, action) => {
try { await reviewTask(t.id, action); await refresh(); }
catch (e) { setErr((e && e.message) || '验收失败'); }
};
if (!authed) {
return (
<ContentTemplate>
<div className="page-content" style={{ paddingTop: 40 }}>
<AuthGate title="企业端登录" subtitle="以甲方企业(service)身份登录,发布任务 / 评审竞标 / 验收。" onAuthed={() => { setAuthed(true); refresh(); }} />
</div>
</ContentTemplate>
);
}
if (role !== 'service') {
return (
<ContentTemplate>
<div className="page-content" style={{ paddingTop: 60, textAlign: 'center' }}>
<h1 className="page-title">企业任务中心</h1>
<p className="page-desc">当前账号非企业service身份无法使用企业发标/评审功能</p>
</div>
</ContentTemplate>
);
}
return (
<ContentTemplate>
<section className="ent-page">
<header className="page-hero">
<SectionHead no="ENT" title="企业任务中心" en="Enterprise · Tasks" />
<p className="page-desc">发布任务抢单 / 竞标 / 指派查看竞标并评审中标履约后验收结算资金由运营平台托管</p>
</header>
{editing ? (
<div className="ent-form card">
<h3 className="ent-form-title">发新任务</h3>
<div className="ent-grid">
<Field label="任务标题" value={form.title} ph="如:电商小程序首页 UI 设计" onChange={set('title')} />
<Field label="分类" value={form.category} ph="设计创意" onChange={set('category')} />
<Field label="子分类" value={form.sub_category} ph="UI设计" onChange={set('sub_category')} />
<Field label="标签(逗号)" value={form.tags} ph="UI,设计" onChange={set('tags')} />
<div className="field"><label>接单方式</label>
<select value={form.mode} onChange={(e) => setSel('mode', e.target.value)}>{MODE_OPT.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select>
</div>
<div className="field"><label>可见性</label>
<select value={form.visibility} onChange={(e) => setSel('visibility', e.target.value)}>{VIS_OPT.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select>
</div>
<Field label="预算下限(¥)" value={form.budget_min} type="number" onChange={set('budget_min')} />
<Field label="预算上限(¥)" value={form.budget_max} type="number" onChange={set('budget_max')} />
<Field label="报名截止" value={form.deadline} ph="2026-09-01" onChange={set('deadline')} />
<Field label="交付时限(天)" value={form.delivery_days} type="number" onChange={set('delivery_days')} />
<Field label="限制人数(0=不限)" value={form.headcount} type="number" onChange={set('headcount')} />
<Field label="竞标报名上限(0=不限)" value={form.bid_quota} type="number" onChange={set('bid_quota')} />
<Field label="竞标中标人数(0=1)" value={form.win_quota} type="number" onChange={set('win_quota')} />
<Field label="发包方名称" value={form.publisher_name} ph="云南某甲方企业" onChange={set('publisher_name')} />
</div>
<div className="field"><label>任务说明</label><textarea onChange={set('description')} value={form.description} /></div>
<div className="field"><label>接单资格条件(JSONopc_certified/region/gender/years_min/field/skill/case_req)</label><textarea className="mono" onChange={set('eligibility')} value={form.eligibility} /></div>
<div className="ent-checks">
<label><input type="checkbox" checked={form.c_visible} onChange={set('c_visible')} /> C端可见</label>
<label><input type="checkbox" checked={form.park_public} onChange={set('park_public')} /> 公有(各园大屏)</label>
<label><input type="checkbox" checked={form.exclusive} onChange={set('exclusive')} /> 独占</label>
</div>
<div className="ent-actions">
<Button variant="cta" onClick={save} loading={busy}>发布</Button>
<Button variant="ghost" onClick={() => { setEditing(false); setForm(EMPTY()); }}>取消</Button>
</div>
</div>
) : (
<div className="ent-actions"><Button variant="cta" onClick={() => { setEditing(true); setForm(EMPTY()); }}>发新任务</Button></div>
)}
{err && <p className="ent-err">{err}</p>}
{!loaded ? <p className="ent-empty">加载中</p> : items.length === 0
? <p className="ent-empty">暂无已发布任务</p>
: <div className="ent-list">
{items.map((t) => (
<div className="ent-item" key={t.id}>
<div className="ent-item-head">
<span className="ent-cat">{t.category || '综合'}</span>
<span className="ent-mode">{MODE[t.mode] || t.mode}</span>
<span className={`ent-status ${t.status}`}>{t.status === 'published' ? '待接单' : t.status}</span>
</div>
<h3 className="ent-title">{t.title}</h3>
<p className="ent-desc">{t.description}</p>
<div className="ent-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>
{t.mode === 'bid' && (
<div className="ent-bids">
<button className="ent-link" onClick={() => openBids(t)}>查看竞标({(bids[t.id] || []).length})</button>
{(bids[t.id] || []).map((b) => (
<div className="ent-bid" key={b.id}>
<span>{b.opc_name} · ¥{b.quote}</span>
<span className="ent-bid-status">{b.status}</span>
{b.status === 'submitted' && <Button size="sm" variant="cta" onClick={() => doWin(t, b.id)}>中标</Button>}
</div>
))}
</div>
)}
<div className="ent-item-actions">
{t.status === 'published' ? (
<>
<Button variant="ghost" onClick={() => doReview(t, 'accept')}>验收通过并结算</Button>
<Button variant="ghost" onClick={() => doReview(t, 'reject')}>驳回</Button>
</>
) : null}
<Button variant="ghost" onClick={() => { submitTask(t.id).then(refresh); }}>重新上架</Button>
</div>
</div>
))}
</div>}
</section>
</ContentTemplate>
);
}
function Field({ label, value, ph, type, onChange }) {
return <div className="field"><label>{label}</label><input type={type || 'text'} value={value} placeholder={ph} onChange={onChange} /></div>;
}
+49
View File
@@ -0,0 +1,49 @@
/** 企业端(甲方企业 / service 角色)任务 Portal:发标 / 提交发布 / 竞标评审 / 验收。走 /enterprise 端点。 */
import { getToken } from '@/services/auth';
const API_BASE = 'https://opc.pinesound.cn';
function auth() {
const t = getToken();
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
}
export async function listTasks() {
const res = await fetch(`${API_BASE}/enterprise/tasks`, { headers: auth() });
const d = await res.json().catch(() => ({}));
return res.ok ? (Array.isArray(d.items) ? d.items : []) : []; // 企业端返回已发布列表
}
export async function createTask(body) {
const res = await fetch(`${API_BASE}/enterprise/tasks`, { method: 'POST', headers: auth(), body: JSON.stringify(body) });
const d = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(d.detail || '发布失败');
return d;
}
export async function submitTask(id) {
const res = await fetch(`${API_BASE}/enterprise/tasks/${encodeURIComponent(id)}/submit`, { method: 'POST', headers: auth() });
const d = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(d.detail || '提交失败');
return d;
}
export async function listBids(id) {
const res = await fetch(`${API_BASE}/enterprise/tasks/${encodeURIComponent(id)}/bids`, { headers: auth() });
const d = await res.json().catch(() => ({}));
return res.ok ? (Array.isArray(d.items) ? d.items : []) : [];
}
export async function winBid(taskId, bidId) {
const res = await fetch(`${API_BASE}/enterprise/tasks/${encodeURIComponent(taskId)}/bids/${encodeURIComponent(bidId)}/win`, { method: 'POST', headers: auth() });
const d = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(d.detail || '评标失败');
return d;
}
export async function reviewTask(id, action) {
const res = await fetch(`${API_BASE}/enterprise/tasks/${encodeURIComponent(id)}/review`, { method: 'POST', headers: auth(), body: JSON.stringify({ action }) });
const d = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(d.detail || '验收失败');
return d;
}
+34
View File
@@ -0,0 +1,34 @@
/* 企业任务中心 · 浅色 portrait 风格(token) */
.ent-page { padding-top: clamp(24px, 4vh, 40px); }
.ent-actions { display: flex; gap: 12px; flex-wrap: wrap; margin: 6px 0 20px; }
.ent-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; }
.ent-empty { padding: 60px 0; text-align: center; color: var(--muted); font-size: 14px; }
.ent-form { background: var(--surface); border: 1px solid var(--border); border-radius: 26px; padding: 24px; box-shadow: var(--shadow-sm-border); }
.ent-form-title { font-size: 18px; font-weight: 700; color: var(--heading); margin-bottom: 18px; }
.ent-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
.ent-grid .field input, .ent-grid .field select, .ent-form textarea { width: 100%; padding: 10px 12px; border-radius: 12px; background: var(--panel-2); border: 1px solid var(--border); color: var(--text); font-size: 14px; font-family: inherit; outline: none; }
.ent-form textarea { min-height: 80px; resize: vertical; }
.ent-form textarea.mono { font-family: var(--font-mono); font-size: 12.5px; }
.ent-checks { display: flex; gap: 18px; flex-wrap: wrap; margin: 14px 0; }
.ent-checks label { display: flex; align-items: center; gap: 7px; font-size: 13.5px; color: var(--text-soft); cursor: pointer; }
.ent-list { display: grid; gap: 16px; }
.ent-item { background: var(--surface); border: 1px solid var(--border); border-radius: 24px; padding: 20px 22px; box-shadow: var(--shadow-sm-border); }
.ent-item-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ent-cat { font-size: 11px; font-weight: 700; color: var(--primary); background: var(--primary-soft); border-radius: 999px; padding: 3px 10px; }
.ent-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; }
.ent-status { font-size: 11px; font-weight: 700; border-radius: 999px; padding: 3px 10px; }
.ent-status.published { color: var(--success); background: var(--success-soft); }
.ent-status.pending { color: var(--warning); background: var(--warning-soft); }
.ent-status.claimed, .ent-status.in_progress { color: var(--primary); background: var(--primary-soft); }
.ent-status.completed { color: var(--muted); background: var(--panel-2); }
.ent-title { margin-top: 12px; font-size: 18px; font-weight: 700; color: var(--heading); }
.ent-desc { margin-top: 8px; font-size: 13.5px; color: var(--text-soft); line-height: 1.6; }
.ent-meta { display: flex; gap: 16px; margin-top: 12px; font-size: 12.5px; color: var(--muted); }
.ent-bids { margin-top: 12px; }
.ent-link { background: none; border: none; color: var(--primary); font-size: 13.5px; padding: 0; cursor: pointer; font-weight: 600; }
.ent-bid { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-top: 1px solid var(--line); font-size: 13.5px; color: var(--text-soft); }
.ent-bid-status { margin-left: auto; font-size: 12px; font-weight: 600; color: var(--muted); }
.ent-item-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; }
@media (max-width: 720px) { .ent-grid { grid-template-columns: 1fr; } }