feat: 实现管理后台 W1-W3 假数据界面

This commit is contained in:
Nixevol
2026-09-30 07:02:33 +08:00
parent 8dde564517
commit 1cff321f2a
33 changed files with 4773 additions and 31 deletions
+768
View File
@@ -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");
},
};