feat: 实现管理后台 W1-W3 假数据界面
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 管理接口函数集中处。当前走假数据(mock);W4 改为 requestAdmin 即可,页面不用改。
|
||||
*/
|
||||
import { ApiError } from "./http";
|
||||
import { mockApi } from "./mock";
|
||||
import { message } from "@/utils/notify";
|
||||
import type {
|
||||
ApiToken,
|
||||
ApiTokenCreateResult,
|
||||
BatchResult,
|
||||
Endpoint,
|
||||
EndpointCreateRequest,
|
||||
EndpointCreateResult,
|
||||
EndpointListQuery,
|
||||
EndpointPatchRequest,
|
||||
GroupDetail,
|
||||
GroupFailed,
|
||||
GroupSummary,
|
||||
ImportItem,
|
||||
MeInfo,
|
||||
MessageDetail,
|
||||
MessageSummary,
|
||||
Overview,
|
||||
PageResult,
|
||||
RegistrationSettings,
|
||||
RegistrationUpdateRequest,
|
||||
RuntimeSettings,
|
||||
} from "./types";
|
||||
|
||||
export { ApiError };
|
||||
export type * from "./types";
|
||||
|
||||
async function run<T>(fn: () => Promise<T>, silent = false): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (!silent && e instanceof ApiError) {
|
||||
message.error(e.message);
|
||||
} else if (!silent && e instanceof Error) {
|
||||
message.error(e.message);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function login(username: string, password: string) {
|
||||
// 登录页自行展示错误,避免与 message 重复
|
||||
return run(() => mockApi.login(username, password), true);
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return run(() => mockApi.logout());
|
||||
}
|
||||
|
||||
export function fetchMe(silent = false) {
|
||||
return run(() => mockApi.me(), silent);
|
||||
}
|
||||
|
||||
export function changePassword(oldPassword: string, newPassword: string) {
|
||||
return run(() => mockApi.changePassword(oldPassword, newPassword));
|
||||
}
|
||||
|
||||
export function fetchOverview(): Promise<Overview> {
|
||||
return run(() => mockApi.overview());
|
||||
}
|
||||
|
||||
export function listEndpoints(q?: EndpointListQuery): Promise<PageResult<Endpoint>> {
|
||||
return run(() => mockApi.listEndpoints(q));
|
||||
}
|
||||
|
||||
export function createEndpoint(body: EndpointCreateRequest): Promise<EndpointCreateResult> {
|
||||
return run(() => mockApi.createEndpoint(body));
|
||||
}
|
||||
|
||||
export function importEndpoints(csvText: string): Promise<{ items: ImportItem[] }> {
|
||||
return run(() => mockApi.importEndpoints(csvText), true);
|
||||
}
|
||||
|
||||
export function batchEndpoints(ids: string[], action: "disable" | "enable" | "delete"): Promise<BatchResult> {
|
||||
return run(() => mockApi.batchEndpoints(ids, action));
|
||||
}
|
||||
|
||||
export function getEndpoint(id: string): Promise<Endpoint> {
|
||||
return run(() => mockApi.getEndpoint(id));
|
||||
}
|
||||
|
||||
export function patchEndpoint(id: string, body: EndpointPatchRequest): Promise<Endpoint> {
|
||||
return run(() => mockApi.patchEndpoint(id, body));
|
||||
}
|
||||
|
||||
export function deleteEndpoint(id: string) {
|
||||
return run(() => mockApi.deleteEndpoint(id));
|
||||
}
|
||||
|
||||
export function kickEndpoint(id: string) {
|
||||
return run(() => mockApi.kickEndpoint(id));
|
||||
}
|
||||
|
||||
export function resetLoginPassword(id: string, loginPassword?: string) {
|
||||
return run(() => mockApi.resetLoginPassword(id, loginPassword));
|
||||
}
|
||||
|
||||
export function setTalkPassword(id: string, talkPassword: string) {
|
||||
return run(() => mockApi.setTalkPassword(id, talkPassword));
|
||||
}
|
||||
|
||||
export function unlockEndpoint(id: string) {
|
||||
return run(() => mockApi.unlockEndpoint(id));
|
||||
}
|
||||
|
||||
export function getRegistration(): Promise<RegistrationSettings> {
|
||||
return run(() => mockApi.getRegistration());
|
||||
}
|
||||
|
||||
export function updateRegistration(body: RegistrationUpdateRequest): Promise<RegistrationSettings> {
|
||||
return run(() => mockApi.updateRegistration(body));
|
||||
}
|
||||
|
||||
export function listTokens(): Promise<PageResult<ApiToken>> {
|
||||
return run(() => mockApi.listTokens());
|
||||
}
|
||||
|
||||
export function createToken(name: string): Promise<ApiTokenCreateResult> {
|
||||
return run(() => mockApi.createToken(name));
|
||||
}
|
||||
|
||||
export function patchToken(id: number, body: { name?: string; enabled?: boolean }): Promise<ApiToken> {
|
||||
return run(() => mockApi.patchToken(id, body));
|
||||
}
|
||||
|
||||
export function deleteToken(id: number) {
|
||||
return run(() => mockApi.deleteToken(id));
|
||||
}
|
||||
|
||||
export function listGroups(query?: string, cursor?: string, limit?: number): Promise<PageResult<GroupSummary>> {
|
||||
return run(() => mockApi.listGroups(query, cursor, limit));
|
||||
}
|
||||
|
||||
export function createGroup(body: {
|
||||
id?: string;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
member_ids?: string[];
|
||||
}): Promise<{ id: string; name: string; owner_id: string; failed: GroupFailed[] }> {
|
||||
return run(() => mockApi.createGroup(body));
|
||||
}
|
||||
|
||||
export function getGroup(id: string, cursor?: string, limit?: number): Promise<GroupDetail> {
|
||||
return run(() => mockApi.getGroup(id, cursor, limit));
|
||||
}
|
||||
|
||||
export function renameGroup(id: string, name: string): Promise<GroupSummary> {
|
||||
return run(() => mockApi.renameGroup(id, name));
|
||||
}
|
||||
|
||||
export function deleteGroup(id: string) {
|
||||
return run(() => mockApi.deleteGroup(id));
|
||||
}
|
||||
|
||||
export function addGroupMembers(id: string, memberIds: string[]) {
|
||||
return run(() => mockApi.addGroupMembers(id, memberIds));
|
||||
}
|
||||
|
||||
export function removeGroupMember(id: string, endpointId: string) {
|
||||
return run(() => mockApi.removeGroupMember(id, endpointId));
|
||||
}
|
||||
|
||||
export function transferGroup(id: string, endpointId: string) {
|
||||
return run(() => mockApi.transferGroup(id, endpointId));
|
||||
}
|
||||
|
||||
export function listMessages(q: {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
sender_id?: string;
|
||||
endpoint_id?: string;
|
||||
group_id?: string;
|
||||
state?: string;
|
||||
}): Promise<PageResult<MessageSummary>> {
|
||||
return run(() => mockApi.listMessages(q));
|
||||
}
|
||||
|
||||
export function getMessage(seq: number): Promise<MessageDetail> {
|
||||
return run(() => mockApi.getMessage(seq));
|
||||
}
|
||||
|
||||
export function getSettings(): Promise<RuntimeSettings> {
|
||||
return run(() => mockApi.getSettings());
|
||||
}
|
||||
|
||||
export type { MeInfo };
|
||||
@@ -0,0 +1,85 @@
|
||||
import { message } from "@/utils/notify";
|
||||
import type { ApiEnvelope, ApiErrorBody } from "./types";
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly code: string;
|
||||
readonly httpStatus: number;
|
||||
readonly data?: unknown;
|
||||
|
||||
constructor(code: string, msg: string, httpStatus = 400, data?: unknown) {
|
||||
super(msg);
|
||||
this.name = "ApiError";
|
||||
this.code = code;
|
||||
this.httpStatus = httpStatus;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: HttpMethod;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
/** 为 true 时不弹出错误提示(由调用方处理) */
|
||||
silent?: boolean;
|
||||
rawBody?: BodyInit | null;
|
||||
contentType?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理接口请求封装:自动带 credentials 与 X-Nixmsg-Request: 1,错误直接 message 显示。
|
||||
* W4 接真实后端时页面不必改,只需让 admin 模块走本函数。
|
||||
*/
|
||||
export async function requestAdmin<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const method = opts.method ?? (opts.body != null || opts.rawBody != null ? "POST" : "GET");
|
||||
const headers: Record<string, string> = {
|
||||
"X-Nixmsg-Request": "1",
|
||||
...opts.headers,
|
||||
};
|
||||
|
||||
let body: BodyInit | undefined | null = opts.rawBody;
|
||||
if (opts.rawBody === undefined && opts.body !== undefined) {
|
||||
headers["Content-Type"] = opts.contentType ?? "application/json";
|
||||
body = JSON.stringify(opts.body);
|
||||
} else if (opts.contentType) {
|
||||
headers["Content-Type"] = opts.contentType;
|
||||
}
|
||||
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers,
|
||||
body: body ?? undefined,
|
||||
});
|
||||
|
||||
let envelope: ApiEnvelope<T>;
|
||||
try {
|
||||
envelope = (await res.json()) as ApiEnvelope<T>;
|
||||
} catch {
|
||||
const err = new ApiError("internal", `响应不是 JSON(HTTP ${res.status})`, res.status);
|
||||
if (!opts.silent) {
|
||||
message.error(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (typeof envelope !== "object" || envelope === null) {
|
||||
const err = new ApiError("internal", "响应格式无效", res.status);
|
||||
if (!opts.silent) {
|
||||
message.error(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (envelope.ok) {
|
||||
return envelope.data;
|
||||
}
|
||||
|
||||
const errBody: ApiErrorBody = envelope.error ?? { code: "internal", message: "未知错误" };
|
||||
const err = new ApiError(errBody.code, errBody.message, res.status, "data" in envelope ? envelope.data : undefined);
|
||||
if (!opts.silent) {
|
||||
message.error(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
import type {
|
||||
ApiToken,
|
||||
ApiTokenCreateResult,
|
||||
BatchResult,
|
||||
Endpoint,
|
||||
EndpointCreateRequest,
|
||||
EndpointCreateResult,
|
||||
EndpointListQuery,
|
||||
EndpointPatchRequest,
|
||||
GroupDetail,
|
||||
GroupFailed,
|
||||
GroupSummary,
|
||||
ImportItem,
|
||||
MeInfo,
|
||||
MessageDetail,
|
||||
MessageSummary,
|
||||
Overview,
|
||||
PageResult,
|
||||
RegistrationSettings,
|
||||
RegistrationUpdateRequest,
|
||||
RuntimeSettings,
|
||||
} from "./types";
|
||||
import { ApiError } from "./http";
|
||||
|
||||
const SESSION_KEY = "nixmsg_admin_mock_session";
|
||||
const now = () => Date.now();
|
||||
|
||||
function randomId(prefix: string): string {
|
||||
const hex = Math.random().toString(16).slice(2, 10);
|
||||
return `${prefix}_${hex}`;
|
||||
}
|
||||
|
||||
function randomPassword(len = 16): string {
|
||||
const chars = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let out = "";
|
||||
for (let i = 0; i < len; i++) {
|
||||
out += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clone<T>(v: T): T {
|
||||
return JSON.parse(JSON.stringify(v)) as T;
|
||||
}
|
||||
|
||||
const endpoints: Endpoint[] = [
|
||||
{
|
||||
id: "device-1",
|
||||
name: "gate",
|
||||
remark: "lobby tablet",
|
||||
source: "admin",
|
||||
enabled: true,
|
||||
online: true,
|
||||
online_since_ms: now() - 3_600_000,
|
||||
offline_since_ms: null,
|
||||
talk_password_set: false,
|
||||
default_delay_ms: 0,
|
||||
created_at_ms: now() - 86_400_000,
|
||||
login_locked: false,
|
||||
session_issued_at_ms: now() - 3_600_000,
|
||||
session_used_at_ms: now() - 60_000,
|
||||
},
|
||||
{
|
||||
id: "device-2",
|
||||
name: "warehouse",
|
||||
remark: "",
|
||||
source: "self",
|
||||
enabled: true,
|
||||
online: false,
|
||||
online_since_ms: null,
|
||||
offline_since_ms: now() - 7200_000,
|
||||
talk_password_set: true,
|
||||
default_delay_ms: 5000,
|
||||
created_at_ms: now() - 172_800_000,
|
||||
login_locked: true,
|
||||
},
|
||||
{
|
||||
id: "ops-bot",
|
||||
name: "ops-bot",
|
||||
remark: "admin-provisioned",
|
||||
source: "admin",
|
||||
enabled: false,
|
||||
online: false,
|
||||
online_since_ms: null,
|
||||
offline_since_ms: now() - 10_000_000,
|
||||
talk_password_set: false,
|
||||
default_delay_ms: 0,
|
||||
created_at_ms: now() - 259_200_000,
|
||||
login_locked: false,
|
||||
},
|
||||
];
|
||||
|
||||
const registration: RegistrationSettings = {
|
||||
enabled: false,
|
||||
code: "demo-reg-code-01",
|
||||
updated_at_ms: now() - 86_400_000,
|
||||
};
|
||||
|
||||
const tokens: ApiToken[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "ops",
|
||||
enabled: true,
|
||||
created_at_ms: now() - 86_400_000,
|
||||
last_used_at_ms: now() - 3_600_000,
|
||||
},
|
||||
];
|
||||
let nextTokenId = 2;
|
||||
|
||||
interface GroupRow {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
created_at_ms: number;
|
||||
members: { id: string; joined_at_ms: number }[];
|
||||
}
|
||||
|
||||
const groups: GroupRow[] = [
|
||||
{
|
||||
id: "g_demo0001",
|
||||
name: "team-a",
|
||||
owner_id: "device-1",
|
||||
created_at_ms: now() - 50_000_000,
|
||||
members: [
|
||||
{ id: "device-1", joined_at_ms: now() - 50_000_000 },
|
||||
{ id: "device-2", joined_at_ms: now() - 40_000_000 },
|
||||
{ id: "ops-bot", joined_at_ms: now() - 30_000_000 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const messages: MessageDetail[] = [
|
||||
{
|
||||
seq: 1,
|
||||
id: "018f000000000000000000000001",
|
||||
sender_id: "device-1",
|
||||
dest_kind: "endpoint",
|
||||
dest_id: "device-2",
|
||||
state: "completed",
|
||||
reason: "",
|
||||
send_at_ms: now() - 3_600_000,
|
||||
created_at_ms: now() - 3_601_000,
|
||||
meta: {},
|
||||
content_type: "text/plain",
|
||||
deliveries: [
|
||||
{
|
||||
endpoint_id: "device-2",
|
||||
state: "accepted",
|
||||
reason: "",
|
||||
attempts: 1,
|
||||
pushed_at_ms: now() - 3_599_000,
|
||||
updated_at_ms: now() - 3_598_000,
|
||||
},
|
||||
],
|
||||
next_cursor: "",
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
id: "018f000000000000000000000002",
|
||||
sender_id: "ops-bot",
|
||||
dest_kind: "group",
|
||||
dest_id: "g_demo0001",
|
||||
state: "dispatched",
|
||||
reason: "",
|
||||
send_at_ms: now() - 600_000,
|
||||
created_at_ms: now() - 601_000,
|
||||
meta: {},
|
||||
content_type: "application/json",
|
||||
deliveries: [
|
||||
{
|
||||
endpoint_id: "device-1",
|
||||
state: "accepted",
|
||||
reason: "",
|
||||
attempts: 1,
|
||||
pushed_at_ms: now() - 599_000,
|
||||
updated_at_ms: now() - 598_000,
|
||||
},
|
||||
{
|
||||
endpoint_id: "device-2",
|
||||
state: "pending",
|
||||
reason: "",
|
||||
attempts: 2,
|
||||
pushed_at_ms: now() - 590_000,
|
||||
updated_at_ms: now() - 580_000,
|
||||
},
|
||||
],
|
||||
next_cursor: "",
|
||||
},
|
||||
{
|
||||
seq: 3,
|
||||
id: "018f000000000000000000000003",
|
||||
sender_id: "device-2",
|
||||
dest_kind: "endpoint",
|
||||
dest_id: "device-1",
|
||||
state: "scheduled",
|
||||
reason: "",
|
||||
send_at_ms: now() + 3_600_000,
|
||||
created_at_ms: now() - 100_000,
|
||||
meta: {},
|
||||
content_type: "text/plain",
|
||||
deliveries: [],
|
||||
next_cursor: "",
|
||||
},
|
||||
];
|
||||
|
||||
const settings: RuntimeSettings = {
|
||||
listen: ":7443",
|
||||
admin_listen: "",
|
||||
session_idle_days: 30,
|
||||
record_retention_days: 7,
|
||||
idempotency_hours: 24,
|
||||
receipt_retention_days: 7,
|
||||
sqlite_synchronous: "FULL",
|
||||
limits: {
|
||||
max_body_bytes: 262144,
|
||||
max_meta_bytes: 4096,
|
||||
max_frame_bytes: 786432,
|
||||
max_ttl_seconds: 2592000,
|
||||
max_schedule_seconds: 31536000,
|
||||
max_group_members: 1000,
|
||||
grace_seconds: 60,
|
||||
ack_timeout_seconds: 300,
|
||||
delivery_window: 32,
|
||||
receipt_window: 64,
|
||||
requests_per_second: 50,
|
||||
max_pending_per_sender: 10000,
|
||||
max_pending_per_receiver: 10000,
|
||||
},
|
||||
};
|
||||
|
||||
const startedAt = now();
|
||||
|
||||
function requireSession(): void {
|
||||
if (sessionStorage.getItem(SESSION_KEY) !== "1") {
|
||||
throw new ApiError("unauthorized", "未登录", 401);
|
||||
}
|
||||
}
|
||||
|
||||
function paginate<T>(items: T[], cursor?: string, limit = 50): PageResult<T> {
|
||||
const offset = cursor ? Number.parseInt(cursor, 10) || 0 : 0;
|
||||
const size = Math.min(Math.max(limit, 1), 200);
|
||||
const slice = items.slice(offset, offset + size);
|
||||
const next = offset + size < items.length ? String(offset + size) : "";
|
||||
return { items: clone(slice), next_cursor: next, total: items.length };
|
||||
}
|
||||
|
||||
function endpointName(id: string): string {
|
||||
return endpoints.find((e) => e.id === id)?.name ?? "";
|
||||
}
|
||||
|
||||
function toGroupSummary(g: GroupRow): GroupSummary {
|
||||
return {
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
owner_id: g.owner_id,
|
||||
member_count: g.members.length,
|
||||
created_at_ms: g.created_at_ms,
|
||||
};
|
||||
}
|
||||
|
||||
function toMessageSummary(m: MessageDetail): MessageSummary {
|
||||
const counts = { pending: 0, accepted: 0, recalled: 0, expired: 0, dropped: 0, rejected: 0 };
|
||||
for (const d of m.deliveries) {
|
||||
const key = d.state as keyof typeof counts;
|
||||
if (key in counts) {
|
||||
counts[key] += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
seq: m.seq,
|
||||
id: m.id,
|
||||
sender_id: m.sender_id,
|
||||
dest_kind: m.dest_kind,
|
||||
dest_id: m.dest_id,
|
||||
state: m.state as MessageSummary["state"],
|
||||
reason: m.reason,
|
||||
send_at_ms: m.send_at_ms,
|
||||
created_at_ms: m.created_at_ms,
|
||||
keep: false,
|
||||
receipt: true,
|
||||
content_type: m.content_type,
|
||||
delivery_counts: counts,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockApi = {
|
||||
async login(username: string, password: string): Promise<{ username: string }> {
|
||||
if (username !== "admin" || password !== "adminpassword") {
|
||||
throw new ApiError("unauthorized", "用户名或密码错误", 401);
|
||||
}
|
||||
sessionStorage.setItem(SESSION_KEY, "1");
|
||||
return { username: "admin" };
|
||||
},
|
||||
|
||||
async logout(): Promise<Record<string, never>> {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
return {};
|
||||
},
|
||||
|
||||
async me(): Promise<MeInfo> {
|
||||
requireSession();
|
||||
return { username: "admin", auth: "cookie" };
|
||||
},
|
||||
|
||||
async changePassword(oldPassword: string, newPassword: string): Promise<Record<string, never>> {
|
||||
requireSession();
|
||||
if (oldPassword !== "adminpassword") {
|
||||
throw new ApiError("unauthorized", "原密码错误", 401);
|
||||
}
|
||||
if (newPassword.length < 12) {
|
||||
throw new ApiError("bad_request", "新密码至少 12 位", 400);
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
||||
async overview(): Promise<Overview> {
|
||||
requireSession();
|
||||
return {
|
||||
version: "0.1.0-mock",
|
||||
endpoints_total: endpoints.length,
|
||||
endpoints_online: endpoints.filter((e) => e.online).length,
|
||||
endpoints_disabled: endpoints.filter((e) => !e.enabled).length,
|
||||
groups_total: groups.length,
|
||||
messages_pending: messages.filter((m) => m.state === "dispatched").length,
|
||||
messages_scheduled: messages.filter((m) => m.state === "scheduled").length,
|
||||
uptime_ms: now() - startedAt,
|
||||
};
|
||||
},
|
||||
|
||||
async listEndpoints(q: EndpointListQuery = {}): Promise<PageResult<Endpoint>> {
|
||||
requireSession();
|
||||
let list = [...endpoints];
|
||||
if (q.source) {
|
||||
list = list.filter((e) => e.source === q.source);
|
||||
}
|
||||
if (q.online === true || q.online === false) {
|
||||
list = list.filter((e) => e.online === q.online);
|
||||
}
|
||||
if (q.enabled === true || q.enabled === false) {
|
||||
list = list.filter((e) => e.enabled === q.enabled);
|
||||
}
|
||||
if (q.query) {
|
||||
const needle = q.query.toLowerCase();
|
||||
list = list.filter(
|
||||
(e) => e.id.toLowerCase().includes(needle) || e.name.toLowerCase().includes(needle),
|
||||
);
|
||||
}
|
||||
return paginate(list, q.cursor, q.limit);
|
||||
},
|
||||
|
||||
async createEndpoint(body: EndpointCreateRequest): Promise<EndpointCreateResult> {
|
||||
requireSession();
|
||||
const id = (body.id || "").trim() || randomId("e");
|
||||
if (endpoints.some((e) => e.id === id)) {
|
||||
throw new ApiError("id_taken", "编号已占用", 409);
|
||||
}
|
||||
const loginPassword = (body.login_password || "").trim() || randomPassword();
|
||||
endpoints.unshift({
|
||||
id,
|
||||
name: body.name,
|
||||
remark: body.remark ?? "",
|
||||
source: "admin",
|
||||
enabled: true,
|
||||
online: false,
|
||||
online_since_ms: null,
|
||||
offline_since_ms: now(),
|
||||
talk_password_set: Boolean(body.talk_password),
|
||||
default_delay_ms: (body.default_delay_seconds ?? 0) * 1000,
|
||||
created_at_ms: now(),
|
||||
login_locked: false,
|
||||
});
|
||||
return { id, login_password: loginPassword };
|
||||
},
|
||||
|
||||
async importEndpoints(csvText: string): Promise<{ items: ImportItem[] }> {
|
||||
requireSession();
|
||||
const lines = csvText.replace(/^\uFEFF/, "").split(/\r?\n/).filter((l) => l.trim());
|
||||
if (lines.length < 2) {
|
||||
throw new ApiError("bad_request", "CSV 校验失败", 400, {
|
||||
errors: [{ line: 1, reason: "表头不正确" }],
|
||||
});
|
||||
}
|
||||
const header = lines[0].split(",").map((s) => s.trim());
|
||||
const expected = ["id", "name", "login_password", "talk_password", "default_delay_seconds", "remark"];
|
||||
if (expected.some((h, i) => header[i] !== h)) {
|
||||
throw new ApiError("bad_request", "CSV 校验失败", 400, {
|
||||
errors: [{ line: 1, reason: "?????" }],
|
||||
});
|
||||
}
|
||||
const errors: { line: number; reason: string }[] = [];
|
||||
const rows: ImportItem[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cols = lines[i].split(",");
|
||||
const id = (cols[0] || "").trim() || randomId("e");
|
||||
const name = (cols[1] || "").trim();
|
||||
if (!name) {
|
||||
errors.push({ line: i + 1, reason: "名称不能为空" });
|
||||
continue;
|
||||
}
|
||||
if (endpoints.some((e) => e.id === id) || rows.some((r) => r.id === id)) {
|
||||
errors.push({ line: i + 1, reason: "编号占用" });
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
id,
|
||||
name,
|
||||
login_password: (cols[2] || "").trim() || randomPassword(),
|
||||
});
|
||||
}
|
||||
if (errors.length) {
|
||||
throw new ApiError("bad_request", "CSV 校验失败", 400, { errors });
|
||||
}
|
||||
for (const r of rows) {
|
||||
endpoints.unshift({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
remark: "",
|
||||
source: "admin",
|
||||
enabled: true,
|
||||
online: false,
|
||||
online_since_ms: null,
|
||||
offline_since_ms: now(),
|
||||
talk_password_set: false,
|
||||
default_delay_ms: 0,
|
||||
created_at_ms: now(),
|
||||
login_locked: false,
|
||||
});
|
||||
}
|
||||
return { items: rows };
|
||||
},
|
||||
|
||||
async batchEndpoints(ids: string[], action: "disable" | "enable" | "delete"): Promise<BatchResult> {
|
||||
requireSession();
|
||||
const ok_ids: string[] = [];
|
||||
const failed: { id: string; code: string }[] = [];
|
||||
for (const id of ids) {
|
||||
const idx = endpoints.findIndex((e) => e.id === id);
|
||||
if (idx < 0) {
|
||||
failed.push({ id, code: "not_found" });
|
||||
continue;
|
||||
}
|
||||
if (action === "delete") {
|
||||
endpoints.splice(idx, 1);
|
||||
} else if (action === "disable") {
|
||||
endpoints[idx].enabled = false;
|
||||
} else {
|
||||
endpoints[idx].enabled = true;
|
||||
}
|
||||
ok_ids.push(id);
|
||||
}
|
||||
return { ok_ids, failed };
|
||||
},
|
||||
|
||||
async getEndpoint(id: string): Promise<Endpoint> {
|
||||
requireSession();
|
||||
const e = endpoints.find((x) => x.id === id);
|
||||
if (!e) {
|
||||
throw new ApiError("not_found", "端不存在", 404);
|
||||
}
|
||||
return clone(e);
|
||||
},
|
||||
|
||||
async patchEndpoint(id: string, body: EndpointPatchRequest): Promise<Endpoint> {
|
||||
requireSession();
|
||||
const e = endpoints.find((x) => x.id === id);
|
||||
if (!e) {
|
||||
throw new ApiError("not_found", "端不存在", 404);
|
||||
}
|
||||
if (body.name !== undefined) e.name = body.name;
|
||||
if (body.remark !== undefined) e.remark = body.remark;
|
||||
if (body.enabled !== undefined) e.enabled = body.enabled;
|
||||
if (body.default_delay_seconds !== undefined) {
|
||||
e.default_delay_ms = body.default_delay_seconds * 1000;
|
||||
}
|
||||
return clone(e);
|
||||
},
|
||||
|
||||
async deleteEndpoint(id: string): Promise<Record<string, never>> {
|
||||
requireSession();
|
||||
const idx = endpoints.findIndex((e) => e.id === id);
|
||||
if (idx < 0) {
|
||||
throw new ApiError("not_found", "端不存在", 404);
|
||||
}
|
||||
endpoints.splice(idx, 1);
|
||||
return {};
|
||||
},
|
||||
|
||||
async kickEndpoint(id: string): Promise<{ kicked: boolean }> {
|
||||
requireSession();
|
||||
const e = endpoints.find((x) => x.id === id);
|
||||
if (!e) {
|
||||
throw new ApiError("not_found", "端不存在", 404);
|
||||
}
|
||||
const kicked = e.online;
|
||||
if (kicked) {
|
||||
e.online = false;
|
||||
e.online_since_ms = null;
|
||||
e.offline_since_ms = now();
|
||||
}
|
||||
return { kicked };
|
||||
},
|
||||
|
||||
async resetLoginPassword(id: string, loginPassword?: string): Promise<{ login_password: string }> {
|
||||
requireSession();
|
||||
const e = endpoints.find((x) => x.id === id);
|
||||
if (!e) {
|
||||
throw new ApiError("not_found", "端不存在", 404);
|
||||
}
|
||||
return { login_password: (loginPassword || "").trim() || randomPassword() };
|
||||
},
|
||||
|
||||
async setTalkPassword(id: string, talkPassword: string): Promise<{ talk_password_set: boolean }> {
|
||||
requireSession();
|
||||
const e = endpoints.find((x) => x.id === id);
|
||||
if (!e) {
|
||||
throw new ApiError("not_found", "端不存在", 404);
|
||||
}
|
||||
e.talk_password_set = talkPassword.length > 0;
|
||||
return { talk_password_set: e.talk_password_set };
|
||||
},
|
||||
|
||||
async unlockEndpoint(id: string): Promise<Record<string, never>> {
|
||||
requireSession();
|
||||
const e = endpoints.find((x) => x.id === id);
|
||||
if (!e) {
|
||||
throw new ApiError("not_found", "端不存在", 404);
|
||||
}
|
||||
e.login_locked = false;
|
||||
return {};
|
||||
},
|
||||
|
||||
async getRegistration(): Promise<RegistrationSettings> {
|
||||
requireSession();
|
||||
return clone(registration);
|
||||
},
|
||||
|
||||
async updateRegistration(body: RegistrationUpdateRequest): Promise<RegistrationSettings> {
|
||||
requireSession();
|
||||
if (body.enabled !== undefined) registration.enabled = body.enabled;
|
||||
if (body.generate) {
|
||||
registration.code = randomPassword(16);
|
||||
} else if (body.code !== undefined) {
|
||||
if (body.code.length < 8 || body.code.length > 64) {
|
||||
throw new ApiError("bad_request", "安全码须为 8–64 字符", 400);
|
||||
}
|
||||
registration.code = body.code;
|
||||
}
|
||||
registration.updated_at_ms = now();
|
||||
return clone(registration);
|
||||
},
|
||||
|
||||
async listTokens(): Promise<PageResult<ApiToken>> {
|
||||
requireSession();
|
||||
return { items: clone(tokens), next_cursor: "", total: tokens.length };
|
||||
},
|
||||
|
||||
async createToken(name: string): Promise<ApiTokenCreateResult> {
|
||||
requireSession();
|
||||
const item: ApiToken = {
|
||||
id: nextTokenId++,
|
||||
name,
|
||||
enabled: true,
|
||||
created_at_ms: now(),
|
||||
last_used_at_ms: null,
|
||||
};
|
||||
tokens.unshift(item);
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
token: `nxm_${randomPassword(32)}`,
|
||||
created_at_ms: item.created_at_ms,
|
||||
};
|
||||
},
|
||||
|
||||
async patchToken(id: number, body: { name?: string; enabled?: boolean }): Promise<ApiToken> {
|
||||
requireSession();
|
||||
const t = tokens.find((x) => x.id === id);
|
||||
if (!t) {
|
||||
throw new ApiError("not_found", "令牌不存在", 404);
|
||||
}
|
||||
if (body.name !== undefined) t.name = body.name;
|
||||
if (body.enabled !== undefined) t.enabled = body.enabled;
|
||||
return clone(t);
|
||||
},
|
||||
|
||||
async deleteToken(id: number): Promise<Record<string, never>> {
|
||||
requireSession();
|
||||
const idx = tokens.findIndex((x) => x.id === id);
|
||||
if (idx < 0) {
|
||||
throw new ApiError("not_found", "令牌不存在", 404);
|
||||
}
|
||||
tokens.splice(idx, 1);
|
||||
return {};
|
||||
},
|
||||
|
||||
async listGroups(query?: string, cursor?: string, limit?: number): Promise<PageResult<GroupSummary>> {
|
||||
requireSession();
|
||||
let list = groups.map(toGroupSummary);
|
||||
if (query) {
|
||||
const n = query.toLowerCase();
|
||||
list = list.filter((g) => g.name.toLowerCase().includes(n) || g.id.toLowerCase().includes(n));
|
||||
}
|
||||
return paginate(list, cursor, limit);
|
||||
},
|
||||
|
||||
async createGroup(body: {
|
||||
id?: string;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
member_ids?: string[];
|
||||
}): Promise<{ id: string; name: string; owner_id: string; failed: GroupFailed[] }> {
|
||||
requireSession();
|
||||
const id = (body.id || "").trim() || randomId("g");
|
||||
const failed: GroupFailed[] = [];
|
||||
const memberIds = new Set<string>([body.owner_id, ...(body.member_ids ?? [])]);
|
||||
const members: GroupRow["members"] = [];
|
||||
for (const mid of memberIds) {
|
||||
if (!endpoints.some((e) => e.id === mid)) {
|
||||
failed.push({ id: mid, code: "not_found" });
|
||||
continue;
|
||||
}
|
||||
members.push({ id: mid, joined_at_ms: now() });
|
||||
}
|
||||
if (!members.some((m) => m.id === body.owner_id)) {
|
||||
throw new ApiError("bad_request", "群主必须存在", 400);
|
||||
}
|
||||
groups.unshift({
|
||||
id,
|
||||
name: body.name,
|
||||
owner_id: body.owner_id,
|
||||
created_at_ms: now(),
|
||||
members,
|
||||
});
|
||||
return { id, name: body.name, owner_id: body.owner_id, failed };
|
||||
},
|
||||
|
||||
async getGroup(id: string, cursor?: string, limit?: number): Promise<GroupDetail> {
|
||||
requireSession();
|
||||
const g = groups.find((x) => x.id === id);
|
||||
if (!g) {
|
||||
throw new ApiError("not_found", "群不存在", 404);
|
||||
}
|
||||
const all = g.members.map((m) => {
|
||||
const ep = endpoints.find((e) => e.id === m.id);
|
||||
return {
|
||||
id: m.id,
|
||||
name: ep?.name ?? endpointName(m.id),
|
||||
online: ep?.online ?? false,
|
||||
joined_at_ms: m.joined_at_ms,
|
||||
};
|
||||
});
|
||||
const page = paginate(all, cursor, limit);
|
||||
return {
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
owner_id: g.owner_id,
|
||||
created_at_ms: g.created_at_ms,
|
||||
members: page.items,
|
||||
next_cursor: page.next_cursor,
|
||||
};
|
||||
},
|
||||
|
||||
async renameGroup(id: string, name: string): Promise<GroupSummary> {
|
||||
requireSession();
|
||||
const g = groups.find((x) => x.id === id);
|
||||
if (!g) {
|
||||
throw new ApiError("not_found", "群不存在", 404);
|
||||
}
|
||||
g.name = name;
|
||||
return toGroupSummary(g);
|
||||
},
|
||||
|
||||
async deleteGroup(id: string): Promise<Record<string, never>> {
|
||||
requireSession();
|
||||
const idx = groups.findIndex((x) => x.id === id);
|
||||
if (idx < 0) {
|
||||
throw new ApiError("not_found", "群不存在", 404);
|
||||
}
|
||||
groups.splice(idx, 1);
|
||||
return {};
|
||||
},
|
||||
|
||||
async addGroupMembers(id: string, memberIds: string[]): Promise<{ failed: GroupFailed[] }> {
|
||||
requireSession();
|
||||
const g = groups.find((x) => x.id === id);
|
||||
if (!g) {
|
||||
throw new ApiError("not_found", "群不存在", 404);
|
||||
}
|
||||
const failed: GroupFailed[] = [];
|
||||
for (const mid of memberIds) {
|
||||
if (!endpoints.some((e) => e.id === mid)) {
|
||||
failed.push({ id: mid, code: "not_found" });
|
||||
continue;
|
||||
}
|
||||
if (!g.members.some((m) => m.id === mid)) {
|
||||
g.members.push({ id: mid, joined_at_ms: now() });
|
||||
}
|
||||
}
|
||||
return { failed };
|
||||
},
|
||||
|
||||
async removeGroupMember(id: string, endpointId: string): Promise<Record<string, never>> {
|
||||
requireSession();
|
||||
const g = groups.find((x) => x.id === id);
|
||||
if (!g) {
|
||||
throw new ApiError("not_found", "群不存在", 404);
|
||||
}
|
||||
g.members = g.members.filter((m) => m.id !== endpointId);
|
||||
return {};
|
||||
},
|
||||
|
||||
async transferGroup(id: string, endpointId: string): Promise<{ owner_id: string }> {
|
||||
requireSession();
|
||||
const g = groups.find((x) => x.id === id);
|
||||
if (!g) {
|
||||
throw new ApiError("not_found", "群不存在", 404);
|
||||
}
|
||||
if (!g.members.some((m) => m.id === endpointId)) {
|
||||
throw new ApiError("bad_request", "新群主须为成员", 400);
|
||||
}
|
||||
g.owner_id = endpointId;
|
||||
return { owner_id: endpointId };
|
||||
},
|
||||
|
||||
async listMessages(q: {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
sender_id?: string;
|
||||
endpoint_id?: string;
|
||||
group_id?: string;
|
||||
state?: string;
|
||||
}): Promise<PageResult<MessageSummary>> {
|
||||
requireSession();
|
||||
let list = messages.map(toMessageSummary);
|
||||
if (q.sender_id) list = list.filter((m) => m.sender_id === q.sender_id);
|
||||
if (q.group_id) list = list.filter((m) => m.dest_kind === "group" && m.dest_id === q.group_id);
|
||||
if (q.endpoint_id) {
|
||||
list = list.filter((m) => {
|
||||
const detail = messages.find((x) => x.seq === m.seq);
|
||||
return detail?.deliveries.some((d) => d.endpoint_id === q.endpoint_id);
|
||||
});
|
||||
}
|
||||
if (q.state) list = list.filter((m) => m.state === q.state);
|
||||
return paginate(list, q.cursor, q.limit);
|
||||
},
|
||||
|
||||
async getMessage(seq: number): Promise<MessageDetail> {
|
||||
requireSession();
|
||||
const m = messages.find((x) => x.seq === seq);
|
||||
if (!m) {
|
||||
throw new ApiError("not_found", "消息不存在", 404);
|
||||
}
|
||||
return clone(m);
|
||||
},
|
||||
|
||||
async getSettings(): Promise<RuntimeSettings> {
|
||||
requireSession();
|
||||
return clone(settings);
|
||||
},
|
||||
|
||||
_resetSession(): void {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
},
|
||||
|
||||
_setSession(): void {
|
||||
sessionStorage.setItem(SESSION_KEY, "1");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
/** 与 docs/api/admin-api.md 对齐的类型(蛇形字段)。 */
|
||||
|
||||
export interface ApiErrorBody {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ApiSuccess<T> {
|
||||
ok: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface ApiFailure {
|
||||
ok: false;
|
||||
error: ApiErrorBody;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export type ApiEnvelope<T> = ApiSuccess<T> | ApiFailure;
|
||||
|
||||
export interface PageResult<T> {
|
||||
items: T[];
|
||||
next_cursor: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface MeInfo {
|
||||
username: string;
|
||||
auth: "cookie" | "token";
|
||||
}
|
||||
|
||||
export interface Overview {
|
||||
version: string;
|
||||
endpoints_total: number;
|
||||
endpoints_online: number;
|
||||
endpoints_disabled: number;
|
||||
groups_total: number;
|
||||
messages_pending: number;
|
||||
messages_scheduled: number;
|
||||
uptime_ms: number;
|
||||
}
|
||||
|
||||
export type EndpointSource = "admin" | "self";
|
||||
|
||||
export interface Endpoint {
|
||||
id: string;
|
||||
name: string;
|
||||
remark: string;
|
||||
source: EndpointSource;
|
||||
enabled: boolean;
|
||||
online: boolean;
|
||||
online_since_ms: number | null;
|
||||
offline_since_ms: number | null;
|
||||
talk_password_set: boolean;
|
||||
default_delay_ms: number;
|
||||
created_at_ms: number;
|
||||
login_locked: boolean;
|
||||
session_issued_at_ms?: number | null;
|
||||
session_used_at_ms?: number | null;
|
||||
}
|
||||
|
||||
export interface EndpointCreateRequest {
|
||||
id?: string;
|
||||
name: string;
|
||||
remark?: string;
|
||||
login_password?: string;
|
||||
talk_password?: string;
|
||||
default_delay_seconds?: number;
|
||||
}
|
||||
|
||||
export interface EndpointCreateResult {
|
||||
id: string;
|
||||
login_password: string;
|
||||
}
|
||||
|
||||
export interface EndpointPatchRequest {
|
||||
name?: string;
|
||||
remark?: string;
|
||||
default_delay_seconds?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface EndpointListQuery {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
source?: EndpointSource | "";
|
||||
online?: boolean | "";
|
||||
enabled?: boolean | "";
|
||||
query?: string;
|
||||
}
|
||||
|
||||
export interface ImportError {
|
||||
line: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ImportItem {
|
||||
id: string;
|
||||
login_password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BatchResult {
|
||||
ok_ids: string[];
|
||||
failed: { id: string; code: string }[];
|
||||
}
|
||||
|
||||
export interface RegistrationSettings {
|
||||
enabled: boolean;
|
||||
code: string;
|
||||
updated_at_ms: number;
|
||||
}
|
||||
|
||||
export interface RegistrationUpdateRequest {
|
||||
enabled?: boolean;
|
||||
code?: string;
|
||||
generate?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiToken {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
created_at_ms: number;
|
||||
last_used_at_ms: number | null;
|
||||
}
|
||||
|
||||
export interface ApiTokenCreateResult {
|
||||
id: number;
|
||||
name: string;
|
||||
token: string;
|
||||
created_at_ms: number;
|
||||
}
|
||||
|
||||
export interface GroupSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
member_count: number;
|
||||
created_at_ms: number;
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
id: string;
|
||||
name: string;
|
||||
online: boolean;
|
||||
joined_at_ms: number;
|
||||
}
|
||||
|
||||
export interface GroupDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
created_at_ms: number;
|
||||
members: GroupMember[];
|
||||
next_cursor: string;
|
||||
}
|
||||
|
||||
export interface GroupFailed {
|
||||
id: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface MessageDeliveryCounts {
|
||||
pending: number;
|
||||
accepted: number;
|
||||
recalled: number;
|
||||
expired: number;
|
||||
dropped: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export interface MessageSummary {
|
||||
seq: number;
|
||||
id: string;
|
||||
sender_id: string;
|
||||
dest_kind: "endpoint" | "group";
|
||||
dest_id: string;
|
||||
state: "scheduled" | "dispatched" | "completed";
|
||||
reason: string;
|
||||
send_at_ms: number;
|
||||
created_at_ms: number;
|
||||
keep: boolean;
|
||||
receipt: boolean;
|
||||
content_type: string;
|
||||
delivery_counts: MessageDeliveryCounts;
|
||||
}
|
||||
|
||||
export interface MessageDelivery {
|
||||
endpoint_id: string;
|
||||
state: string;
|
||||
reason: string;
|
||||
attempts: number;
|
||||
pushed_at_ms: number | null;
|
||||
updated_at_ms: number;
|
||||
}
|
||||
|
||||
export interface MessageDetail {
|
||||
seq: number;
|
||||
id: string;
|
||||
sender_id: string;
|
||||
dest_kind: "endpoint" | "group";
|
||||
dest_id: string;
|
||||
state: string;
|
||||
reason: string;
|
||||
send_at_ms: number;
|
||||
created_at_ms: number;
|
||||
meta: Record<string, unknown>;
|
||||
content_type: string;
|
||||
deliveries: MessageDelivery[];
|
||||
next_cursor: string;
|
||||
}
|
||||
|
||||
export interface RuntimeSettings {
|
||||
listen: string;
|
||||
admin_listen: string;
|
||||
session_idle_days: number;
|
||||
record_retention_days: number;
|
||||
idempotency_hours: number;
|
||||
receipt_retention_days: number;
|
||||
sqlite_synchronous: string;
|
||||
limits: Record<string, number>;
|
||||
}
|
||||
Reference in New Issue
Block a user