Merge branch 'ji' into master
按钮管理「登录参数」两套实现冲突解决——两分支各自独立给跳转按钮加了登录态追加能力, 二者正交、保留两套,互不影响: - master: login_params (JSON 列 login_params_json,从 userInfo 解析,类似轻应用登录参数) - ji: append_auth + auth_token_param + auth_name_param (token 登录时追加 token1+用户名) 迁移 revision 碰撞修复(两分支都用了 20260625_01):重编号为线性链 20260620_02 → 20260625_01 (login_params) → 20260625_02 (auth_params) → 20260625_03 (users_approved) 并同步 column_additions_mysql.sql 注释里的 migration 编号。 前后端 task_buttons(model/sql/memory/router/前端 type·api·admin 页)均保留两套字段; 两份 CLAUDE.md 文档合并为同时记录两种机制。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
58357547c9
44 files changed
+1207
-46
No files matched your search
@@ -11,6 +11,7 @@ import {
|
||||
MousePointerClick,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
User,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
@@ -222,6 +223,14 @@ function SecondaryNavSidebar() {
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/login-whitelist")}
|
||||
>
|
||||
<ShieldCheck className="size-4" />
|
||||
白名单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSensitiveWords() && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
|
||||
@@ -508,6 +517,12 @@ function LegacyAccordionSidebar({
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
||||
<ShieldCheck className="size-4" />
|
||||
白名单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSensitiveWords() && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/sensitive-words")}>
|
||||
<ShieldAlert className="size-4" />
|
||||
|
||||
@@ -138,6 +138,7 @@ export function MessageListItem({
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onMessageDeleted,
|
||||
turnTime,
|
||||
}: {
|
||||
className?: string;
|
||||
fitRowToContent?: boolean;
|
||||
@@ -154,6 +155,13 @@ export function MessageListItem({
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||
/**
|
||||
* Durable ask time of the turn this message belongs to (the preceding
|
||||
* question's `client_ts`). Used to time-stamp the AI answer, which carries no
|
||||
* timestamp of its own — without it the answer would fall back to the current
|
||||
* wall clock at render time and appear to "drift" to now on every reload.
|
||||
*/
|
||||
turnTime?: string;
|
||||
}) {
|
||||
const isHuman = message.type === 'human';
|
||||
const messageMarkdown =
|
||||
@@ -182,6 +190,7 @@ export function MessageListItem({
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onMessageDeleted={onMessageDeleted}
|
||||
turnTime={turnTime}
|
||||
/>
|
||||
{/*
|
||||
Human messages carry their own inline copy/edit toolbar inside
|
||||
@@ -390,6 +399,7 @@ function MessageContent_({
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onMessageDeleted,
|
||||
turnTime,
|
||||
}: {
|
||||
className?: string;
|
||||
message: Message;
|
||||
@@ -400,6 +410,7 @@ function MessageContent_({
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||
turnTime?: string;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
|
||||
@@ -456,9 +467,15 @@ function MessageContent_({
|
||||
[threadId, resolveArtifact],
|
||||
);
|
||||
|
||||
// Question carries its own `client_ts` (round-trips through the checkpoint).
|
||||
// The AI answer has no timestamp of its own, so it inherits the turn's ask
|
||||
// time (its question's `client_ts`, threaded down as `turnTime`). This is
|
||||
// durable and turn-accurate, and — unlike the old per-id `now` fallback —
|
||||
// never drifts to the current clock when an unfinished answer is reloaded.
|
||||
const ownAskTime = message.id ? askTimes[message.id] : undefined;
|
||||
const messageTime = formatAskedTime(
|
||||
message.additional_kwargs?.client_ts ??
|
||||
(message.id ? askTimes[message.id] : undefined),
|
||||
(isHuman ? ownAskTime : (turnTime ?? ownAskTime)),
|
||||
);
|
||||
|
||||
const rawContent = extractContentFromMessage(message);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { rememberAskTimes } from "@/core/chat-timestamps";
|
||||
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
@@ -167,15 +167,24 @@ export function MessageList({
|
||||
[allMessages, hiddenMessageIds],
|
||||
);
|
||||
|
||||
// Stamp a wall-clock time onto every question/answer the first time it is
|
||||
// seen, in a single batched commit. Questions sent this session already carry
|
||||
// an accurate time (see useThreadStream); this also covers assistant answers
|
||||
// and messages loaded from history. First-write-wins keeps the earliest time.
|
||||
const askTimes = useChatAskTimes();
|
||||
|
||||
// Stamp a wall-clock time onto every *question* the first time it is seen, in
|
||||
// a single batched commit. Questions sent this session already carry an
|
||||
// accurate `client_ts` (see useThreadStream) and are skipped; this only backs
|
||||
// up legacy questions loaded from history that predate `client_ts`.
|
||||
// First-write-wins keeps the earliest time.
|
||||
//
|
||||
// AI answers are deliberately NOT stamped here: they carry no timestamp of
|
||||
// their own, so a `now` stamp would be the *load* time, not the answer time —
|
||||
// and for an unfinished/interrupted turn that re-stamps to the current clock
|
||||
// on every reload. Instead the answer inherits its question's durable time
|
||||
// via `turnTimes` below.
|
||||
useEffect(() => {
|
||||
const now = new Date().toISOString();
|
||||
const entries: Record<string, string> = {};
|
||||
for (const message of messages) {
|
||||
if (message.type !== "human" && message.type !== "ai") continue;
|
||||
if (message.type !== "human") continue;
|
||||
if (!message.id || message.id.startsWith("opt-")) continue;
|
||||
if (typeof message.additional_kwargs?.client_ts === "string") continue;
|
||||
entries[message.id] = now;
|
||||
@@ -183,6 +192,24 @@ export function MessageList({
|
||||
if (Object.keys(entries).length > 0) rememberAskTimes(entries);
|
||||
}, [messages]);
|
||||
|
||||
// Durable ask time per message id, used to time-stamp the AI answer (which has
|
||||
// no timestamp of its own) with the time of the question that triggered it.
|
||||
const turnTimes = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
let lastQuestionTime: string | undefined;
|
||||
for (const message of messages) {
|
||||
if (message.type === "human") {
|
||||
const ts = message.additional_kwargs?.client_ts;
|
||||
const fromAskTimes = message.id ? askTimes[message.id] : undefined;
|
||||
lastQuestionTime =
|
||||
typeof ts === "string" && ts ? ts : fromAskTimes;
|
||||
} else if (message.type === "ai" && message.id && lastQuestionTime) {
|
||||
map.set(message.id, lastQuestionTime);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [messages, askTimes]);
|
||||
|
||||
const [settings] = useLocalSettings();
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
@@ -358,6 +385,7 @@ export function MessageList({
|
||||
onMessageDeleted={
|
||||
allowDelete ? handleMessageDeleted : undefined
|
||||
}
|
||||
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,44 @@ export function formatDuration(ms: number): string {
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 速度条颜色含义图例 —— 生成结束后常驻在速度条下方一行,向用户解释进度条
|
||||
* 颜色代表的「模型生成延迟」状态。进度条本身仍是 5 档(极快/流畅/一般/偏慢/
|
||||
* 缓慢)的连续配色;这里把它归并成用户视角的 3 类延迟 + 「无响应」来说明:
|
||||
* 绿(#16a34a,极快/流畅) → 延迟正常
|
||||
* 黄(#eab308,一般) → 延迟微长
|
||||
* 红(#ef4444,偏慢/缓慢) → 延迟长,可能失败
|
||||
* 灰(#9ca3af) → 无响应(无进度条/0 tok/s),任务失败,请重试
|
||||
*/
|
||||
const SPEED_LEGEND: { color: string; label: string }[] = [
|
||||
{ color: "#16a34a", label: "延迟正常" },
|
||||
{ color: "#eab308", label: "延迟微长" },
|
||||
{ color: "#ef4444", label: "延迟长,可能失败" },
|
||||
{ color: "#9ca3af", label: "无响应,任务失败,请重试" },
|
||||
];
|
||||
|
||||
export function SpeedLegend({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground inline-flex flex-wrap items-center gap-x-2.5 gap-y-0.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{SPEED_LEGEND.map((item) => (
|
||||
<span className="inline-flex items-center gap-1" key={item.label}>
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpeedPill({ metric }: { metric: MessageMetric }) {
|
||||
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
|
||||
const { color, label } = classifySpeed(metric.tokensPerSec);
|
||||
@@ -132,6 +170,8 @@ export function MessageTokenUsage({
|
||||
{formatDuration(resolvedMetric.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
{/* 生成结束(非流式中)后常驻一行颜色含义说明。 */}
|
||||
{!resolvedMetric.isActive && <SpeedLegend className="basis-full" />}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface UserListItem {
|
||||
agent_count: number;
|
||||
/** 问答次数:用户提问的次数(一次提问算一次)。 */
|
||||
qa_count: number;
|
||||
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
|
||||
approved: boolean;
|
||||
}
|
||||
|
||||
export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
@@ -19,6 +21,20 @@ export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
return res.json() as Promise<UserListItem[]>;
|
||||
}
|
||||
|
||||
/** 放行 / 取消放行某个注册用户(登录白名单)。Admin only。 */
|
||||
export async function setUserApproval(userId: string, approved: boolean): Promise<UserListItem> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/approval`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ approved }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(extractErrorMessage(data, "保存失败"));
|
||||
}
|
||||
return res.json() as Promise<UserListItem>;
|
||||
}
|
||||
|
||||
// Normalize a FastAPI error body into a human-readable string. `detail` may be
|
||||
// a plain string, an object like AuthErrorResponse ({ code, message }), or an
|
||||
// array of validation errors — anything but a string would otherwise render as
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { listAllUsers } from "./api";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
import { listAllUsers, setUserApproval } from "./api";
|
||||
|
||||
export function useAllUsers(enabled: boolean) {
|
||||
return useQuery({
|
||||
@@ -9,3 +11,20 @@ export function useAllUsers(enabled: boolean) {
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** 放行 / 取消放行某个用户(登录白名单),成功后刷新用户列表缓存。 */
|
||||
export function useSetUserApproval() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, approved }: { userId: string; approved: boolean }) => setUserApproval(userId, approved),
|
||||
onSuccess: (_data, vars) => {
|
||||
writeYLog({
|
||||
optType: vars.approved ? OptType.enabled : OptType.disabled,
|
||||
optDetail: `CM助手-全量-白名单管理-${vars.approved ? "放行" : "取消放行"}用户`,
|
||||
url: `/api/v1/auth/users/${vars.userId}/approval`,
|
||||
reqParam: vars,
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,12 @@ export interface LoginByUsernameResponse {
|
||||
system_role: string;
|
||||
needs_setup: boolean;
|
||||
created: boolean;
|
||||
/**
|
||||
* 本次登录解析出的原始登录用户名(token 登录=getTokenInfo 解析值;用户名登录=所传用户名)。
|
||||
* 用于跳转外链时拼 ``?username=``——后端落库的邮箱前缀是归一化(小写/替换)后的,有损,故以此为准。
|
||||
* 旧后端可能不返回。
|
||||
*/
|
||||
username?: string;
|
||||
}
|
||||
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 任务工作区跳转外链时要拼接的「登录态凭据」:token1 + 登录用户名 的存取。
|
||||
*
|
||||
* - **token1** = token 登录用的上游 token(登录 URL 的 ``?authToken=``,即走
|
||||
* ``/api/v1/auth/login/token`` 这条 token 换登录路径用的 token)。
|
||||
* - **username** = 后端 token 登录时用该 token 调 ``getTokenInfo`` 解析出的**登录用户名**
|
||||
* (见 ``auth.py`` ``/login/token`` → ``UsernameLoginResponse.username``)。**不是**邮箱
|
||||
* 前缀、也**不是**解 JWT 得来——后端把它落库时会 ``.lower()`` + 归一化,邮箱前缀是有损的,
|
||||
* 所以以后端回传的原始值为准。
|
||||
*
|
||||
* 二者均经 [[scoped-storage]] 的 ``authStorage*`` 存取(键用 ``login.`` 前缀,属其受保护
|
||||
* 鉴权键族):嵌入(iframe)/独立会话态自动落 sessionStorage 隔离、正常态落 localStorage,
|
||||
* 并随「开启独立会话」一起被 ``migrateAuthToSession`` 迁移。
|
||||
*
|
||||
* **门控**:仅 token 登录(``authToken`` 存在)才写入。非 token 登录不写 token1 → 跳转按钮
|
||||
* 的「追加登录态」开关此时读不到 token,自然不追加(符合「必须用 login/token 登录才能带
|
||||
* token 跳转」)。
|
||||
*/
|
||||
|
||||
import {
|
||||
authStorageGet,
|
||||
authStorageRemove,
|
||||
authStorageSet,
|
||||
} from "@/core/auth/scoped-storage";
|
||||
import { getAccountDisplayName, type LoginByUsernameResponse } from "@/core/auth";
|
||||
|
||||
const LOGIN_TOKEN_KEY = "login.token1";
|
||||
const LOGIN_USERNAME_KEY = "login.username";
|
||||
|
||||
/**
|
||||
* 持久化 token 登录的 token1 + 用户名(token 登录成功后调用)。
|
||||
* ``authToken`` 为空则整次忽略(非 token 登录不留痕)。
|
||||
*/
|
||||
export function persistLoginCredentials(
|
||||
authToken: string | null | undefined,
|
||||
auth: LoginByUsernameResponse | null,
|
||||
): void {
|
||||
const token = authToken?.trim();
|
||||
if (!token) return;
|
||||
authStorageSet(LOGIN_TOKEN_KEY, token);
|
||||
// 优先用后端回传的原始登录名(getTokenInfo 解析值);旧后端无该字段时退回账号显示名
|
||||
// (邮箱 @ 前缀)兜底,保证总有个值可用。
|
||||
const username = auth?.username?.trim() || getAccountDisplayName(auth);
|
||||
if (username) authStorageSet(LOGIN_USERNAME_KEY, username);
|
||||
}
|
||||
|
||||
/** 读 token1(无则 ``null`` → 跳转不追加登录态)。 */
|
||||
export function readLoginToken(): string | null {
|
||||
return authStorageGet(LOGIN_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/** 读登录用户名(无则 ``null``)。 */
|
||||
export function readLoginUsername(): string | null {
|
||||
return authStorageGet(LOGIN_USERNAME_KEY);
|
||||
}
|
||||
|
||||
/** 清除登录态凭据(登出时调用)。 */
|
||||
export function clearLoginCredentials(): void {
|
||||
authStorageRemove(LOGIN_TOKEN_KEY);
|
||||
authStorageRemove(LOGIN_USERNAME_KEY);
|
||||
}
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
resolveTaskDeeplinkTarget,
|
||||
} from "@/core/auth/task-deeplink";
|
||||
import { readUserInfo, resolveRouteParamValue } from "@/strategy-components/lib/light-app";
|
||||
import {
|
||||
readLoginToken,
|
||||
readLoginUsername,
|
||||
} from "@/core/auth/login-credentials";
|
||||
|
||||
/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */
|
||||
export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent";
|
||||
@@ -102,6 +106,16 @@ export interface TaskButton {
|
||||
openMode: TaskButtonOpenMode;
|
||||
/** ``url`` 外链追加的登录参数列表(仅 ``url`` 类型有意义;为空则不追加,行为同旧版)。 */
|
||||
loginParams: TaskButtonLoginParam[];
|
||||
/**
|
||||
* ``url`` 跳转是否追加登录态:token1(token 登录的上游 token)+ 登录用户名,供外链所在的
|
||||
* 外部系统识别当前登录用户。**仅 token 登录**(前端存有 token1,见 [[login-credentials]])时
|
||||
* 实际拼上;非 token 登录读不到 token1,自动跳过(按钮照常跳,只是不带这两个参数)。
|
||||
*/
|
||||
appendAuth: boolean;
|
||||
/** 追加 token 的查询参数名(空则用 ``token``)。 */
|
||||
authTokenParam: string;
|
||||
/** 追加用户名的查询参数名(空则用 ``username``)。 */
|
||||
authNameParam: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
@@ -154,6 +168,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
appendAuth: false,
|
||||
authTokenParam: "token",
|
||||
authNameParam: "username",
|
||||
enabled: true,
|
||||
sortOrder,
|
||||
});
|
||||
@@ -169,6 +186,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
appendAuth: false,
|
||||
authTokenParam: "token",
|
||||
authNameParam: "username",
|
||||
enabled: true,
|
||||
sortOrder,
|
||||
});
|
||||
@@ -185,6 +205,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
appendAuth: false,
|
||||
authTokenParam: "token",
|
||||
authNameParam: "username",
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
},
|
||||
@@ -204,6 +227,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idKind: "task",
|
||||
openMode: "self",
|
||||
loginParams: [],
|
||||
appendAuth: false,
|
||||
authTokenParam: "token",
|
||||
authNameParam: "username",
|
||||
enabled: true,
|
||||
sortOrder: 1,
|
||||
},
|
||||
@@ -218,6 +244,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
appendAuth: false,
|
||||
authTokenParam: "token",
|
||||
authNameParam: "username",
|
||||
enabled: true,
|
||||
sortOrder: 2,
|
||||
},
|
||||
@@ -232,6 +261,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
appendAuth: false,
|
||||
authTokenParam: "token",
|
||||
authNameParam: "username",
|
||||
enabled: true,
|
||||
sortOrder: 3,
|
||||
},
|
||||
@@ -309,6 +341,18 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro
|
||||
url = appendQueryParam(url, key, resolveLoginParamValue(lp, userInfo));
|
||||
}
|
||||
}
|
||||
// 追加登录态:仅 token 登录(存在 token1)才拼 token + 用户名,供外链所在的外部系统识别
|
||||
// 当前登录用户。非 token 登录读不到 token1 → 整段跳过(不影响跳转本身)。
|
||||
if (btn.appendAuth) {
|
||||
const token1 = readLoginToken();
|
||||
if (token1) {
|
||||
url = appendQueryParam(url, (btn.authTokenParam || "token").trim() || "token", token1);
|
||||
const name = readLoginUsername();
|
||||
if (name) {
|
||||
url = appendQueryParam(url, (btn.authNameParam || "username").trim() || "username", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 应用内部路由(以单个 / 开头,如「上一步」→ 大屏绘制页):
|
||||
// self → 走 react-router 同窗导航(不刷新、保留 SPA);blank → 拼成当前源的 hash 路由新标签打开。
|
||||
if (base.startsWith("/") && !base.startsWith("//")) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '@/core/page-layout/sidebar-enabled';
|
||||
import { fetchYLogConfig } from '@/core/log/y-log-config';
|
||||
import { persistYLogToken } from '@/core/log/y-log-token';
|
||||
import { persistLoginCredentials } from '@/core/auth/login-credentials';
|
||||
import { OptType, writeYLog } from '@/core/log/y-log';
|
||||
|
||||
const DEFAULT_USERNAME = 'guest';
|
||||
@@ -197,6 +198,9 @@ export default function LoginPage() {
|
||||
// 持久化 token1(走 authStorage,嵌入态自动隔离)+ 预热上报配置 + 上报登录。
|
||||
if (authToken) {
|
||||
persistYLogToken(authToken);
|
||||
// 存下 token1 + 登录用户名(getTokenInfo 解析出的原始名,后端回传),供任务工作区
|
||||
// 跳转按钮的「追加登录态」拼到外链上(仅 token 登录才有这两个值)。
|
||||
persistLoginCredentials(authToken, auth);
|
||||
void fetchYLogConfig();
|
||||
writeYLog({
|
||||
optType: OptType.login,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Info, Search, ShieldCheck, UserCheck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useAllUsers, useSetUserApproval } from "@/core/auth/hooks";
|
||||
import {
|
||||
useLoginWhitelistEnabled,
|
||||
useSetLoginWhitelistEnabled,
|
||||
} from "@/strategy-components/api/login-whitelist";
|
||||
|
||||
/**
|
||||
* 登录白名单管理(管理员)。
|
||||
*
|
||||
* 顶部一个「总开关」:关 = 所有人可登录;开 = 仅已放行用户(或 admin)可登录。
|
||||
* 下方列出全部注册用户,逐个放行 / 取消放行。用户仍可自助注册,注册后默认
|
||||
* 「待审批」,开关打开时被拦在登录外,直到管理员在此放行。
|
||||
*/
|
||||
export default function LoginWhitelistPage() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
|
||||
const { data: enabled, isLoading: loadingSwitch } = useLoginWhitelistEnabled();
|
||||
const setEnabled = useSetLoginWhitelistEnabled();
|
||||
|
||||
const { data: users = [], isLoading: loadingUsers } = useAllUsers(isAdmin);
|
||||
const setApproval = useSetUserApproval();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const visibleUsers = useMemo(
|
||||
() => (!q ? users : users.filter((u) => u.email.toLowerCase().includes(q) || u.id.toLowerCase().includes(q))),
|
||||
[users, q],
|
||||
);
|
||||
|
||||
const approvedCount = users.filter((u) => u.approved || u.system_role === "admin").length;
|
||||
|
||||
const handleToggleSwitch = async (next: boolean) => {
|
||||
try {
|
||||
await setEnabled.mutateAsync(next);
|
||||
toast.success(next ? "已启用登录白名单" : "已停用登录白名单(所有人可登录)");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleApproval = async (userId: string, next: boolean) => {
|
||||
setPendingId(userId);
|
||||
try {
|
||||
await setApproval.mutateAsync({ userId, approved: next });
|
||||
toast.success(next ? "已放行该用户" : "已取消放行(下次登录被拦)");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "保存失败");
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-slate-50/60 px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* 顶部信息卡 + 总开关 */}
|
||||
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="pointer-events-none absolute right-0 top-0 select-none p-8 opacity-[0.03]">
|
||||
<ShieldCheck className="size-44 text-slate-900" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="mb-4 flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-red-100 bg-red-50 px-3 py-1 text-xs font-semibold text-red-600">
|
||||
<ShieldCheck className="size-3" />
|
||||
登录访问控制
|
||||
</span>
|
||||
<h1 className="flex items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
|
||||
白名单管理
|
||||
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
|
||||
{users.length} 用户 / {approvedCount} 可登录
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-slate-200/60 bg-slate-50/70 px-4 py-3">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold text-slate-700">登录白名单</div>
|
||||
<div className="text-xs text-slate-500">{enabled ? "已启用:仅已放行用户可登录" : "已停用:所有人可登录"}</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(enabled)}
|
||||
disabled={loadingSwitch || setEnabled.isPending}
|
||||
onCheckedChange={handleToggleSwitch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||
<span>
|
||||
用户仍可<strong>自助注册</strong>,注册后默认<strong> 待审批</strong>。开关<strong>关闭</strong>时所有人可正常登录使用;
|
||||
<strong> 开启</strong>后仅<strong>已放行</strong>的用户可登录,其余被拦在登录外,请在下方逐个放行。
|
||||
<strong> 管理员(admin)始终可登录</strong>,不受开关与放行状态影响。
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="mb-6 rounded-2xl border border-slate-200/60 bg-white p-4 shadow-sm md:p-5">
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索邮箱 / 用户ID…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-2 pl-10 pr-4 text-sm transition-all hover:border-slate-300 focus:border-red-500 focus:outline-none focus:ring-4 focus:ring-red-500/5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用户列表 */}
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200/60 bg-white shadow-sm">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-3 border-b border-slate-200/60 bg-slate-50/70 px-4 py-2.5 text-xs font-semibold text-slate-500">
|
||||
<span>用户</span>
|
||||
<span className="px-1 text-center">问答</span>
|
||||
<span className="px-1 text-center">放行</span>
|
||||
</div>
|
||||
|
||||
{loadingUsers ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-slate-400">加载中…</div>
|
||||
) : visibleUsers.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-slate-400">
|
||||
{users.length === 0 ? "暂无注册用户" : "没有匹配的用户"}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{visibleUsers.map((user) => {
|
||||
const isUserAdmin = user.system_role === "admin";
|
||||
const allowed = user.approved || isUserAdmin;
|
||||
return (
|
||||
<li
|
||||
key={user.id}
|
||||
className="grid grid-cols-[1fr_auto_auto] items-center gap-3 px-4 py-3 transition-colors hover:bg-slate-50/60"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-slate-800">{user.email}</span>
|
||||
{isUserAdmin ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[11px] font-semibold text-amber-600">
|
||||
<UserCheck className="size-3" />
|
||||
管理员
|
||||
</span>
|
||||
) : allowed ? (
|
||||
<span className="rounded-md border border-emerald-200 bg-emerald-50 px-1.5 py-0.5 text-[11px] font-semibold text-emerald-600">
|
||||
已放行
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-md border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[11px] font-semibold text-slate-500">
|
||||
待审批
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate font-mono text-xs text-slate-400">{user.id}</div>
|
||||
</div>
|
||||
<div className="px-1 text-center text-sm tabular-nums text-slate-500">{user.qa_count}</div>
|
||||
<div className="px-1">
|
||||
<Switch
|
||||
checked={allowed}
|
||||
disabled={isUserAdmin || (setApproval.isPending && pendingId === user.id)}
|
||||
onCheckedChange={(v) => handleToggleApproval(user.id, v)}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import LightAppIframeView from "./LightAppIframeView";
|
||||
import LightAppListPage from "./LightAppListPage";
|
||||
import LightAppManagementPage from "./LightAppManagementPage";
|
||||
import MenuManagementPage from "./MenuManagementPage";
|
||||
import LoginWhitelistPage from "./LoginWhitelistPage";
|
||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||
import SkillAddressesPage from "./SkillAddressesPage";
|
||||
import TaskButtonsPage from "./TaskButtonsPage";
|
||||
@@ -395,6 +396,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/login-whitelist"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<LoginWhitelistPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/roundtable-diagnostics"
|
||||
element={
|
||||
|
||||
@@ -108,6 +108,9 @@ export default function TaskButtonsPage() {
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
appendAuth: false,
|
||||
authTokenParam: "token",
|
||||
authNameParam: "username",
|
||||
enabled: true,
|
||||
sortOrder: maxSort + 1,
|
||||
},
|
||||
@@ -337,6 +340,37 @@ export default function TaskButtonsPage() {
|
||||
<SelectItem value="self">当前窗口</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex shrink-0 items-center gap-1.5 text-xs"
|
||||
title="跳转时在链接后追加 token + 用户名(仅 token 登录时生效)"
|
||||
>
|
||||
<Switch
|
||||
checked={r.appendAuth}
|
||||
onCheckedChange={(v) => update(r.id, { appendAuth: v })}
|
||||
/>
|
||||
追加登录态
|
||||
</label>
|
||||
{r.appendAuth && (
|
||||
<div className="flex shrink-0 items-center gap-1 text-xs">
|
||||
<span className="text-muted-foreground">&</span>
|
||||
<Input
|
||||
value={r.authTokenParam}
|
||||
onChange={(e) => update(r.id, { authTokenParam: e.target.value })}
|
||||
placeholder="token"
|
||||
className="h-8 w-24"
|
||||
title="token 的查询参数名(默认 token)"
|
||||
/>
|
||||
<span className="text-muted-foreground">=token1 &</span>
|
||||
<Input
|
||||
value={r.authNameParam}
|
||||
onChange={(e) => update(r.id, { authNameParam: e.target.value })}
|
||||
placeholder="username"
|
||||
className="h-8 w-28"
|
||||
title="用户名的查询参数名(默认 username)"
|
||||
/>
|
||||
<span className="text-muted-foreground">=用户名</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Select value={r.target} onValueChange={(v) => update(r.id, { target: v })}>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 登录白名单「总开关」API 客户端 + React Query hooks。
|
||||
*
|
||||
* 后端:`GET|PUT /api/system-settings/login-whitelist`(admin only)。
|
||||
* 开关本身存系统设置 JSON,即时生效、免重启;逐用户的放行标记走
|
||||
* `core/auth`(`UserListItem.approved` + `setUserApproval`)。
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/system-settings/login-whitelist`;
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null as unknown);
|
||||
const detail =
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? String((data as { detail: unknown }).detail)
|
||||
: `${ctx} failed (${res.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function getLoginWhitelistEnabled(): Promise<boolean> {
|
||||
const res = await apiFetch(BASE(), { method: "GET" });
|
||||
const data = await readJson<{ enabled: boolean }>(res, "load login whitelist");
|
||||
return Boolean(data.enabled);
|
||||
}
|
||||
|
||||
export async function setLoginWhitelistEnabled(enabled: boolean): Promise<boolean> {
|
||||
const res = await apiFetch(BASE(), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
const data = await readJson<{ enabled: boolean }>(res, "save login whitelist");
|
||||
writeYLog({
|
||||
optType: enabled ? OptType.enabled : OptType.disabled,
|
||||
optDetail: `CM助手-全量-白名单管理-${enabled ? "启用" : "停用"}登录白名单`,
|
||||
url: "/api/system-settings/login-whitelist",
|
||||
reqParam: { enabled },
|
||||
});
|
||||
return Boolean(data.enabled);
|
||||
}
|
||||
|
||||
export const LOGIN_WHITELIST_KEY = "login-whitelist-enabled";
|
||||
|
||||
export function useLoginWhitelistEnabled() {
|
||||
return useQuery({
|
||||
queryKey: [LOGIN_WHITELIST_KEY],
|
||||
queryFn: getLoginWhitelistEnabled,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetLoginWhitelistEnabled() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: setLoginWhitelistEnabled,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: [LOGIN_WHITELIST_KEY] }),
|
||||
});
|
||||
}
|
||||
@@ -39,6 +39,9 @@ interface ServerButton {
|
||||
id_kind: string;
|
||||
open_mode: string;
|
||||
login_params: ServerLoginParam[];
|
||||
append_auth: boolean;
|
||||
auth_token_param: string;
|
||||
auth_name_param: string;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
@@ -63,6 +66,9 @@ function toButton(s: ServerButton): TaskButton {
|
||||
idKind: (s.id_kind as TaskButtonIdKind) ?? "task",
|
||||
openMode: (s.open_mode as TaskButtonOpenMode) ?? "blank",
|
||||
loginParams: (s.login_params ?? []).map(toLoginParam),
|
||||
appendAuth: s.append_auth ?? false,
|
||||
authTokenParam: s.auth_token_param || "token",
|
||||
authNameParam: s.auth_name_param || "username",
|
||||
enabled: s.enabled ?? true,
|
||||
sortOrder: s.sort_order ?? 0,
|
||||
};
|
||||
@@ -84,6 +90,9 @@ function toBody(b: TaskButton): ServerButton {
|
||||
source: p.source,
|
||||
custom_value: p.customValue,
|
||||
})),
|
||||
append_auth: b.appendAuth,
|
||||
auth_token_param: b.authTokenParam,
|
||||
auth_name_param: b.authNameParam,
|
||||
enabled: b.enabled,
|
||||
sort_order: b.sortOrder,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user