feat(settings): 轻应用登录参数与界面模式管理员默认
- 登录时从路由参数 yUserId/access_token 获取并存入 userInfo - 轻应用管理页路由参数新增 userName/gateDepartName/typeName 选项 - 添加系统默认界面模式设置(管理员可指定全量/简洁) - useLayoutMode hook 支持优先级:用户自选 > 管理员默认 > 会话级 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab22b50faf
commit
01012ff300
11 files changed
+150
-23
No files matched your search
@@ -26,6 +26,7 @@ import { useLocalSettings } from "@/core/settings";
|
||||
import {
|
||||
useAppearanceSettings,
|
||||
useCitationDisplaySettings,
|
||||
useLayoutMode,
|
||||
useUpdateAppearanceSettings,
|
||||
useUpdateCitationDisplaySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
@@ -57,6 +58,9 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
const globalDefault = appearance?.default_theme ?? "light";
|
||||
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||
const globalAgentView = appearance?.default_agent_view ?? "card";
|
||||
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
|
||||
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。
|
||||
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
|
||||
@@ -74,6 +78,18 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefaultLayoutMode = async (value: "full" | "compact") => {
|
||||
if (value === globalLayoutMode) return;
|
||||
try {
|
||||
await updateAppearance.mutateAsync({ default_layout_mode: value });
|
||||
toast.success(
|
||||
`已将系统默认界面模式设置为${value === "full" ? "全量模式" : "简洁模式"}`,
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefaultView = async (
|
||||
kind: "skill" | "agent",
|
||||
value: "card" | "list",
|
||||
@@ -156,10 +172,10 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={settings.display.layoutMode !== "compact" ? "default" : "outline"}
|
||||
variant={effectiveLayoutMode !== "compact" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSettings("display", { layoutMode: "full" });
|
||||
setLayoutMode("full");
|
||||
onClose?.();
|
||||
navigate("/page/workspace/chats/new");
|
||||
}}
|
||||
@@ -169,10 +185,10 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={settings.display.layoutMode === "compact" ? "default" : "outline"}
|
||||
variant={effectiveLayoutMode === "compact" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSettings("display", { layoutMode: "compact" });
|
||||
setLayoutMode("compact");
|
||||
onClose?.();
|
||||
navigate("/page/workspace/chats/new");
|
||||
}}
|
||||
@@ -183,6 +199,43 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="系统默认界面模式(管理员)"
|
||||
description="给所有还没有手动切换过界面模式的用户的默认;已经自己切换过的用户保留其个人选择不受影响。"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalLayoutMode === "full" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetDefaultLayoutMode("full")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<PanelLeftIcon className="mr-1.5 size-4" />
|
||||
全量模式
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalLayoutMode === "compact" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetDefaultLayoutMode("compact")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<LayoutIcon className="mr-1.5 size-4" />
|
||||
简洁模式
|
||||
</Button>
|
||||
<p className="text-muted-foreground ml-2 text-xs">
|
||||
当前系统默认:
|
||||
{globalLayoutMode === "full" ? "全量模式" : "简洁模式"}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
@@ -31,15 +31,44 @@ export async function loginByYUserId(
|
||||
{ yuserId: yUserId },
|
||||
)) as UserDetailResponse;
|
||||
|
||||
const result = res.data;
|
||||
if (!result?.id) {
|
||||
throw new Error("无法根据 yUserId 获取用户信息。");
|
||||
}
|
||||
|
||||
localStorage.setItem(EXTERNAL_USER_ID_STORAGE_KEY, result.id);
|
||||
localStorage.setItem(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(result));
|
||||
// consumer 返回 { accessToken, gateDepartName, typeName, userName }。
|
||||
// 一并存入路由参数 yUserId,使「应用管理」里 source=yUserId 的路由参数能取到(与
|
||||
// source=userName/gateDepartName/typeName 直接取接口值统一走 userInfo[source])。
|
||||
// 注意:接口也会返回 accessToken,但 XK/PG 及 source=accessToken 要用路由透传的
|
||||
// access_token,故登录流程随后会用 persistAccessToken 覆盖 userInfo.accessToken。
|
||||
const result = res.data ?? {};
|
||||
localStorage.setItem(
|
||||
EXTERNAL_USER_INFO_STORAGE_KEY,
|
||||
JSON.stringify({ ...result, yUserId }),
|
||||
);
|
||||
}
|
||||
|
||||
export function persistToolsToken(token: string): void {
|
||||
localStorage.setItem(TOOLS_TOKEN_STORAGE_KEY, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把内网登录路由参数 access_token 合并写入 userInfo.accessToken(覆盖接口返回的同名值)。
|
||||
* 据此拼参的有:XK/PG 中任务 iframe(XK 用 accessToken、PG 用 access_token,二者均读
|
||||
* external-links.getAccessToken() = userInfo.accessToken);以及「应用管理」里
|
||||
* source=accessToken 的轻应用路由参数(resolveRouteParamValue 取 userInfo.accessToken)。
|
||||
* 用合并而非覆盖整对象,避免抹掉 loginByYUserId 从 consumer 拿到的用户信息。
|
||||
*/
|
||||
export function persistAccessToken(token: string): void {
|
||||
const trimmed = token?.trim();
|
||||
if (!trimmed) return;
|
||||
let info: Record<string, unknown> = {};
|
||||
try {
|
||||
const raw = localStorage.getItem(EXTERNAL_USER_INFO_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
info = parsed as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
info = {};
|
||||
}
|
||||
info.accessToken = trimmed;
|
||||
localStorage.setItem(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(info));
|
||||
}
|
||||
@@ -137,17 +137,20 @@ export async function updatePublishSquares(
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "lightblue";
|
||||
export type ViewMode = "card" | "list";
|
||||
export type LayoutMode = "full" | "compact";
|
||||
|
||||
export interface AppearanceSettings {
|
||||
default_theme: ThemeMode;
|
||||
default_skill_view: ViewMode;
|
||||
default_agent_view: ViewMode;
|
||||
default_layout_mode: LayoutMode;
|
||||
}
|
||||
|
||||
export interface AppearanceUpdate {
|
||||
default_theme?: ThemeMode;
|
||||
default_skill_view?: ViewMode;
|
||||
default_agent_view?: ViewMode;
|
||||
default_layout_mode?: LayoutMode;
|
||||
}
|
||||
|
||||
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type PrivateSquareUpdate,
|
||||
type PromptPrefixUpdate,
|
||||
type PublishSquaresUpdate,
|
||||
type LayoutMode,
|
||||
type ViewMode,
|
||||
} from "./api";
|
||||
|
||||
@@ -143,6 +144,27 @@ export function useViewMode(
|
||||
return [mode, setMode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective interface layout mode (full chrome vs compact). A user's own choice
|
||||
* (persisted in local settings) wins; otherwise the admin-configured system
|
||||
* default applies; otherwise `undefined` (callers fall back to the session-level
|
||||
* `sidebarEnabled` flag set by portal / yUserId login). `setMode` persists the
|
||||
* user's override locally.
|
||||
*/
|
||||
export function useLayoutMode(): [
|
||||
LayoutMode | undefined,
|
||||
(mode: LayoutMode) => void,
|
||||
] {
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const userOverride = settings.display.layoutMode;
|
||||
const adminDefault = appearance?.default_layout_mode;
|
||||
const mode = userOverride ?? adminDefault;
|
||||
const setMode = (next: LayoutMode) =>
|
||||
setSettings("display", { layoutMode: next });
|
||||
return [mode, setMode];
|
||||
}
|
||||
|
||||
export function useCitationDisplaySettings() {
|
||||
return useQuery({
|
||||
queryKey: CITATION_DISPLAY_KEY,
|
||||
|
||||
@@ -42,10 +42,10 @@ import {
|
||||
getLastAgentId,
|
||||
saveLastAgentId,
|
||||
useKnowledgeRagEnabled,
|
||||
useLocalSettings,
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
@@ -96,7 +96,8 @@ export default function ChatPage() {
|
||||
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
|
||||
useSidebar();
|
||||
const { t } = useI18n();
|
||||
const [localSettings] = useLocalSettings();
|
||||
// 有效界面模式:用户自选 > 管理员系统默认 > (下方再回退会话级 sidebarEnabled)
|
||||
const [effectiveLayoutMode] = useLayoutMode();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
@@ -418,7 +419,7 @@ export default function ChatPage() {
|
||||
// 优先看外观设置的「全量/简洁」,未设置过才回退到会话级 sidebarEnabled。否则用户显式
|
||||
// 选「全量」时 PageRoutes 已挂出顶栏+侧栏,这里却还按旧 sidebarEnabled 判定,导致深蓝
|
||||
// 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。
|
||||
const layoutMode = localSettings.display.layoutMode;
|
||||
const layoutMode = effectiveLayoutMode;
|
||||
const chromeByPreference =
|
||||
layoutMode === "full"
|
||||
? true
|
||||
|
||||
@@ -16,6 +16,7 @@ import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
|
||||
import {
|
||||
GENERAL_QA_ROUTE,
|
||||
loginByYUserId,
|
||||
persistAccessToken,
|
||||
persistToolsToken,
|
||||
} from '@/core/auth/yuser-login';
|
||||
import {
|
||||
@@ -53,6 +54,8 @@ export default function LoginPage() {
|
||||
// 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage
|
||||
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
|
||||
const toolToken = searchParams.get('token')?.trim() ?? '';
|
||||
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
|
||||
const accessToken = searchParams.get('access_token')?.trim() ?? '';
|
||||
// 用户名直登口令门:开启后端开关时,口令从 ?password= 透传
|
||||
const gatePassword = searchParams.get('password') ?? '';
|
||||
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
|
||||
@@ -137,6 +140,9 @@ export default function LoginPage() {
|
||||
setStoredAuth(auth);
|
||||
// 标准登录把用户名写入 userInfo,供顶栏用户区显示(已有则不覆盖)
|
||||
setStoredUserName(loginUserName);
|
||||
// 内网透传的 access_token 合并进 userInfo(在 loginByYUserId 覆盖之后),
|
||||
// XK/PG 中任务 iframe 据此取 accessToken
|
||||
persistAccessToken(accessToken);
|
||||
persistSidebarEnabled(sidebarEnabled);
|
||||
setStatus('success');
|
||||
router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
|
||||
@@ -161,6 +167,7 @@ export default function LoginPage() {
|
||||
sidebarEnabled,
|
||||
yUserId,
|
||||
toolToken,
|
||||
accessToken,
|
||||
onlineTicket,
|
||||
authToken,
|
||||
gatePassword,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-rout
|
||||
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
|
||||
import WorkspaceRoutes from "./WorkspaceRoutes";
|
||||
import StrategyRoutes from "./StrategyRoutes";
|
||||
@@ -22,11 +22,10 @@ function PageLayout() {
|
||||
const location = useLocation();
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const [settings] = useLocalSettings();
|
||||
// 界面模式开关(外观设置):用户显式选「全量/简洁」时以其为准;未设置过则回退到
|
||||
// 会话级 sidebarEnabled(外部门户/yUserId 登录时置位)这一旧默认。这样无论是否走门户
|
||||
// 登录,「全量↔简洁」切换都能真正控制顶栏+侧边栏的显示。
|
||||
const layoutMode = settings.display.layoutMode;
|
||||
// 界面模式(外观设置):用户显式选「全量/简洁」时以其为准;否则跟随管理员系统默认
|
||||
// (default_layout_mode);两者都没有才回退到会话级 sidebarEnabled(外部门户/yUserId
|
||||
// 登录时置位)这一旧默认。这样无论是否走门户登录,「全量↔简洁」都能真正控制顶栏+侧边栏。
|
||||
const [layoutMode] = useLayoutMode();
|
||||
const chromeByPreference =
|
||||
layoutMode === "full" ? true : layoutMode === "compact" ? false : sidebarEnabled;
|
||||
const pageSidebarAllowed =
|
||||
|
||||
@@ -19,11 +19,17 @@ export function mountLabel(value: string | null | undefined): string {
|
||||
return MOUNT_OPTIONS.find((o) => o.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
/** 登录参数可选来源;值均可在 userInfo 中取到,"other" 表示自填。 */
|
||||
/**
|
||||
* 登录参数可选来源;值均可在 userInfo 中取到,"other" 表示自填。
|
||||
* accessToken / yUserId 取登录路由透传值(写入 userInfo 的同名键);
|
||||
* userName / gateDepartName / typeName 取 consumer 接口 userDetailByYuserId 的返回值。
|
||||
*/
|
||||
export const LOGIN_SOURCE_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: "accessToken", value: "accessToken" },
|
||||
{ label: "yUserId", value: "yUserId" },
|
||||
{ label: "userName", value: "userName" },
|
||||
{ label: "gateDepartName", value: "gateDepartName" },
|
||||
{ label: "typeName", value: "typeName" },
|
||||
{ label: "其他(自定义值)", value: "other" },
|
||||
];
|
||||
|
||||
|
||||
@@ -932,7 +932,7 @@ body {
|
||||
.strategy-app-header {
|
||||
background-color: transparent;
|
||||
background-image: url("../assets/img/red-header.png");
|
||||
background-size: 100% 100%;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
@@ -311,12 +311,14 @@ class AppearanceResponse(BaseModel):
|
||||
default_theme: Literal["light", "dark", "lightblue"] = Field(..., description="System default theme — light, dark or lightblue")
|
||||
default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list")
|
||||
default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list")
|
||||
default_layout_mode: Literal["full", "compact"] = Field(..., description="System default interface layout — full (sidebar + header) or compact (hidden)")
|
||||
|
||||
|
||||
class AppearanceUpdateRequest(BaseModel):
|
||||
default_theme: Literal["light", "dark", "lightblue"] | None = Field(default=None)
|
||||
default_skill_view: Literal["card", "list"] | None = Field(default=None)
|
||||
default_agent_view: Literal["card", "list"] | None = Field(default=None)
|
||||
default_layout_mode: Literal["full", "compact"] | None = Field(default=None)
|
||||
|
||||
|
||||
def _appearance_response(appearance) -> AppearanceResponse:
|
||||
@@ -324,6 +326,7 @@ def _appearance_response(appearance) -> AppearanceResponse:
|
||||
default_theme=appearance.default_theme,
|
||||
default_skill_view=appearance.default_skill_view,
|
||||
default_agent_view=appearance.default_agent_view,
|
||||
default_layout_mode=appearance.default_layout_mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -343,7 +346,7 @@ async def update_appearance_settings(
|
||||
current = load_system_settings()
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
update: dict[str, object] = {}
|
||||
for key in ("default_theme", "default_skill_view", "default_agent_view"):
|
||||
for key in ("default_theme", "default_skill_view", "default_agent_view", "default_layout_mode"):
|
||||
if key in fields and fields[key] is not None:
|
||||
update[key] = fields[key]
|
||||
if update:
|
||||
|
||||
@@ -59,6 +59,10 @@ class AppearanceSettings(BaseModel):
|
||||
default="card",
|
||||
description="Default agent list display mode for users who haven't picked their own (card / list)",
|
||||
)
|
||||
default_layout_mode: Literal["full", "compact"] = Field(
|
||||
default="full",
|
||||
description="Default interface layout for users who haven't picked their own — full (sidebar + header) / compact (hidden)",
|
||||
)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user