Merge branch 'dhk-1' into 'master'
Dhk 1 See merge request knowledge-base-bj/deerflow-code!12
This commit is contained in:
commit
bd8ec41914
29 files changed
+1210
-74
No files matched your search
@@ -40,6 +40,10 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
|
||||
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
|
||||
|
||||
// routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
|
||||
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
|
||||
VITE_ROUTE_LOGIN_PASSWORD: "",
|
||||
|
||||
// 轻应用「新建笔记」系列接口地址。
|
||||
VITE_NOTE_API_BASE_URL: "http://47.88.25.99:8000",
|
||||
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
|
||||
.sidebar-contnet--header-hidden {
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar-contnet--header-hidden .item-nav-content {
|
||||
height: calc(100vh - 20px);
|
||||
min-height: calc(100vh - 20px);
|
||||
}
|
||||
|
||||
.sidebar-contents {
|
||||
width: 98px;
|
||||
min-height: calc(100% - 20px);
|
||||
@@ -618,6 +629,12 @@ html.lightyellow .light-topnav-item--active::after {
|
||||
width: 236px;
|
||||
}
|
||||
|
||||
/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */
|
||||
.light-sidebar--header-hidden {
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
|
||||
.light-sidebar__primary {
|
||||
flex: 0 0 96px;
|
||||
|
||||
@@ -17,9 +17,15 @@ type PageSidebarDarkProps = {
|
||||
/** 是否收起(true 时只渲染左下角浮动入口,sidebar 主体隐藏) */
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
/** 全局 Header 隐藏时,侧边栏从视口顶部开始占满高度。 */
|
||||
headerHidden?: boolean;
|
||||
};
|
||||
|
||||
export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) {
|
||||
export function PageSidebarDark({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
headerHidden = false,
|
||||
}: PageSidebarDarkProps) {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||||
const inWujieEnv = isWujieEnv();
|
||||
@@ -213,7 +219,11 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`sidebar-contnet ${inWujieEnv ? "" : "sidebar-contnet--top-submenu"}`}>
|
||||
<aside
|
||||
className={`sidebar-contnet ${inWujieEnv ? "" : "sidebar-contnet--top-submenu"} ${
|
||||
headerHidden ? "sidebar-contnet--header-hidden" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="sidebar-contents">
|
||||
{sidebarMenuItemsToRender.map((item) => (
|
||||
<div
|
||||
|
||||
@@ -49,6 +49,8 @@ import "@/components/page-sdebar.css";
|
||||
type PageSidebarProps = {
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
/** Global Header is hidden but this sidebar remains available. */
|
||||
headerHidden?: boolean;
|
||||
};
|
||||
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||||
@@ -79,23 +81,28 @@ const findFirstRoutePath = (items?: MenuItem[]): string | null => {
|
||||
* - wujie 嵌入环境:顶栏被宿主隐藏,一级导航无处安放,故回落到原手风琴整树菜单
|
||||
* (保证嵌入模式下所有层级仍可达,零回归)。
|
||||
*/
|
||||
export function PageSidebar({ collapsed, onToggleCollapsed }: PageSidebarProps) {
|
||||
export function PageSidebar({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
headerHidden = false,
|
||||
}: PageSidebarProps) {
|
||||
if (isWujieEnv()) {
|
||||
return (
|
||||
<LegacyAccordionSidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={onToggleCollapsed}
|
||||
headerHidden={headerHidden}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <SecondaryNavSidebar />;
|
||||
return <SecondaryNavSidebar headerHidden={headerHidden} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。
|
||||
* 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。
|
||||
*/
|
||||
function SecondaryNavSidebar() {
|
||||
function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||||
const { t } = useI18n();
|
||||
@@ -150,7 +157,11 @@ function SecondaryNavSidebar() {
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<aside className={`light-sidebar ${expanded ? "light-sidebar--expanded" : ""}`}>
|
||||
<aside
|
||||
className={`light-sidebar ${expanded ? "light-sidebar--expanded" : ""} ${
|
||||
headerHidden ? "light-sidebar--header-hidden" : ""
|
||||
}`}
|
||||
>
|
||||
{/* 一级列:二级图标卡片 + 底部设置 */}
|
||||
<div className="light-sidebar__primary">
|
||||
<nav className="light-sidebar__list" aria-label="二级菜单">
|
||||
@@ -287,6 +298,7 @@ function SecondaryNavSidebar() {
|
||||
function LegacyAccordionSidebar({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
headerHidden,
|
||||
}: PageSidebarProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
@@ -438,7 +450,9 @@ function LegacyAccordionSidebar({
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<aside
|
||||
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
||||
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
||||
headerHidden ? "top-0 h-screen" : "top-16 h-[calc(100vh-4rem)]"
|
||||
} ${
|
||||
isDark
|
||||
? "border-r border-blue-800/30 bg-slate-900"
|
||||
: "border-r border-red-100 bg-white"
|
||||
|
||||
@@ -184,7 +184,7 @@ export function WorkspaceNavChatList({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{/* <SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
|
||||
asChild
|
||||
@@ -192,10 +192,10 @@ export function WorkspaceNavChatList({
|
||||
>
|
||||
<Link href="/page/workspace/roundtable/position">
|
||||
<BriefcaseBusiness />
|
||||
<span>岗位会商1</span>
|
||||
<span>岗位会商</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem> */}
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
|
||||
@@ -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<Record<string, string>> = {
|
||||
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] ?? "";
|
||||
}
|
||||
@@ -43,6 +43,8 @@ export interface EmbedSession {
|
||||
theme: EmbedTheme;
|
||||
/** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */
|
||||
hideJump: boolean;
|
||||
/** Only hide the global Header while retaining PageSidebar and normal app behavior. */
|
||||
hideHeader: boolean;
|
||||
/**
|
||||
* 仅去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权。
|
||||
* 区别于 `embed`:嵌入态会额外强制主题 + 走 sessionStorage 隔离登录身份;`hideChrome` 只想要
|
||||
@@ -79,6 +81,11 @@ export function readHideJumpParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "hideJump");
|
||||
}
|
||||
|
||||
/** Read the URL's `hideHeader` three-state flag (hide only the global Header). */
|
||||
export function readHideHeaderParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "hideHeader");
|
||||
}
|
||||
|
||||
/** 读 URL 里的 `hideChrome` 三态(仅去全局导航栏 + 侧边栏)。 */
|
||||
export function readHideChromeParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "hideChrome");
|
||||
@@ -104,6 +111,7 @@ export function readEmbedSession(): EmbedSession | null {
|
||||
embed: p.embed === true,
|
||||
theme: p.theme === "dark" ? "dark" : "light",
|
||||
hideJump: p.hideJump === true,
|
||||
hideHeader: p.hideHeader === true,
|
||||
hideChrome: p.hideChrome === true,
|
||||
isolate: p.isolate === true,
|
||||
};
|
||||
@@ -115,7 +123,7 @@ export function readEmbedSession(): EmbedSession | null {
|
||||
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
|
||||
function writeEmbedSession(next: EmbedSession): void {
|
||||
try {
|
||||
if (!next.embed && !next.hideJump && !next.hideChrome && !next.isolate) {
|
||||
if (!next.embed && !next.hideJump && !next.hideHeader && !next.hideChrome && !next.isolate) {
|
||||
sessionStorage.removeItem(EMBED_SESSION_KEY);
|
||||
return;
|
||||
}
|
||||
@@ -135,6 +143,16 @@ export function isJumpBarHidden(): boolean {
|
||||
return readEmbedSession()?.hideJump === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this tab should hide its global Header.
|
||||
* `embed` and `hideChrome` also hide the Header, while `hideHeader` alone
|
||||
* deliberately leaves PageSidebar visible.
|
||||
*/
|
||||
export function isHeaderHidden(): boolean {
|
||||
const session = readEmbedSession();
|
||||
return session?.embed === true || session?.hideChrome === true || session?.hideHeader === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本标签页是否要去全局 chrome(Header 导航栏 + PageSidebar 侧边栏)。
|
||||
* `embed` 已隐含去 chrome,故这里取「embed || hideChrome」之并——任一置位都隐藏。
|
||||
@@ -166,7 +184,14 @@ export function isIsolatedSession(): boolean {
|
||||
export function syncEmbedSessionFromSearch(search: string): void {
|
||||
const cur =
|
||||
readEmbedSession() ??
|
||||
{ embed: false, theme: "light" as EmbedTheme, hideJump: false, hideChrome: false, isolate: false };
|
||||
{
|
||||
embed: false,
|
||||
theme: "light" as EmbedTheme,
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: false,
|
||||
};
|
||||
const next: EmbedSession = { ...cur };
|
||||
|
||||
const embedParam = readEmbedParam(search);
|
||||
@@ -181,6 +206,10 @@ export function syncEmbedSessionFromSearch(search: string): void {
|
||||
if (hideJumpParam === "on") next.hideJump = true;
|
||||
else if (hideJumpParam === "off") next.hideJump = false;
|
||||
|
||||
const hideHeaderParam = readHideHeaderParam(search);
|
||||
if (hideHeaderParam === "on") next.hideHeader = true;
|
||||
else if (hideHeaderParam === "off") next.hideHeader = false;
|
||||
|
||||
const hideChromeParam = readHideChromeParam(search);
|
||||
if (hideChromeParam === "on") next.hideChrome = true;
|
||||
else if (hideChromeParam === "off") next.hideChrome = false;
|
||||
@@ -232,6 +261,13 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined
|
||||
export function setIsolatedSession(on: boolean): void {
|
||||
const cur =
|
||||
readEmbedSession() ??
|
||||
{ embed: false, theme: "light" as EmbedTheme, hideJump: false, hideChrome: false, isolate: false };
|
||||
{
|
||||
embed: false,
|
||||
theme: "light" as EmbedTheme,
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: false,
|
||||
};
|
||||
writeEmbedSession({ ...cur, isolate: on });
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import test from "node:test";
|
||||
import {
|
||||
buildChatsIframeRedirectPath,
|
||||
buildChatsIframeThreadPath,
|
||||
isExplicitBrainyIframeMode,
|
||||
isChatsIframeWorkspacePath,
|
||||
resolvePostLoginRedirectPath,
|
||||
resolveForcedThemeForPath,
|
||||
resolveStandardChatFallback,
|
||||
} from "./chats-iframe-route.ts";
|
||||
|
||||
test("matches only workspace chats_iframe routes", () => {
|
||||
@@ -35,6 +37,42 @@ test("forces light theme for landing and chats_iframe routes only", () => {
|
||||
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
|
||||
});
|
||||
|
||||
test("requires an explicit chatMode=iframe opt-in for the Brainy page", () => {
|
||||
assert.equal(isExplicitBrainyIframeMode(""), false);
|
||||
assert.equal(isExplicitBrainyIframeMode("?from=iframe"), false);
|
||||
assert.equal(isExplicitBrainyIframeMode("?chatMode=full"), false);
|
||||
assert.equal(isExplicitBrainyIframeMode("?chatMode=iframe"), true);
|
||||
assert.equal(isExplicitBrainyIframeMode("chatMode=IFRAME"), true);
|
||||
});
|
||||
|
||||
test("redirects accidental chats_iframe routes to standard full chat", () => {
|
||||
assert.equal(
|
||||
resolveStandardChatFallback(
|
||||
"/page/workspace/chats_iframe/new",
|
||||
"?from=iframe&hideChrome=1",
|
||||
),
|
||||
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
|
||||
);
|
||||
assert.equal(
|
||||
resolveStandardChatFallback(
|
||||
"/page/workspace/chats_iframe/thread-123",
|
||||
"?hideChrome=1",
|
||||
),
|
||||
"/page/workspace/chats/thread-123?hideChrome=1&chatMode=full",
|
||||
);
|
||||
assert.equal(
|
||||
resolveStandardChatFallback(
|
||||
"/page/workspace/chats_iframe/new",
|
||||
"?chatMode=iframe",
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveStandardChatFallback("/page/workspace/chats/new", ""),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("honors the dark-blue theme query on chats_iframe routes", () => {
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath(
|
||||
@@ -121,3 +159,18 @@ test("preserves iframe theme when a new chat becomes a thread", () => {
|
||||
"/page/workspace/chats_iframe/thread-123?from=iframe&theme=dark-blue",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves explicit Brainy iframe mode and theme across redirects", () => {
|
||||
assert.equal(
|
||||
buildChatsIframeRedirectPath("?chatMode=iframe&theme=dark-blue&ticket=secret"),
|
||||
"/page/workspace/chats_iframe/new?chatMode=iframe&theme=dark-blue",
|
||||
);
|
||||
assert.equal(
|
||||
buildChatsIframeThreadPath(
|
||||
"/page/workspace",
|
||||
"thread-123",
|
||||
"?chatMode=iframe&theme=dark-blue",
|
||||
),
|
||||
"/page/workspace/chats_iframe/thread-123?chatMode=iframe&theme=dark-blue",
|
||||
);
|
||||
});
|
||||
@@ -23,6 +23,10 @@ export function buildChatsIframeRedirectPath(search: string): string {
|
||||
target.set("from", IFRAME_LOGIN_SOURCE);
|
||||
}
|
||||
|
||||
if (isExplicitBrainyIframeMode(search)) {
|
||||
target.set("chatMode", "iframe");
|
||||
}
|
||||
|
||||
const theme = source.get("theme")?.trim();
|
||||
if (theme) {
|
||||
target.set("theme", theme);
|
||||
@@ -59,6 +63,53 @@ export function isChatsIframeWorkspacePath(pathname: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lightweight iframe page is a private transport for the Brainy
|
||||
* postMessage protocol, not a general-purpose embedded chat page.
|
||||
*
|
||||
* Requiring an explicit mode prevents legacy `from=iframe` links, ordinary
|
||||
* iframes and micro-frontend containers from accidentally landing on the
|
||||
* protocol-only page.
|
||||
*/
|
||||
export function isExplicitBrainyIframeMode(search: string): boolean {
|
||||
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
|
||||
return (
|
||||
new URLSearchParams(normalizedSearch)
|
||||
.get("chatMode")
|
||||
?.trim()
|
||||
.toLowerCase() === "iframe"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an accidental/legacy `chats_iframe` URL to the equivalent standard
|
||||
* chat URL. Existing thread ids and unrelated query parameters are retained.
|
||||
*/
|
||||
export function resolveStandardChatFallback(
|
||||
pathname: string,
|
||||
search: string,
|
||||
): string | null {
|
||||
if (
|
||||
!isChatsIframeWorkspacePath(pathname) ||
|
||||
isExplicitBrainyIframeMode(search)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const routePrefix = "/page/workspace/chats_iframe/";
|
||||
const routeSuffix = pathname.slice(routePrefix.length);
|
||||
const targetPath =
|
||||
routeSuffix && routeSuffix !== "new"
|
||||
? `/page/workspace/chats/${routeSuffix}`
|
||||
: "/page/workspace/chats/new";
|
||||
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
|
||||
const params = new URLSearchParams(normalizedSearch);
|
||||
params.delete("from");
|
||||
params.set("chatMode", "full");
|
||||
const targetSearch = params.toString();
|
||||
return targetSearch ? `${targetPath}?${targetSearch}` : targetPath;
|
||||
}
|
||||
|
||||
export function resolveForcedThemeForPath(
|
||||
pathname: string,
|
||||
search = "",
|
||||
|
||||
@@ -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<BusinessMapping | null>(null);
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [selectedChainId, setSelectedChainId] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 = (
|
||||
<>
|
||||
<div className="eightbf-roundtable__chain-status" aria-live="polite">
|
||||
<span className="eightbf-roundtable__chain-status-label">8BF 业务链</span>
|
||||
<span className="eightbf-roundtable__chain-status-value">
|
||||
{isLoading ? "加载中…" : mapping?.chainTitle || "待管理员配置"}
|
||||
</span>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
className="eightbf-roundtable__chain-settings-button"
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
配置业务链
|
||||
</button>
|
||||
)}
|
||||
{isSettingsOpen && (
|
||||
<div className="eightbf-roundtable__settings-mask" role="presentation">
|
||||
<section
|
||||
className="eightbf-roundtable__settings-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="eightbf-chain-settings-title"
|
||||
>
|
||||
<div className="eightbf-roundtable__settings-heading">
|
||||
<div>
|
||||
<p className="eightbf-roundtable__settings-kicker">管理员配置</p>
|
||||
<h2 id="eightbf-chain-settings-title">设置 8BF 公共业务链</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="eightbf-roundtable__settings-close"
|
||||
onClick={() => setIsSettingsOpen(false)}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="eightbf-roundtable__settings-tip">
|
||||
保存后,所有 8BF 用户都会使用这条已发布业务链;会商记录仍按用户独立保存。
|
||||
</p>
|
||||
<label className="eightbf-roundtable__settings-field">
|
||||
<span>已发布业务链</span>
|
||||
<select value={selectedChainId} onChange={(event) => setSelectedChainId(event.target.value)}>
|
||||
<option value="">请选择业务链</option>
|
||||
{chains.map((chain) => (
|
||||
<option key={chain.id} value={chain.id}>
|
||||
{chain.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{error && <p className="eightbf-roundtable__settings-error">{error}</p>}
|
||||
<div className="eightbf-roundtable__settings-actions">
|
||||
<button type="button" onClick={() => setIsSettingsOpen(false)}>取消</button>
|
||||
<button type="button" onClick={() => void handleSave()} disabled={isSaving}>
|
||||
{isSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||
保存并应用
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && mapping?.chainId && <Check className="eightbf-roundtable__configured-icon h-4 w-4" aria-label="已配置" />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<RoundtablePlanningPage
|
||||
compactChrome={compactChrome}
|
||||
lockedChainId={mapping?.chainId ?? null}
|
||||
lockBusinessChain
|
||||
storageTaskId={storageTaskId}
|
||||
toolbarSlot={toolbarSlot}
|
||||
rootClassName="eightbf-roundtable"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 步的任务理解面板与业务链流程图样式。 */
|
||||
@@ -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; }
|
||||
|
||||
/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */
|
||||
@@ -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 步报告、流程图与放大弹窗的样式。 */
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
BookMarkedIcon,
|
||||
BotIcon,
|
||||
BrainCircuitIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
ShrinkIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
@@ -26,7 +28,7 @@ import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||||
import { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent";
|
||||
import { getAccessToken } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -44,6 +46,7 @@ import { usePluginManagementSettings } from "@/core/system-settings/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type BrainyChatSubmitMessage,
|
||||
@@ -129,6 +132,7 @@ function postBrainyAuthToken(accessToken: string) {
|
||||
export default function IframeChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
@@ -144,6 +148,34 @@ export default function IframeChatPage() {
|
||||
useState<PendingIframeSubmit | null>(null);
|
||||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||||
useAgentSelector();
|
||||
// The protocol page must still be escapable when no curated selector list
|
||||
// has been configured. Include the user's own and public agents in this
|
||||
// page's native fallback picker. A native select also remains interactive
|
||||
// in iframe/micro-frontend environments where a portal dialog can fail.
|
||||
const { agents: mineAgents } = useAgentsPaginated({
|
||||
scope: "mine",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
const { agents: squareAgents } = useAgentsPaginated({
|
||||
scope: "square",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
const fallbackAgents = useMemo(() => {
|
||||
const uniqueAgents = new Map<
|
||||
string,
|
||||
(typeof selectorAgents)[number]
|
||||
>();
|
||||
for (const agent of [
|
||||
...selectorAgents,
|
||||
...mineAgents,
|
||||
...squareAgents,
|
||||
]) {
|
||||
uniqueAgents.set(agent.id, agent);
|
||||
}
|
||||
return [...uniqueAgents.values()];
|
||||
}, [mineAgents, selectorAgents, squareAgents]);
|
||||
const { data: pluginManagementSettings } = usePluginManagementSettings();
|
||||
const pendingRequestIdRef = useRef<string | null>(null);
|
||||
const handleSubmitRef = useRef<
|
||||
@@ -352,6 +384,30 @@ export default function IframeChatPage() {
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Manual fallback for accidental protocol-page visits.
|
||||
*
|
||||
* A real Brainy integration drives this page with postMessage. A human who
|
||||
* reaches it directly should not be trapped here: move them to the standard
|
||||
* chat workspace, optionally opening the selected agent.
|
||||
*/
|
||||
const enterStandardChat = useCallback(
|
||||
(agentId?: string) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("from");
|
||||
params.set("chatMode", "full");
|
||||
params.delete("agent");
|
||||
if (agentId) {
|
||||
params.set("agent", agentId);
|
||||
saveLastAgentId(agentId);
|
||||
}
|
||||
navigate(`${routeBase}/chats/new?${params.toString()}`, {
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[location.search, navigate, routeBase],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
@@ -650,15 +706,57 @@ export default function IframeChatPage() {
|
||||
</div>
|
||||
</div>
|
||||
{isLandingState && mounted && (
|
||||
<div className="mb-6 flex min-h-40 flex-col items-center justify-center">
|
||||
<div className="mb-6 flex min-h-40 flex-col items-center justify-center px-4 text-center">
|
||||
<div className="text-2xl font-medium">你好</div>
|
||||
<div className="mt-4">
|
||||
<AgentSelector
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentChange={handleAgentChange}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
选择智能体进入问答,或直接使用通用问答
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
<label className="relative inline-flex h-9 min-w-40 items-center rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 text-[#7e6a57] shadow-sm transition-colors hover:bg-white">
|
||||
<BotIcon className="mr-2 size-3.5 shrink-0" />
|
||||
<select
|
||||
aria-label="选择智能体并进入问答"
|
||||
defaultValue=""
|
||||
disabled={isUploading || selectorLoading}
|
||||
onChange={(event) => {
|
||||
const agentId = event.target.value;
|
||||
if (agentId) enterStandardChat(agentId);
|
||||
}}
|
||||
className="min-w-0 flex-1 cursor-pointer appearance-none bg-transparent pr-5 text-xs outline-none disabled:cursor-wait"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{selectorLoading ? "正在加载智能体…" : "选择 Agent"}
|
||||
</option>
|
||||
{fallbackAgents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{desensitize(agent.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute right-3 text-[10px]"
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isUploading}
|
||||
onClick={() => enterStandardChat()}
|
||||
className="rounded-full"
|
||||
>
|
||||
直接进入通用问答
|
||||
<ArrowRightIcon className="ml-1 size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{!selectorLoading && fallbackAgents.length === 0 && (
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
暂无可选智能体,仍可直接进入通用问答
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
|
||||
@@ -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,
|
||||
@@ -28,6 +33,7 @@ import {
|
||||
persistToolsToken,
|
||||
} from '@/core/auth/yuser-login';
|
||||
import {
|
||||
buildChatsIframeRedirectPath,
|
||||
isChatsIframeLogin,
|
||||
resolvePostLoginRedirectPath,
|
||||
} from '@/core/page-layout/chats-iframe-route';
|
||||
@@ -70,6 +76,8 @@ function summarizeStoredUserInfo(): Record<string, unknown> | null {
|
||||
}
|
||||
|
||||
const DEFAULT_USERNAME = 'guest';
|
||||
const IFRAME_LOGIN_SOURCE = 'iframe';
|
||||
const IFRAME_CHAT_MODE = 'iframe';
|
||||
const DEFAULT_REDIRECT_PATH = '/page/workspace/chats/new';
|
||||
const FULL_CHAT_MODE = 'full';
|
||||
|
||||
@@ -92,20 +100,26 @@ 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 isIframeLogin = useMemo(
|
||||
() =>
|
||||
!fullChatRequested && isChatsIframeLogin(searchParams.toString()),
|
||||
[fullChatRequested, searchParams],
|
||||
brainyIframeRequested ||
|
||||
(!fullChatRequested && isChatsIframeLogin(searchParams.toString())),
|
||||
[brainyIframeRequested, fullChatRequested, searchParams],
|
||||
);
|
||||
const resolvedUsername = useMemo(() => {
|
||||
const raw = username?.trim();
|
||||
@@ -120,6 +134,9 @@ export default function LoginPage() {
|
||||
// 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 buildChatsIframeRedirectPath(searchParams.toString());
|
||||
}
|
||||
if (fullChatRequested) {
|
||||
return `${DEFAULT_REDIRECT_PATH}?chatMode=${FULL_CHAT_MODE}`;
|
||||
}
|
||||
@@ -128,7 +145,7 @@ export default function LoginPage() {
|
||||
DEFAULT_REDIRECT_PATH,
|
||||
);
|
||||
},
|
||||
[fullChatRequested, searchParams, yUserId],
|
||||
[brainyIframeRequested, fullChatRequested, searchParams, yUserId],
|
||||
);
|
||||
const toolToken = searchParams.get('token')?.trim() ?? '';
|
||||
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
|
||||
@@ -137,6 +154,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() ?? '';
|
||||
@@ -152,6 +177,7 @@ export default function LoginPage() {
|
||||
// (token/authToken/ticket 等)一律**不透传**,避免泄漏到应用页地址栏。
|
||||
const redirectTo = useMemo(() => {
|
||||
const raw =
|
||||
routeKeyTarget ||
|
||||
searchParams.get('redirect')?.trim() ||
|
||||
searchParams.get('route')?.trim() ||
|
||||
'';
|
||||
@@ -172,7 +198,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;
|
||||
@@ -181,7 +207,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') ?? '',
|
||||
@@ -260,7 +286,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,
|
||||
@@ -305,7 +333,7 @@ export default function LoginPage() {
|
||||
persistToolsToken(toolToken);
|
||||
}
|
||||
|
||||
auth = await loginByUsername(deerflowUsername, gatePassword);
|
||||
auth = await loginByUsername(deerflowUsername, effectiveGatePassword);
|
||||
loginUserName = deerflowUsername;
|
||||
}
|
||||
if (cancelled) {
|
||||
@@ -407,6 +435,9 @@ export default function LoginPage() {
|
||||
access_token,
|
||||
onlineTicket,
|
||||
authToken,
|
||||
effectiveGatePassword,
|
||||
usesConfiguredRouteLoginPassword,
|
||||
routeKey,
|
||||
gatePassword,
|
||||
taskId,
|
||||
goPath,
|
||||
|
||||
@@ -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 (
|
||||
<RoundtableJobNotifier>
|
||||
<div className="h-full flex flex-col bg-muted/20">
|
||||
{pageSidebarAllowed && <Header collapsed={collapsed} />}
|
||||
{pageSidebarAllowed && !headerHidden && <Header collapsed={collapsed} />}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{pageSidebarAllowed && (
|
||||
isDark ? (
|
||||
<PageSidebarDark
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
|
||||
headerHidden={headerHidden}
|
||||
/>
|
||||
) : (
|
||||
<PageSidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => 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
|
||||
|
||||
@@ -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 (
|
||||
<RouteBaseProvider value="/page/strategy">
|
||||
<SidebarProvider>
|
||||
@@ -92,6 +96,14 @@ export default function StrategyRoutes() {
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="qa/8bf-roundtable"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<EightBfRoundtablePage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="qa/position-roundtable"
|
||||
element={
|
||||
@@ -312,7 +324,16 @@ export default function StrategyRoutes() {
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
||||
<Route path="memory" element={<MemoryPage />} />
|
||||
<Route path="business-chain-mapping" element={<BusinessChainMappingPage />} />
|
||||
<Route
|
||||
path="business-chain-mapping"
|
||||
element={
|
||||
canManageBusinessMappings ? (
|
||||
<BusinessChainMappingPage />
|
||||
) : (
|
||||
<Navigate to="/page/strategy/qa/general" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="business-chains" element={<BusinessChainListPage />} />
|
||||
<Route path="roundtable/chains" element={<BusinessChainEditorPage />} />
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -257,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,
|
||||
@@ -279,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 (
|
||||
<Navigate
|
||||
to={targetSearch ? `${targetPath}?${targetSearch}` : targetPath}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
if (standardChatTarget) {
|
||||
return <Navigate to={standardChatTarget} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -493,6 +482,16 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/8bf"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<EightBfRoundtablePage compactChrome={headerHidden && !compactActionsHidden} />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/position"
|
||||
element={
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* 5) Persona 抽屉改的 model 不会立即重启编排,下一轮才生效
|
||||
* 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { setRoundtableErrorContext } from "@/roundtable-planning/api/diagnostics-report";
|
||||
@@ -288,8 +288,23 @@ function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
|
||||
|
||||
export default function RoundtablePlanningPage({
|
||||
compactChrome = false,
|
||||
/** 独立业务页锁定的公共业务链;不影响原多智能体会商入口。 */
|
||||
lockedChainId = null,
|
||||
/** 即使尚未配置业务链,也禁止用户改为推荐/手选链路。 */
|
||||
lockBusinessChain = false,
|
||||
/** 独立业务页的草稿/后台作业存储键。 */
|
||||
storageTaskId,
|
||||
/** 供独立业务页在原页头部插入自己的操作入口。 */
|
||||
toolbarSlot,
|
||||
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
|
||||
rootClassName,
|
||||
}: {
|
||||
compactChrome?: boolean;
|
||||
lockedChainId?: string | null;
|
||||
lockBusinessChain?: boolean;
|
||||
storageTaskId?: string | null;
|
||||
toolbarSlot?: ReactNode;
|
||||
rootClassName?: string;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
|
||||
@@ -309,6 +324,8 @@ export default function RoundtablePlanningPage({
|
||||
readWujiePrefill() ??
|
||||
readPersistedDeeplinkPrefill(location.pathname),
|
||||
);
|
||||
// 8BF 等独立页面可提供专属存储键;原会商仍沿用深链 taskId / 个人草稿语义。
|
||||
const draftStorageTaskId = storageTaskId ?? recordTaskIdOf(prefillRef.current);
|
||||
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
|
||||
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(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 进度) */}
|
||||
<DispatchChainJobModal
|
||||
jobId={chainModalJobId}
|
||||
taskId={prefillRef.current?.taskId ?? null}
|
||||
taskId={draftStorageTaskId ?? null}
|
||||
open={chainModalJobId !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setChainModalJobId(null);
|
||||
@@ -1973,6 +2020,7 @@ export default function RoundtablePlanningPage({
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{toolbarSlot}
|
||||
{/* 深链 taskId 模式:查看线上当前策划进度(目的树)。与单智能体闸门同一条接口。 */}
|
||||
{prefillRef.current?.taskId && (
|
||||
<button
|
||||
@@ -1987,7 +2035,7 @@ export default function RoundtablePlanningPage({
|
||||
)}
|
||||
|
||||
{/* 深链接(taskId)模式:业务链由 businessCode 自动决定,隐藏手动「业务链条」入口。 */}
|
||||
{!inWujieEmbed && !prefillRef.current?.taskId && (
|
||||
{!lockBusinessChain && !inWujieEmbed && !prefillRef.current?.taskId && (
|
||||
<button
|
||||
id="business-chain-btn"
|
||||
onClick={() => setIsChainListOpen(true)}
|
||||
@@ -2118,7 +2166,10 @@ export default function RoundtablePlanningPage({
|
||||
onOpenDraft={drafts.handleLoadDraft}
|
||||
activeDraftId={drafts.currentDraftId}
|
||||
entryRailTaskId={recordTaskIdOf(prefillRef.current)}
|
||||
historyTaskId={draftStorageTaskId}
|
||||
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
|
||||
lockedChainId={lockedChainId}
|
||||
lockBusinessChain={lockBusinessChain}
|
||||
/>
|
||||
|
||||
{/* MIDDLE VIEWPORT */}
|
||||
@@ -2163,6 +2214,14 @@ export default function RoundtablePlanningPage({
|
||||
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
|
||||
return;
|
||||
}
|
||||
if (lockBusinessChain) {
|
||||
triggerToast(
|
||||
autoChainLoading
|
||||
? "正在加载管理员配置的业务链,请稍候…"
|
||||
: "管理员尚未配置可用的业务链,请联系管理员处理",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
inWujieEmbed &&
|
||||
autoChainLoading &&
|
||||
@@ -2662,8 +2721,14 @@ interface LeftSidebarProps {
|
||||
activeDraftId: string | null;
|
||||
/** 深链 taskId 模式:Step1 落地左栏「分析记录」只列该任务的草稿。 */
|
||||
entryRailTaskId?: string | null;
|
||||
/** 独立页面的历史草稿存储键。 */
|
||||
historyTaskId?: string | null;
|
||||
/** 深链业务码:左上角卡片据此决定显示「当前任务」(rwfx/3qfx) 还是「当前行动」(xdfx)。 */
|
||||
entryRailBusinessCode?: string | null;
|
||||
/** 独立页面禁止在左栏改选业务链。 */
|
||||
lockBusinessChain?: boolean;
|
||||
/** 独立页面锁定显示的业务链。 */
|
||||
lockedChainId?: string | null;
|
||||
}
|
||||
|
||||
function Step2SandboxSidebarSync({
|
||||
@@ -2710,7 +2775,10 @@ function LeftSidebar({
|
||||
onOpenDraft,
|
||||
activeDraftId,
|
||||
entryRailTaskId,
|
||||
historyTaskId,
|
||||
entryRailBusinessCode,
|
||||
lockBusinessChain,
|
||||
lockedChainId,
|
||||
}: LeftSidebarProps) {
|
||||
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
|
||||
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
|
||||
@@ -2849,7 +2917,10 @@ function LeftSidebar({
|
||||
onOpenDraft={onOpenDraft}
|
||||
activeDraftId={activeDraftId}
|
||||
taskId={entryRailTaskId ?? null}
|
||||
historyTaskId={historyTaskId ?? null}
|
||||
businessCode={entryRailBusinessCode ?? null}
|
||||
lockBusinessChain={lockBusinessChain}
|
||||
lockedChainId={lockedChainId ?? null}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
@@ -2900,7 +2971,10 @@ function RoundtableEntryRail({
|
||||
onOpenDraft,
|
||||
activeDraftId,
|
||||
taskId,
|
||||
historyTaskId,
|
||||
businessCode,
|
||||
lockedChainId,
|
||||
lockBusinessChain = false,
|
||||
}: {
|
||||
selectedChainId: string | null;
|
||||
onSelectChain: (chain: BusinessChain) => void;
|
||||
@@ -2908,13 +2982,18 @@ function RoundtableEntryRail({
|
||||
activeDraftId: string | null;
|
||||
/** 深链 taskId 模式:分析记录只列该任务的草稿。 */
|
||||
taskId?: string | null;
|
||||
/** 独立页面的草稿存储键;不改变当前任务卡片/业务链区域的展示语义。 */
|
||||
historyTaskId?: string | null;
|
||||
/** 深链业务码:xdfx(7BF) 左上角显示「当前行动」,其余显示「当前任务」。 */
|
||||
businessCode?: string | null;
|
||||
/** 固定业务链时,左侧只展示这条链的流程图及放大入口。 */
|
||||
lockedChainId?: string | null;
|
||||
lockBusinessChain?: boolean;
|
||||
}) {
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
|
||||
const [expandedChainId, setExpandedChainId] = useState<string | null>(null);
|
||||
const { drafts: records, refresh } = useRoundtableDrafts(taskId);
|
||||
const { drafts: records, refresh } = useRoundtableDrafts(historyTaskId ?? taskId);
|
||||
// 分析记录「按业务链筛选」—— 选项从已加载草稿的 chainTitle 去重得到,
|
||||
// 无业务链的草稿(自由 / recommend 模式)归入「自由模式」。
|
||||
const [chainFilter, setChainFilter] = useState<string>(RECORD_FILTER_ALL);
|
||||
@@ -2963,7 +3042,9 @@ function RoundtableEntryRail({
|
||||
let cancelled = false;
|
||||
void listChains("public")
|
||||
.then((all) => {
|
||||
if (!cancelled) setChains(all);
|
||||
if (!cancelled) {
|
||||
setChains(lockBusinessChain ? all.filter((chain) => chain.id === lockedChainId) : all);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* 业务链非关键,静默忽略 */
|
||||
@@ -2972,7 +3053,7 @@ function RoundtableEntryRail({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refresh, deeplink]);
|
||||
}, [refresh, deeplink, lockedChainId, lockBusinessChain]);
|
||||
|
||||
const formatTime = (value: string) => {
|
||||
const date = new Date(value);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.gitignore
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.sha256
|
||||
README*.md
|
||||
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 远程部署,请使用密钥或交互式认证,并在密码已经暴露后及时轮换。
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
d322ba812dcd3e89fb8c0afd14f27e79e73432f7bc5a5a52324a0ca67e784014 nginx-oneclick-1.28.tar.gz
|
||||
@@ -463,6 +463,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
|
||||
|
||||
@@ -21,6 +21,8 @@ from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/business-mapping", tags=["business-mapping"])
|
||||
|
||||
@@ -62,6 +64,54 @@ def _get_store(request: Request):
|
||||
return store
|
||||
|
||||
|
||||
def _is_admin(request: Request) -> bool:
|
||||
"""Treat no-auth deployments as the single administrative workspace."""
|
||||
user = getattr(request.state, "user", None)
|
||||
return user is None or getattr(user, "system_role", None) == "admin"
|
||||
|
||||
|
||||
def _current_user_id(request: Request) -> str:
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is not None:
|
||||
return str(user.id)
|
||||
return get_effective_user_id()
|
||||
|
||||
|
||||
async def _validate_public_chain_mapping(
|
||||
request: Request,
|
||||
fields: dict[str, object],
|
||||
) -> None:
|
||||
"""Require globally mapped chains to remain readable by every account."""
|
||||
if "chain_id" not in fields:
|
||||
return
|
||||
|
||||
raw_chain_id = fields["chain_id"]
|
||||
chain_id = raw_chain_id.strip() if isinstance(raw_chain_id, str) else ""
|
||||
if not chain_id:
|
||||
fields["chain_id"] = None
|
||||
fields["chain_title"] = None
|
||||
return
|
||||
|
||||
chain_store = getattr(request.app.state, "roundtable_chain_store", None)
|
||||
if chain_store is None:
|
||||
raise HTTPException(status_code=503, detail="Business chain store not available")
|
||||
|
||||
chain = await chain_store.get_chain(
|
||||
chain_id,
|
||||
_current_user_id(request),
|
||||
is_admin=True,
|
||||
)
|
||||
if chain is None:
|
||||
raise HTTPException(status_code=400, detail="Business chain not found")
|
||||
if not chain.get("is_public"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Global business mappings require a public business chain",
|
||||
)
|
||||
fields["chain_id"] = chain_id
|
||||
fields["chain_title"] = str(chain.get("title") or "") or None
|
||||
|
||||
|
||||
# ── routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -78,9 +128,12 @@ async def list_mappings(request: Request) -> BusinessMappingListResponse:
|
||||
async def update_mapping(
|
||||
request: Request, business_code: str, body: BusinessMappingUpdateRequest
|
||||
) -> BusinessMappingResponse:
|
||||
if not _is_admin(request):
|
||||
raise HTTPException(status_code=403, detail="Only administrators can update business mappings")
|
||||
store = _get_store(request)
|
||||
# Only forward fields the caller explicitly sent (distinguish omitted vs null).
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
await _validate_public_chain_mapping(request, fields)
|
||||
row = await store.update_mapping(business_code, **fields)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Business mapping not found")
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Authorization and shared-chain invariants for global business mappings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.gateway.routers import business_mapping as business_mapping_router
|
||||
|
||||
|
||||
class _MappingStore:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, object]]] = []
|
||||
|
||||
async def update_mapping(self, business_code: str, **fields: object) -> dict[str, object]:
|
||||
self.calls.append((business_code, fields))
|
||||
return {
|
||||
"business_code": business_code,
|
||||
"label": "岗位多智能体会商",
|
||||
"agent_id": None,
|
||||
"agent_name": None,
|
||||
"chain_id": fields.get("chain_id"),
|
||||
"chain_title": fields.get("chain_title"),
|
||||
"sort_order": 3,
|
||||
}
|
||||
|
||||
|
||||
class _ChainStore:
|
||||
def __init__(self, chain: dict[str, object] | None) -> None:
|
||||
self.chain = chain
|
||||
|
||||
async def get_chain(self, chain_id: str, user_id: str, *, is_admin: bool) -> dict[str, object] | None:
|
||||
assert chain_id == "chain-8bf"
|
||||
assert user_id == "admin-id"
|
||||
assert is_admin is True
|
||||
return self.chain
|
||||
|
||||
|
||||
def _request(role: str, chain: dict[str, object] | None = None):
|
||||
return SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
business_mapping_store=_MappingStore(),
|
||||
roundtable_chain_store=_ChainStore(chain),
|
||||
)
|
||||
),
|
||||
state=SimpleNamespace(user=SimpleNamespace(id="admin-id", system_role=role)),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_administrators_can_update_global_business_mappings() -> None:
|
||||
request = _request("user")
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await business_mapping_router.update_mapping(
|
||||
request,
|
||||
"8BF",
|
||||
business_mapping_router.BusinessMappingUpdateRequest(chain_id="chain-8bf"),
|
||||
)
|
||||
|
||||
assert error.value.status_code == 403
|
||||
assert request.app.state.business_mapping_store.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_mapping_requires_a_public_chain_and_canonicalizes_its_title() -> None:
|
||||
request = _request("admin", {"id": "chain-8bf", "title": "岗位协同链", "is_public": True})
|
||||
|
||||
response = await business_mapping_router.update_mapping(
|
||||
request,
|
||||
"8BF",
|
||||
business_mapping_router.BusinessMappingUpdateRequest(
|
||||
chain_id="chain-8bf",
|
||||
chain_title="客户端伪造的标题",
|
||||
),
|
||||
)
|
||||
|
||||
assert response.chain_id == "chain-8bf"
|
||||
assert response.chain_title == "岗位协同链"
|
||||
assert request.app.state.business_mapping_store.calls == [
|
||||
("8BF", {"chain_id": "chain-8bf", "chain_title": "岗位协同链"})
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_mapping_rejects_a_private_chain() -> None:
|
||||
request = _request("admin", {"id": "chain-8bf", "title": "私有链", "is_public": False})
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await business_mapping_router.update_mapping(
|
||||
request,
|
||||
"8BF",
|
||||
business_mapping_router.BusinessMappingUpdateRequest(chain_id="chain-8bf"),
|
||||
)
|
||||
|
||||
assert error.value.status_code == 400
|
||||
assert request.app.state.business_mapping_store.calls == []
|
||||
Reference in new issue
Block a user