From e70bb4aa6fcddb47d35bed8c66b51dba07882bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Mon, 27 Jul 2026 18:59:42 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E8BF=E5=A4=9A?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=E4=BC=9A=E5=95=86=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/src/core/auth/route-key-login.ts | 33 ++++ .../pages/EightBfRoundtablePage.tsx | 165 ++++++++++++++++++ .../src/eightbf-roundtable/styles/step1.css | 24 +++ .../src/eightbf-roundtable/styles/step2.css | 17 ++ .../src/eightbf-roundtable/styles/step3.css | 34 ++++ frontend-web/src/pages/StrategyRoutes.tsx | 23 ++- frontend-web/src/pages/WorkspaceRoutes.tsx | 11 ++ .../pages/RoundtablePlanningPage.tsx | 105 +++++++++-- .../app/gateway/routers/business_mapping.py | 53 ++++++ .../backend/tests/test_business_mapping.py | 100 +++++++++++ 10 files changed, 552 insertions(+), 13 deletions(-) create mode 100644 frontend-web/src/core/auth/route-key-login.ts create mode 100644 frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx create mode 100644 frontend-web/src/eightbf-roundtable/styles/step1.css create mode 100644 frontend-web/src/eightbf-roundtable/styles/step2.css create mode 100644 frontend-web/src/eightbf-roundtable/styles/step3.css create mode 100644 offline-backend-20260512/backend/tests/test_business_mapping.py diff --git a/frontend-web/src/core/auth/route-key-login.ts b/frontend-web/src/core/auth/route-key-login.ts new file mode 100644 index 00000000..7c7601ff --- /dev/null +++ b/frontend-web/src/core/auth/route-key-login.ts @@ -0,0 +1,33 @@ +import { readRuntimeEnv } from "@/env"; + +/** + * Short-link route map maintained by the frontend. + * + * Add a new entry here when an external system needs a short ``routeKey``. + * Targets must remain internal SPA routes; LoginPage validates them again + * before navigating. + */ +export const ROUTE_KEY_MAPPINGS: Readonly> = { + a: "/page/canvas/ai-writing", + "8bf": "/page/strategy/qa/8bf-roundtable", +}; + +/** Login URL parameter that selects a route from ``ROUTE_KEY_MAPPINGS``. */ +export const ROUTE_KEY_PARAM = "routeKey"; + +/** + * Optional frontend-configured username-login password. + * + * This is intentionally an environment/runtime value rather than a literal in + * source code. It is used only with a mapped ``routeKey`` link. When it is + * empty, LoginPage falls back to the URL's existing ``password`` parameter. + */ +export function getRouteKeyLoginPassword(): string { + return readRuntimeEnv("VITE_ROUTE_LOGIN_PASSWORD") ?? ""; +} + +export function resolveRouteKey(routeKey: string | null | undefined): string { + const key = routeKey?.trim(); + if (!key) return ""; + return ROUTE_KEY_MAPPINGS[key] ?? ""; +} diff --git a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx new file mode 100644 index 00000000..a1e2a1b3 --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Check, Loader2, Save, Settings2, X } from "lucide-react"; + +import { getStoredAuth } from "@/core/auth"; +import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; +import { listChains } from "@/roundtable-planning/api/chains"; +import type { BusinessChain } from "@/roundtable-planning/lib/business-chain"; +import { + listBusinessMappings, + updateBusinessMapping, + type BusinessMapping, +} from "@/strategy-components/api/business-mapping"; + +import "../styles/step1.css"; +import "../styles/step2.css"; +import "../styles/step3.css"; + +const BUSINESS_CODE = "8BF"; + +/** + * 8BF 独立多智能体会商页。 + * + * 页面复用圆桌会商的三步能力,但业务链选择、草稿存储键和 CSS 根节点完全独立; + * 不引用岗位会商的页面、会话或接口。 + */ +export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) { + const auth = getStoredAuth(); + const isAdmin = auth?.system_role === "admin"; + const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]); + const [mapping, setMapping] = useState(null); + const [chains, setChains] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [selectedChainId, setSelectedChainId] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const loadConfiguration = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const [mappings, availableChains] = await Promise.all([ + listBusinessMappings(), + listChains(isAdmin ? "all" : "public"), + ]); + const nextMapping = mappings.find((item) => item.businessCode === BUSINESS_CODE) ?? null; + // 8BF 只允许绑定已发布业务链;后端同样会进行二次校验。 + const nextChains = availableChains.filter((chain) => chain.isPublic); + setMapping(nextMapping); + setChains(nextChains); + setSelectedChainId(nextMapping?.chainId ?? ""); + } catch { + setError("业务链配置加载失败,请刷新后重试"); + } finally { + setIsLoading(false); + } + }, [isAdmin]); + + useEffect(() => { + void loadConfiguration(); + }, [loadConfiguration]); + + const handleSave = useCallback(async () => { + const selected = chains.find((chain) => chain.id === selectedChainId); + if (!selected) { + setError("请选择一条已发布的业务链"); + return; + } + setIsSaving(true); + setError(null); + try { + const next = await updateBusinessMapping(BUSINESS_CODE, { + chainId: selected.id, + chainTitle: selected.title, + }); + if (!next) throw new Error("8BF 映射不存在"); + setMapping(next); + setIsSettingsOpen(false); + } catch (err) { + setError(err instanceof Error ? err.message : "保存业务链失败"); + } finally { + setIsSaving(false); + } + }, [chains, selectedChainId]); + + const toolbarSlot = ( + <> +
+ 8BF 业务链 + + {isLoading ? "加载中…" : mapping?.chainTitle || "待管理员配置"} + +
+ {isAdmin && ( + + )} + {isSettingsOpen && ( +
+
+
+
+

管理员配置

+

设置 8BF 公共业务链

+
+ +
+

+ 保存后,所有 8BF 用户都会使用这条已发布业务链;会商记录仍按用户独立保存。 +

+ + {error &&

{error}

} +
+ + +
+
+
+ )} + {!isLoading && mapping?.chainId && } + + ); + + return ( + + ); +} diff --git a/frontend-web/src/eightbf-roundtable/styles/step1.css b/frontend-web/src/eightbf-roundtable/styles/step1.css new file mode 100644 index 00000000..7804ce37 --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/styles/step1.css @@ -0,0 +1,24 @@ +/* 8BF 第 1 步(任务理解)独立样式入口。所有规则限定在 .eightbf-roundtable 内,不影响原多智能体会商。 */ +.eightbf-roundtable { + --eightbf-accent: #0f766e; + --eightbf-accent-soft: #ecfdf5; + --eightbf-border: #b7ded8; +} + +.eightbf-roundtable .eightbf-roundtable__chain-status { + display: inline-flex; + min-width: 184px; + align-items: center; + gap: 8px; + border: 1px solid var(--eightbf-border); + border-radius: 10px; + background: var(--eightbf-accent-soft); + padding: 6px 10px; + font-size: 12px; +} + +.eightbf-roundtable .eightbf-roundtable__chain-status-label { color: #0f5f56; font-weight: 700; } +.eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; } +.eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); } + +/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */ diff --git a/frontend-web/src/eightbf-roundtable/styles/step2.css b/frontend-web/src/eightbf-roundtable/styles/step2.css new file mode 100644 index 00000000..0abc75de --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/styles/step2.css @@ -0,0 +1,17 @@ +/* 8BF 第 2 步(多智能体会商)独立样式入口。 */ +.eightbf-roundtable .eightbf-roundtable__chain-settings-button { + display: inline-flex; + align-items: center; + gap: 5px; + border-radius: 9px; + background: #0f766e; + color: #fff; + padding: 7px 10px; + font-size: 12px; + font-weight: 700; + transition: background-color .15s ease; +} + +.eightbf-roundtable .eightbf-roundtable__chain-settings-button:hover { background: #115e59; } + +/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */ diff --git a/frontend-web/src/eightbf-roundtable/styles/step3.css b/frontend-web/src/eightbf-roundtable/styles/step3.css new file mode 100644 index 00000000..c442cdff --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/styles/step3.css @@ -0,0 +1,34 @@ +/* 8BF 第 3 步(结果绘制)独立样式入口。 */ +.eightbf-roundtable .eightbf-roundtable__settings-mask { + position: fixed; + inset: 0; + z-index: 90; + display: grid; + place-items: center; + background: rgb(15 23 42 / 42%); + padding: 20px; +} + +.eightbf-roundtable .eightbf-roundtable__settings-dialog { + width: min(460px, 100%); + border-radius: 16px; + background: #fff; + padding: 20px; + box-shadow: 0 24px 56px rgb(15 23 42 / 22%); +} + +.eightbf-roundtable .eightbf-roundtable__settings-heading, +.eightbf-roundtable .eightbf-roundtable__settings-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.eightbf-roundtable .eightbf-roundtable__settings-kicker { color: #0f766e; font-size: 12px; font-weight: 700; } +.eightbf-roundtable .eightbf-roundtable__settings-heading h2 { margin-top: 2px; color: #0f172a; font-size: 16px; font-weight: 700; } +.eightbf-roundtable .eightbf-roundtable__settings-close { color: #64748b; } +.eightbf-roundtable .eightbf-roundtable__settings-tip { margin-top: 14px; color: #64748b; font-size: 13px; line-height: 1.6; } +.eightbf-roundtable .eightbf-roundtable__settings-field { display: grid; gap: 7px; margin-top: 16px; color: #334155; font-size: 13px; font-weight: 600; } +.eightbf-roundtable .eightbf-roundtable__settings-field select { width: 100%; border: 1px solid #cbd5e1; border-radius: 8px; padding: 9px 10px; font-weight: 500; } +.eightbf-roundtable .eightbf-roundtable__settings-error { margin-top: 10px; color: #b91c1c; font-size: 12px; } +.eightbf-roundtable .eightbf-roundtable__settings-actions { margin-top: 20px; justify-content: flex-end; } +.eightbf-roundtable .eightbf-roundtable__settings-actions button { border-radius: 8px; padding: 8px 12px; font-size: 13px; font-weight: 700; } +.eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child { display: inline-flex; align-items: center; gap: 6px; background: #0f766e; color: #fff; } +.eightbf-roundtable .eightbf-roundtable__settings-actions button:disabled { cursor: not-allowed; opacity: .65; } + +/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */ diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 31ee9214..90d37b3c 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -22,6 +22,7 @@ import KnowledgeBaseResourceManagement from "@/strategy-components/components/re import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement"; import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage"; +import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage"; import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage"; import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage"; import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage"; @@ -67,6 +68,9 @@ import AdminUserActivityPage from "./AdminUserActivityPage"; import { ChatRuntime } from "./WorkspaceRoutes"; export default function StrategyRoutes() { + const storedAuth = getStoredAuth(); + const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin"; + return ( @@ -92,6 +96,14 @@ export default function StrategyRoutes() { } /> + + + + } + /> } /> } /> } /> - } /> + + ) : ( + + ) + } + /> } /> } /> diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 78b48b25..0373fadb 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -56,6 +56,7 @@ import StudioNotebooksPage from './StudioNotebooksPage'; import StudioNotebookDetailPage from './StudioNotebookDetailPage'; import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage'; import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage'; +import EightBfRoundtablePage from '@/eightbf-roundtable/pages/EightBfRoundtablePage'; import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage'; import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage'; import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage'; @@ -493,6 +494,16 @@ export default function WorkspaceRoutes({ } /> + + + + + + } + /> (null); /** 无界直传 chainId 时,链条解析完成前禁止弹选链窗(避免 modal 布局抖动)。 */ @@ -317,6 +334,31 @@ export default function RoundtablePlanningPage({ inWujieEmbed && !!(prefillRef.current?.chainId || prefillRef.current?.businessCode), ); + + // 独立业务页使用管理员配置的固定业务链。它只负责预选,不触发深链的自动开跑逻辑。 + useEffect(() => { + if (!lockedChainId) { + setAutoChainResult(null); + return; + } + let cancelled = false; + setAutoChainLoading(true); + void Promise.all([getChain(lockedChainId), listAgents().catch(() => [])]) + .then(([chain, agents]) => { + if (cancelled || !chain) return; + const result = buildChainSeatSelection(chain, filterRecommendCandidates(agents)); + if (!cancelled) setAutoChainResult(result); + }) + .catch(() => { + if (!cancelled) setAutoChainResult(null); + }) + .finally(() => { + if (!cancelled) setAutoChainLoading(false); + }); + return () => { + cancelled = true; + }; + }, [lockedChainId]); const autoStartedRef = useRef(false); const prefillClearedRef = useRef(false); // 共识达成后只向父页面(无界)回传一次交付结果。 @@ -756,7 +798,7 @@ export default function RoundtablePlanningPage({ setCurrentStep, triggerToast, // 聊天记录存储键:xdfx-A 按行动id 隔离,其它业务按真实 taskId(见 recordTaskIdOf)。 - taskId: recordTaskIdOf(prefillRef.current), + taskId: draftStorageTaskId, intentSending: step1.isIntentSending, step2FinalizedCount, step3SavedTick, @@ -1042,6 +1084,10 @@ export default function RoundtablePlanningPage({ // 再次点击同一条链 = 取消勾选。 const handleSelectChainFromSidebar = useCallback( async (chain: BusinessChain) => { + if (lockBusinessChain) { + triggerToast("当前页面的业务链由管理员统一配置"); + return; + } if (autoChainResult?.chain?.id === chain.id) { setAutoChainResult(null); triggerToast(`已取消业务链「${chain.title}」`); @@ -1061,7 +1107,7 @@ export default function RoundtablePlanningPage({ triggerToast("选择业务链失败,请重试"); } }, - [autoChainResult, triggerToast], + [autoChainResult, lockBusinessChain, triggerToast], ); // ─── 会商自动化串联(模块 C) ────────────────────────────────── @@ -1524,7 +1570,7 @@ export default function RoundtablePlanningPage({ step2.stopOrchestration(); // 1. 取消后端作业:避免前后端双跑/抢同一线程(409),线程随后由前端接管续跑。 // executor.cancel 会 task.cancel() 中断在跑的 run,DB 同步置 cancelled。 - await cancelJob(jobId, recordTaskIdOf(prefillRef.current)).catch(() => null); + await cancelJob(jobId, draftStorageTaskId).catch(() => null); // 2. 加载作业最近写回草稿的快照:恢复 threadIds(作业真实线程)+ 协调名 + 完整对话。 // hydrateFromDraft 已同步写好 threadIds/coordinatorName 的 ref,供下一步接管即时读取。 await drafts.handleLoadDraft(draftId); @@ -1629,7 +1675,7 @@ export default function RoundtablePlanningPage({ // 后台挂起发起前**立刻强制存一次**当前聊天(保住发起时刻的本地研讨记录,防丢)。 const draftId = await drafts.saveDraftNow(2); // 后台作业按聊天记录存储键归属(xdfx-A=行动id):报告写回对应 task-draft 存储、去重一致。 - const tid = recordTaskIdOf(prefillRef.current); + const tid = draftStorageTaskId; // 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。 const existing = draftId ? await getJobByDraft(draftId, tid).catch(() => null) : null; const alreadyRunning = @@ -1701,7 +1747,7 @@ export default function RoundtablePlanningPage({ const planSnap = mode === "dag" ? orchestrationPlan : null; const draftId = drafts.currentDraftId; // 后台作业按聊天记录存储键归属(xdfx-A=行动id),与草稿存储一致。 - const tid = recordTaskIdOf(prefillRef.current); + const tid = draftStorageTaskId; // 立刻:标记「挂起中」+ 跳回第一步 + 提示(不等接口)。 if (draftId) markPendingSuspend(draftId); @@ -1796,7 +1842,7 @@ export default function RoundtablePlanningPage({ if (agents.length === 0) return; startJobKeepalive({ draftId: drafts.currentDraftId ?? undefined, - taskId: recordTaskIdOf(prefillRef.current) ?? undefined, + taskId: draftStorageTaskId ?? undefined, intent: buildInitialIntent(), agents, model: selectedModel || undefined, @@ -1866,6 +1912,7 @@ export default function RoundtablePlanningPage({ className={cn( "roundtable-planning theme-transition relative flex h-full min-h-0 flex-col bg-[#fef6f6] font-sans text-slate-800 selection:bg-red-200", isDark && "roundtable-planning--dark", + rootClassName, )} > {/* 启动加载遮罩:深链/预填自动会话从进入页面到问答真正开始(建意图线程 / 回显历史草稿) @@ -1936,7 +1983,7 @@ export default function RoundtablePlanningPage({ {/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */} { if (!o) setChainModalJobId(null); @@ -1973,6 +2020,7 @@ export default function RoundtablePlanningPage({ />
+ {toolbarSlot} {/* 深链 taskId 模式:查看线上当前策划进度(目的树)。与单智能体闸门同一条接口。 */} {prefillRef.current?.taskId && ( + )} + {isSettingsOpen && ( +
+
+
+
+

管理员配置

+

设置 8BF 公共业务链

+
+ +
+

+ 保存后,所有 8BF 用户都会使用这条已发布业务链;会商记录仍按用户独立保存。 +

+ + {error &&

{error}

} +
+ + +
+
+
+ )} + {!isLoading && mapping?.chainId && } + + ); + + return ( + + ); +} diff --git a/frontend-web/src/eightbf-roundtable/styles/step1.css b/frontend-web/src/eightbf-roundtable/styles/step1.css new file mode 100644 index 00000000..7804ce37 --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/styles/step1.css @@ -0,0 +1,24 @@ +/* 8BF 第 1 步(任务理解)独立样式入口。所有规则限定在 .eightbf-roundtable 内,不影响原多智能体会商。 */ +.eightbf-roundtable { + --eightbf-accent: #0f766e; + --eightbf-accent-soft: #ecfdf5; + --eightbf-border: #b7ded8; +} + +.eightbf-roundtable .eightbf-roundtable__chain-status { + display: inline-flex; + min-width: 184px; + align-items: center; + gap: 8px; + border: 1px solid var(--eightbf-border); + border-radius: 10px; + background: var(--eightbf-accent-soft); + padding: 6px 10px; + font-size: 12px; +} + +.eightbf-roundtable .eightbf-roundtable__chain-status-label { color: #0f5f56; font-weight: 700; } +.eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; } +.eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); } + +/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */ diff --git a/frontend-web/src/eightbf-roundtable/styles/step2.css b/frontend-web/src/eightbf-roundtable/styles/step2.css new file mode 100644 index 00000000..0abc75de --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/styles/step2.css @@ -0,0 +1,17 @@ +/* 8BF 第 2 步(多智能体会商)独立样式入口。 */ +.eightbf-roundtable .eightbf-roundtable__chain-settings-button { + display: inline-flex; + align-items: center; + gap: 5px; + border-radius: 9px; + background: #0f766e; + color: #fff; + padding: 7px 10px; + font-size: 12px; + font-weight: 700; + transition: background-color .15s ease; +} + +.eightbf-roundtable .eightbf-roundtable__chain-settings-button:hover { background: #115e59; } + +/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */ diff --git a/frontend-web/src/eightbf-roundtable/styles/step3.css b/frontend-web/src/eightbf-roundtable/styles/step3.css new file mode 100644 index 00000000..c442cdff --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/styles/step3.css @@ -0,0 +1,34 @@ +/* 8BF 第 3 步(结果绘制)独立样式入口。 */ +.eightbf-roundtable .eightbf-roundtable__settings-mask { + position: fixed; + inset: 0; + z-index: 90; + display: grid; + place-items: center; + background: rgb(15 23 42 / 42%); + padding: 20px; +} + +.eightbf-roundtable .eightbf-roundtable__settings-dialog { + width: min(460px, 100%); + border-radius: 16px; + background: #fff; + padding: 20px; + box-shadow: 0 24px 56px rgb(15 23 42 / 22%); +} + +.eightbf-roundtable .eightbf-roundtable__settings-heading, +.eightbf-roundtable .eightbf-roundtable__settings-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.eightbf-roundtable .eightbf-roundtable__settings-kicker { color: #0f766e; font-size: 12px; font-weight: 700; } +.eightbf-roundtable .eightbf-roundtable__settings-heading h2 { margin-top: 2px; color: #0f172a; font-size: 16px; font-weight: 700; } +.eightbf-roundtable .eightbf-roundtable__settings-close { color: #64748b; } +.eightbf-roundtable .eightbf-roundtable__settings-tip { margin-top: 14px; color: #64748b; font-size: 13px; line-height: 1.6; } +.eightbf-roundtable .eightbf-roundtable__settings-field { display: grid; gap: 7px; margin-top: 16px; color: #334155; font-size: 13px; font-weight: 600; } +.eightbf-roundtable .eightbf-roundtable__settings-field select { width: 100%; border: 1px solid #cbd5e1; border-radius: 8px; padding: 9px 10px; font-weight: 500; } +.eightbf-roundtable .eightbf-roundtable__settings-error { margin-top: 10px; color: #b91c1c; font-size: 12px; } +.eightbf-roundtable .eightbf-roundtable__settings-actions { margin-top: 20px; justify-content: flex-end; } +.eightbf-roundtable .eightbf-roundtable__settings-actions button { border-radius: 8px; padding: 8px 12px; font-size: 13px; font-weight: 700; } +.eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child { display: inline-flex; align-items: center; gap: 6px; background: #0f766e; color: #fff; } +.eightbf-roundtable .eightbf-roundtable__settings-actions button:disabled { cursor: not-allowed; opacity: .65; } + +/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */ diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 31ee9214..90d37b3c 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -22,6 +22,7 @@ import KnowledgeBaseResourceManagement from "@/strategy-components/components/re import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement"; import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage"; +import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage"; import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage"; import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage"; import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage"; @@ -67,6 +68,9 @@ import AdminUserActivityPage from "./AdminUserActivityPage"; import { ChatRuntime } from "./WorkspaceRoutes"; export default function StrategyRoutes() { + const storedAuth = getStoredAuth(); + const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin"; + return ( @@ -92,6 +96,14 @@ export default function StrategyRoutes() { } /> + + + + } + /> } /> } /> } /> - } /> + + ) : ( + + ) + } + /> } /> } /> diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 78b48b25..0373fadb 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -56,6 +56,7 @@ import StudioNotebooksPage from './StudioNotebooksPage'; import StudioNotebookDetailPage from './StudioNotebookDetailPage'; import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage'; import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage'; +import EightBfRoundtablePage from '@/eightbf-roundtable/pages/EightBfRoundtablePage'; import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage'; import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage'; import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage'; @@ -493,6 +494,16 @@ export default function WorkspaceRoutes({ } /> + + + + + + } + /> (null); /** 无界直传 chainId 时,链条解析完成前禁止弹选链窗(避免 modal 布局抖动)。 */ @@ -317,6 +334,31 @@ export default function RoundtablePlanningPage({ inWujieEmbed && !!(prefillRef.current?.chainId || prefillRef.current?.businessCode), ); + + // 独立业务页使用管理员配置的固定业务链。它只负责预选,不触发深链的自动开跑逻辑。 + useEffect(() => { + if (!lockedChainId) { + setAutoChainResult(null); + return; + } + let cancelled = false; + setAutoChainLoading(true); + void Promise.all([getChain(lockedChainId), listAgents().catch(() => [])]) + .then(([chain, agents]) => { + if (cancelled || !chain) return; + const result = buildChainSeatSelection(chain, filterRecommendCandidates(agents)); + if (!cancelled) setAutoChainResult(result); + }) + .catch(() => { + if (!cancelled) setAutoChainResult(null); + }) + .finally(() => { + if (!cancelled) setAutoChainLoading(false); + }); + return () => { + cancelled = true; + }; + }, [lockedChainId]); const autoStartedRef = useRef(false); const prefillClearedRef = useRef(false); // 共识达成后只向父页面(无界)回传一次交付结果。 @@ -756,7 +798,7 @@ export default function RoundtablePlanningPage({ setCurrentStep, triggerToast, // 聊天记录存储键:xdfx-A 按行动id 隔离,其它业务按真实 taskId(见 recordTaskIdOf)。 - taskId: recordTaskIdOf(prefillRef.current), + taskId: draftStorageTaskId, intentSending: step1.isIntentSending, step2FinalizedCount, step3SavedTick, @@ -1042,6 +1084,10 @@ export default function RoundtablePlanningPage({ // 再次点击同一条链 = 取消勾选。 const handleSelectChainFromSidebar = useCallback( async (chain: BusinessChain) => { + if (lockBusinessChain) { + triggerToast("当前页面的业务链由管理员统一配置"); + return; + } if (autoChainResult?.chain?.id === chain.id) { setAutoChainResult(null); triggerToast(`已取消业务链「${chain.title}」`); @@ -1061,7 +1107,7 @@ export default function RoundtablePlanningPage({ triggerToast("选择业务链失败,请重试"); } }, - [autoChainResult, triggerToast], + [autoChainResult, lockBusinessChain, triggerToast], ); // ─── 会商自动化串联(模块 C) ────────────────────────────────── @@ -1524,7 +1570,7 @@ export default function RoundtablePlanningPage({ step2.stopOrchestration(); // 1. 取消后端作业:避免前后端双跑/抢同一线程(409),线程随后由前端接管续跑。 // executor.cancel 会 task.cancel() 中断在跑的 run,DB 同步置 cancelled。 - await cancelJob(jobId, recordTaskIdOf(prefillRef.current)).catch(() => null); + await cancelJob(jobId, draftStorageTaskId).catch(() => null); // 2. 加载作业最近写回草稿的快照:恢复 threadIds(作业真实线程)+ 协调名 + 完整对话。 // hydrateFromDraft 已同步写好 threadIds/coordinatorName 的 ref,供下一步接管即时读取。 await drafts.handleLoadDraft(draftId); @@ -1629,7 +1675,7 @@ export default function RoundtablePlanningPage({ // 后台挂起发起前**立刻强制存一次**当前聊天(保住发起时刻的本地研讨记录,防丢)。 const draftId = await drafts.saveDraftNow(2); // 后台作业按聊天记录存储键归属(xdfx-A=行动id):报告写回对应 task-draft 存储、去重一致。 - const tid = recordTaskIdOf(prefillRef.current); + const tid = draftStorageTaskId; // 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。 const existing = draftId ? await getJobByDraft(draftId, tid).catch(() => null) : null; const alreadyRunning = @@ -1701,7 +1747,7 @@ export default function RoundtablePlanningPage({ const planSnap = mode === "dag" ? orchestrationPlan : null; const draftId = drafts.currentDraftId; // 后台作业按聊天记录存储键归属(xdfx-A=行动id),与草稿存储一致。 - const tid = recordTaskIdOf(prefillRef.current); + const tid = draftStorageTaskId; // 立刻:标记「挂起中」+ 跳回第一步 + 提示(不等接口)。 if (draftId) markPendingSuspend(draftId); @@ -1796,7 +1842,7 @@ export default function RoundtablePlanningPage({ if (agents.length === 0) return; startJobKeepalive({ draftId: drafts.currentDraftId ?? undefined, - taskId: recordTaskIdOf(prefillRef.current) ?? undefined, + taskId: draftStorageTaskId ?? undefined, intent: buildInitialIntent(), agents, model: selectedModel || undefined, @@ -1866,6 +1912,7 @@ export default function RoundtablePlanningPage({ className={cn( "roundtable-planning theme-transition relative flex h-full min-h-0 flex-col bg-[#fef6f6] font-sans text-slate-800 selection:bg-red-200", isDark && "roundtable-planning--dark", + rootClassName, )} > {/* 启动加载遮罩:深链/预填自动会话从进入页面到问答真正开始(建意图线程 / 回显历史草稿) @@ -1936,7 +1983,7 @@ export default function RoundtablePlanningPage({ {/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */} { if (!o) setChainModalJobId(null); @@ -1973,6 +2020,7 @@ export default function RoundtablePlanningPage({ />
+ {toolbarSlot} {/* 深链 taskId 模式:查看线上当前策划进度(目的树)。与单智能体闸门同一条接口。 */} {prefillRef.current?.taskId && ( +
+ {!selectorLoading && fallbackAgents.length === 0 && ( +
+ 暂无可选智能体,仍可直接进入通用问答 +
+ )}
)} {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index f924deb0..68faabf0 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -7,6 +7,11 @@ import { useEffect, useMemo, useState } from 'react'; import { useTheme } from '@/contexts/ThemeContext'; import { loginByToken, loginByUsername } from '@/core/auth/api'; import { setStoredAuth, setStoredUserName } from '@/core/auth'; +import { + getRouteKeyLoginPassword, + resolveRouteKey, + ROUTE_KEY_PARAM, +} from '@/core/auth/route-key-login'; import { persistExternalLoginParams, readExternalLoginParams, @@ -67,6 +72,7 @@ function summarizeStoredUserInfo(): Record | null { const DEFAULT_USERNAME = 'guest'; const IFRAME_LOGIN_SOURCE = 'iframe'; +const IFRAME_CHAT_MODE = 'iframe'; const DEFAULT_REDIRECT_PATH = '/page/workspace/chats/new'; const IFRAME_REDIRECT_PATH = '/page/workspace/chats_iframe/new'; const FULL_CHAT_MODE = 'full'; @@ -90,29 +96,35 @@ export default function LoginPage() { () => readEmbedParam(searchParams.toString()) === 'on', [searchParams], ); + const requestedChatMode = + searchParams.get('chatMode')?.trim().toLowerCase() ?? ''; + const brainyIframeRequested = requestedChatMode === IFRAME_CHAT_MODE; // Wujie is an application container, not the Brainy iframe protocol. It - // must therefore land on the regular full ChatPage even when an old portal - // URL still carries `from=iframe`. Plain embeds can request the same route - // explicitly with `chatMode=full`. + // must therefore land on the regular full ChatPage. Legacy `from=iframe` + // links now do the same; only the explicit `chatMode=iframe` protocol opt-in + // is allowed to open IframeChatPage. const fullChatRequested = useMemo( () => - isWujieEnv() || - searchParams.get('chatMode')?.trim().toLowerCase() === FULL_CHAT_MODE, - [searchParams], + !brainyIframeRequested && + (isWujieEnv() || + requestedChatMode === FULL_CHAT_MODE || + searchParams.get('from') === IFRAME_LOGIN_SOURCE), + [brainyIframeRequested, requestedChatMode, searchParams], ); const redirectPath = useMemo( () => { // Keep the mode on the destination URL so PageRoutes can retain the // full chat workspace sidebar while hideChrome hides only the global // application chrome. + if (brainyIframeRequested) { + return `${IFRAME_REDIRECT_PATH}?chatMode=${IFRAME_CHAT_MODE}`; + } if (fullChatRequested) { return `${DEFAULT_REDIRECT_PATH}?chatMode=${FULL_CHAT_MODE}`; } - return searchParams.get('from') === IFRAME_LOGIN_SOURCE - ? IFRAME_REDIRECT_PATH - : DEFAULT_REDIRECT_PATH; + return DEFAULT_REDIRECT_PATH; }, - [fullChatRequested, searchParams], + [brainyIframeRequested, fullChatRequested], ); const resolvedUsername = useMemo(() => { const raw = username?.trim(); @@ -127,6 +139,14 @@ export default function LoginPage() { const gatePassword = searchParams.get('password') ?? ''; // token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开) const authToken = searchParams.get('authToken')?.trim() ?? ''; + const routeKey = searchParams.get(ROUTE_KEY_PARAM)?.trim() ?? ''; + const routeKeyTarget = useMemo(() => resolveRouteKey(routeKey), [routeKey]); + const configuredRouteLoginPassword = useMemo( + () => (routeKeyTarget ? getRouteKeyLoginPassword() : ''), + [routeKeyTarget], + ); + const effectiveGatePassword = configuredRouteLoginPassword || gatePassword; + const usesConfiguredRouteLoginPassword = Boolean(configuredRouteLoginPassword); // 任务深链:外部系统带 ?taskId=&goPath= 时,登录后拉任务详情并直送对应问答/会商页 const taskId = searchParams.get('taskId')?.trim() ?? ''; const goPath = searchParams.get('goPath')?.trim() ?? ''; @@ -142,6 +162,7 @@ export default function LoginPage() { // (token/authToken/ticket 等)一律**不透传**,避免泄漏到应用页地址栏。 const redirectTo = useMemo(() => { const raw = + routeKeyTarget || searchParams.get('redirect')?.trim() || searchParams.get('route')?.trim() || ''; @@ -162,7 +183,7 @@ export default function LoginPage() { // 登录流程自身消费的参数,不透传到目标页(尤以鉴权类为甚)。 const LOGIN_OWNED = new Set([ 'redirect', 'route', 'ticket', 'userId', 'env', 'theme', 'yUserId', - 'token', 'access_token', 'password', 'authToken', 'goPath', 'from', + 'token', 'access_token', 'password', 'authToken', 'goPath', 'from', ROUTE_KEY_PARAM, ]); new URLSearchParams(searchParams.toString()).forEach((value, key) => { if (LOGIN_OWNED.has(key)) return; @@ -171,7 +192,7 @@ export default function LoginPage() { }); const qs = target.toString(); return qs ? `${pathOnly}?${qs}` : pathOnly; - }, [fullChatRequested, searchParams]); + }, [fullChatRequested, routeKeyTarget, searchParams]); const externalParams = useMemo( () => ({ theme: searchParams.get('theme') ?? '', @@ -250,7 +271,9 @@ export default function LoginPage() { access_token: maskLoginToken(access_token), toolToken: maskLoginToken(toolToken), authToken: maskLoginToken(authToken), - gatePassword: gatePassword ? maskLoginToken(gatePassword) : '', + routeKey, + configuredRouteLoginPassword: usesConfiguredRouteLoginPassword, + gatePassword: effectiveGatePassword ? maskLoginToken(effectiveGatePassword) : '', onlineTicket: onlineTicket ? maskLoginToken(onlineTicket) : '', taskId, goPath, @@ -295,7 +318,7 @@ export default function LoginPage() { persistToolsToken(toolToken); } - auth = await loginByUsername(deerflowUsername, gatePassword); + auth = await loginByUsername(deerflowUsername, effectiveGatePassword); loginUserName = deerflowUsername; } if (cancelled) { @@ -397,6 +420,9 @@ export default function LoginPage() { access_token, onlineTicket, authToken, + effectiveGatePassword, + usesConfiguredRouteLoginPassword, + routeKey, gatePassword, taskId, goPath, diff --git a/frontend-web/src/pages/PageRoutes.tsx b/frontend-web/src/pages/PageRoutes.tsx index 146e50af..e6374f72 100644 --- a/frontend-web/src/pages/PageRoutes.tsx +++ b/frontend-web/src/pages/PageRoutes.tsx @@ -9,8 +9,10 @@ import { isWujieEnv } from "@/core/embed/wujie"; import { isEmbedActive, isChromeHidden, + isHeaderHidden, isHideChromeActive, readEmbedParam, + readHideHeaderParam, readHideChromeParam, } from "@/core/embed/embed-session"; import { useTheme } from "@/contexts/ThemeContext"; @@ -69,6 +71,12 @@ function PageLayout() { () => isHideChromeActive() || readHideChromeParam(location.search) === "on", [location.pathname, location.search], ); + // `?hideHeader=1` only removes the global top navigation. Unlike + // `hideChrome=1`, PageSidebar remains available in the full layout. + const headerHidden = useMemo( + () => isHeaderHidden() || readHideHeaderParam(location.search) === "on", + [location.pathname, location.search], + ); // `chatMode=full` is the embed-friendly standard chat view: hide the global // Header/PageSidebar when `hideChrome=1`, but keep WorkspaceSidebar so users // can still pick an agent and access their common agents/history. @@ -101,18 +109,20 @@ function PageLayout() { return (
- {pageSidebarAllowed &&
} + {pageSidebarAllowed && !headerHidden &&
}
{pageSidebarAllowed && ( isDark ? ( setCollapsed((prev) => !prev)} + headerHidden={headerHidden} /> ) : ( setCollapsed((prev) => !prev)} + headerHidden={headerHidden} /> ) )} @@ -139,12 +149,12 @@ function PageLayout() { pageSidebarCollapsed={collapsed} pageSidebarHidden={!pageSidebarAllowed} workspaceSidebarHidden={hideChromeActive && !fullChatMode} - headerHidden={!pageSidebarAllowed} + headerHidden={!pageSidebarAllowed || headerHidden} // Full-chat embedding has no global PageSidebar, but it // still needs the full GeneralQaSidebar (common agents and // conversation history) inside the workspace. compactWorkspaceSidebar={pageSidebarAllowed || fullChatMode} - compactActionsHidden={hideChromeActive} + compactActionsHidden={hideChromeActive || headerHidden} pageSidebarDark={isDark} forceWorkspaceSidebarCollapsed={ forceCollapsedSidebar && !fullChatMode diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 0373fadb..57e06f1c 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -2,7 +2,7 @@ import type { CSSProperties, ReactNode } from 'react'; import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom'; import { getStoredAuth } from '@/core/auth'; -import { isWujieEnv } from '@/core/embed/wujie'; +import { resolveStandardChatFallback } from '@/core/page-layout/chats-iframe-route'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { PromptInputProvider } from '@/components/ai-elements/prompt-input'; @@ -258,10 +258,10 @@ function NewChatEntryRoute({ /** * `chats_iframe` is reserved for the Brainy postMessage protocol. Some legacy - * Wujie links still target it (or carry `from=iframe` during login), but Wujie - * needs the normal full chat workspace instead. Redirect it before mounting - * IframeChatPage; `chatMode=iframe` remains an explicit opt-in escape hatch - * for a Wujie integration that really implements that protocol. + * Legacy links may still target it (or carry `from=iframe` during login), but + * ordinary iframe/Wujie embedding needs the normal full chat workspace. + * `chatMode=iframe` is the only explicit opt-in for integrations that really + * implement the Brainy protocol. */ function IframeChatEntryRoute({ layoutProps, @@ -280,25 +280,13 @@ function IframeChatEntryRoute({ hasThreadParam: boolean; }) { const location = useLocation(); - const { thread_id } = useParams<{ thread_id: string }>(); - const requestedMode = new URLSearchParams(location.search) - .get('chatMode') - ?.trim() - .toLowerCase(); + const standardChatTarget = resolveStandardChatFallback( + location.pathname, + location.search, + ); - if (isWujieEnv() && requestedMode !== 'iframe') { - const targetParams = new URLSearchParams(location.search); - targetParams.set('chatMode', 'full'); - const targetPath = hasThreadParam && thread_id - ? `/page/workspace/chats/${thread_id}` - : '/page/workspace/chats/new'; - const targetSearch = targetParams.toString(); - return ( - - ); + if (standardChatTarget) { + return ; } return ( diff --git a/nginx-1.28-deployment/.dockerignore b/nginx-1.28-deployment/.dockerignore new file mode 100644 index 00000000..78799bb1 --- /dev/null +++ b/nginx-1.28-deployment/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +*.tar +*.tar.gz +*.sha256 +README*.md +DEPLOY*.md diff --git a/nginx-1.28-deployment/DEPLOY.md b/nginx-1.28-deployment/DEPLOY.md new file mode 100644 index 00000000..6262849a --- /dev/null +++ b/nginx-1.28-deployment/DEPLOY.md @@ -0,0 +1,104 @@ +# Nginx 1.28 一键部署包 + +该目录用于在安装了 Docker 的 Linux 服务器上一键部署 Nginx 1.28。默认公网端口为 `80`,映射关系为 `80:80`,容器名为 `nginx-1.28`。 + +## 文件说明 + +- `Dockerfile`:以官方 `nginx:1.28` 为基础镜像构建本地镜像 `nginx-oneclick:1.28`。 +- `build-image.sh`:构建镜像并导出为 `nginx-oneclick-1.28.tar.gz`,用于离线分发。 +- `deploy.sh`:加载镜像包(或自动构建),创建/启动容器并进行 HTTP 验证。 +- `docker-compose.yml`:可选的 Compose 部署方式。 +- `nginx-oneclick-1.28.tar.gz`:如果随包提供,则优先离线加载,不需要访问 Docker Hub。 + +## 一键部署 + +将整个目录复制到目标服务器后执行: + +```bash +cd nginx-1.28-deployment +chmod +x build-image.sh deploy.sh +./deploy.sh +``` + +部署脚本的行为是幂等的: + +1. 如果目录中有镜像压缩包,优先执行 `docker load`。 +2. 没有镜像包但本机已有镜像时直接复用。 +3. 两者都没有时,以 `Dockerfile` 自动构建并拉取 `nginx:1.28` 基础镜像。 +4. 创建或启动 `nginx-1.28`,设置 `unless-stopped` 自动重启。 +5. 访问 `http://服务器公网IP/` 做 HTTP 检查。 + +## 构建并导出离线镜像 + +在可访问 Docker Hub 的 Docker 主机执行: + +```bash +./build-image.sh +``` + +生成的 `nginx-oneclick-1.28.tar.gz` 和 `.sha256` 文件可以与脚本一起复制到无外网服务器。脚本会自动加载该镜像包。 + +本次服务器上的离线镜像包已生成在: + +```text +/opt/nginx-1.28-deployment/nginx-oneclick-1.28.tar.gz +``` + +对应 SHA-256:`d322ba812dcd3e89fb8c0afd14f27e79e73432f7bc5a5a52324a0ca67e784014`。 + +## 端口和容器名 + +默认配置: + +```text +公网地址:http://服务器公网IP/ +主机端口:80 +容器端口:80 +容器名:nginx-1.28 +``` + +如 80 端口已被占用,可临时改用主机端口 8080: + +```bash +NGINX_HOST_PORT=8080 ./deploy.sh +``` + +此时访问 `http://服务器公网IP:8080/`。如要更换容器名: + +```bash +NGINX_CONTAINER_NAME=nginx-prod ./deploy.sh +``` + +## 已存在容器的处理 + +脚本不会默认删除或覆盖同名但配置不同的容器。确认要重建时显式执行: + +```bash +FORCE_RECREATE=1 ./deploy.sh +``` + +该操作会删除并重新创建 `NGINX_CONTAINER_NAME` 指定的容器,不会删除镜像。 + +## Compose 部署(可选) + +```bash +docker compose up -d --build +docker compose ps +curl -I http://127.0.0.1/ +``` + +## 常用运维命令 + +```bash +docker ps --filter name=nginx-1.28 +docker logs --tail 100 nginx-1.28 +docker exec nginx-1.28 nginx -v +docker restart nginx-1.28 +docker stop nginx-1.28 +``` + +## 安全与公网访问检查 + +只映射 HTTP 端口时,服务器防火墙/云安全组需要允许 TCP `80`(或自定义的 `NGINX_HOST_PORT`)。部署脚本只负责 Docker 端口发布,不会修改云厂商安全组规则。 + +不要把 SSH 密码、私钥或生产配置写入该目录。若通过 SSH 远程部署,请使用密钥或交互式认证,并在密码已经暴露后及时轮换。 diff --git a/nginx-1.28-deployment/Dockerfile b/nginx-1.28-deployment/Dockerfile new file mode 100644 index 00000000..ca554e45 --- /dev/null +++ b/nginx-1.28-deployment/Dockerfile @@ -0,0 +1,6 @@ +ARG NGINX_VERSION=1.28 +FROM nginx:${NGINX_VERSION} + +LABEL org.opencontainers.image.title="Nginx 1.28 one-click deployment" \ + org.opencontainers.image.description="Reusable Nginx 1.28 deployment image" \ + org.opencontainers.image.version="1.28" diff --git a/nginx-1.28-deployment/build-image.sh b/nginx-1.28-deployment/build-image.sh new file mode 100644 index 00000000..cfa9c701 --- /dev/null +++ b/nginx-1.28-deployment/build-image.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +IMAGE="${NGINX_IMAGE:-nginx-oneclick:1.28}" +NGINX_VERSION="${NGINX_VERSION:-1.28}" +ARCHIVE="${NGINX_IMAGE_ARCHIVE:-${SCRIPT_DIR}/nginx-oneclick-1.28.tar.gz}" + +command -v docker >/dev/null 2>&1 || { + echo "错误:未找到 Docker,请先安装 Docker。" >&2 + exit 1 +} + +echo "[1/2] 构建镜像 ${IMAGE}(基础镜像 nginx:${NGINX_VERSION})" +docker build \ + --pull \ + --build-arg "NGINX_VERSION=${NGINX_VERSION}" \ + --tag "${IMAGE}" \ + "${SCRIPT_DIR}" + +echo "[2/2] 导出镜像 ${ARCHIVE}" +mkdir -p "$(dirname -- "${ARCHIVE}")" +tmp_archive="${ARCHIVE}.tmp" +trap 'rm -f -- "${tmp_archive}"' EXIT +docker save "${IMAGE}" | gzip -c >"${tmp_archive}" +mv -f -- "${tmp_archive}" "${ARCHIVE}" + +if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${ARCHIVE}" >"${ARCHIVE}.sha256" +fi + +echo +echo "构建完成:" +echo " 镜像:${IMAGE}" +echo " 文件:${ARCHIVE}" diff --git a/nginx-1.28-deployment/deploy.sh b/nginx-1.28-deployment/deploy.sh new file mode 100644 index 00000000..86297a57 --- /dev/null +++ b/nginx-1.28-deployment/deploy.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +IMAGE="${NGINX_IMAGE:-nginx-oneclick:1.28}" +NGINX_VERSION="${NGINX_VERSION:-1.28}" +CONTAINER_NAME="${NGINX_CONTAINER_NAME:-nginx-1.28}" +HOST_PORT="${NGINX_HOST_PORT:-80}" +CONTAINER_PORT=80 +FORCE_RECREATE="${FORCE_RECREATE:-0}" +ARCHIVE="${NGINX_IMAGE_ARCHIVE:-${SCRIPT_DIR}/nginx-oneclick-1.28.tar.gz}" + +die() { + echo "错误:$*" >&2 + exit 1 +} + +command -v docker >/dev/null 2>&1 || die "未找到 Docker,请先安装 Docker。" +[[ "${HOST_PORT}" =~ ^[0-9]+$ ]] || die "NGINX_HOST_PORT 必须是数字。" +(( HOST_PORT >= 1 && HOST_PORT <= 65535 )) || die "NGINX_HOST_PORT 必须在 1-65535 范围内。" + +echo "部署参数:" +echo " 镜像:${IMAGE}" +echo " 容器:${CONTAINER_NAME}" +echo " 端口:${HOST_PORT}:${CONTAINER_PORT}" + +if ! docker image inspect "${IMAGE}" >/dev/null 2>&1 && [[ -f "${ARCHIVE}" ]]; then + echo "[1/4] 从镜像包加载 ${ARCHIVE}" + case "${ARCHIVE}" in + *.tar.gz|*.tgz) gzip -cd -- "${ARCHIVE}" | docker load ;; + *) docker load --input "${ARCHIVE}" ;; + esac +fi + +if ! docker image inspect "${IMAGE}" >/dev/null 2>&1; then + echo "[1/4] 未找到本地镜像,自动构建 ${IMAGE}" + docker build \ + --pull \ + --build-arg "NGINX_VERSION=${NGINX_VERSION}" \ + --tag "${IMAGE}" \ + "${SCRIPT_DIR}" +else + echo "[1/4] 已找到镜像 ${IMAGE}" +fi + +existing_container=0 +if docker container inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then + existing_container=1 + current_image="$(docker inspect -f '{{.Config.Image}}' "${CONTAINER_NAME}")" + current_host_port="$(docker inspect -f '{{with (index .HostConfig.PortBindings "80/tcp")}}{{(index . 0).HostPort}}{{end}}' "${CONTAINER_NAME}" 2>/dev/null || true)" + + if [[ "${current_image}" != "${IMAGE}" || "${current_host_port}" != "${HOST_PORT}" ]]; then + if [[ "${FORCE_RECREATE}" != "1" ]]; then + die "已存在同名容器但镜像或端口不匹配。确认后可设置 FORCE_RECREATE=1 再执行。" + fi + echo "[2/4] FORCE_RECREATE=1,重建已有容器 ${CONTAINER_NAME}" + docker rm -f "${CONTAINER_NAME}" >/dev/null + existing_container=0 + fi +fi + +if (( existing_container == 0 )); then + echo "[2/4] 创建容器 ${CONTAINER_NAME}" + docker run -d \ + --name "${CONTAINER_NAME}" \ + --restart unless-stopped \ + --publish "${HOST_PORT}:${CONTAINER_PORT}" \ + --log-opt max-size=10m \ + --log-opt max-file=3 \ + "${IMAGE}" >/dev/null +else + running="$(docker inspect -f '{{.State.Running}}' "${CONTAINER_NAME}")" + if [[ "${running}" != "true" ]]; then + echo "[2/4] 启动已存在的容器 ${CONTAINER_NAME}" + docker start "${CONTAINER_NAME}" >/dev/null + else + echo "[2/4] 容器 ${CONTAINER_NAME} 已在运行" + fi +fi + +echo "[3/4] 等待容器就绪" +for _ in {1..30}; do + running="$(docker inspect -f '{{.State.Running}}' "${CONTAINER_NAME}" 2>/dev/null || true)" + [[ "${running}" == "true" ]] && break + sleep 1 +done +[[ "${running}" == "true" ]] || die "容器未能进入运行状态。" + +echo "[4/4] 验证 HTTP 服务" +url="http://127.0.0.1:${HOST_PORT}/" +http_ok=0 +if command -v curl >/dev/null 2>&1; then + for _ in {1..30}; do + if curl --fail --silent --max-time 2 "${url}" >/dev/null; then + http_ok=1 + break + fi + sleep 1 + done +elif command -v wget >/dev/null 2>&1; then + for _ in {1..30}; do + if wget -q -O /dev/null --timeout=2 "${url}"; then + http_ok=1 + break + fi + sleep 1 + done +else + echo "警告:系统没有 curl 或 wget,跳过 HTTP 验证。" >&2 + http_ok=1 +fi +(( http_ok == 1 )) || die "HTTP 验证失败:${url}" + +nginx_version="$(docker exec "${CONTAINER_NAME}" nginx -v 2>&1 || true)" +echo +echo "部署成功。" +echo " 容器:${CONTAINER_NAME}" +echo " 端口:0.0.0.0:${HOST_PORT} -> ${CONTAINER_PORT}" +echo " 版本:${nginx_version}" +echo " 查看状态:docker ps --filter name=${CONTAINER_NAME}" diff --git a/nginx-1.28-deployment/docker-compose.yml b/nginx-1.28-deployment/docker-compose.yml new file mode 100644 index 00000000..3089d376 --- /dev/null +++ b/nginx-1.28-deployment/docker-compose.yml @@ -0,0 +1,11 @@ +services: + nginx: + build: + context: . + args: + NGINX_VERSION: "${NGINX_VERSION:-1.28}" + image: "${NGINX_IMAGE:-nginx-oneclick:1.28}" + container_name: "${NGINX_CONTAINER_NAME:-nginx-1.28}" + restart: unless-stopped + ports: + - "${NGINX_HOST_PORT:-80}:80" diff --git a/nginx-1.28-deployment/nginx-oneclick-1.28.tar.gz.sha256 b/nginx-1.28-deployment/nginx-oneclick-1.28.tar.gz.sha256 new file mode 100644 index 00000000..95f1e3b7 --- /dev/null +++ b/nginx-1.28-deployment/nginx-oneclick-1.28.tar.gz.sha256 @@ -0,0 +1 @@ +d322ba812dcd3e89fb8c0afd14f27e79e73432f7bc5a5a52324a0ca67e784014 nginx-oneclick-1.28.tar.gz diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 0ca4dcc0..d053b425 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -462,6 +462,7 @@ See the [LICENSE](../LICENSE) file in the project root. ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. + ## Position roundtable durable history The position-roundtable workflow stores database-owned recovery snapshots for