diff --git a/website/src/App.jsx b/website/src/App.jsx
index 692ced8..82399f1 100644
--- a/website/src/App.jsx
+++ b/website/src/App.jsx
@@ -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 }
];
diff --git a/website/src/platform/Enterprise.jsx b/website/src/platform/Enterprise.jsx
new file mode 100644
index 0000000..1c1cd97
--- /dev/null
+++ b/website/src/platform/Enterprise.jsx
@@ -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 (
+
+
+
{ setAuthed(true); refresh(); }} />
+
+
+ );
+ }
+ if (role !== 'service') {
+ return (
+
+
+
企业任务中心
+
当前账号非企业(service)身份,无法使用企业发标/评审功能。
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {editing ? (
+
+
发新任务
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+ {err && {err}
}
+ {!loaded ? 加载中…
: items.length === 0
+ ? 暂无已发布任务。
+ :
+ {items.map((t) => (
+
+
+ {t.category || '综合'}
+ {MODE[t.mode] || t.mode}
+ {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.mode === 'bid' && (
+
+
+ {(bids[t.id] || []).map((b) => (
+
+ {b.opc_name} · ¥{b.quote}
+ {b.status}
+ {b.status === 'submitted' && }
+
+ ))}
+
+ )}
+
+ {t.status === 'published' ? (
+ <>
+
+
+ >
+ ) : null}
+
+
+
+ ))}
+
}
+
+
+ );
+}
+
+function Field({ label, value, ph, type, onChange }) {
+ return
;
+}
diff --git a/website/src/services/enterprise.js b/website/src/services/enterprise.js
new file mode 100644
index 0000000..f8d41d3
--- /dev/null
+++ b/website/src/services/enterprise.js
@@ -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;
+}
diff --git a/website/src/styles/enterprise.css b/website/src/styles/enterprise.css
new file mode 100644
index 0000000..cc643d9
--- /dev/null
+++ b/website/src/styles/enterprise.css
@@ -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; } }