feat: 实现管理后台 W1-W3 假数据界面
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import js from "@eslint/js";
|
||||
import pluginVue from "eslint-plugin-vue";
|
||||
import tseslint from "typescript-eslint";
|
||||
import globals from "globals";
|
||||
import eslintConfigPrettier from "eslint-config-prettier";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist/**", "node_modules/**", "stub/**"] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...pluginVue.configs["flat/recommended"],
|
||||
{
|
||||
files: ["**/*.{ts,vue}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
extraFileExtensions: [".vue"],
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"vue/multi-word-component-names": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"vue/max-attributes-per-line": "off",
|
||||
"vue/singleline-html-element-content-newline": "off",
|
||||
"vue/html-self-closing": "off",
|
||||
},
|
||||
},
|
||||
eslintConfigPrettier,
|
||||
);
|
||||
+17
-2
@@ -7,7 +7,10 @@
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc -b --noEmit"
|
||||
"typecheck": "vue-tsc -b --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"naive-ui": "^2.43.1",
|
||||
@@ -16,14 +19,26 @@
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^26.6.3",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-vue": "^10.11.1",
|
||||
"globals": "^17.12.0",
|
||||
"happy-dom": "^20.14.5",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.71.0",
|
||||
"vite": "^7.1.7",
|
||||
"vitest": "^5.0.2",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-tsc": "^3.0.8"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["esbuild"]
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1299
File diff suppressed because it is too large
Load Diff
@@ -12,3 +12,12 @@ import { RouterView } from "vue-router";
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { NTooltip } from "naive-ui";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<span class="help-tip" aria-label="帮助">?</span>
|
||||
</template>
|
||||
<slot />
|
||||
</n-tooltip>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.help-tip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-left: 4px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #999;
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NText } from "naive-ui";
|
||||
import HelpTip from "./HelpTip.vue";
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
help?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<n-space align="center" :size="4">
|
||||
<n-text strong style="font-size: 16px">{{ title }}</n-text>
|
||||
<HelpTip v-if="help">{{ help }}</HelpTip>
|
||||
</n-space>
|
||||
<n-space align="center" :size="8">
|
||||
<slot name="actions" />
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--n-border-color, #efeff5);
|
||||
background: var(--n-color, #fff);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NSpace, NText } from "naive-ui";
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
secret: string;
|
||||
filename?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
dismiss: [];
|
||||
}>();
|
||||
|
||||
function copy() {
|
||||
void navigator.clipboard?.writeText(props.secret);
|
||||
}
|
||||
|
||||
function download() {
|
||||
const blob = new Blob([props.secret], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = props.filename ?? "secret.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-alert type="warning" :title="title" style="margin-bottom: 12px">
|
||||
<n-space vertical :size="8">
|
||||
<n-text code>{{ secret }}</n-text>
|
||||
<n-text depth="3">关闭后将无法再次查看,请立即复制或下载。</n-text>
|
||||
<n-space>
|
||||
<n-button size="small" @click="copy">复制</n-button>
|
||||
<n-button size="small" @click="download">下载</n-button>
|
||||
<n-button size="small" secondary @click="emit('dismiss')">我已保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-alert>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter, RouterView } from "vue-router";
|
||||
import {
|
||||
NButton,
|
||||
NLayout,
|
||||
NLayoutContent,
|
||||
NLayoutHeader,
|
||||
NLayoutSider,
|
||||
NMenu,
|
||||
NSpace,
|
||||
NText,
|
||||
} from "naive-ui";
|
||||
import type { MenuOption } from "naive-ui";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const menuOptions: MenuOption[] = [
|
||||
{ label: "概览", key: "/overview" },
|
||||
{ label: "端", key: "/endpoints" },
|
||||
{ label: "注册设置", key: "/registration" },
|
||||
{ label: "群", key: "/groups" },
|
||||
{ label: "投递记录", key: "/messages" },
|
||||
{ label: "API 令牌", key: "/tokens" },
|
||||
{ label: "系统", key: "/settings" },
|
||||
];
|
||||
|
||||
const activeKey = computed(() => {
|
||||
const path = route.path;
|
||||
const hit = menuOptions.find((m) => typeof m.key === "string" && path.startsWith(String(m.key)));
|
||||
return hit?.key ?? "/overview";
|
||||
});
|
||||
|
||||
function onMenuUpdate(key: string) {
|
||||
void router.push(key);
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
await auth.logout();
|
||||
void router.push({ name: "login" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-layout has-sider style="height: 100vh">
|
||||
<n-layout-sider bordered :width="180" :native-scrollbar="false" content-style="padding-top: 8px">
|
||||
<div style="padding: 12px 16px">
|
||||
<n-text strong>NixMsg</n-text>
|
||||
</div>
|
||||
<n-menu :value="activeKey" :options="menuOptions" @update:value="onMenuUpdate" />
|
||||
</n-layout-sider>
|
||||
<n-layout>
|
||||
<n-layout-header bordered style="height: 48px; padding: 0 16px; display: flex; align-items: center; justify-content: space-between">
|
||||
<n-text depth="3">管理后台</n-text>
|
||||
<n-space align="center">
|
||||
<n-text>{{ auth.username }}</n-text>
|
||||
<n-button quaternary size="small" @click="onLogout">退出</n-button>
|
||||
</n-space>
|
||||
</n-layout-header>
|
||||
<n-layout-content
|
||||
:native-scrollbar="false"
|
||||
content-style="height: calc(100vh - 48px); display: flex; flex-direction: column; overflow: hidden"
|
||||
>
|
||||
<RouterView />
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
@@ -1,11 +1,9 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import naive from "naive-ui";
|
||||
import App from "./App.vue";
|
||||
import { router } from "./router";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.use(naive);
|
||||
app.mount("#app");
|
||||
|
||||
+35
-3
@@ -1,13 +1,45 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import HomeView from "../views/HomeView.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: () => import("../views/LoginView.vue"),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: HomeView,
|
||||
component: () => import("../layouts/AdminLayout.vue"),
|
||||
children: [
|
||||
{ path: "", redirect: "/overview" },
|
||||
{ path: "overview", name: "overview", component: () => import("../views/OverviewView.vue") },
|
||||
{ path: "endpoints", name: "endpoints", component: () => import("../views/EndpointsView.vue") },
|
||||
{ path: "registration", name: "registration", component: () => import("../views/RegistrationView.vue") },
|
||||
{ path: "groups", name: "groups", component: () => import("../views/GroupsView.vue") },
|
||||
{ path: "messages", name: "messages", component: () => import("../views/MessagesView.vue") },
|
||||
{ path: "tokens", name: "tokens", component: () => import("../views/TokensView.vue") },
|
||||
{ path: "settings", name: "settings", component: () => import("../views/SettingsView.vue") },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore();
|
||||
if (!auth.ready) {
|
||||
await auth.hydrate();
|
||||
}
|
||||
if (to.meta.public) {
|
||||
if (auth.isLoggedIn && to.name === "login") {
|
||||
return { name: "overview" };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!auth.isLoggedIn) {
|
||||
return { name: "login", query: { redirect: to.fullPath } };
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
export const useAppStore = defineStore("app", () => {
|
||||
const title = ref("NixMsg");
|
||||
return { title };
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { fetchMe, login as apiLogin, logout as apiLogout } from "@/api/admin";
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const username = ref<string | null>(null);
|
||||
const ready = ref(false);
|
||||
|
||||
const isLoggedIn = computed(() => username.value != null);
|
||||
|
||||
async function hydrate() {
|
||||
try {
|
||||
const me = await fetchMe(true);
|
||||
username.value = me.username;
|
||||
} catch {
|
||||
username.value = null;
|
||||
} finally {
|
||||
ready.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function login(user: string, password: string) {
|
||||
const res = await apiLogin(user, password);
|
||||
username.value = res.username;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await apiLogout();
|
||||
} finally {
|
||||
username.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { username, ready, isLoggedIn, hydrate, login, logout };
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createDiscreteApi, dateZhCN, zhCN } from "naive-ui";
|
||||
|
||||
const { message, dialog } = createDiscreteApi(["message", "dialog"], {
|
||||
configProviderProps: {
|
||||
locale: zhCN,
|
||||
dateLocale: dateZhCN,
|
||||
},
|
||||
});
|
||||
|
||||
export { message, dialog };
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatLocalMs } from "./time";
|
||||
|
||||
describe("formatLocalMs", () => {
|
||||
it("格式化为 YYYY-MM-DD HH:mm:ss", () => {
|
||||
const ms = Date.UTC(2026, 0, 2, 3, 4, 5);
|
||||
// 结果随本地时区变化,只校验形态
|
||||
const s = formatLocalMs(ms);
|
||||
expect(s).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("空值显示破折号", () => {
|
||||
expect(formatLocalMs(null)).toBe("—");
|
||||
expect(formatLocalMs(undefined)).toBe("—");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/** 将 Unix 毫秒格式化为浏览器本地时区 YYYY-MM-DD HH:mm:ss。 */
|
||||
export function formatLocalMs(ms: number | null | undefined): string {
|
||||
if (ms == null || !Number.isFinite(ms)) {
|
||||
return "—";
|
||||
}
|
||||
const d = new Date(ms);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return "—";
|
||||
}
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
||||
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { config, mount, flushPromises } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
NConfigProvider,
|
||||
NDialogProvider,
|
||||
NMessageProvider,
|
||||
dateZhCN,
|
||||
zhCN,
|
||||
} from "naive-ui";
|
||||
import { defineComponent, h, nextTick } from "vue";
|
||||
import EndpointsView from "./EndpointsView.vue";
|
||||
import { mockApi } from "@/api/mock";
|
||||
|
||||
config.global.stubs = { teleport: true };
|
||||
|
||||
function wrap(Comp: object) {
|
||||
return defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(NConfigProvider, { locale: zhCN, dateLocale: dateZhCN, size: "small" }, {
|
||||
default: () =>
|
||||
h(NMessageProvider, null, {
|
||||
default: () =>
|
||||
h(NDialogProvider, null, {
|
||||
default: () => h(Comp),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("EndpointsView 开通端", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
mockApi._setSession();
|
||||
});
|
||||
|
||||
it("开通后密码只显示一次,关闭后消失", async () => {
|
||||
const pinia = createPinia();
|
||||
const w = mount(wrap(EndpointsView), {
|
||||
global: { plugins: [pinia] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const openBtn = w.findAll("button").find((b) => b.text().trim() === "开通");
|
||||
expect(openBtn).toBeTruthy();
|
||||
await openBtn!.trigger("click");
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
const nameRoot = w.find('[data-testid="create-name"]');
|
||||
expect(nameRoot.exists()).toBe(true);
|
||||
await nameRoot.find("input").setValue("测试端");
|
||||
await w.find('[data-testid="create-submit"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(w.text()).toContain("只显示一次");
|
||||
|
||||
const dismiss = w.findAll("button").find((b) => b.text().includes("我已保存"));
|
||||
expect(dismiss).toBeTruthy();
|
||||
await dismiss!.trigger("click");
|
||||
await nextTick();
|
||||
|
||||
expect(w.text()).not.toContain("只显示一次");
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,505 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from "vue";
|
||||
import type { DataTableColumns, DataTableRowKey } from "naive-ui";
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NModal,
|
||||
NScrollbar,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NSwitch,
|
||||
NText,
|
||||
NUpload,
|
||||
useDialog,
|
||||
} from "naive-ui";
|
||||
import type { UploadCustomRequestOptions } from "naive-ui";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import HelpTip from "@/components/HelpTip.vue";
|
||||
import SecretOnceAlert from "@/components/SecretOnceAlert.vue";
|
||||
import {
|
||||
batchEndpoints,
|
||||
createEndpoint,
|
||||
importEndpoints,
|
||||
kickEndpoint,
|
||||
listEndpoints,
|
||||
patchEndpoint,
|
||||
resetLoginPassword,
|
||||
setTalkPassword,
|
||||
unlockEndpoint,
|
||||
type Endpoint,
|
||||
type ImportItem,
|
||||
} from "@/api/admin";
|
||||
import { ApiError } from "@/api/http";
|
||||
import { formatLocalMs } from "@/utils/time";
|
||||
import { message } from "@/utils/notify";
|
||||
|
||||
const dialog = useDialog();
|
||||
|
||||
const loading = ref(false);
|
||||
const rows = ref<Endpoint[]>([]);
|
||||
const total = ref(0);
|
||||
const checkedKeys = ref<DataTableRowKey[]>([]);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(50);
|
||||
|
||||
const filters = reactive({
|
||||
source: "" as "" | "admin" | "self",
|
||||
online: "" as "" | "true" | "false",
|
||||
query: "",
|
||||
});
|
||||
|
||||
const sourceOptions = [
|
||||
{ label: "全部来源", value: "" },
|
||||
{ label: "后台开通", value: "admin" },
|
||||
{ label: "自助注册", value: "self" },
|
||||
];
|
||||
const onlineOptions = [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "在线", value: "true" },
|
||||
{ label: "离线", value: "false" },
|
||||
];
|
||||
|
||||
const onceSecret = ref<{ title: string; secret: string; filename: string } | null>(null);
|
||||
const importResult = ref<ImportItem[] | null>(null);
|
||||
|
||||
const createOpen = ref(false);
|
||||
const createForm = reactive({
|
||||
id: "",
|
||||
name: "",
|
||||
remark: "",
|
||||
login_password: "",
|
||||
talk_password: "",
|
||||
default_delay_seconds: 0,
|
||||
});
|
||||
const createLoading = ref(false);
|
||||
|
||||
const editOpen = ref(false);
|
||||
const editId = ref("");
|
||||
const editForm = reactive({
|
||||
name: "",
|
||||
remark: "",
|
||||
default_delay_seconds: 0,
|
||||
enabled: true,
|
||||
});
|
||||
const editLoading = ref(false);
|
||||
|
||||
const talkOpen = ref(false);
|
||||
const talkId = ref("");
|
||||
const talkPassword = ref("");
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
|
||||
const res = await listEndpoints({
|
||||
cursor,
|
||||
limit: pageSize.value,
|
||||
source: filters.source || undefined,
|
||||
online: filters.online === "" ? undefined : filters.online === "true",
|
||||
query: filters.query.trim() || undefined,
|
||||
});
|
||||
rows.value = res.items;
|
||||
total.value = res.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
function sourceLabel(s: string) {
|
||||
return s === "self" ? "自助注册" : "后台开通";
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<Endpoint>>(() => [
|
||||
{ type: "selection" },
|
||||
{ title: "编号", key: "id", width: 120, ellipsis: { tooltip: true } },
|
||||
{ title: "名称", key: "name", width: 120, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: "来源",
|
||||
key: "source",
|
||||
width: 90,
|
||||
render: (r) => sourceLabel(r.source),
|
||||
},
|
||||
{
|
||||
title: "在线",
|
||||
key: "online",
|
||||
width: 70,
|
||||
render: (r) => (r.online ? "是" : "否"),
|
||||
},
|
||||
{
|
||||
title: "启用",
|
||||
key: "enabled",
|
||||
width: 70,
|
||||
render: (r) => (r.enabled ? "是" : "否"),
|
||||
},
|
||||
{
|
||||
title: "锁定",
|
||||
key: "login_locked",
|
||||
width: 70,
|
||||
render: (r) => (r.login_locked ? "是" : "否"),
|
||||
},
|
||||
{
|
||||
title: "最近在线",
|
||||
key: "online_since_ms",
|
||||
width: 160,
|
||||
render: (r) => formatLocalMs(r.online ? r.online_since_ms : r.offline_since_ms),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "created_at_ms",
|
||||
width: 160,
|
||||
render: (r) => formatLocalMs(r.created_at_ms),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 360,
|
||||
fixed: "right",
|
||||
render: (r) =>
|
||||
h(NSpace, { size: 4, wrap: false }, () => [
|
||||
h(NButton, { size: "tiny", onClick: () => openEdit(r) }, () => "编辑"),
|
||||
h(NButton, { size: "tiny", onClick: () => void onResetPwd(r) }, () => "重置密码"),
|
||||
h(NButton, { size: "tiny", onClick: () => openTalk(r) }, () => "对话密码"),
|
||||
h(
|
||||
NButton,
|
||||
{ size: "tiny", disabled: !r.login_locked, onClick: () => void onUnlock(r) },
|
||||
() => "解锁",
|
||||
),
|
||||
h(NButton, { size: "tiny", onClick: () => void onKick(r) }, () => "踢下线"),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(createForm, {
|
||||
id: "",
|
||||
name: "",
|
||||
remark: "",
|
||||
login_password: "",
|
||||
talk_password: "",
|
||||
default_delay_seconds: 0,
|
||||
});
|
||||
createOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!createForm.name.trim()) {
|
||||
message.error("名称不能为空");
|
||||
return;
|
||||
}
|
||||
createLoading.value = true;
|
||||
try {
|
||||
const res = await createEndpoint({
|
||||
id: createForm.id.trim() || undefined,
|
||||
name: createForm.name.trim(),
|
||||
remark: createForm.remark,
|
||||
login_password: createForm.login_password || undefined,
|
||||
talk_password: createForm.talk_password || undefined,
|
||||
default_delay_seconds: createForm.default_delay_seconds,
|
||||
});
|
||||
createOpen.value = false;
|
||||
onceSecret.value = {
|
||||
title: "开通成功,登录密码只显示一次",
|
||||
secret: `${res.id}\t${res.login_password}`,
|
||||
filename: `${res.id}-password.txt`,
|
||||
};
|
||||
await load();
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(r: Endpoint) {
|
||||
editId.value = r.id;
|
||||
Object.assign(editForm, {
|
||||
name: r.name,
|
||||
remark: r.remark,
|
||||
default_delay_seconds: Math.round(r.default_delay_ms / 1000),
|
||||
enabled: r.enabled,
|
||||
});
|
||||
editOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
editLoading.value = true;
|
||||
try {
|
||||
await patchEndpoint(editId.value, {
|
||||
name: editForm.name,
|
||||
remark: editForm.remark,
|
||||
default_delay_seconds: editForm.default_delay_seconds,
|
||||
enabled: editForm.enabled,
|
||||
});
|
||||
editOpen.value = false;
|
||||
message.success("已保存");
|
||||
await load();
|
||||
} finally {
|
||||
editLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openTalk(r: Endpoint) {
|
||||
talkId.value = r.id;
|
||||
talkPassword.value = "";
|
||||
talkOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitTalk() {
|
||||
await setTalkPassword(talkId.value, talkPassword.value);
|
||||
talkOpen.value = false;
|
||||
message.success(talkPassword.value ? "已设置对话密码" : "已清除对话密码");
|
||||
await load();
|
||||
}
|
||||
|
||||
async function onResetPwd(r: Endpoint) {
|
||||
dialog.warning({
|
||||
title: "重置登录密码",
|
||||
content: `确定重置「${r.id}」的登录密码?当前会话将失效。`,
|
||||
positiveText: "重置",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
const res = await resetLoginPassword(r.id);
|
||||
onceSecret.value = {
|
||||
title: "新登录密码只显示一次",
|
||||
secret: res.login_password,
|
||||
filename: `${r.id}-new-password.txt`,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onUnlock(r: Endpoint) {
|
||||
await unlockEndpoint(r.id);
|
||||
message.success("已解除锁定");
|
||||
await load();
|
||||
}
|
||||
|
||||
async function onKick(r: Endpoint) {
|
||||
const res = await kickEndpoint(r.id);
|
||||
message.success(res.kicked ? "已踢下线" : "当时不在线");
|
||||
await load();
|
||||
}
|
||||
|
||||
async function onBatch(action: "disable" | "delete") {
|
||||
const ids = checkedKeys.value.map(String);
|
||||
if (!ids.length) {
|
||||
message.warning("请先多选");
|
||||
return;
|
||||
}
|
||||
dialog.warning({
|
||||
title: action === "disable" ? "批量停用" : "批量删除",
|
||||
content: `对 ${ids.length} 个端执行${action === "disable" ? "停用" : "删除"}?`,
|
||||
positiveText: "确定",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
const res = await batchEndpoints(ids, action);
|
||||
message.success(`成功 ${res.ok_ids.length},失败 ${res.failed.length}`);
|
||||
checkedKeys.value = [];
|
||||
await load();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onImport(options: UploadCustomRequestOptions) {
|
||||
const raw = options.file.file;
|
||||
if (!raw) {
|
||||
options.onError();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const text = await raw.text();
|
||||
const res = await importEndpoints(text);
|
||||
importResult.value = res.items;
|
||||
onceSecret.value = {
|
||||
title: "CSV 导入成功,密码只显示一次",
|
||||
secret: res.items.map((i) => `${i.id},${i.name},${i.login_password}`).join("\n"),
|
||||
filename: "import-passwords.csv",
|
||||
};
|
||||
options.onFinish();
|
||||
await load();
|
||||
} catch (e) {
|
||||
options.onError();
|
||||
if (e instanceof ApiError && e.data && typeof e.data === "object" && "errors" in (e.data as object)) {
|
||||
const errors = (e.data as { errors: { line: number; reason: string }[] }).errors;
|
||||
message.error(errors.map((x) => `第 ${x.line} 行:${x.reason}`).join(";") || e.message);
|
||||
} else if (e instanceof Error) {
|
||||
message.error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(p: number) {
|
||||
page.value = p;
|
||||
void load();
|
||||
}
|
||||
|
||||
function onPageSizeChange(s: number) {
|
||||
pageSize.value = s;
|
||||
page.value = 1;
|
||||
void load();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader title="端" help="可按来源与在线筛选;开通/重置/导入生成的密码只显示一次。">
|
||||
<template #actions>
|
||||
<n-button type="primary" @click="openCreate">开通</n-button>
|
||||
<n-upload :show-file-list="false" accept=".csv,text/csv" :custom-request="onImport">
|
||||
<n-button>CSV 导入</n-button>
|
||||
</n-upload>
|
||||
<n-button @click="onBatch('disable')">批量停用</n-button>
|
||||
<n-button type="error" secondary @click="onBatch('delete')">批量删除</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<div class="page-body">
|
||||
<SecretOnceAlert
|
||||
v-if="onceSecret"
|
||||
:title="onceSecret.title"
|
||||
:secret="onceSecret.secret"
|
||||
:filename="onceSecret.filename"
|
||||
@dismiss="onceSecret = null"
|
||||
/>
|
||||
<n-space style="margin-bottom: 12px" align="center">
|
||||
<n-select v-model:value="filters.source" :options="sourceOptions" style="width: 140px" @update:value="page = 1; load()" />
|
||||
<n-select v-model:value="filters.online" :options="onlineOptions" style="width: 120px" @update:value="page = 1; load()" />
|
||||
<n-input
|
||||
v-model:value="filters.query"
|
||||
clearable
|
||||
placeholder="编号/名称"
|
||||
style="width: 180px"
|
||||
@keyup.enter="page = 1; load()"
|
||||
/>
|
||||
<n-button @click="page = 1; load()">查询</n-button>
|
||||
<span>
|
||||
来源筛选
|
||||
<HelpTip>admin=后台开通,self=自助注册。</HelpTip>
|
||||
</span>
|
||||
</n-space>
|
||||
<n-data-table
|
||||
remote
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:row-key="(r: Endpoint) => r.id"
|
||||
:checked-row-keys="checkedKeys"
|
||||
:max-height="480"
|
||||
:scroll-x="1200"
|
||||
:pagination="{
|
||||
page,
|
||||
pageSize,
|
||||
itemCount: total,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100],
|
||||
onChange: onPageChange,
|
||||
onUpdatePageSize: onPageSizeChange,
|
||||
}"
|
||||
@update:checked-row-keys="(k) => (checkedKeys = k)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="createOpen" preset="card" title="开通端" style="width: 520px" :mask-closable="false">
|
||||
<n-scrollbar style="max-height: 360px">
|
||||
<n-form label-placement="left" label-width="110" :show-feedback="false">
|
||||
<n-form-item label="编号">
|
||||
<n-input v-model:value="createForm.id" placeholder="留空则生成" data-testid="create-id" />
|
||||
</n-form-item>
|
||||
<n-form-item label="名称">
|
||||
<n-input v-model:value="createForm.name" data-testid="create-name" />
|
||||
</n-form-item>
|
||||
<n-form-item label="登录密码">
|
||||
<n-input v-model:value="createForm.login_password" placeholder="留空则生成" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-form-item label="对话密码">
|
||||
<n-input v-model:value="createForm.talk_password" placeholder="可选" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-form-item label="默认延迟(秒)">
|
||||
<n-input-number v-model:value="createForm.default_delay_seconds" :min="0" style="width: 100%" />
|
||||
</n-form-item>
|
||||
<n-form-item label="备注" label-placement="top">
|
||||
<n-input v-model:value="createForm.remark" type="textarea" :rows="3" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-scrollbar>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="createOpen = false">取消</n-button>
|
||||
<n-button type="primary" :loading="createLoading" data-testid="create-submit" @click="submitCreate">开通</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="editOpen" preset="card" title="编辑端" style="width: 480px" :mask-closable="false">
|
||||
<n-scrollbar style="max-height: 320px">
|
||||
<n-form label-placement="left" label-width="110" :show-feedback="false">
|
||||
<n-form-item label="名称">
|
||||
<n-input v-model:value="editForm.name" />
|
||||
</n-form-item>
|
||||
<n-form-item label="默认延迟(秒)">
|
||||
<n-input-number v-model:value="editForm.default_delay_seconds" :min="0" style="width: 100%" />
|
||||
</n-form-item>
|
||||
<n-form-item label="启用">
|
||||
<n-switch v-model:value="editForm.enabled" />
|
||||
</n-form-item>
|
||||
<n-form-item label="备注" label-placement="top">
|
||||
<n-input v-model:value="editForm.remark" type="textarea" :rows="3" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-scrollbar>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="editOpen = false">取消</n-button>
|
||||
<n-button type="primary" :loading="editLoading" @click="submitEdit">保存</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="talkOpen" preset="card" title="设置对话密码" style="width: 420px">
|
||||
<n-form label-placement="left" label-width="90" :show-feedback="false">
|
||||
<n-form-item label="对话密码">
|
||||
<n-input v-model:value="talkPassword" type="password" show-password-on="click" placeholder="留空表示清除" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="talkOpen = false">取消</n-button>
|
||||
<n-button type="primary" @click="submitTalk">确定</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-if="importResult" :show="true" preset="card" title="导入结果" style="width: 560px" @update:show="(v) => !v && (importResult = null)">
|
||||
<n-scrollbar style="max-height: 280px">
|
||||
<n-text depth="3">以下密码仅本次可见,请下载保存。</n-text>
|
||||
<pre style="font-size: 12px; white-space: pre-wrap">{{ importResult.map((i) => `${i.id}\t${i.name}\t${i.login_password}`).join("\n") }}</pre>
|
||||
</n-scrollbar>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="importResult = null">关闭</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.page-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from "vue";
|
||||
import type { DataTableColumns } from "naive-ui";
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NScrollbar,
|
||||
NSpace,
|
||||
useDialog,
|
||||
} from "naive-ui";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
addGroupMembers,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
getGroup,
|
||||
listGroups,
|
||||
removeGroupMember,
|
||||
renameGroup,
|
||||
transferGroup,
|
||||
type GroupDetail,
|
||||
type GroupMember,
|
||||
type GroupSummary,
|
||||
} from "@/api/admin";
|
||||
import { formatLocalMs } from "@/utils/time";
|
||||
import { message } from "@/utils/notify";
|
||||
|
||||
const dialog = useDialog();
|
||||
const loading = ref(false);
|
||||
const rows = ref<GroupSummary[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(50);
|
||||
const query = ref("");
|
||||
|
||||
const createOpen = ref(false);
|
||||
const createForm = reactive({ id: "", name: "", owner_id: "", member_ids: "" });
|
||||
const createLoading = ref(false);
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detail = ref<GroupDetail | null>(null);
|
||||
const renameName = ref("");
|
||||
const addMembers = ref("");
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
|
||||
const res = await listGroups(query.value.trim() || undefined, cursor, pageSize.value);
|
||||
rows.value = res.items;
|
||||
total.value = res.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
const columns = computed<DataTableColumns<GroupSummary>>(() => [
|
||||
{ title: "编号", key: "id", width: 140 },
|
||||
{ title: "名称", key: "name", width: 140 },
|
||||
{ title: "群主", key: "owner_id", width: 120 },
|
||||
{ title: "成员数", key: "member_count", width: 80 },
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "created_at_ms",
|
||||
width: 160,
|
||||
render: (r) => formatLocalMs(r.created_at_ms),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 200,
|
||||
render: (r) =>
|
||||
h(NSpace, { size: 4 }, () => [
|
||||
h(NButton, { size: "tiny", onClick: () => void openDetail(r.id) }, () => "详情"),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "tiny",
|
||||
type: "error",
|
||||
secondary: true,
|
||||
onClick: () => onDelete(r),
|
||||
},
|
||||
() => "解散",
|
||||
),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
|
||||
const memberColumns = computed<DataTableColumns<GroupMember>>(() => [
|
||||
{ title: "编号", key: "id" },
|
||||
{ title: "名称", key: "name" },
|
||||
{ title: "在线", key: "online", render: (r) => (r.online ? "是" : "否") },
|
||||
{
|
||||
title: "加入时间",
|
||||
key: "joined_at_ms",
|
||||
render: (r) => formatLocalMs(r.joined_at_ms),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
render: (r) =>
|
||||
h(NSpace, { size: 4 }, () => [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "tiny",
|
||||
disabled: detail.value?.owner_id === r.id,
|
||||
onClick: () => void onTransfer(r.id),
|
||||
},
|
||||
() => "设为群主",
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "tiny",
|
||||
type: "error",
|
||||
secondary: true,
|
||||
disabled: detail.value?.owner_id === r.id,
|
||||
onClick: () => void onRemove(r.id),
|
||||
},
|
||||
() => "移除",
|
||||
),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
detail.value = await getGroup(id);
|
||||
renameName.value = detail.value.name;
|
||||
addMembers.value = "";
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!createForm.name.trim() || !createForm.owner_id.trim()) {
|
||||
message.error("名称与群主必填");
|
||||
return;
|
||||
}
|
||||
createLoading.value = true;
|
||||
try {
|
||||
const member_ids = createForm.member_ids
|
||||
.split(/[,,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const res = await createGroup({
|
||||
id: createForm.id.trim() || undefined,
|
||||
name: createForm.name.trim(),
|
||||
owner_id: createForm.owner_id.trim(),
|
||||
member_ids,
|
||||
});
|
||||
createOpen.value = false;
|
||||
if (res.failed.length) {
|
||||
message.warning(`已创建,部分成员失败:${res.failed.map((f) => f.id).join(", ")}`);
|
||||
} else {
|
||||
message.success("已创建");
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(r: GroupSummary) {
|
||||
dialog.warning({
|
||||
title: "解散群",
|
||||
content: `确定解散「${r.name}」?`,
|
||||
positiveText: "解散",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
await deleteGroup(r.id);
|
||||
message.success("已解散");
|
||||
await load();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onRename() {
|
||||
if (!detail.value) return;
|
||||
await renameGroup(detail.value.id, renameName.value.trim());
|
||||
message.success("已改名");
|
||||
await openDetail(detail.value.id);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function onAddMembers() {
|
||||
if (!detail.value) return;
|
||||
const ids = addMembers.value
|
||||
.split(/[,,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
const res = await addGroupMembers(detail.value.id, ids);
|
||||
if (res.failed.length) {
|
||||
message.warning(`部分失败:${res.failed.map((f) => f.id).join(", ")}`);
|
||||
} else {
|
||||
message.success("已加人");
|
||||
}
|
||||
await openDetail(detail.value.id);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function onRemove(endpointId: string) {
|
||||
if (!detail.value) return;
|
||||
await removeGroupMember(detail.value.id, endpointId);
|
||||
message.success("已移除");
|
||||
await openDetail(detail.value.id);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function onTransfer(endpointId: string) {
|
||||
if (!detail.value) return;
|
||||
await transferGroup(detail.value.id, endpointId);
|
||||
message.success("已转让群主");
|
||||
await openDetail(detail.value.id);
|
||||
await load();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader title="群" help="后台建群与加人不必填对话密码。">
|
||||
<template #actions>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="
|
||||
Object.assign(createForm, { id: '', name: '', owner_id: '', member_ids: '' });
|
||||
createOpen = true;
|
||||
"
|
||||
>
|
||||
新建群
|
||||
</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<div class="page-body">
|
||||
<n-space style="margin-bottom: 12px">
|
||||
<n-input v-model:value="query" placeholder="名称/编号" style="width: 200px" @keyup.enter="page = 1; load()" />
|
||||
<n-button @click="page = 1; load()">查询</n-button>
|
||||
</n-space>
|
||||
<n-data-table
|
||||
remote
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:row-key="(r: GroupSummary) => r.id"
|
||||
:max-height="480"
|
||||
:pagination="{
|
||||
page,
|
||||
pageSize,
|
||||
itemCount: total,
|
||||
onChange: (p: number) => {
|
||||
page = p;
|
||||
load();
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="createOpen" preset="card" title="新建群" style="width: 480px">
|
||||
<n-scrollbar style="max-height: 320px">
|
||||
<n-form label-placement="left" label-width="90" :show-feedback="false">
|
||||
<n-form-item label="编号">
|
||||
<n-input v-model:value="createForm.id" placeholder="留空则生成" />
|
||||
</n-form-item>
|
||||
<n-form-item label="名称">
|
||||
<n-input v-model:value="createForm.name" />
|
||||
</n-form-item>
|
||||
<n-form-item label="群主编号">
|
||||
<n-input v-model:value="createForm.owner_id" />
|
||||
</n-form-item>
|
||||
<n-form-item label="成员" label-placement="top">
|
||||
<n-input v-model:value="createForm.member_ids" type="textarea" :rows="3" placeholder="多个编号用逗号或空格分隔" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-scrollbar>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="createOpen = false">取消</n-button>
|
||||
<n-button type="primary" :loading="createLoading" @click="submitCreate">创建</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="detailOpen" preset="card" title="群详情" style="width: 720px">
|
||||
<n-scrollbar v-if="detail" style="max-height: 420px">
|
||||
<n-form label-placement="left" label-width="80" :show-feedback="false" style="margin-bottom: 12px">
|
||||
<n-form-item label="编号">{{ detail.id }}</n-form-item>
|
||||
<n-form-item label="群主">{{ detail.owner_id }}</n-form-item>
|
||||
<n-form-item label="名称">
|
||||
<n-space>
|
||||
<n-input v-model:value="renameName" style="width: 200px" />
|
||||
<n-button size="small" @click="onRename">改名</n-button>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<n-form-item label="加人" label-placement="top">
|
||||
<n-space vertical style="width: 100%">
|
||||
<n-input v-model:value="addMembers" type="textarea" :rows="2" placeholder="成员编号,逗号分隔" />
|
||||
<n-button size="small" @click="onAddMembers">加人</n-button>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-data-table :columns="memberColumns" :data="detail.members" :row-key="(r: GroupMember) => r.id" size="small" />
|
||||
</n-scrollbar>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="detailOpen = false">关闭</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.page-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { NCard, NSpace, NText } from "naive-ui";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
const { title } = storeToRefs(useAppStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-space vertical style="padding: 24px">
|
||||
<n-card :title="title">
|
||||
<n-text>管理后台骨架(T0.1)。后续页面由 W 线实现。</n-text>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
import { config, mount, flushPromises } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { createMemoryHistory, createRouter } from "vue-router";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
NConfigProvider,
|
||||
NDialogProvider,
|
||||
NMessageProvider,
|
||||
dateZhCN,
|
||||
zhCN,
|
||||
} from "naive-ui";
|
||||
import { defineComponent, h } from "vue";
|
||||
import LoginView from "./LoginView.vue";
|
||||
import { mockApi } from "@/api/mock";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
config.global.stubs = { teleport: true };
|
||||
|
||||
function wrap(child: object) {
|
||||
return defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(NConfigProvider, { locale: zhCN, dateLocale: dateZhCN, size: "small" }, {
|
||||
default: () =>
|
||||
h(NMessageProvider, null, {
|
||||
default: () =>
|
||||
h(NDialogProvider, null, {
|
||||
default: () => h(child),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("LoginView", () => {
|
||||
beforeEach(() => {
|
||||
mockApi._resetSession();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("登录成功后写入会话", async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: "/login", component: LoginView },
|
||||
{ path: "/overview", component: { template: "<div>ok</div>" } },
|
||||
],
|
||||
});
|
||||
await router.push("/login");
|
||||
await router.isReady();
|
||||
|
||||
const w = mount(wrap(LoginView), {
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
const inputs = w.findAll("input");
|
||||
expect(inputs.length).toBeGreaterThanOrEqual(2);
|
||||
await inputs[0].setValue("admin");
|
||||
await inputs[1].setValue("adminpassword");
|
||||
await w.find('[data-testid="login-submit"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(useAuthStore().isLoggedIn).toBe(true);
|
||||
expect(useAuthStore().username).toBe("admin");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("密码错误时显示错误且未登录", async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: LoginView },
|
||||
{ path: "/overview", component: { template: "<div />" } },
|
||||
],
|
||||
});
|
||||
await router.push("/");
|
||||
await router.isReady();
|
||||
|
||||
const w = mount(wrap(LoginView), {
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
const inputs = w.findAll("input");
|
||||
await inputs[0].setValue("admin");
|
||||
await inputs[1].setValue("wrong");
|
||||
await w.find('[data-testid="login-submit"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(useAuthStore().isLoggedIn).toBe(false);
|
||||
expect(w.text()).toContain("用户名或密码错误");
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { NButton, NCard, NForm, NFormItem, NInput, NSpace, NText } from "naive-ui";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const username = ref("admin");
|
||||
const password = ref("");
|
||||
const loading = ref(false);
|
||||
const formError = ref("");
|
||||
|
||||
async function onSubmit() {
|
||||
formError.value = "";
|
||||
loading.value = true;
|
||||
try {
|
||||
await auth.login(username.value.trim(), password.value);
|
||||
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/overview";
|
||||
await router.replace(redirect || "/overview");
|
||||
} catch (e) {
|
||||
formError.value = e instanceof Error ? e.message : "登录失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<n-card title="NixMsg 管理后台" style="width: 380px" size="small">
|
||||
<n-form label-placement="left" label-width="72" :show-feedback="false" @submit.prevent="onSubmit">
|
||||
<n-form-item label="用户名">
|
||||
<n-input v-model:value="username" autocomplete="username" placeholder="admin" />
|
||||
</n-form-item>
|
||||
<n-form-item label="密码">
|
||||
<n-input
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
@keyup.enter="onSubmit"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-text v-if="formError" type="error" style="display: block; margin-bottom: 8px">{{ formError }}</n-text>
|
||||
<n-space justify="end">
|
||||
<n-button type="primary" :loading="loading" data-testid="login-submit" @click="onSubmit">
|
||||
登录
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
<n-text depth="3" style="display: block; margin-top: 12px; font-size: 12px">
|
||||
假数据模式:用户名 admin,密码 adminpassword
|
||||
</n-text>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from "vue";
|
||||
import type { DataTableColumns } from "naive-ui";
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NScrollbar,
|
||||
NSelect,
|
||||
NSpace,
|
||||
} from "naive-ui";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import HelpTip from "@/components/HelpTip.vue";
|
||||
import { getMessage, listMessages, type MessageDelivery, type MessageDetail, type MessageSummary } from "@/api/admin";
|
||||
import { formatLocalMs } from "@/utils/time";
|
||||
|
||||
const loading = ref(false);
|
||||
const rows = ref<MessageSummary[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(50);
|
||||
const filters = reactive({
|
||||
sender_id: "",
|
||||
endpoint_id: "",
|
||||
group_id: "",
|
||||
state: "" as "" | "scheduled" | "dispatched" | "completed",
|
||||
});
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detail = ref<MessageDetail | null>(null);
|
||||
|
||||
const stateOptions = [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "定时中", value: "scheduled" },
|
||||
{ label: "投递中", value: "dispatched" },
|
||||
{ label: "已完成", value: "completed" },
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
|
||||
const res = await listMessages({
|
||||
cursor,
|
||||
limit: pageSize.value,
|
||||
sender_id: filters.sender_id.trim() || undefined,
|
||||
endpoint_id: filters.endpoint_id.trim() || undefined,
|
||||
group_id: filters.group_id.trim() || undefined,
|
||||
state: filters.state || undefined,
|
||||
});
|
||||
rows.value = res.items;
|
||||
total.value = res.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
const columns = computed<DataTableColumns<MessageSummary>>(() => [
|
||||
{ title: "序号", key: "seq", width: 70 },
|
||||
{ title: "消息号", key: "id", width: 180, ellipsis: { tooltip: true } },
|
||||
{ title: "发送方", key: "sender_id", width: 100 },
|
||||
{
|
||||
title: "目标",
|
||||
key: "dest",
|
||||
width: 140,
|
||||
render: (r) => `${r.dest_kind}:${r.dest_id}`,
|
||||
},
|
||||
{ title: "状态", key: "state", width: 100 },
|
||||
{
|
||||
title: "投递统计",
|
||||
key: "delivery_counts",
|
||||
width: 200,
|
||||
render: (r) => {
|
||||
const c = r.delivery_counts;
|
||||
return `待${c.pending}/收${c.accepted}/撤${c.recalled}/过${c.expired}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "created_at_ms",
|
||||
width: 160,
|
||||
render: (r) => formatLocalMs(r.created_at_ms),
|
||||
},
|
||||
{
|
||||
title: "发送时刻",
|
||||
key: "send_at_ms",
|
||||
width: 160,
|
||||
render: (r) => formatLocalMs(r.send_at_ms),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 80,
|
||||
render: (r) => h(NButton, { size: "tiny", onClick: () => void openDetail(r.seq) }, () => "详情"),
|
||||
},
|
||||
]);
|
||||
|
||||
const deliveryColumns = computed<DataTableColumns<MessageDelivery>>(() => [
|
||||
{ title: "接收端", key: "endpoint_id" },
|
||||
{ title: "状态", key: "state" },
|
||||
{ title: "次数", key: "attempts", width: 60 },
|
||||
{
|
||||
title: "推送时间",
|
||||
key: "pushed_at_ms",
|
||||
render: (r) => formatLocalMs(r.pushed_at_ms),
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
key: "updated_at_ms",
|
||||
render: (r) => formatLocalMs(r.updated_at_ms),
|
||||
},
|
||||
]);
|
||||
|
||||
async function openDetail(seq: number) {
|
||||
detail.value = await getMessage(seq);
|
||||
detailOpen.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader title="投递记录" help="只显示投递痕迹,不显示正文;接口响应也不含 body 字段。" />
|
||||
<div class="page-body">
|
||||
<n-space style="margin-bottom: 12px" align="center">
|
||||
<n-input v-model:value="filters.sender_id" placeholder="发送方" style="width: 120px" />
|
||||
<n-input v-model:value="filters.endpoint_id" placeholder="接收端" style="width: 120px" />
|
||||
<n-input v-model:value="filters.group_id" placeholder="群编号" style="width: 120px" />
|
||||
<n-select v-model:value="filters.state" :options="stateOptions" style="width: 120px" />
|
||||
<n-button
|
||||
@click="
|
||||
page = 1;
|
||||
load();
|
||||
"
|
||||
>
|
||||
查询
|
||||
</n-button>
|
||||
<span>
|
||||
无正文
|
||||
<HelpTip>页面与网络响应均不得出现消息正文。</HelpTip>
|
||||
</span>
|
||||
</n-space>
|
||||
<n-data-table
|
||||
remote
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:row-key="(r: MessageSummary) => r.seq"
|
||||
:max-height="480"
|
||||
:scroll-x="1100"
|
||||
:pagination="{
|
||||
page,
|
||||
pageSize,
|
||||
itemCount: total,
|
||||
onChange: (p: number) => {
|
||||
page = p;
|
||||
load();
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="detailOpen" preset="card" title="投递详情" style="width: 720px">
|
||||
<n-scrollbar v-if="detail" style="max-height: 420px">
|
||||
<n-descriptions label-placement="left" :column="2" size="small" bordered style="margin-bottom: 12px">
|
||||
<n-descriptions-item label="序号">{{ detail.seq }}</n-descriptions-item>
|
||||
<n-descriptions-item label="消息号">{{ detail.id }}</n-descriptions-item>
|
||||
<n-descriptions-item label="发送方">{{ detail.sender_id }}</n-descriptions-item>
|
||||
<n-descriptions-item label="目标">{{ detail.dest_kind }}:{{ detail.dest_id }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">{{ detail.state }}</n-descriptions-item>
|
||||
<n-descriptions-item label="类型">{{ detail.content_type }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建">{{ formatLocalMs(detail.created_at_ms) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="发送">{{ formatLocalMs(detail.send_at_ms) }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-data-table :columns="deliveryColumns" :data="detail.deliveries" size="small" :row-key="(r: MessageDelivery) => r.endpoint_id" />
|
||||
</n-scrollbar>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="detailOpen = false">关闭</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.page-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { NDescriptions, NDescriptionsItem, NSpin } from "naive-ui";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import HelpTip from "@/components/HelpTip.vue";
|
||||
import { fetchOverview, type Overview } from "@/api/admin";
|
||||
import { formatLocalMs } from "@/utils/time";
|
||||
|
||||
const loading = ref(true);
|
||||
const data = ref<Overview | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
data.value = await fetchOverview();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader title="概览" help="汇总当前实例的端、群与未完成投递数量,不含正文与编号明细。" />
|
||||
<div class="page-body">
|
||||
<n-spin :show="loading">
|
||||
<n-descriptions v-if="data" label-placement="left" :column="2" bordered size="small">
|
||||
<n-descriptions-item label="版本">{{ data.version }}</n-descriptions-item>
|
||||
<n-descriptions-item>
|
||||
<template #label>
|
||||
运行时长
|
||||
<HelpTip>自进程启动起的毫秒数,按本地时区换算展示起点附近时间差。</HelpTip>
|
||||
</template>
|
||||
{{ Math.floor(data.uptime_ms / 1000) }} 秒(约 {{ formatLocalMs(Date.now() - data.uptime_ms) }} 起)
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="端总数">{{ data.endpoints_total }}</n-descriptions-item>
|
||||
<n-descriptions-item label="在线端">{{ data.endpoints_online }}</n-descriptions-item>
|
||||
<n-descriptions-item label="已停用">{{ data.endpoints_disabled }}</n-descriptions-item>
|
||||
<n-descriptions-item label="群数量">{{ data.groups_total }}</n-descriptions-item>
|
||||
<n-descriptions-item label="待完成投递">{{ data.messages_pending }}</n-descriptions-item>
|
||||
<n-descriptions-item label="定时中">{{ data.messages_scheduled }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.page-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { NButton, NForm, NFormItem, NInput, NSpace, NSpin, NSwitch, NText } from "naive-ui";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import HelpTip from "@/components/HelpTip.vue";
|
||||
import { getRegistration, updateRegistration, type RegistrationSettings } from "@/api/admin";
|
||||
import { formatLocalMs } from "@/utils/time";
|
||||
import { message } from "@/utils/notify";
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const data = ref<RegistrationSettings | null>(null);
|
||||
const codeDraft = ref("");
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
data.value = await getRegistration();
|
||||
codeDraft.value = data.value.code;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
async function onToggle(enabled: boolean) {
|
||||
if (!data.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
data.value = await updateRegistration({ enabled });
|
||||
message.success(enabled ? "已开启自助注册" : "已关闭自助注册");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCode() {
|
||||
saving.value = true;
|
||||
try {
|
||||
data.value = await updateRegistration({ code: codeDraft.value });
|
||||
codeDraft.value = data.value.code;
|
||||
message.success("安全码已更新");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateCode() {
|
||||
saving.value = true;
|
||||
try {
|
||||
data.value = await updateRegistration({ generate: true });
|
||||
codeDraft.value = data.value.code;
|
||||
message.success("已生成新安全码");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader title="注册设置" help="关闭时任何自助注册失败。安全码 8–64 字符,可手填或生成,后台可查看明文。" />
|
||||
<div class="page-body">
|
||||
<n-spin :show="loading">
|
||||
<n-form v-if="data" label-placement="left" label-width="100" :show-feedback="false" style="max-width: 560px">
|
||||
<n-form-item>
|
||||
<template #label>
|
||||
开放注册
|
||||
<HelpTip>开启后需同时设置安全码,客户端注册时携带。</HelpTip>
|
||||
</template>
|
||||
<n-switch :value="data.enabled" :loading="saving" @update:value="onToggle" />
|
||||
</n-form-item>
|
||||
<n-form-item label="安全码">
|
||||
<n-input v-model:value="codeDraft" placeholder="8–64 字符" />
|
||||
</n-form-item>
|
||||
<n-form-item label="更新时间">
|
||||
<n-text>{{ formatLocalMs(data.updated_at_ms) }}</n-text>
|
||||
</n-form-item>
|
||||
<n-space>
|
||||
<n-button type="primary" :loading="saving" @click="saveCode">保存安全码</n-button>
|
||||
<n-button :loading="saving" @click="generateCode">生成安全码</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</n-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.page-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import {
|
||||
NButton,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NTabPane,
|
||||
NTabs,
|
||||
NText,
|
||||
} from "naive-ui";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import HelpTip from "@/components/HelpTip.vue";
|
||||
import { changePassword, getSettings, type RuntimeSettings } from "@/api/admin";
|
||||
import { message } from "@/utils/notify";
|
||||
|
||||
const loading = ref(true);
|
||||
const settings = ref<RuntimeSettings | null>(null);
|
||||
const pwd = reactive({ old_password: "", new_password: "", confirm: "" });
|
||||
const pwdLoading = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
settings.value = await getSettings();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function submitPassword() {
|
||||
if (pwd.new_password.length < 12) {
|
||||
message.error("新密码至少 12 位");
|
||||
return;
|
||||
}
|
||||
if (pwd.new_password !== pwd.confirm) {
|
||||
message.error("两次输入的新密码不一致");
|
||||
return;
|
||||
}
|
||||
pwdLoading.value = true;
|
||||
try {
|
||||
await changePassword(pwd.old_password, pwd.new_password);
|
||||
message.success("管理员密码已修改");
|
||||
pwd.old_password = "";
|
||||
pwd.new_password = "";
|
||||
pwd.confirm = "";
|
||||
} finally {
|
||||
pwdLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader title="系统" help="管理员密码在此修改;运行参数只读,修改请改配置文件后重启。" />
|
||||
<div class="page-body">
|
||||
<n-tabs type="line" animated>
|
||||
<n-tab-pane name="password" tab="管理员密码">
|
||||
<n-form label-placement="left" label-width="100" :show-feedback="false" style="max-width: 480px">
|
||||
<n-form-item label="原密码">
|
||||
<n-input v-model:value="pwd.old_password" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<template #label>
|
||||
新密码
|
||||
<HelpTip>至少 12 位,且不能以 nst_ 开头(与端登录密码规则一致的安全要求)。</HelpTip>
|
||||
</template>
|
||||
<n-input v-model:value="pwd.new_password" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-form-item label="确认新密码">
|
||||
<n-input v-model:value="pwd.confirm" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-space>
|
||||
<n-button type="primary" :loading="pwdLoading" @click="submitPassword">修改密码</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="settings" tab="运行参数(只读)">
|
||||
<n-spin :show="loading">
|
||||
<template v-if="settings">
|
||||
<n-descriptions label-placement="left" :column="2" bordered size="small" style="margin-bottom: 16px">
|
||||
<n-descriptions-item label="listen">{{ settings.listen }}</n-descriptions-item>
|
||||
<n-descriptions-item label="admin_listen">{{ settings.admin_listen || "(空,与端共用)" }}</n-descriptions-item>
|
||||
<n-descriptions-item label="会话闲置天数">{{ settings.session_idle_days }}</n-descriptions-item>
|
||||
<n-descriptions-item label="记录保留天数">{{ settings.record_retention_days }}</n-descriptions-item>
|
||||
<n-descriptions-item label="防重小时数">{{ settings.idempotency_hours }}</n-descriptions-item>
|
||||
<n-descriptions-item label="回执保留天数">{{ settings.receipt_retention_days }}</n-descriptions-item>
|
||||
<n-descriptions-item label="SQLite synchronous">{{ settings.sqlite_synchronous }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-text strong style="display: block; margin-bottom: 8px">limits</n-text>
|
||||
<n-descriptions label-placement="left" :column="2" bordered size="small">
|
||||
<n-descriptions-item v-for="(v, k) in settings.limits" :key="k" :label="String(k)">
|
||||
{{ v }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</template>
|
||||
</n-spin>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.page-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
import { config, mount, flushPromises } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
NConfigProvider,
|
||||
NDialogProvider,
|
||||
NMessageProvider,
|
||||
dateZhCN,
|
||||
zhCN,
|
||||
} from "naive-ui";
|
||||
import { defineComponent, h, nextTick } from "vue";
|
||||
import TokensView from "./TokensView.vue";
|
||||
import { mockApi } from "@/api/mock";
|
||||
|
||||
config.global.stubs = { teleport: true };
|
||||
|
||||
function wrap(Comp: object) {
|
||||
return defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(NConfigProvider, { locale: zhCN, dateLocale: dateZhCN, size: "small" }, {
|
||||
default: () =>
|
||||
h(NMessageProvider, null, {
|
||||
default: () =>
|
||||
h(NDialogProvider, null, {
|
||||
default: () => h(Comp),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("TokensView 令牌只显示一次", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
mockApi._setSession();
|
||||
});
|
||||
|
||||
it("创建后显示令牌,关闭后列表不再含明文", async () => {
|
||||
const pinia = createPinia();
|
||||
const w = mount(wrap(TokensView), {
|
||||
global: { plugins: [pinia] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
await w.find('[data-testid="token-create-open"]').trigger("click");
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
await w.find('[data-testid="token-name"]').find("input").setValue("ci-test");
|
||||
await w.find('[data-testid="token-create-submit"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(w.text()).toContain("令牌只显示一次");
|
||||
expect(w.text()).toMatch(/nxm_/);
|
||||
|
||||
const shown = w.text().match(/nxm_[A-Za-z0-9]+/)?.[0];
|
||||
expect(shown).toBeTruthy();
|
||||
|
||||
const dismiss = w.findAll("button").find((b) => b.text().includes("我已保存"));
|
||||
await dismiss!.trigger("click");
|
||||
await nextTick();
|
||||
|
||||
expect(w.text()).not.toContain("令牌只显示一次");
|
||||
expect(w.text()).not.toContain(shown!);
|
||||
expect(w.text()).toContain("ci-test");
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from "vue";
|
||||
import type { DataTableColumns } from "naive-ui";
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NSpace,
|
||||
useDialog,
|
||||
} from "naive-ui";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import SecretOnceAlert from "@/components/SecretOnceAlert.vue";
|
||||
import { createToken, deleteToken, listTokens, patchToken, type ApiToken } from "@/api/admin";
|
||||
import { formatLocalMs } from "@/utils/time";
|
||||
import { message } from "@/utils/notify";
|
||||
|
||||
const dialog = useDialog();
|
||||
const loading = ref(false);
|
||||
const rows = ref<ApiToken[]>([]);
|
||||
const createOpen = ref(false);
|
||||
const createName = ref("");
|
||||
const createLoading = ref(false);
|
||||
const onceToken = ref<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await listTokens();
|
||||
rows.value = res.items;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
const columns = computed<DataTableColumns<ApiToken>>(() => [
|
||||
{ title: "ID", key: "id", width: 60 },
|
||||
{ title: "名称", key: "name", width: 160 },
|
||||
{
|
||||
title: "启用",
|
||||
key: "enabled",
|
||||
width: 70,
|
||||
render: (r) => (r.enabled ? "是" : "否"),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "created_at_ms",
|
||||
width: 160,
|
||||
render: (r) => formatLocalMs(r.created_at_ms),
|
||||
},
|
||||
{
|
||||
title: "最近使用",
|
||||
key: "last_used_at_ms",
|
||||
width: 160,
|
||||
render: (r) => formatLocalMs(r.last_used_at_ms),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 200,
|
||||
render: (r) =>
|
||||
h(NSpace, { size: 4 }, () => [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "tiny",
|
||||
onClick: () => void onToggle(r),
|
||||
},
|
||||
() => (r.enabled ? "停用" : "启用"),
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "tiny",
|
||||
type: "error",
|
||||
secondary: true,
|
||||
onClick: () => onDelete(r),
|
||||
},
|
||||
() => "删除",
|
||||
),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
|
||||
async function submitCreate() {
|
||||
if (!createName.value.trim()) {
|
||||
message.error("名称不能为空");
|
||||
return;
|
||||
}
|
||||
createLoading.value = true;
|
||||
try {
|
||||
const res = await createToken(createName.value.trim());
|
||||
createOpen.value = false;
|
||||
createName.value = "";
|
||||
onceToken.value = res.token;
|
||||
await load();
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggle(r: ApiToken) {
|
||||
await patchToken(r.id, { enabled: !r.enabled });
|
||||
message.success(r.enabled ? "已停用" : "已启用");
|
||||
await load();
|
||||
}
|
||||
|
||||
function onDelete(r: ApiToken) {
|
||||
dialog.warning({
|
||||
title: "删除令牌",
|
||||
content: `确定删除「${r.name}」?删除后立即失效。`,
|
||||
positiveText: "删除",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
await deleteToken(r.id);
|
||||
message.success("已删除");
|
||||
await load();
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader title="API 令牌" help="令牌明文只在创建时显示一次;列表不含明文。">
|
||||
<template #actions>
|
||||
<n-button type="primary" data-testid="token-create-open" @click="createOpen = true; createName = ''">
|
||||
创建令牌
|
||||
</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<div class="page-body">
|
||||
<SecretOnceAlert
|
||||
v-if="onceToken"
|
||||
title="令牌只显示一次"
|
||||
:secret="onceToken"
|
||||
filename="api-token.txt"
|
||||
data-testid="token-once"
|
||||
@dismiss="onceToken = null"
|
||||
/>
|
||||
<n-data-table
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:row-key="(r: ApiToken) => r.id"
|
||||
:max-height="480"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="createOpen" preset="card" title="创建 API 令牌" style="width: 420px">
|
||||
<n-form label-placement="left" label-width="60" :show-feedback="false">
|
||||
<n-form-item label="名称">
|
||||
<n-input v-model:value="createName" data-testid="token-name" placeholder="例如 ops" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="createOpen = false">取消</n-button>
|
||||
<n-button type="primary" :loading="createLoading" data-testid="token-create-submit" @click="submitCreate">
|
||||
创建
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.page-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
+5
-1
@@ -1,4 +1,4 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -18,4 +18,8 @@ export default defineConfig({
|
||||
"/healthz": "http://127.0.0.1:7443",
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "happy-dom",
|
||||
include: ["src/**/*.{spec,test}.ts"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user