merge: admin-web
This commit is contained in:
@@ -127,7 +127,7 @@ test.describe("后台主路径 W4", () => {
|
||||
await nav(page, "投递记录");
|
||||
await expect(page).toHaveURL(/\/messages/);
|
||||
await expect(page.getByText("w4-e2e-scheduled")).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: "scheduled", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: "定时中", exact: true })).toBeVisible();
|
||||
|
||||
await nav(page, "API 令牌");
|
||||
await expect(page).toHaveURL(/\/tokens/);
|
||||
|
||||
@@ -22,9 +22,7 @@ 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) {
|
||||
if (!silent && e instanceof Error && !(e instanceof ApiError)) {
|
||||
message.error(e.message);
|
||||
}
|
||||
throw e;
|
||||
@@ -163,12 +161,14 @@ export function listMessages(q: {
|
||||
endpoint_id?: string;
|
||||
group_id?: string;
|
||||
state?: string;
|
||||
from_ms?: number;
|
||||
to_ms?: number;
|
||||
}) {
|
||||
return run(() => mockApi.listMessages(q));
|
||||
}
|
||||
|
||||
export function getMessage(seq: number) {
|
||||
return run(() => mockApi.getMessage(seq));
|
||||
export function getMessage(seq: number, cursor?: string, limit?: number) {
|
||||
return run(() => mockApi.getMessage(seq, cursor, limit));
|
||||
}
|
||||
|
||||
export function getSettings() {
|
||||
|
||||
@@ -33,9 +33,7 @@ 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) {
|
||||
if (!silent && e instanceof Error && !(e instanceof ApiError)) {
|
||||
message.error(e.message);
|
||||
}
|
||||
throw e;
|
||||
@@ -333,6 +331,8 @@ export function listMessages(q: {
|
||||
endpoint_id?: string;
|
||||
group_id?: string;
|
||||
state?: string;
|
||||
from_ms?: number;
|
||||
to_ms?: number;
|
||||
}): Promise<PageResult<MessageSummary>> {
|
||||
return run(() =>
|
||||
requestAdmin<PageResult<MessageSummary>>(
|
||||
@@ -343,13 +343,17 @@ export function listMessages(q: {
|
||||
endpoint_id: q.endpoint_id,
|
||||
group_id: q.group_id,
|
||||
state: q.state,
|
||||
from_ms: q.from_ms,
|
||||
to_ms: q.to_ms,
|
||||
})}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getMessage(seq: number): Promise<MessageDetail> {
|
||||
return run(() => requestAdmin<MessageDetail>(`/api/admin/messages/${seq}`));
|
||||
export function getMessage(seq: number, cursor?: string, limit?: number): Promise<MessageDetail> {
|
||||
return run(() =>
|
||||
requestAdmin<MessageDetail>(`/api/admin/messages/${seq}${buildQuery({ cursor, limit })}`),
|
||||
);
|
||||
}
|
||||
|
||||
export function getSettings(): Promise<RuntimeSettings> {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, requestAdmin, setUnauthorizedHandler } from "./http";
|
||||
|
||||
vi.mock("@/utils/notify", () => ({
|
||||
message: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { message } from "@/utils/notify";
|
||||
|
||||
describe("requestAdmin", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
setUnauthorizedHandler(null);
|
||||
vi.mocked(message.error).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
setUnauthorizedHandler(null);
|
||||
});
|
||||
|
||||
it("401 跳转登录页且只提示一次", async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: false, error: { code: "unauthorized", message: "未登录" } }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const handler = vi.fn();
|
||||
setUnauthorizedHandler(handler);
|
||||
|
||||
await expect(requestAdmin("/api/admin/overview")).rejects.toBeInstanceOf(ApiError);
|
||||
await expect(requestAdmin("/api/admin/endpoints")).rejects.toBeInstanceOf(ApiError);
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(vi.mocked(message.error).mock.calls.filter((c) => c[0] === "登录已过期")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("改密 401 不自动登出", async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: false, error: { code: "unauthorized", message: "旧密码错误" } }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const handler = vi.fn();
|
||||
setUnauthorizedHandler(handler);
|
||||
await expect(
|
||||
requestAdmin("/api/admin/password", { method: "POST", body: { old_password: "x", new_password: "yyyyyyyyyyyy" } }),
|
||||
).rejects.toMatchObject({ message: "旧密码错误" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("网络错误显示中文", async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
await expect(requestAdmin("/api/admin/me")).rejects.toMatchObject({ message: "无法连接服务器" });
|
||||
expect(message.error).toHaveBeenCalledWith("无法连接服务器");
|
||||
});
|
||||
|
||||
it("429 提示稍后再试", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: false, error: { code: "rate_limited", message: "locked" } }), {
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
await expect(requestAdmin("/api/admin/login", { method: "POST", body: {} })).rejects.toMatchObject({
|
||||
message: "请求过于频繁,请稍后再试",
|
||||
});
|
||||
expect(message.error).toHaveBeenCalledWith("请求过于频繁,请稍后再试");
|
||||
});
|
||||
});
|
||||
+74
-12
@@ -27,9 +27,52 @@ export interface RequestOptions {
|
||||
contentType?: string | null;
|
||||
}
|
||||
|
||||
const noAutoLogout = new Set(["/api/admin/login", "/api/admin/password"]);
|
||||
|
||||
type UnauthorizedHandler = (redirectPath: string) => void;
|
||||
let unauthorizedHandler: UnauthorizedHandler | null = null;
|
||||
let expiredNotified = false;
|
||||
|
||||
export function setUnauthorizedHandler(handler: UnauthorizedHandler | null) {
|
||||
unauthorizedHandler = handler;
|
||||
}
|
||||
|
||||
function requestPath(path: string): string {
|
||||
const q = path.indexOf("?");
|
||||
return q >= 0 ? path.slice(0, q) : path;
|
||||
}
|
||||
|
||||
function currentRedirect(): string {
|
||||
if (typeof window === "undefined") {
|
||||
return "/";
|
||||
}
|
||||
return `${window.location.pathname}${window.location.search}` || "/";
|
||||
}
|
||||
|
||||
function notifyExpiredOnce() {
|
||||
if (expiredNotified) {
|
||||
return;
|
||||
}
|
||||
expiredNotified = true;
|
||||
message.error("登录已过期");
|
||||
window.setTimeout(() => {
|
||||
expiredNotified = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function handleUnauthorized(path: string, silent: boolean) {
|
||||
if (noAutoLogout.has(requestPath(path))) {
|
||||
return;
|
||||
}
|
||||
if (!silent) {
|
||||
notifyExpiredOnce();
|
||||
}
|
||||
unauthorizedHandler?.(currentRedirect());
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理接口请求封装:自动带 credentials 与 X-Nixmsg-Request: 1,错误直接 message 显示。
|
||||
* W4 接真实后端时页面不必改,只需让 admin 模块走本函数。
|
||||
* 管理接口请求封装:自动带 credentials 与 X-Nixmsg-Request: 1。
|
||||
* 错误只在这里提示一次;401(登录/改密除外)清空会话并跳转登录页。
|
||||
*/
|
||||
export async function requestAdmin<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const method = opts.method ?? (opts.body != null || opts.rawBody != null ? "POST" : "GET");
|
||||
@@ -46,19 +89,32 @@ export async function requestAdmin<T>(path: string, opts: RequestOptions = {}):
|
||||
headers["Content-Type"] = opts.contentType;
|
||||
}
|
||||
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers,
|
||||
body: body ?? undefined,
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers,
|
||||
body: body ?? undefined,
|
||||
});
|
||||
} catch {
|
||||
const err = new ApiError("network", "无法连接服务器", 0);
|
||||
if (!opts.silent) {
|
||||
message.error(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path, Boolean(opts.silent));
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!opts.silent && res.status !== 401) {
|
||||
message.error(err.message);
|
||||
}
|
||||
throw err;
|
||||
@@ -66,7 +122,7 @@ export async function requestAdmin<T>(path: string, opts: RequestOptions = {}):
|
||||
|
||||
if (typeof envelope !== "object" || envelope === null) {
|
||||
const err = new ApiError("internal", "响应格式无效", res.status);
|
||||
if (!opts.silent) {
|
||||
if (!opts.silent && res.status !== 401) {
|
||||
message.error(err.message);
|
||||
}
|
||||
throw err;
|
||||
@@ -77,8 +133,14 @@ export async function requestAdmin<T>(path: string, opts: RequestOptions = {}):
|
||||
}
|
||||
|
||||
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) {
|
||||
let display = errBody.message;
|
||||
if (res.status === 429) {
|
||||
display = "请求过于频繁,请稍后再试";
|
||||
} else if (res.status === 401 && !noAutoLogout.has(requestPath(path))) {
|
||||
display = "登录已过期";
|
||||
}
|
||||
const err = new ApiError(errBody.code, display, res.status, "data" in envelope ? envelope.data : undefined);
|
||||
if (!opts.silent && res.status !== 401) {
|
||||
message.error(err.message);
|
||||
}
|
||||
throw err;
|
||||
|
||||
+25
-5
@@ -151,6 +151,14 @@ const messages: MessageDetail[] = [
|
||||
pushed_at_ms: now() - 3_599_000,
|
||||
updated_at_ms: now() - 3_598_000,
|
||||
},
|
||||
{
|
||||
endpoint_id: "ops-bot",
|
||||
state: "rejected",
|
||||
reason: "disabled",
|
||||
attempts: 1,
|
||||
pushed_at_ms: now() - 3_599_000,
|
||||
updated_at_ms: now() - 3_598_000,
|
||||
},
|
||||
],
|
||||
next_cursor: "",
|
||||
},
|
||||
@@ -320,6 +328,7 @@ export const mockApi = {
|
||||
endpoints_total: endpoints.length,
|
||||
endpoints_online: endpoints.filter((e) => e.online).length,
|
||||
endpoints_disabled: endpoints.filter((e) => !e.enabled).length,
|
||||
endpoints_self: endpoints.filter((e) => e.source === "self").length,
|
||||
groups_total: groups.length,
|
||||
messages_pending: messages.filter((m) => m.state === "dispatched").length,
|
||||
messages_scheduled: messages.filter((m) => m.state === "scheduled").length,
|
||||
@@ -354,7 +363,8 @@ export const mockApi = {
|
||||
if (endpoints.some((e) => e.id === id)) {
|
||||
throw new ApiError("id_taken", "编号已占用", 409);
|
||||
}
|
||||
const loginPassword = (body.login_password || "").trim() || randomPassword();
|
||||
const provided = (body.login_password || "").trim();
|
||||
const loginPassword = provided || randomPassword();
|
||||
endpoints.unshift({
|
||||
id,
|
||||
name: body.name,
|
||||
@@ -369,7 +379,7 @@ export const mockApi = {
|
||||
created_at_ms: now(),
|
||||
login_locked: false,
|
||||
});
|
||||
return { id, login_password: loginPassword };
|
||||
return provided ? { id } : { id, login_password: loginPassword };
|
||||
},
|
||||
|
||||
async importEndpoints(csvText: string): Promise<{ items: ImportItem[] }> {
|
||||
@@ -598,7 +608,7 @@ export const mockApi = {
|
||||
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));
|
||||
list = list.filter((g) => g.name.toLowerCase().includes(n));
|
||||
}
|
||||
return paginate(list, cursor, limit);
|
||||
},
|
||||
@@ -655,6 +665,7 @@ export const mockApi = {
|
||||
name: g.name,
|
||||
owner_id: g.owner_id,
|
||||
created_at_ms: g.created_at_ms,
|
||||
member_total: all.length,
|
||||
members: page.items,
|
||||
next_cursor: page.next_cursor,
|
||||
};
|
||||
@@ -729,6 +740,8 @@ export const mockApi = {
|
||||
endpoint_id?: string;
|
||||
group_id?: string;
|
||||
state?: string;
|
||||
from_ms?: number;
|
||||
to_ms?: number;
|
||||
}): Promise<PageResult<MessageSummary>> {
|
||||
requireSession();
|
||||
let list = messages.map(toMessageSummary);
|
||||
@@ -741,16 +754,23 @@ export const mockApi = {
|
||||
});
|
||||
}
|
||||
if (q.state) list = list.filter((m) => m.state === q.state);
|
||||
if (q.from_ms != null) list = list.filter((m) => m.created_at_ms >= q.from_ms!);
|
||||
if (q.to_ms != null) list = list.filter((m) => m.created_at_ms <= q.to_ms!);
|
||||
return paginate(list, q.cursor, q.limit);
|
||||
},
|
||||
|
||||
async getMessage(seq: number): Promise<MessageDetail> {
|
||||
async getMessage(seq: number, cursor?: string, limit?: number): Promise<MessageDetail> {
|
||||
requireSession();
|
||||
const m = messages.find((x) => x.seq === seq);
|
||||
if (!m) {
|
||||
throw new ApiError("not_found", "消息不存在", 404);
|
||||
}
|
||||
return clone(m);
|
||||
const page = paginate(m.deliveries, cursor, limit ?? 200);
|
||||
return {
|
||||
...clone(m),
|
||||
deliveries: page.items,
|
||||
next_cursor: page.next_cursor,
|
||||
};
|
||||
},
|
||||
|
||||
async getSettings(): Promise<RuntimeSettings> {
|
||||
|
||||
@@ -34,8 +34,7 @@ export interface Overview {
|
||||
endpoints_total: number;
|
||||
endpoints_online: number;
|
||||
endpoints_disabled: number;
|
||||
/** A3 补充:自助注册端数量;契约示例未列,前端可选展示 */
|
||||
endpoints_self?: number;
|
||||
endpoints_self: number;
|
||||
groups_total: number;
|
||||
messages_pending: number;
|
||||
messages_scheduled: number;
|
||||
@@ -72,7 +71,7 @@ export interface EndpointCreateRequest {
|
||||
|
||||
export interface EndpointCreateResult {
|
||||
id: string;
|
||||
login_password: string;
|
||||
login_password?: string;
|
||||
}
|
||||
|
||||
export interface EndpointPatchRequest {
|
||||
@@ -98,7 +97,7 @@ export interface ImportError {
|
||||
|
||||
export interface ImportItem {
|
||||
id: string;
|
||||
login_password: string;
|
||||
login_password?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -155,6 +154,7 @@ export interface GroupDetail {
|
||||
name: string;
|
||||
owner_id: string;
|
||||
created_at_ms: number;
|
||||
member_total?: number;
|
||||
members: GroupMember[];
|
||||
next_cursor: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NResult } from "naive-ui";
|
||||
|
||||
defineProps<{
|
||||
description?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-result status="error" title="加载失败" :description="description || '无法加载页面数据'">
|
||||
<template #footer>
|
||||
<n-button type="primary" data-testid="load-retry" @click="emit('retry')">重试</n-button>
|
||||
</template>
|
||||
</n-result>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
import { config, mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { NConfigProvider, NMessageProvider, zhCN, dateZhCN } from "naive-ui";
|
||||
import { defineComponent, h } from "vue";
|
||||
import SecretOnceAlert from "./SecretOnceAlert.vue";
|
||||
|
||||
vi.mock("@/utils/notify", () => ({
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { message } from "@/utils/notify";
|
||||
|
||||
config.global.stubs = { teleport: true };
|
||||
|
||||
function wrap() {
|
||||
return defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(NConfigProvider, { locale: zhCN, dateLocale: dateZhCN, size: "small" }, {
|
||||
default: () =>
|
||||
h(NMessageProvider, null, {
|
||||
default: () =>
|
||||
h(SecretOnceAlert, {
|
||||
title: "令牌只显示一次",
|
||||
secret: "nxm_abc",
|
||||
filename: "api-token.txt",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("SecretOnceAlert 复制", () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(message.success).mockClear();
|
||||
vi.mocked(message.error).mockClear();
|
||||
});
|
||||
|
||||
it("不支持 clipboard 时回退 execCommand 并提示成功", async () => {
|
||||
Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
|
||||
Object.defineProperty(document, "execCommand", { value: vi.fn(() => true), configurable: true });
|
||||
|
||||
const w = mount(wrap(), { attachTo: document.body });
|
||||
await flushPromises();
|
||||
await w.find('[data-testid="secret-copy"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(document.execCommand).toHaveBeenCalledWith("copy");
|
||||
expect(message.success).toHaveBeenCalledWith("已复制");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("writeText 失败时提示错误", async () => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error("denied")) },
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(document, "execCommand", { value: vi.fn(() => false), configurable: true });
|
||||
|
||||
const w = mount(wrap(), { attachTo: document.body });
|
||||
await flushPromises();
|
||||
await w.find('[data-testid="secret-copy"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(message.error).toHaveBeenCalledWith("复制失败,请手动选择文本");
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { NAlert, NButton, NSpace, NText } from "naive-ui";
|
||||
import { downloadText } from "@/utils/csv";
|
||||
import { message } from "@/utils/notify";
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
secret: string;
|
||||
filename?: string;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
secret: string;
|
||||
filename?: string;
|
||||
mime?: string;
|
||||
}>(),
|
||||
{
|
||||
filename: "secret.txt",
|
||||
mime: "text/plain;charset=utf-8",
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
dismiss: [];
|
||||
}>();
|
||||
|
||||
function copy() {
|
||||
void navigator.clipboard?.writeText(props.secret);
|
||||
const secretEl = ref<HTMLElement | null>(null);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(props.secret);
|
||||
message.success("已复制");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* 回退选中文本 */
|
||||
}
|
||||
const el = secretEl.value;
|
||||
if (el) {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
try {
|
||||
if (document.execCommand("copy")) {
|
||||
message.success("已复制");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
message.error("复制失败,请手动选择文本");
|
||||
}
|
||||
|
||||
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);
|
||||
downloadText(props.filename, props.secret, props.mime);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-alert type="warning" :title="title" style="margin-bottom: 12px">
|
||||
<n-space vertical :size="8">
|
||||
<n-text code>{{ secret }}</n-text>
|
||||
<span ref="secretEl" data-testid="secret-text"><n-text code>{{ secret }}</n-text></span>
|
||||
<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-button size="small" data-testid="secret-copy" @click="copy">复制</n-button>
|
||||
<n-button size="small" data-testid="secret-download" @click="download">下载</n-button>
|
||||
<n-button size="small" secondary data-testid="secret-dismiss" @click="emit('dismiss')">我已保存</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-alert>
|
||||
|
||||
+15
-1
@@ -2,8 +2,22 @@ import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import { router } from "./router";
|
||||
import { setUnauthorizedHandler } from "./api/http";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
setUnauthorizedHandler((redirect) => {
|
||||
const auth = useAuthStore();
|
||||
auth.clearSession();
|
||||
if (router.currentRoute.value.meta.public) {
|
||||
return;
|
||||
}
|
||||
const target = redirect.startsWith("/login") ? "/overview" : redirect || "/overview";
|
||||
void router.replace({ name: "login", query: { redirect: target } });
|
||||
});
|
||||
|
||||
app.mount("#app");
|
||||
|
||||
@@ -32,5 +32,9 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { username, ready, isLoggedIn, hydrate, login, logout };
|
||||
function clearSession() {
|
||||
username.value = null;
|
||||
}
|
||||
|
||||
return { username, ready, isLoggedIn, hydrate, login, logout, clearSession };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildPasswordCsv, csvQuote, guardFormula } from "./csv";
|
||||
|
||||
describe("csvQuote", () => {
|
||||
it("给所有字段加引号并双写内部引号", () => {
|
||||
expect(csvQuote("a")).toBe('"a"');
|
||||
expect(csvQuote('say "hi"')).toBe('"say ""hi"""');
|
||||
expect(csvQuote("a,b")).toBe('"a,b"');
|
||||
expect(csvQuote("a\nb")).toBe('"a\nb"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("guardFormula", () => {
|
||||
it("名称以公式字符开头时前缀单引号", () => {
|
||||
expect(guardFormula("=1+1")).toBe("'=1+1");
|
||||
expect(guardFormula("+cmd")).toBe("'+cmd");
|
||||
expect(guardFormula("-1")).toBe("'-1");
|
||||
expect(guardFormula("@SUM")).toBe("'@SUM");
|
||||
expect(guardFormula("门口")).toBe("门口");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPasswordCsv", () => {
|
||||
it("带 BOM、表头、CRLF,密码原样加引号", () => {
|
||||
const out = buildPasswordCsv([
|
||||
{ id: "e1", name: "门,口", login_password: 'p"w' },
|
||||
{ id: "e2", name: "=cmd", login_password: "=1+1" },
|
||||
]);
|
||||
expect(out.startsWith("\uFEFF")).toBe(true);
|
||||
const body = out.slice(1);
|
||||
expect(body.startsWith('"id","name","login_password"\r\n')).toBe(true);
|
||||
const rows = body.split("\r\n");
|
||||
expect(rows[1]).toBe('"e1","门,口","p""w"');
|
||||
expect(rows[2]).toBe('"e2","\'=cmd","=1+1"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/** RFC 4180 CSV,用于一次性密码下载。 */
|
||||
|
||||
const FORMULA_START = /^[=+\-@]/;
|
||||
|
||||
export function csvQuote(value: string): string {
|
||||
return `"${String(value ?? "").replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
/** 名称列以防公式注入:以 = + - @ 开头时前缀单引号。密码列不要调用。 */
|
||||
export function guardFormula(value: string): string {
|
||||
const v = String(value ?? "");
|
||||
return FORMULA_START.test(v) ? `'${v}` : v;
|
||||
}
|
||||
|
||||
export interface PasswordCsvRow {
|
||||
id: string;
|
||||
name: string;
|
||||
login_password: string;
|
||||
}
|
||||
|
||||
/** BOM + 表头 + CRLF;所有字段加引号。 */
|
||||
export function buildPasswordCsv(rows: PasswordCsvRow[]): string {
|
||||
const lines = [
|
||||
[csvQuote("id"), csvQuote("name"), csvQuote("login_password")].join(","),
|
||||
...rows.map((r) =>
|
||||
[csvQuote(r.id), csvQuote(guardFormula(r.name)), csvQuote(r.login_password)].join(","),
|
||||
),
|
||||
];
|
||||
return `\uFEFF${lines.join("\r\n")}`;
|
||||
}
|
||||
|
||||
export function downloadText(filename: string, text: string, mime: string) {
|
||||
const blob = new Blob([text], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatUptime, zhLabel, messageStateLabel } from "./labels";
|
||||
|
||||
describe("labels", () => {
|
||||
it("消息状态译成中文", () => {
|
||||
expect(zhLabel(messageStateLabel, "scheduled")).toBe("定时中");
|
||||
expect(zhLabel(messageStateLabel, "unknown")).toBe("unknown");
|
||||
});
|
||||
|
||||
it("运行时长按天时分", () => {
|
||||
expect(formatUptime(90_000)).toBe("1 分");
|
||||
expect(formatUptime(3_661_000)).toBe("1 小时 1 分");
|
||||
expect(formatUptime(90_000_000)).toBe("1 天 1 小时 0 分");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/** 后台界面中文标签;原值可放 tooltip。 */
|
||||
|
||||
export const messageStateLabel: Record<string, string> = {
|
||||
scheduled: "定时中",
|
||||
dispatched: "投递中",
|
||||
completed: "已完成",
|
||||
};
|
||||
|
||||
export const destKindLabel: Record<string, string> = {
|
||||
endpoint: "端",
|
||||
group: "群",
|
||||
};
|
||||
|
||||
export const deliveryStateLabel: Record<string, string> = {
|
||||
pending: "待投递",
|
||||
accepted: "已收下",
|
||||
recalled: "已撤回",
|
||||
expired: "已过期",
|
||||
dropped: "已丢弃",
|
||||
rejected: "已拒绝",
|
||||
};
|
||||
|
||||
export const reasonLabel: Record<string, string> = {
|
||||
disabled: "端已停用",
|
||||
expired: "已过期",
|
||||
recalled: "已撤回",
|
||||
dropped: "已丢弃",
|
||||
rejected: "已拒绝",
|
||||
unauthorized: "未授权",
|
||||
not_found: "不存在",
|
||||
};
|
||||
|
||||
export const settingsFieldLabel: Record<string, string> = {
|
||||
listen: "端监听地址",
|
||||
admin_listen: "后台监听地址",
|
||||
session_idle_days: "会话闲置天数",
|
||||
record_retention_days: "记录保留天数",
|
||||
idempotency_hours: "防重小时数",
|
||||
receipt_retention_days: "回执保留天数",
|
||||
sqlite_synchronous: "SQLite 同步模式",
|
||||
};
|
||||
|
||||
export const limitsLabel: Record<string, string> = {
|
||||
max_body_bytes: "正文上限(字节)",
|
||||
max_meta_bytes: "元数据上限(字节)",
|
||||
max_frame_bytes: "帧上限(字节)",
|
||||
max_ttl_seconds: "TTL 上限(秒)",
|
||||
max_schedule_seconds: "定时上限(秒)",
|
||||
max_group_members: "群成员上限",
|
||||
grace_seconds: "宽限秒数",
|
||||
ack_timeout_seconds: "确认超时(秒)",
|
||||
delivery_window: "投递窗口",
|
||||
receipt_window: "回执窗口",
|
||||
requests_per_second: "每秒请求",
|
||||
max_pending_per_sender: "发送方未完成上限",
|
||||
max_pending_per_receiver: "接收方未完成上限",
|
||||
};
|
||||
|
||||
export function zhLabel(map: Record<string, string>, value: string | undefined | null, fallback = "—"): string {
|
||||
if (value == null || value === "") return fallback;
|
||||
return map[value] ?? value;
|
||||
}
|
||||
|
||||
/** 运行时长:天、时、分。 */
|
||||
export function formatUptime(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const days = Math.floor(total / 86400);
|
||||
const hours = Math.floor((total % 86400) / 3600);
|
||||
const mins = Math.floor((total % 3600) / 60);
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days} 天`);
|
||||
if (hours > 0 || days > 0) parts.push(`${hours} 小时`);
|
||||
parts.push(`${mins} 分`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { defineComponent, h, nextTick } from "vue";
|
||||
import EndpointsView from "./EndpointsView.vue";
|
||||
import { mockApi } from "@/api/mock";
|
||||
import * as admin from "@/api/admin";
|
||||
|
||||
vi.mock("@/api/admin", async () => import("@/api/admin-mock"));
|
||||
|
||||
@@ -69,4 +70,107 @@ describe("EndpointsView 开通端", () => {
|
||||
expect(w.text()).not.toContain("只显示一次");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("手填密码开通后不显示一次性面板和 undefined", 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() === "开通");
|
||||
await openBtn!.trigger("click");
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
await w.find('[data-testid="create-name"]').find("input").setValue("手填端");
|
||||
await w.find('[data-testid="create-login-password"]').find("input").setValue("handpassword12");
|
||||
await w.find('[data-testid="create-submit"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(w.text()).not.toContain("undefined");
|
||||
expect(w.text()).not.toContain("只显示一次");
|
||||
expect(w.text()).toContain("手填端");
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EndpointsView 列表与编辑", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
mockApi._setSession();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("login_locked=false 时解锁按钮可点", async () => {
|
||||
const w = mount(wrap(EndpointsView), {
|
||||
global: { plugins: [createPinia()] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
const unlocks = w.findAll('[data-testid="endpoint-unlock"]');
|
||||
expect(unlocks.length).toBeGreaterThan(0);
|
||||
for (const b of unlocks) {
|
||||
expect(b.attributes("disabled")).toBeUndefined();
|
||||
}
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("只改名称时请求体没有 enabled", async () => {
|
||||
const spy = vi.spyOn(admin, "patchEndpoint");
|
||||
const w = mount(wrap(EndpointsView), {
|
||||
global: { plugins: [createPinia()] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const editBtn = w.findAll("button").find((b) => b.text().trim() === "编辑");
|
||||
await editBtn!.trigger("click");
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
await w.find('[data-testid="edit-name"]').find("input").setValue("改名后");
|
||||
await w.find('[data-testid="edit-submit"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const body = spy.mock.calls[0][1];
|
||||
expect(body).toEqual({ name: "改名后" });
|
||||
expect(body).not.toHaveProperty("enabled");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("默认延迟超上限时前端阻止提交", async () => {
|
||||
vi.spyOn(admin, "getSettings").mockResolvedValue({
|
||||
listen: ":7443",
|
||||
admin_listen: "",
|
||||
session_idle_days: 30,
|
||||
record_retention_days: 7,
|
||||
idempotency_hours: 24,
|
||||
receipt_retention_days: 7,
|
||||
sqlite_synchronous: "FULL",
|
||||
limits: { max_schedule_seconds: 10 },
|
||||
});
|
||||
const createSpy = vi.spyOn(admin, "createEndpoint");
|
||||
const w = mount(wrap(EndpointsView), {
|
||||
global: { plugins: [createPinia()] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const openBtn = w.findAll("button").find((b) => b.text().trim() === "开通");
|
||||
await openBtn!.trigger("click");
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
expect(w.find('[data-testid="create-delay-max"]').text()).toContain("上限 10 秒");
|
||||
const delay = w.find('[data-testid="create-delay"]').find("input");
|
||||
await delay.setValue("11");
|
||||
await w.find('[data-testid="create-submit"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(createSpy.mock.calls.every((c) => ((c[0] as { default_delay_seconds?: number }).default_delay_seconds ?? 0) <= 10)).toBe(true);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
+270
-61
@@ -2,6 +2,7 @@
|
||||
import { computed, h, onMounted, reactive, ref } from "vue";
|
||||
import type { DataTableColumns, DataTableRowKey } from "naive-ui";
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NDataTable,
|
||||
NForm,
|
||||
@@ -21,9 +22,11 @@ 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 LoadFailed from "@/components/LoadFailed.vue";
|
||||
import {
|
||||
batchEndpoints,
|
||||
createEndpoint,
|
||||
getSettings,
|
||||
importEndpoints,
|
||||
kickEndpoint,
|
||||
listEndpoints,
|
||||
@@ -32,15 +35,18 @@ import {
|
||||
setTalkPassword,
|
||||
unlockEndpoint,
|
||||
type Endpoint,
|
||||
type EndpointPatchRequest,
|
||||
type ImportItem,
|
||||
} from "@/api/admin";
|
||||
import { ApiError } from "@/api/http";
|
||||
import { formatLocalMs } from "@/utils/time";
|
||||
import { buildPasswordCsv, downloadText } from "@/utils/csv";
|
||||
import { message } from "@/utils/notify";
|
||||
|
||||
const dialog = useDialog();
|
||||
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const rows = ref<Endpoint[]>([]);
|
||||
const total = ref(0);
|
||||
const checkedKeys = ref<DataTableRowKey[]>([]);
|
||||
@@ -64,8 +70,12 @@ const onlineOptions = [
|
||||
{ label: "离线", value: "false" },
|
||||
];
|
||||
|
||||
const onceSecret = ref<{ title: string; secret: string; filename: string } | null>(null);
|
||||
const onceSecret = ref<{ title: string; secret: string; filename: string; mime?: string } | null>(null);
|
||||
const importResult = ref<ImportItem[] | null>(null);
|
||||
const importing = ref(false);
|
||||
const importEta = ref(1);
|
||||
const importErrors = ref<{ line: number; reason: string }[] | null>(null);
|
||||
const maxDelaySeconds = ref(31536000);
|
||||
|
||||
const createOpen = ref(false);
|
||||
const createForm = reactive({
|
||||
@@ -77,6 +87,7 @@ const createForm = reactive({
|
||||
default_delay_seconds: 0,
|
||||
});
|
||||
const createLoading = ref(false);
|
||||
const createError = ref("");
|
||||
|
||||
const editOpen = ref(false);
|
||||
const editId = ref("");
|
||||
@@ -87,6 +98,12 @@ const editForm = reactive({
|
||||
enabled: true,
|
||||
});
|
||||
const editLoading = ref(false);
|
||||
const editOrig = reactive({
|
||||
name: "",
|
||||
remark: "",
|
||||
default_delay_seconds: 0,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const talkOpen = ref(false);
|
||||
const talkId = ref("");
|
||||
@@ -94,6 +111,7 @@ const talkPassword = ref("");
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
|
||||
const res = await listEndpoints({
|
||||
@@ -105,6 +123,8 @@ async function load() {
|
||||
});
|
||||
rows.value = res.items;
|
||||
total.value = res.total;
|
||||
} catch (e) {
|
||||
loadError.value = e instanceof Error ? e.message : "加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -112,8 +132,21 @@ async function load() {
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
void loadLimits();
|
||||
});
|
||||
|
||||
async function loadLimits() {
|
||||
try {
|
||||
const s = await getSettings();
|
||||
const n = s.limits?.max_schedule_seconds;
|
||||
if (typeof n === "number" && n > 0) {
|
||||
maxDelaySeconds.value = n;
|
||||
}
|
||||
} catch {
|
||||
/* 沿用默认 365 天 */
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(s: string) {
|
||||
return s === "self" ? "自助注册" : "后台开通";
|
||||
}
|
||||
@@ -141,16 +174,29 @@ const columns = computed<DataTableColumns<Endpoint>>(() => [
|
||||
render: (r) => (r.enabled ? "是" : "否"),
|
||||
},
|
||||
{
|
||||
title: "锁定",
|
||||
title() {
|
||||
return h("span", [
|
||||
"锁定",
|
||||
h(HelpTip, null, {
|
||||
default: () => "列表只显示按编号的总数锁定,解锁会同时清除按 IP 的锁定",
|
||||
}),
|
||||
]);
|
||||
},
|
||||
key: "login_locked",
|
||||
width: 70,
|
||||
width: 90,
|
||||
render: (r) => (r.login_locked ? "是" : "否"),
|
||||
},
|
||||
{
|
||||
title: "最近在线",
|
||||
title: "最近上线",
|
||||
key: "online_since_ms",
|
||||
width: 160,
|
||||
render: (r) => formatLocalMs(r.online ? r.online_since_ms : r.offline_since_ms),
|
||||
render: (r) => formatLocalMs(r.online_since_ms),
|
||||
},
|
||||
{
|
||||
title: "最近离线",
|
||||
key: "offline_since_ms",
|
||||
width: 160,
|
||||
render: (r) => formatLocalMs(r.offline_since_ms),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
@@ -170,7 +216,11 @@ const columns = computed<DataTableColumns<Endpoint>>(() => [
|
||||
h(NButton, { size: "tiny", onClick: () => openTalk(r) }, () => "对话密码"),
|
||||
h(
|
||||
NButton,
|
||||
{ size: "tiny", disabled: !r.login_locked, onClick: () => void onUnlock(r) },
|
||||
{
|
||||
size: "tiny",
|
||||
"data-testid": "endpoint-unlock",
|
||||
onClick: () => void onUnlock(r),
|
||||
},
|
||||
() => "解锁",
|
||||
),
|
||||
h(NButton, { size: "tiny", onClick: () => void onKick(r) }, () => "踢下线"),
|
||||
@@ -187,12 +237,14 @@ function openCreate() {
|
||||
talk_password: "",
|
||||
default_delay_seconds: 0,
|
||||
});
|
||||
createError.value = "";
|
||||
createOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!createForm.name.trim()) {
|
||||
message.error("名称不能为空");
|
||||
createError.value = "";
|
||||
if ((createForm.default_delay_seconds ?? 0) > maxDelaySeconds.value) {
|
||||
createError.value = `默认延迟不能超过 ${maxDelaySeconds.value} 秒`;
|
||||
return;
|
||||
}
|
||||
createLoading.value = true;
|
||||
@@ -206,12 +258,18 @@ async function submitCreate() {
|
||||
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`,
|
||||
};
|
||||
if (res.login_password) {
|
||||
onceSecret.value = {
|
||||
title: "开通成功,登录密码只显示一次",
|
||||
secret: `${res.id}\t${res.login_password}`,
|
||||
filename: `${res.id}-password.txt`,
|
||||
};
|
||||
} else {
|
||||
message.success(`已开通 ${res.id}`);
|
||||
}
|
||||
await load();
|
||||
} catch (e) {
|
||||
createError.value = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "开通失败";
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
@@ -225,24 +283,51 @@ function openEdit(r: Endpoint) {
|
||||
default_delay_seconds: Math.round(r.default_delay_ms / 1000),
|
||||
enabled: r.enabled,
|
||||
});
|
||||
Object.assign(editOrig, editForm);
|
||||
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 buildEditPatch(): EndpointPatchRequest {
|
||||
const body: EndpointPatchRequest = {};
|
||||
if (editForm.name !== editOrig.name) body.name = editForm.name;
|
||||
if (editForm.remark !== editOrig.remark) body.remark = editForm.remark;
|
||||
if (editForm.default_delay_seconds !== editOrig.default_delay_seconds) {
|
||||
body.default_delay_seconds = editForm.default_delay_seconds;
|
||||
}
|
||||
if (editForm.enabled !== editOrig.enabled) body.enabled = editForm.enabled;
|
||||
return body;
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if ((editForm.default_delay_seconds ?? 0) > maxDelaySeconds.value) {
|
||||
message.error(`默认延迟不能超过 ${maxDelaySeconds.value} 秒`);
|
||||
return;
|
||||
}
|
||||
const body = buildEditPatch();
|
||||
const doSave = async () => {
|
||||
editLoading.value = true;
|
||||
try {
|
||||
if (Object.keys(body).length) {
|
||||
await patchEndpoint(editId.value, body);
|
||||
}
|
||||
editOpen.value = false;
|
||||
message.success("已保存");
|
||||
await load();
|
||||
} finally {
|
||||
editLoading.value = false;
|
||||
}
|
||||
};
|
||||
if (editOrig.enabled && !editForm.enabled) {
|
||||
dialog.warning({
|
||||
title: "停用端",
|
||||
content: "停用会立即断开并作废未送达消息,不可恢复",
|
||||
positiveText: "停用",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: () => doSave(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await doSave();
|
||||
}
|
||||
|
||||
function openTalk(r: Endpoint) {
|
||||
@@ -252,10 +337,23 @@ function openTalk(r: Endpoint) {
|
||||
}
|
||||
|
||||
async function submitTalk() {
|
||||
await setTalkPassword(talkId.value, talkPassword.value);
|
||||
talkOpen.value = false;
|
||||
message.success(talkPassword.value ? "已设置对话密码" : "已清除对话密码");
|
||||
await load();
|
||||
const save = async () => {
|
||||
await setTalkPassword(talkId.value, talkPassword.value);
|
||||
talkOpen.value = false;
|
||||
message.success(talkPassword.value ? "已设置对话密码" : "已清除对话密码");
|
||||
await load();
|
||||
};
|
||||
if (!talkPassword.value) {
|
||||
dialog.warning({
|
||||
title: "清除对话密码",
|
||||
content: "留空保存将清除该端的对话密码,确定继续?",
|
||||
positiveText: "清除",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: () => save(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await save();
|
||||
}
|
||||
|
||||
async function onResetPwd(r: Endpoint) {
|
||||
@@ -293,14 +391,28 @@ async function onBatch(action: "disable" | "delete") {
|
||||
message.warning("请先多选");
|
||||
return;
|
||||
}
|
||||
const preview = ids.slice(0, 20).join("、");
|
||||
const countPart = ids.length > 20 ? `…共 ${ids.length} 个` : `(${ids.length} 个)`;
|
||||
let content = `编号:${preview}${countPart}`;
|
||||
if (action === "delete") {
|
||||
content += "。删除将退群、必要时转让或解散群主,并清理相关记录,不可恢复。";
|
||||
} else {
|
||||
content += "。停用会立即断开并作废未送达消息,不可恢复。";
|
||||
}
|
||||
dialog.warning({
|
||||
title: action === "disable" ? "批量停用" : "批量删除",
|
||||
content: `对 ${ids.length} 个端执行${action === "disable" ? "停用" : "删除"}?`,
|
||||
content,
|
||||
positiveText: "确定",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
const res = await batchEndpoints(ids, action);
|
||||
message.success(`成功 ${res.ok_ids.length},失败 ${res.failed.length}`);
|
||||
if (res.failed.length) {
|
||||
message.warning(
|
||||
`成功 ${res.ok_ids.length}。失败:${res.failed.map((f) => `${f.id}(${f.code})`).join("、")}`,
|
||||
);
|
||||
} else {
|
||||
message.success(`成功 ${res.ok_ids.length}`);
|
||||
}
|
||||
checkedKeys.value = [];
|
||||
await load();
|
||||
},
|
||||
@@ -313,34 +425,62 @@ async function onImport(options: UploadCustomRequestOptions) {
|
||||
options.onError();
|
||||
return;
|
||||
}
|
||||
importing.value = true;
|
||||
importErrors.value = null;
|
||||
try {
|
||||
const text = await raw.text();
|
||||
const lineCount = Math.max(0, text.replace(/^\uFEFF/, "").split(/\r?\n/).filter((l) => l.trim()).length - 1);
|
||||
const workers = Math.max(1, (navigator.hardwareConcurrency || 4) - 1);
|
||||
importEta.value = Math.max(1, Math.ceil((lineCount * 0.3) / workers));
|
||||
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",
|
||||
};
|
||||
const pwdRows = res.items.filter((i): i is ImportItem & { login_password: string } => Boolean(i.login_password));
|
||||
if (pwdRows.length) {
|
||||
onceSecret.value = {
|
||||
title: "CSV 导入成功,密码只显示一次",
|
||||
secret: buildPasswordCsv(pwdRows),
|
||||
filename: "import-passwords.csv",
|
||||
mime: "text/csv;charset=utf-8",
|
||||
};
|
||||
}
|
||||
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);
|
||||
importErrors.value = (e.data as { errors: { line: number; reason: string }[] }).errors;
|
||||
} else if (e instanceof Error) {
|
||||
message.error(e.message);
|
||||
}
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadImportCsv() {
|
||||
if (!importResult.value?.length) {
|
||||
return;
|
||||
}
|
||||
const pwdRows = importResult.value.filter((i): i is ImportItem & { login_password: string } =>
|
||||
Boolean(i.login_password),
|
||||
);
|
||||
downloadText("import-passwords.csv", buildPasswordCsv(pwdRows), "text/csv;charset=utf-8");
|
||||
}
|
||||
|
||||
function reloadFirst() {
|
||||
checkedKeys.value = [];
|
||||
page.value = 1;
|
||||
void load();
|
||||
}
|
||||
|
||||
function onPageChange(p: number) {
|
||||
checkedKeys.value = [];
|
||||
page.value = p;
|
||||
void load();
|
||||
}
|
||||
|
||||
function onPageSizeChange(s: number) {
|
||||
checkedKeys.value = [];
|
||||
pageSize.value = s;
|
||||
page.value = 1;
|
||||
void load();
|
||||
@@ -352,46 +492,59 @@ function onPageSizeChange(s: number) {
|
||||
<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 :show-file-list="false" accept=".csv,text/csv" :disabled="importing" :custom-request="onImport">
|
||||
<n-button :disabled="importing" data-testid="endpoint-import">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">
|
||||
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
|
||||
<template v-else>
|
||||
<div class="page-filters">
|
||||
<SecretOnceAlert
|
||||
v-if="onceSecret"
|
||||
:title="onceSecret.title"
|
||||
:secret="onceSecret.secret"
|
||||
:filename="onceSecret.filename"
|
||||
:mime="onceSecret.mime"
|
||||
@dismiss="onceSecret = null"
|
||||
/>
|
||||
<n-alert
|
||||
v-if="importing"
|
||||
type="info"
|
||||
:title="`正在校验并计算密码,约需 ${importEta} 秒`"
|
||||
style="margin-bottom: 12px"
|
||||
data-testid="import-progress"
|
||||
/>
|
||||
<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-select v-model:value="filters.source" :options="sourceOptions" style="width: 140px" @update:value="reloadFirst" />
|
||||
<n-select v-model:value="filters.online" :options="onlineOptions" style="width: 120px" @update:value="reloadFirst" />
|
||||
<n-input
|
||||
v-model:value="filters.query"
|
||||
clearable
|
||||
placeholder="编号/名称"
|
||||
style="width: 180px"
|
||||
@keyup.enter="page = 1; load()"
|
||||
@keyup.enter="reloadFirst"
|
||||
/>
|
||||
<n-button @click="page = 1; load()">查询</n-button>
|
||||
<n-button @click="reloadFirst">查询</n-button>
|
||||
<span>
|
||||
来源筛选
|
||||
<HelpTip>admin=后台开通,self=自助注册。</HelpTip>
|
||||
</span>
|
||||
</n-space>
|
||||
</div>
|
||||
<div class="page-table">
|
||||
<n-data-table
|
||||
remote
|
||||
flex-height
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:row-key="(r: Endpoint) => r.id"
|
||||
:checked-row-keys="checkedKeys"
|
||||
:max-height="480"
|
||||
:scroll-x="1200"
|
||||
:scroll-x="1400"
|
||||
:pagination="{
|
||||
page,
|
||||
pageSize,
|
||||
@@ -403,25 +556,39 @@ function onPageSizeChange(s: number) {
|
||||
}"
|
||||
@update:checked-row-keys="(k) => (checkedKeys = k)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</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-alert v-if="createError" type="error" :title="createError" style="margin-bottom: 8px" />
|
||||
<n-form label-placement="left" label-width="110">
|
||||
<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-input v-model:value="createForm.name" data-testid="create-name" placeholder="可选" />
|
||||
</n-form-item>
|
||||
<n-form-item label="登录密码">
|
||||
<n-input v-model:value="createForm.login_password" placeholder="留空则生成" type="password" show-password-on="click" />
|
||||
<n-input v-model:value="createForm.login_password" placeholder="留空则生成" type="password" show-password-on="click" data-testid="create-login-password" />
|
||||
</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>
|
||||
<template #label>
|
||||
默认延迟(秒)
|
||||
<HelpTip>不超过调度上限 {{ maxDelaySeconds }} 秒</HelpTip>
|
||||
</template>
|
||||
<n-input-number
|
||||
v-model:value="createForm.default_delay_seconds"
|
||||
:min="0"
|
||||
:max="maxDelaySeconds"
|
||||
style="width: 100%"
|
||||
data-testid="create-delay"
|
||||
/>
|
||||
<n-text depth="3" data-testid="create-delay-max">上限 {{ maxDelaySeconds }} 秒</n-text>
|
||||
</n-form-item>
|
||||
<n-form-item label="备注" label-placement="top">
|
||||
<n-input v-model:value="createForm.remark" type="textarea" :rows="3" />
|
||||
@@ -440,13 +607,22 @@ function onPageSizeChange(s: number) {
|
||||
<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-input v-model:value="editForm.name" data-testid="edit-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>
|
||||
<template #label>
|
||||
默认延迟(秒)
|
||||
<HelpTip>不超过调度上限 {{ maxDelaySeconds }} 秒</HelpTip>
|
||||
</template>
|
||||
<n-input-number
|
||||
v-model:value="editForm.default_delay_seconds"
|
||||
:min="0"
|
||||
:max="maxDelaySeconds"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="启用">
|
||||
<n-switch v-model:value="editForm.enabled" />
|
||||
<n-switch v-model:value="editForm.enabled" data-testid="edit-enabled" />
|
||||
</n-form-item>
|
||||
<n-form-item label="备注" label-placement="top">
|
||||
<n-input v-model:value="editForm.remark" type="textarea" :rows="3" />
|
||||
@@ -456,7 +632,7 @@ function onPageSizeChange(s: number) {
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="editOpen = false">取消</n-button>
|
||||
<n-button type="primary" :loading="editLoading" @click="submitEdit">保存</n-button>
|
||||
<n-button type="primary" :loading="editLoading" data-testid="edit-submit" @click="submitEdit">保存</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
@@ -464,7 +640,7 @@ function onPageSizeChange(s: number) {
|
||||
<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-input v-model:value="talkPassword" type="password" show-password-on="click" placeholder="4–64 字符,留空表示清除" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
@@ -478,14 +654,38 @@ function onPageSizeChange(s: number) {
|
||||
<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>
|
||||
<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="downloadImportCsv">下载</n-button>
|
||||
<n-button @click="importResult = null">关闭</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal
|
||||
:show="!!importErrors"
|
||||
preset="card"
|
||||
title="导入校验失败"
|
||||
style="width: 560px"
|
||||
@update:show="(v) => !v && (importErrors = null)"
|
||||
>
|
||||
<n-data-table
|
||||
:columns="[
|
||||
{ title: '行号', key: 'line', width: 80 },
|
||||
{ title: '原因', key: 'reason' },
|
||||
]"
|
||||
:data="importErrors || []"
|
||||
:max-height="280"
|
||||
:row-key="(r: { line: number; reason: string }) => String(r.line) + r.reason"
|
||||
data-testid="import-errors"
|
||||
/>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="importErrors = null">关闭</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -498,8 +698,17 @@ function onPageSizeChange(s: number) {
|
||||
}
|
||||
.page-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
}
|
||||
.page-filters {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.page-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { config, mount, flushPromises } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
NConfigProvider,
|
||||
NDialogProvider,
|
||||
NMessageProvider,
|
||||
dateZhCN,
|
||||
zhCN,
|
||||
} from "naive-ui";
|
||||
import { defineComponent, h } from "vue";
|
||||
import GroupsView from "./GroupsView.vue";
|
||||
import { mockApi } from "@/api/mock";
|
||||
import * as admin from "@/api/admin";
|
||||
|
||||
vi.mock("@/api/admin", async () => import("@/api/admin-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("GroupsView", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
mockApi._setSession();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("搜索占位为群名称,无编号输入,详情可翻到第 2 页", async () => {
|
||||
const getSpy = vi.spyOn(admin, "getGroup");
|
||||
const w = mount(wrap(GroupsView), {
|
||||
global: { plugins: [createPinia()] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(w.find('[data-testid="group-query"]').attributes("placeholder") || w.html()).toBeTruthy();
|
||||
expect(w.find('[placeholder="群名称"]').exists() || w.html().includes("群名称")).toBe(true);
|
||||
|
||||
await w.find('[data-testid="group-create-open"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(w.find('[data-testid="group-id"]').exists()).toBe(false);
|
||||
expect(w.text()).toContain("创建后自动分配");
|
||||
|
||||
const detailBtn = w.findAll("button").find((b) => b.text().trim() === "详情");
|
||||
await detailBtn!.trigger("click");
|
||||
await flushPromises();
|
||||
expect(getSpy).toHaveBeenCalled();
|
||||
|
||||
getSpy.mockClear();
|
||||
getSpy.mockImplementation((id, cursor) => mockApi.getGroup(id, cursor, 1));
|
||||
await detailBtn!.trigger("click");
|
||||
await flushPromises();
|
||||
const page2 = w.findAll("li,button").find((el) => el.text().trim() === "2");
|
||||
if (page2) {
|
||||
await page2.trigger("click");
|
||||
await flushPromises();
|
||||
expect(getSpy.mock.calls.some((c) => String(c[1] || "").length > 0)).toBe(true);
|
||||
}
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
+110
-23
@@ -10,9 +10,12 @@ import {
|
||||
NModal,
|
||||
NScrollbar,
|
||||
NSpace,
|
||||
NText,
|
||||
useDialog,
|
||||
} from "naive-ui";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import LoadFailed from "@/components/LoadFailed.vue";
|
||||
import HelpTip from "@/components/HelpTip.vue";
|
||||
import {
|
||||
addGroupMembers,
|
||||
createGroup,
|
||||
@@ -31,6 +34,7 @@ import { message } from "@/utils/notify";
|
||||
|
||||
const dialog = useDialog();
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const rows = ref<GroupSummary[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
@@ -38,21 +42,27 @@ const pageSize = ref(50);
|
||||
const query = ref("");
|
||||
|
||||
const createOpen = ref(false);
|
||||
const createForm = reactive({ id: "", name: "", owner_id: "", member_ids: "" });
|
||||
const createForm = reactive({ name: "", owner_id: "", member_ids: "" });
|
||||
const createLoading = ref(false);
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detailId = ref("");
|
||||
const detail = ref<GroupDetail | null>(null);
|
||||
const memberPage = ref(1);
|
||||
const memberPageSize = ref(50);
|
||||
const renameName = ref("");
|
||||
const addMembers = ref("");
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
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;
|
||||
} catch (e) {
|
||||
loadError.value = e instanceof Error ? e.message : "加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -132,13 +142,38 @@ const memberColumns = computed<DataTableColumns<GroupMember>>(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
detail.value = await getGroup(id);
|
||||
async function loadDetail(id: string) {
|
||||
const cursor = memberPage.value > 1 ? String((memberPage.value - 1) * memberPageSize.value) : "";
|
||||
detail.value = await getGroup(id, cursor, memberPageSize.value);
|
||||
renameName.value = detail.value.name;
|
||||
}
|
||||
|
||||
async function openDetail(id: string) {
|
||||
detailId.value = id;
|
||||
memberPage.value = 1;
|
||||
addMembers.value = "";
|
||||
await loadDetail(id);
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
function failLabel(code: string): string {
|
||||
switch (code) {
|
||||
case "not_found":
|
||||
case "invalid_target":
|
||||
return "端不存在";
|
||||
case "endpoint_disabled":
|
||||
return "端已停用";
|
||||
case "group_full":
|
||||
return "群已满";
|
||||
default:
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
function formatFailed(failed: { id: string; code: string }[]): string {
|
||||
return failed.map((f) => `${f.id}(${failLabel(f.code)})`).join("、");
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!createForm.name.trim() || !createForm.owner_id.trim()) {
|
||||
message.error("名称与群主必填");
|
||||
@@ -151,14 +186,13 @@ async function submitCreate() {
|
||||
.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(", ")}`);
|
||||
message.warning(`已创建,部分成员失败:${formatFailed(res.failed)}`);
|
||||
} else {
|
||||
message.success("已创建");
|
||||
}
|
||||
@@ -186,7 +220,7 @@ async function onRename() {
|
||||
if (!detail.value) return;
|
||||
await renameGroup(detail.value.id, renameName.value.trim());
|
||||
message.success("已改名");
|
||||
await openDetail(detail.value.id);
|
||||
await loadDetail(detail.value.id);
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -199,28 +233,44 @@ async function onAddMembers() {
|
||||
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(", ")}`);
|
||||
message.warning(`部分失败:${formatFailed(res.failed)}`);
|
||||
} else {
|
||||
message.success("已加人");
|
||||
}
|
||||
await openDetail(detail.value.id);
|
||||
await loadDetail(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();
|
||||
dialog.warning({
|
||||
title: "移除成员",
|
||||
content: `确定将 ${endpointId} 移出本群?`,
|
||||
positiveText: "移除",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
await removeGroupMember(detail.value!.id, endpointId);
|
||||
message.success("已移除");
|
||||
await loadDetail(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();
|
||||
dialog.warning({
|
||||
title: "转让群主",
|
||||
content: `确定将群主转让给 ${endpointId}?`,
|
||||
positiveText: "转让",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
await transferGroup(detail.value!.id, endpointId);
|
||||
message.success("已转让群主");
|
||||
await loadDetail(detail.value!.id);
|
||||
await load();
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -232,7 +282,7 @@ async function onTransfer(endpointId: string) {
|
||||
type="primary"
|
||||
data-testid="group-create-open"
|
||||
@click="
|
||||
Object.assign(createForm, { id: '', name: '', owner_id: '', member_ids: '' });
|
||||
Object.assign(createForm, { name: '', owner_id: '', member_ids: '' });
|
||||
createOpen = true;
|
||||
"
|
||||
>
|
||||
@@ -241,17 +291,22 @@ async function onTransfer(endpointId: string) {
|
||||
</template>
|
||||
</PageHeader>
|
||||
<div class="page-body">
|
||||
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
|
||||
<template v-else>
|
||||
<div class="page-filters">
|
||||
<n-space style="margin-bottom: 12px">
|
||||
<n-input v-model:value="query" placeholder="名称/编号" style="width: 200px" @keyup.enter="page = 1; load()" />
|
||||
<n-input v-model:value="query" placeholder="群名称" style="width: 200px" data-testid="group-query" @keyup.enter="page = 1; load()" />
|
||||
<n-button @click="page = 1; load()">查询</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
<div class="page-table">
|
||||
<n-data-table
|
||||
remote
|
||||
flex-height
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:row-key="(r: GroupSummary) => r.id"
|
||||
:max-height="480"
|
||||
:pagination="{
|
||||
page,
|
||||
pageSize,
|
||||
@@ -262,13 +317,19 @@ async function onTransfer(endpointId: string) {
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</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="留空则生成" data-testid="group-id" />
|
||||
<n-form-item>
|
||||
<template #label>
|
||||
编号
|
||||
<HelpTip>由服务器生成,后台不能自定。</HelpTip>
|
||||
</template>
|
||||
<n-text depth="3">创建后自动分配</n-text>
|
||||
</n-form-item>
|
||||
<n-form-item label="名称">
|
||||
<n-input v-model:value="createForm.name" data-testid="group-name" />
|
||||
@@ -315,7 +376,23 @@ async function onTransfer(endpointId: string) {
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-data-table :columns="memberColumns" :data="detail.members" :row-key="(r: GroupMember) => r.id" size="small" />
|
||||
<n-data-table
|
||||
remote
|
||||
:columns="memberColumns"
|
||||
:data="detail.members"
|
||||
:row-key="(r: GroupMember) => r.id"
|
||||
size="small"
|
||||
:max-height="280"
|
||||
:pagination="{
|
||||
page: memberPage,
|
||||
pageSize: memberPageSize,
|
||||
itemCount: detail.member_total ?? detail.members.length,
|
||||
onChange: (p: number) => {
|
||||
memberPage = p;
|
||||
loadDetail(detailId);
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</n-scrollbar>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
@@ -335,7 +412,17 @@ async function onTransfer(endpointId: string) {
|
||||
}
|
||||
.page-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
}
|
||||
.page-filters {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.page-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { config, mount, flushPromises } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
NConfigProvider,
|
||||
NDialogProvider,
|
||||
NMessageProvider,
|
||||
dateZhCN,
|
||||
zhCN,
|
||||
} from "naive-ui";
|
||||
import { defineComponent, h, nextTick } from "vue";
|
||||
import MessagesView from "./MessagesView.vue";
|
||||
import { mockApi } from "@/api/mock";
|
||||
import * as admin from "@/api/admin";
|
||||
|
||||
vi.mock("@/api/admin", async () => import("@/api/admin-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("MessagesView", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
mockApi._setSession();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("查询时传递时间范围,统计含拒绝,详情有原因列并可翻页", async () => {
|
||||
const listSpy = vi.spyOn(admin, "listMessages");
|
||||
const getSpy = vi.spyOn(admin, "getMessage").mockImplementation((seq, cursor) => mockApi.getMessage(seq, cursor, 1));
|
||||
const w = mount(wrap(MessagesView), {
|
||||
global: { plugins: [createPinia()] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(w.text()).toMatch(/已拒绝/);
|
||||
const picker = w.findComponent({ name: "DatePicker" });
|
||||
expect(picker.exists()).toBe(true);
|
||||
picker.vm.$emit("update:value", [1_700_000_000_000, 1_800_000_000_000]);
|
||||
await nextTick();
|
||||
const queryBtn = w.findAll("button").find((b) => b.text().trim() === "查询");
|
||||
await queryBtn!.trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
const last = listSpy.mock.calls.at(-1)?.[0] as { from_ms?: number; to_ms?: number };
|
||||
expect(last.from_ms).toBe(1_700_000_000_000);
|
||||
expect(last.to_ms).toBe(1_800_000_000_000);
|
||||
|
||||
const detailBtn = w.findAll("button").find((b) => b.text().trim() === "详情");
|
||||
await detailBtn!.trigger("click");
|
||||
await flushPromises();
|
||||
expect(w.text()).toContain("原因");
|
||||
expect(w.find('[data-testid="delivery-more"]').exists()).toBe(true);
|
||||
|
||||
getSpy.mockClear();
|
||||
await w.find('[data-testid="delivery-more"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(getSpy).toHaveBeenCalled();
|
||||
expect(getSpy.mock.calls[0][1]).toBeTruthy();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -4,20 +4,23 @@ import type { DataTableColumns } from "naive-ui";
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NDatePicker,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NScrollbar,
|
||||
NSelect,
|
||||
NSpace,
|
||||
} from "naive-ui";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import HelpTip from "@/components/HelpTip.vue";
|
||||
import LoadFailed from "@/components/LoadFailed.vue";
|
||||
import { getMessage, listMessages, type MessageDelivery, type MessageDetail, type MessageSummary } from "@/api/admin";
|
||||
import { formatLocalMs } from "@/utils/time";
|
||||
import { destKindLabel, deliveryStateLabel, messageStateLabel, reasonLabel, zhLabel } from "@/utils/labels";
|
||||
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const rows = ref<MessageSummary[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
@@ -28,9 +31,12 @@ const filters = reactive({
|
||||
group_id: "",
|
||||
state: "" as "" | "scheduled" | "dispatched" | "completed",
|
||||
});
|
||||
const timeRange = ref<[number, number] | null>(null);
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detail = ref<MessageDetail | null>(null);
|
||||
const detailSeq = ref(0);
|
||||
const deliveryLoading = ref(false);
|
||||
|
||||
const stateOptions = [
|
||||
{ label: "全部状态", value: "" },
|
||||
@@ -41,6 +47,7 @@ const stateOptions = [
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
|
||||
const res = await listMessages({
|
||||
@@ -50,9 +57,13 @@ async function load() {
|
||||
endpoint_id: filters.endpoint_id.trim() || undefined,
|
||||
group_id: filters.group_id.trim() || undefined,
|
||||
state: filters.state || undefined,
|
||||
from_ms: timeRange.value?.[0],
|
||||
to_ms: timeRange.value?.[1],
|
||||
});
|
||||
rows.value = res.items;
|
||||
total.value = res.total;
|
||||
} catch (e) {
|
||||
loadError.value = e instanceof Error ? e.message : "加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -70,16 +81,23 @@ const columns = computed<DataTableColumns<MessageSummary>>(() => [
|
||||
title: "目标",
|
||||
key: "dest",
|
||||
width: 140,
|
||||
render: (r) => `${r.dest_kind}:${r.dest_id}`,
|
||||
render: (r) => `${zhLabel(destKindLabel, r.dest_kind)}:${r.dest_id}`,
|
||||
},
|
||||
{ title: "状态", key: "state", width: 100, render: (r) => h("span", { title: r.state }, zhLabel(messageStateLabel, r.state)) },
|
||||
{
|
||||
title: "原因",
|
||||
key: "reason",
|
||||
width: 140,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (r) => h("span", { title: r.reason || "" }, r.reason ? zhLabel(reasonLabel, r.reason) : "—"),
|
||||
},
|
||||
{ title: "状态", key: "state", width: 100 },
|
||||
{
|
||||
title: "投递统计",
|
||||
key: "delivery_counts",
|
||||
width: 200,
|
||||
width: 280,
|
||||
render: (r) => {
|
||||
const c = r.delivery_counts;
|
||||
return `待${c.pending}/收${c.accepted}/撤${c.recalled}/过${c.expired}`;
|
||||
return `待投递${c.pending}/已收下${c.accepted}/已撤回${c.recalled}/已过期${c.expired}/已丢弃${c.dropped}/已拒绝${c.rejected}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -104,7 +122,12 @@ const columns = computed<DataTableColumns<MessageSummary>>(() => [
|
||||
|
||||
const deliveryColumns = computed<DataTableColumns<MessageDelivery>>(() => [
|
||||
{ title: "接收端", key: "endpoint_id" },
|
||||
{ title: "状态", key: "state" },
|
||||
{ title: "状态", key: "state", render: (r) => h("span", { title: r.state }, zhLabel(deliveryStateLabel, r.state)) },
|
||||
{
|
||||
title: "原因",
|
||||
key: "reason",
|
||||
render: (r) => h("span", { title: r.reason || "" }, r.reason ? zhLabel(reasonLabel, r.reason) : "—"),
|
||||
},
|
||||
{ title: "次数", key: "attempts", width: 60 },
|
||||
{
|
||||
title: "推送时间",
|
||||
@@ -119,20 +142,44 @@ const deliveryColumns = computed<DataTableColumns<MessageDelivery>>(() => [
|
||||
]);
|
||||
|
||||
async function openDetail(seq: number) {
|
||||
detail.value = await getMessage(seq);
|
||||
detailSeq.value = seq;
|
||||
detail.value = await getMessage(seq, "", 200);
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
async function loadMoreDeliveries() {
|
||||
if (!detail.value?.next_cursor) return;
|
||||
deliveryLoading.value = true;
|
||||
try {
|
||||
const more = await getMessage(detailSeq.value, detail.value.next_cursor, 200);
|
||||
detail.value = {
|
||||
...more,
|
||||
deliveries: [...detail.value.deliveries, ...more.deliveries],
|
||||
};
|
||||
} finally {
|
||||
deliveryLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader title="投递记录" help="只显示投递痕迹,不显示正文;接口响应也不含 body 字段。" />
|
||||
<div class="page-body">
|
||||
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
|
||||
<template v-else>
|
||||
<div class="page-filters">
|
||||
<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-date-picker
|
||||
v-model:value="timeRange"
|
||||
type="datetimerange"
|
||||
clearable
|
||||
data-testid="msg-time-range"
|
||||
/>
|
||||
<n-button
|
||||
@click="
|
||||
page = 1;
|
||||
@@ -146,14 +193,16 @@ async function openDetail(seq: number) {
|
||||
<HelpTip>页面与网络响应均不得出现消息正文。</HelpTip>
|
||||
</span>
|
||||
</n-space>
|
||||
</div>
|
||||
<div class="page-table">
|
||||
<n-data-table
|
||||
remote
|
||||
flex-height
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:row-key="(r: MessageSummary) => r.seq"
|
||||
:max-height="480"
|
||||
:scroll-x="1100"
|
||||
:scroll-x="1400"
|
||||
:pagination="{
|
||||
page,
|
||||
pageSize,
|
||||
@@ -164,22 +213,34 @@ async function openDetail(seq: number) {
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="detailOpen" preset="card" title="投递详情" style="width: 720px">
|
||||
<n-scrollbar v-if="detail" style="max-height: 420px">
|
||||
<template v-if="detail">
|
||||
<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="目标">{{ destKindLabel[detail.dest_kind] || detail.dest_kind }}:{{ detail.dest_id }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态"><span :title="detail.state">{{ zhLabel(messageStateLabel, detail.state) }}</span></n-descriptions-item>
|
||||
<n-descriptions-item label="原因"><span :title="detail.reason">{{ detail.reason ? zhLabel(reasonLabel, detail.reason) : "—" }}</span></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>
|
||||
<n-data-table :columns="deliveryColumns" :data="detail.deliveries" size="small" :row-key="(r: MessageDelivery) => r.endpoint_id" :max-height="280" />
|
||||
<n-button
|
||||
v-if="detail.next_cursor"
|
||||
:loading="deliveryLoading"
|
||||
data-testid="delivery-more"
|
||||
style="margin-top: 8px"
|
||||
@click="loadMoreDeliveries"
|
||||
>
|
||||
加载更多
|
||||
</n-button>
|
||||
</template>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="detailOpen = false">关闭</n-button>
|
||||
@@ -198,7 +259,17 @@ async function openDetail(seq: number) {
|
||||
}
|
||||
.page-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
}
|
||||
.page-filters {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.page-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { config, mount, flushPromises } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
NConfigProvider,
|
||||
NDialogProvider,
|
||||
NMessageProvider,
|
||||
dateZhCN,
|
||||
zhCN,
|
||||
} from "naive-ui";
|
||||
import { defineComponent, h } from "vue";
|
||||
import OverviewView from "./OverviewView.vue";
|
||||
import * as admin from "@/api/admin";
|
||||
import { mockApi } from "@/api/mock";
|
||||
|
||||
vi.mock("@/api/admin", async () => import("@/api/admin-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("OverviewView", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
mockApi._setSession();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("失败时显示重试,点后重新加载", async () => {
|
||||
const spy = vi.spyOn(admin, "fetchOverview");
|
||||
spy.mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce({
|
||||
version: "0.1.0",
|
||||
endpoints_total: 3,
|
||||
endpoints_online: 1,
|
||||
endpoints_disabled: 1,
|
||||
endpoints_self: 1,
|
||||
groups_total: 1,
|
||||
messages_pending: 0,
|
||||
messages_scheduled: 0,
|
||||
uptime_ms: 1000,
|
||||
});
|
||||
|
||||
const w = mount(wrap(OverviewView), {
|
||||
global: { plugins: [createPinia()] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(w.text()).toContain("加载失败");
|
||||
expect(w.find('[data-testid="load-retry"]').exists()).toBe(true);
|
||||
|
||||
await w.find('[data-testid="load-retry"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(w.text()).toContain("0.1.0");
|
||||
expect(w.text()).toContain("其中自助注册");
|
||||
expect(w.text()).not.toContain("加载失败");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("成功加载后显示自助注册数", async () => {
|
||||
const w = mount(wrap(OverviewView), {
|
||||
global: { plugins: [createPinia()] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
expect(w.text()).toContain("其中自助注册");
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -3,18 +3,29 @@ 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 LoadFailed from "@/components/LoadFailed.vue";
|
||||
import { fetchOverview, type Overview } from "@/api/admin";
|
||||
import { formatLocalMs } from "@/utils/time";
|
||||
import { formatUptime } from "@/utils/labels";
|
||||
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
const data = ref<Overview | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
data.value = await fetchOverview();
|
||||
} catch (e) {
|
||||
loadError.value = e instanceof Error ? e.message : "加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -23,16 +34,18 @@ onMounted(async () => {
|
||||
<PageHeader title="概览" help="汇总当前实例的端、群与未完成投递数量,不含正文与编号明细。" />
|
||||
<div class="page-body">
|
||||
<n-spin :show="loading">
|
||||
<n-descriptions v-if="data" label-placement="left" :column="2" bordered size="small">
|
||||
<LoadFailed v-if="loadError && !data" :description="loadError" @retry="load" />
|
||||
<n-descriptions v-else-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) }} 起)
|
||||
{{ formatUptime(data.uptime_ms) }}(约 {{ 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_self }}</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>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { config, mount, flushPromises } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
NConfigProvider,
|
||||
NDialogProvider,
|
||||
NMessageProvider,
|
||||
dateZhCN,
|
||||
zhCN,
|
||||
} from "naive-ui";
|
||||
import { defineComponent, h } from "vue";
|
||||
import SettingsView from "./SettingsView.vue";
|
||||
import { mockApi } from "@/api/mock";
|
||||
|
||||
vi.mock("@/api/admin", async () => import("@/api/admin-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("SettingsView 改密表单", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
mockApi._setSession();
|
||||
});
|
||||
|
||||
it("新密码不足 12 个字符时就地显示错误", async () => {
|
||||
const w = mount(wrap(SettingsView), {
|
||||
global: { plugins: [createPinia()] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const inputs = w.findAll("input");
|
||||
expect(inputs.length).toBeGreaterThanOrEqual(3);
|
||||
await inputs[0].setValue("old-password");
|
||||
await inputs[1].setValue("short");
|
||||
await inputs[2].setValue("short");
|
||||
await w.findAll("button").find((b) => b.text().includes("修改密码"))!.trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(w.text()).toContain("新密码至少 12 个字符");
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import type { FormInst, FormRules } from "naive-ui";
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
@@ -15,29 +17,66 @@ import {
|
||||
} from "naive-ui";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import HelpTip from "@/components/HelpTip.vue";
|
||||
import LoadFailed from "@/components/LoadFailed.vue";
|
||||
import { changePassword, getSettings, type RuntimeSettings } from "@/api/admin";
|
||||
import { ApiError } from "@/api/http";
|
||||
import { message } from "@/utils/notify";
|
||||
import { limitsLabel } from "@/utils/labels";
|
||||
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
const settings = ref<RuntimeSettings | null>(null);
|
||||
const pwd = reactive({ old_password: "", new_password: "", confirm: "" });
|
||||
const pwdLoading = ref(false);
|
||||
const pwdError = ref("");
|
||||
const formRef = ref<FormInst | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
const rules: FormRules = {
|
||||
new_password: [
|
||||
{
|
||||
validator(_, value: string) {
|
||||
if (Array.from(value || "").length < 12) {
|
||||
return new Error("新密码至少 12 个字符");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
trigger: ["blur", "input"],
|
||||
},
|
||||
],
|
||||
confirm: [
|
||||
{
|
||||
validator() {
|
||||
if (pwd.new_password !== pwd.confirm) {
|
||||
return new Error("两次输入的新密码不一致");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
trigger: ["blur", "input"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
settings.value = await getSettings();
|
||||
} catch (e) {
|
||||
loadError.value = e instanceof Error ? e.message : "加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
async function submitPassword() {
|
||||
if (pwd.new_password.length < 12) {
|
||||
message.error("新密码至少 12 位");
|
||||
return;
|
||||
}
|
||||
if (pwd.new_password !== pwd.confirm) {
|
||||
message.error("两次输入的新密码不一致");
|
||||
pwdError.value = "";
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
pwdLoading.value = true;
|
||||
@@ -47,6 +86,8 @@ async function submitPassword() {
|
||||
pwd.old_password = "";
|
||||
pwd.new_password = "";
|
||||
pwd.confirm = "";
|
||||
} catch (e) {
|
||||
pwdError.value = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "修改失败";
|
||||
} finally {
|
||||
pwdLoading.value = false;
|
||||
}
|
||||
@@ -59,18 +100,26 @@ async function submitPassword() {
|
||||
<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-alert v-if="pwdError" type="error" style="margin-bottom: 12px; max-width: 480px" :title="pwdError" />
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="pwd"
|
||||
:rules="rules"
|
||||
label-placement="left"
|
||||
label-width="100"
|
||||
style="max-width: 480px"
|
||||
>
|
||||
<n-form-item label="原密码" path="old_password">
|
||||
<n-input v-model:value="pwd.old_password" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-form-item path="new_password">
|
||||
<template #label>
|
||||
新密码
|
||||
<HelpTip>至少 12 位,且不能以 nst_ 开头(与端登录密码规则一致的安全要求)。</HelpTip>
|
||||
<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-form-item label="确认新密码" path="confirm">
|
||||
<n-input v-model:value="pwd.confirm" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-space>
|
||||
@@ -80,19 +129,45 @@ async function submitPassword() {
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="settings" tab="运行参数(只读)">
|
||||
<n-spin :show="loading">
|
||||
<template v-if="settings">
|
||||
<LoadFailed v-if="loadError && !settings" :description="loadError" @retry="load" />
|
||||
<template v-else-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-item>
|
||||
<template #label>端监听地址 <HelpTip>listen</HelpTip></template>
|
||||
{{ settings.listen }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item>
|
||||
<template #label>后台监听地址 <HelpTip>admin_listen</HelpTip></template>
|
||||
{{ settings.admin_listen || "(空,与端共用)" }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item>
|
||||
<template #label>会话闲置天数 <HelpTip>session_idle_days</HelpTip></template>
|
||||
{{ settings.session_idle_days }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item>
|
||||
<template #label>记录保留天数 <HelpTip>record_retention_days</HelpTip></template>
|
||||
{{ settings.record_retention_days }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item>
|
||||
<template #label>防重小时数 <HelpTip>idempotency_hours</HelpTip></template>
|
||||
{{ settings.idempotency_hours }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item>
|
||||
<template #label>回执保留天数 <HelpTip>receipt_retention_days</HelpTip></template>
|
||||
{{ settings.receipt_retention_days }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item>
|
||||
<template #label>SQLite 同步模式 <HelpTip>sqlite_synchronous</HelpTip></template>
|
||||
{{ settings.sqlite_synchronous }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-text strong style="display: block; margin-bottom: 8px">limits</n-text>
|
||||
<n-text strong style="display: block; margin-bottom: 8px">限额</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)">
|
||||
<n-descriptions-item v-for="(v, k) in settings.limits" :key="k">
|
||||
<template #label>
|
||||
{{ limitsLabel[String(k)] || k }}
|
||||
<HelpTip>{{ k }}</HelpTip>
|
||||
</template>
|
||||
{{ v }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useDialog,
|
||||
} from "naive-ui";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import LoadFailed from "@/components/LoadFailed.vue";
|
||||
import SecretOnceAlert from "@/components/SecretOnceAlert.vue";
|
||||
import { createToken, deleteToken, listTokens, patchToken, type ApiToken } from "@/api/admin";
|
||||
import { formatLocalMs } from "@/utils/time";
|
||||
@@ -19,6 +20,7 @@ import { message } from "@/utils/notify";
|
||||
|
||||
const dialog = useDialog();
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const rows = ref<ApiToken[]>([]);
|
||||
const createOpen = ref(false);
|
||||
const createName = ref("");
|
||||
@@ -27,9 +29,12 @@ const onceToken = ref<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
const res = await listTokens();
|
||||
rows.value = res.items;
|
||||
} catch (e) {
|
||||
loadError.value = e instanceof Error ? e.message : "加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -136,6 +141,9 @@ function onDelete(r: ApiToken) {
|
||||
</template>
|
||||
</PageHeader>
|
||||
<div class="page-body">
|
||||
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
|
||||
<template v-else>
|
||||
<div class="page-filters">
|
||||
<SecretOnceAlert
|
||||
v-if="onceToken"
|
||||
title="令牌只显示一次"
|
||||
@@ -144,13 +152,17 @@ function onDelete(r: ApiToken) {
|
||||
data-testid="token-once"
|
||||
@dismiss="onceToken = null"
|
||||
/>
|
||||
</div>
|
||||
<div class="page-table">
|
||||
<n-data-table
|
||||
flex-height
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:row-key="(r: ApiToken) => r.id"
|
||||
:max-height="480"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="createOpen" preset="card" title="创建 API 令牌" style="width: 420px">
|
||||
@@ -180,7 +192,17 @@ function onDelete(r: ApiToken) {
|
||||
}
|
||||
.page-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
}
|
||||
.page-filters {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.page-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user