Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
44d7304f8e
14 files changed
+436
-49
No files matched your search
@@ -13,6 +13,12 @@ export interface UserListItem {
|
||||
qa_count: number;
|
||||
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
|
||||
approved: boolean;
|
||||
/**
|
||||
* 账号级「登录口令」(明文,仅管理员可见)。非空 → 该账号用户名直登必须带匹配的
|
||||
* `?password=`;为空/null → 走原有共享口令门/免口令逻辑。管理员据此拼出登录链接
|
||||
* `…/login/<用户名>?password=xxxx` 发给用户。
|
||||
*/
|
||||
login_password: string | null;
|
||||
}
|
||||
|
||||
export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
@@ -35,6 +41,25 @@ export async function setUserApproval(userId: string, approved: boolean): Promis
|
||||
return res.json() as Promise<UserListItem>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 / 清除某个账号的「登录口令」(明文)。Admin only。
|
||||
*
|
||||
* 传非空字符串 → 设置;传 `null` 或空字符串 → 清除。设置后该账号用户名直登需带匹配的
|
||||
* `?password=`。返回更新后的用户项(含明文 `login_password`)。
|
||||
*/
|
||||
export async function setUserLoginPassword(userId: string, password: string | null): Promise<UserListItem> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/login-password`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
import { listAllUsers, setUserApproval } from "./api";
|
||||
import { listAllUsers, setUserApproval, setUserLoginPassword } from "./api";
|
||||
|
||||
export function useAllUsers(enabled: boolean) {
|
||||
return useQuery({
|
||||
@@ -28,3 +28,21 @@ export function useSetUserApproval() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 设置 / 清除某账号的「登录口令」(明文),成功后刷新用户列表缓存。 */
|
||||
export function useSetUserLoginPassword() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, password }: { userId: string; password: string | null }) => setUserLoginPassword(userId, password),
|
||||
onSuccess: (_data, vars) => {
|
||||
const cleared = vars.password == null || vars.password === "";
|
||||
writeYLog({
|
||||
optType: cleared ? OptType.delete : OptType.edit,
|
||||
optDetail: `CM助手-全量-白名单管理-${cleared ? "清除" : "设置"}账号登录口令`,
|
||||
url: `/api/v1/auth/users/${vars.userId}/login-password`,
|
||||
reqParam: { userId: vars.userId },
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -260,6 +260,14 @@ function buildOpeningText(detail: CopTaskDetail, taskId: string, action?: Action
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 3qfx/rwfx 这类“按任务id 直接问答”的任务工作区,在 ``TaskContext`` 丢失但
|
||||
* ``login.copTaskDetail`` 仍存在时,可用它重新拼出开场词做前端自恢复。
|
||||
*/
|
||||
export function buildTaskOpeningText(detail: CopTaskDetail, taskId: string): string {
|
||||
return buildOpeningText(detail, taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 据 goPath + 任务详情解析跳转目标。
|
||||
* - 多智能体会商:返回带 ``roundtablePrefill``(businessCode 让会商页自动选用配置的业务链条;
|
||||
|
||||
@@ -53,6 +53,7 @@ import { env } from "@/env";
|
||||
import { readTaskContext, taskThreadMetadata } from "@/core/auth/task-purpose";
|
||||
import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { buildTaskOpeningText, readCopTaskDetail } from "@/core/auth/task-deeplink";
|
||||
|
||||
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
|
||||
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
|
||||
@@ -100,10 +101,25 @@ export default function AgentChatPage({
|
||||
|
||||
// 任务深链上下文(taskId + 开场信息):闸门(rwfx)与纯绑定(3qfx/xdfx)都要读取。
|
||||
const taskCtxOn = !!(taskGate || taskBind);
|
||||
const taskContext = useMemo(
|
||||
() => (taskCtxOn ? readTaskContext() : null),
|
||||
[taskCtxOn],
|
||||
);
|
||||
const taskContext = useMemo(() => {
|
||||
if (!taskCtxOn) return null;
|
||||
const stored = readTaskContext();
|
||||
if (stored && stored.agentId === agent_id) return stored;
|
||||
// 3qfx:若任务上下文偶发丢失,但登录深链留下的任务详情仍在,则用它前端兜底重建
|
||||
// taskId + openingText,避免 landing 既不自动开始、也没有“开始回答”按钮。
|
||||
if (taskBind && !taskGate) {
|
||||
const detail = readCopTaskDetail();
|
||||
const fallbackTaskId = detail?.id == null ? "" : String(detail.id).trim();
|
||||
if (detail && fallbackTaskId && agent_id) {
|
||||
return {
|
||||
taskId: fallbackTaskId,
|
||||
agentId: agent_id,
|
||||
openingText: buildTaskOpeningText(detail, fallbackTaskId),
|
||||
};
|
||||
}
|
||||
}
|
||||
return stored;
|
||||
}, [agent_id, taskBind, taskCtxOn, taskGate]);
|
||||
const taskId =
|
||||
taskCtxOn && taskContext && taskContext.agentId === agent_id
|
||||
? taskContext.taskId
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Info, Search, ShieldCheck, UserCheck } from "lucide-react";
|
||||
import { Copy, Info, KeyRound, Link2, Search, ShieldCheck, UserCheck, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { UserListItem } from "@/core/auth/api";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useAllUsers, useSetUserApproval } from "@/core/auth/hooks";
|
||||
import { useAllUsers, useSetUserApproval, useSetUserLoginPassword } from "@/core/auth/hooks";
|
||||
import {
|
||||
useLoginWhitelistEnabled,
|
||||
useSetLoginWhitelistEnabled,
|
||||
@@ -28,9 +29,13 @@ export default function LoginWhitelistPage() {
|
||||
|
||||
const { data: users = [], isLoading: loadingUsers } = useAllUsers(isAdmin);
|
||||
const setApproval = useSetUserApproval();
|
||||
const setLoginPassword = useSetUserLoginPassword();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
// 口令编辑:当前展开编辑的用户 id + 输入草稿(明文,便于管理员直接查看/复制)。
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [pwdDraft, setPwdDraft] = useState("");
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const visibleUsers = useMemo(
|
||||
@@ -61,6 +66,56 @@ export default function LoginWhitelistPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 用户名直登链接:`<当前源>/#/login/<邮箱本地部分>?password=<口令>`。用户名取邮箱
|
||||
// `@` 前缀(用户名直登据此解析回同一账号);base 取去掉 hash 的当前地址,保留可能的
|
||||
// 部署子路径。
|
||||
const buildLoginLink = (user: UserListItem, password: string) => {
|
||||
const localPart = (user.email.split("@")[0] || "").trim();
|
||||
const base = window.location.href.split("#")[0];
|
||||
return `${base}#/login/${encodeURIComponent(localPart)}?password=${encodeURIComponent(password)}`;
|
||||
};
|
||||
|
||||
const copyText = async (text: string, okMsg: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(okMsg);
|
||||
} catch {
|
||||
toast.error("复制失败,请手动复制");
|
||||
}
|
||||
};
|
||||
|
||||
const openEditor = (user: UserListItem) => {
|
||||
setEditingId(user.id);
|
||||
setPwdDraft(user.login_password ?? "");
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
setEditingId(null);
|
||||
setPwdDraft("");
|
||||
};
|
||||
|
||||
const handleSavePassword = async (user: UserListItem) => {
|
||||
const pwd = pwdDraft.trim();
|
||||
if (!pwd) return;
|
||||
try {
|
||||
await setLoginPassword.mutateAsync({ userId: user.id, password: pwd });
|
||||
toast.success("已设置登录口令");
|
||||
closeEditor();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearPassword = async (user: UserListItem) => {
|
||||
try {
|
||||
await setLoginPassword.mutateAsync({ userId: user.id, password: null });
|
||||
toast.success("已清除登录口令");
|
||||
closeEditor();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "清除失败");
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
|
||||
|
||||
return (
|
||||
@@ -126,9 +181,10 @@ export default function LoginWhitelistPage() {
|
||||
|
||||
{/* 用户列表 */}
|
||||
<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">
|
||||
<div className="grid grid-cols-[1fr_auto_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>
|
||||
<span className="px-1 text-center">放行</span>
|
||||
</div>
|
||||
|
||||
@@ -143,40 +199,137 @@ export default function LoginWhitelistPage() {
|
||||
{visibleUsers.map((user) => {
|
||||
const isUserAdmin = user.system_role === "admin";
|
||||
const allowed = user.approved || isUserAdmin;
|
||||
const hasPwd = Boolean(user.login_password);
|
||||
const isEditing = editingId === user.id;
|
||||
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-rose-200 bg-rose-50 px-1.5 py-0.5 text-[11px] font-semibold text-rose-600">
|
||||
已拦截
|
||||
</span>
|
||||
)}
|
||||
<Fragment key={user.id}>
|
||||
<li className="grid grid-cols-[1fr_auto_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-rose-200 bg-rose-50 px-1.5 py-0.5 text-[11px] font-semibold text-rose-600">
|
||||
已拦截
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate font-mono text-xs text-slate-400">{user.id}</div>
|
||||
</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>
|
||||
<div className="px-1 text-center text-sm tabular-nums text-slate-500">{user.qa_count}</div>
|
||||
{/* 登录口令:设置 / 修改 + 复制登录链接 */}
|
||||
<div className="px-1">
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (isEditing ? closeEditor() : openEditor(user))}
|
||||
title={hasPwd ? "已设登录口令,点击修改" : "设置登录口令"}
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-semibold transition-colors ${
|
||||
hasPwd
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-600 hover:bg-emerald-100"
|
||||
: "border-slate-200 bg-slate-50 text-slate-500 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<KeyRound className="size-3" />
|
||||
{hasPwd ? "已设" : "设置"}
|
||||
</button>
|
||||
{hasPwd && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(buildLoginLink(user, user.login_password ?? ""), "已复制登录链接")}
|
||||
title="复制登录链接"
|
||||
className="inline-flex items-center rounded-md border border-slate-200 bg-white px-1.5 py-1 text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-700"
|
||||
>
|
||||
<Link2 className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<Switch
|
||||
checked={allowed}
|
||||
disabled={isUserAdmin || (setApproval.isPending && pendingId === user.id)}
|
||||
onCheckedChange={(v) => handleToggleApproval(user.id, v)}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{isEditing && (
|
||||
<li className="bg-slate-50/70 px-4 py-4">
|
||||
<div className="rounded-xl border border-slate-200/70 bg-white p-4 shadow-sm">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
|
||||
<KeyRound className="size-3.5 text-slate-400" />
|
||||
为 <span className="font-mono text-slate-800">{user.email}</span> 设置登录口令
|
||||
</div>
|
||||
<p className="mb-3 text-[11px] leading-relaxed text-slate-400">
|
||||
设置后该账号需用此口令登录:
|
||||
<code className="rounded bg-slate-100 px-1 text-slate-500">/login/用户名?password=口令</code>
|
||||
。口令以明文随链接传输,仅用于临时开通,请妥善保管。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={pwdDraft}
|
||||
onChange={(e) => setPwdDraft(e.target.value)}
|
||||
placeholder="输入登录口令"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleSavePassword(user);
|
||||
}}
|
||||
className="w-56 rounded-lg border border-slate-200 bg-slate-50/50 px-3 py-1.5 text-sm transition-all hover:border-slate-300 focus:border-red-500 focus:outline-none focus:ring-4 focus:ring-red-500/5"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!pwdDraft.trim() || setLoginPassword.isPending}
|
||||
onClick={() => void handleSavePassword(user)}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-red-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
{hasPwd && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={setLoginPassword.isPending}
|
||||
onClick={() => void handleClearPassword(user)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-sm font-semibold text-rose-600 transition-colors hover:bg-rose-100 disabled:opacity-50"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEditor}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-500 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
{pwdDraft.trim() && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg border border-slate-200/70 bg-slate-50/70 p-2">
|
||||
<Link2 className="size-3.5 shrink-0 text-slate-400" />
|
||||
<span className="truncate font-mono text-[11px] text-slate-500">{buildLoginLink(user, pwdDraft.trim())}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(buildLoginLink(user, pwdDraft.trim()), "已复制登录链接")}
|
||||
className="ml-auto inline-flex shrink-0 items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-[11px] font-medium text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-700"
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
复制链接
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
@@ -60,7 +60,12 @@ function useChatsBase(taskBase: string): {
|
||||
const { agent_id } = useParams<{ agent_id: string }>();
|
||||
const agentId = agent_id ?? "";
|
||||
const ctx = readTaskContext();
|
||||
const taskId = ctx?.taskId ?? "";
|
||||
const detail = readCopTaskDetail();
|
||||
const fallbackTaskId =
|
||||
detail?.id == null ? "" : String(detail.id).trim();
|
||||
// 3qfx 偶发会出现 task context 丢失,但任务详情仍在 sessionStorage 的情况。
|
||||
// 这里回退到任务详情里的 taskId,避免左侧对话列表直接失效成“空任务态”。
|
||||
const taskId = ctx?.taskId ?? fallbackTaskId;
|
||||
const actionId = ctx?.partitionByAction ? (ctx.actionId ?? "") : "";
|
||||
return { taskId, agentId, actionId, chatsBase: `${taskBase}/agent/${agentId}/chats` };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user