sync update from LAN repo (source master @ ca48c4ec)

This commit is contained in:
gozeros committed 2026-11-05 10:53:54 +08:00
1 parent 8bc8b0e082
commit ea9dcff9c0
873 files changed
+107866 -14011

No files matched your search

+80 -22
View File
@@ -1,4 +1,5 @@
import { apiFetch } from "@/core/api/fetch-client";
import { fetchWithBindingRetry } from "@/core/auth/login-transport";
import { getBackendBaseURL } from "@/core/config";
import type { LoginByUsernameResponse } from "./index";
@@ -9,29 +10,78 @@ export interface UserListItem {
system_role: "admin" | "user";
skill_count: number;
agent_count: number;
/** 问答次数:用户提问的次数(一次提问算一次)。 */
/** 近 30 天问答次数:用户提问的次数(一次提问算一次)。 */
qa_count: number;
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
approved: boolean;
/**
* 仅兼容尚未迁移的历史口令。新密码使用散列存储,不回传明文或散列,
* 是否已设置凭据以 has_login_password 为准。
* 仅兼容尚未迁移的历史口令。新密码使用散列存储,不回传明文或散列,
* 是否已设置凭据以 has_login_password 为准。
*/
login_password: string | null;
/** 已设置密码/口令,散列凭据不会返回给客户端。 */
has_login_password: boolean;
/** 管理员重置自己的密码时,服务器同步刷新当前会话。 */
access_token?: string;
token_type?: string;
expires_in?: number;
/** 已设置密码/口令,散列凭据不会返回给客户端。 */
has_login_password: boolean;
/** 管理员重置自己的密码时,服务器同步刷新当前会话。 */
access_token?: string;
token_type?: string;
expires_in?: number;
}
export async function listAllUsers(): Promise<UserListItem[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`);
/**
* 拉全部注册用户。`includeCounts=false` 时后端只查 users 表、跳过问答次数
* 统计与 skill/agent 计数(返回 0)——白名单管理页用,避免 runs 表慢查询。
*/
export async function listAllUsers(includeCounts = true): Promise<UserListItem[]> {
const url = new URL(`${getBackendBaseURL()}/api/v1/auth/users`);
if (!includeCounts) url.searchParams.set("include_counts", "false");
const res = await apiFetch(url.toString());
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<UserListItem[]>;
}
/** 分页用户列表(后端传 limit 时返回的 UserListPage 形状)。 */
export interface UserListPage {
items: UserListItem[];
total: number;
approved_count: number;
limit: number;
offset: number;
}
/** 服务端分页拉用户(白名单管理页):只查一页 users 表 + 两个 COUNT。 */
export async function listUsersPaged(opts: {
q?: string;
limit: number;
offset: number;
}, signal?: AbortSignal): Promise<UserListPage> {
const url = new URL(`${getBackendBaseURL()}/api/v1/auth/users`);
url.searchParams.set("limit", String(opts.limit));
url.searchParams.set("offset", String(opts.offset));
url.searchParams.set("include_counts", "false");
if (opts.q) url.searchParams.set("q", opts.q);
const controller = new AbortController();
const cancel = () => controller.abort();
if (signal?.aborted) cancel();
else signal?.addEventListener("abort", cancel, { once: true });
const timer = setTimeout(cancel, 20_000);
try {
const res = await apiFetch(url.toString(), { signal: controller.signal });
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(extractErrorMessage(data, `用户列表加载失败(HTTP ${res.status})`));
}
return await res.json() as UserListPage;
} catch (error) {
if (controller.signal.aborted && !signal?.aborted) {
throw new Error("用户列表加载超时,请重试");
}
throw error;
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", cancel);
}
}
/** 放行 / 取消放行某个注册用户(登录白名单)。Admin only。 */
export async function setUserApproval(userId: string, approved: boolean): Promise<UserListItem> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/approval`, {
@@ -47,7 +97,7 @@ export async function setUserApproval(userId: string, approved: boolean): Promis
}
/**
* 管理员设置 / 重置统一密码与登录口令。服务器不再允许清除以回退免密登录。
* 管理员设置 / 重置统一密码与登录口令。服务器不再允许清除以回退免密登录。
*/
export async function setUserLoginPassword(userId: string, password: string | null): Promise<UserListItem> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/login-password`, {
@@ -95,13 +145,15 @@ export async function loginByUsername(
password != null && password !== ""
? `${base}?password=${encodeURIComponent(password)}`
: base;
const response = await apiFetch(url, {
// application/x-www-form-urlencoded is a CORS simple content type. A JSON
// body makes old Firefox preflight the login and then abort it.
const response = await fetchWithBindingRetry(() => apiFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: JSON.stringify({ username }),
});
body: new URLSearchParams({ username }),
}));
if (!response.ok) {
const error = await response.json().catch(() => null);
throw new Error(extractErrorMessage(error));
@@ -122,6 +174,7 @@ const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve,
export async function loginByToken(
token: string,
isCurrent: () => boolean = () => true,
): Promise<LoginByUsernameResponse> {
// Token-exchange login: the gateway resolves the upstream token to a username
// and signs the matching local account in.
@@ -129,13 +182,18 @@ export async function loginByToken(
let lastError = "Login failed.";
for (let attempt = 1; attempt <= TOKEN_LOGIN_MAX_ATTEMPTS; attempt += 1) {
const response = await apiFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
let response: Response;
try {
// No JSON content type and no custom header: old Firefox can send this
// POST without a preflight. A dropped connection is retried once.
response = await fetchWithBindingRetry(() => apiFetch(url, { method: "POST" }));
} catch (error) {
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
throw error;
}
if (response.ok) {
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
return response.json() as Promise<LoginByUsernameResponse>;
}