fix: 统一管理后台 401 与加载失败处理
This commit is contained in:
@@ -993,6 +993,16 @@
|
|||||||
- 备选:仅文档要求手跑 e2e;未采用。
|
- 备选:仅文档要求手跑 e2e;未采用。
|
||||||
- 影响:`task itest` 变长;需本机 Go/Node/Chromium。
|
- 影响:`task itest` 变长;需本机 Go/Node/Chromium。
|
||||||
|
|
||||||
|
### 复审修复 W-01
|
||||||
|
|
||||||
|
- 日期:2026-09-30
|
||||||
|
- 原条款:TASKS W1 请求封装;审查 #51;PRD F01 名称可选。
|
||||||
|
- 实际做法:`requestAdmin` 对 401(排除 login/password)清空会话并跳转登录页,提示一次「登录已过期」;`run()` 不再对 `ApiError` 重复 toast;网络错误「无法连接服务器」、429「请稍后再试」。概览/系统/端/群/投递/令牌加载失败显示 `LoadFailed` 与重试。开通名称改为可选;改密用 n-form rules 按字符数校验,后端 `login.go` 同步 `utf8.RuneCountInString`;清除对话密码加确认。
|
||||||
|
- 未改:`RegistrationView.vue` / `registration.go`(U-01 归属;本波指令禁止改注册页)。生成新安全码确认留给身份线。
|
||||||
|
- 原因:会话过期后页面不跳转且错误弹两次;加载失败空白;帮助文字误称 nst_。
|
||||||
|
- 备选方案:改密 401 也自动登出;未采用,与契约「旧密码错误 401」冲突。
|
||||||
|
- 影响:改密失败不会踢当前会话;注册页错误提示仍走原逻辑。
|
||||||
|
|
||||||
### S1.1 传输层可注入假实现(Go / JS)
|
### S1.1 传输层可注入假实现(Go / JS)
|
||||||
|
|
||||||
- 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。
|
- 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"git.asio.asia/nixevol/NixMsg/internal/auth"
|
"git.asio.asia/nixevol/NixMsg/internal/auth"
|
||||||
"git.asio.asia/nixevol/NixMsg/internal/httpx"
|
"git.asio.asia/nixevol/NixMsg/internal/httpx"
|
||||||
@@ -127,7 +128,7 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDecodeError(w, err)
|
writeDecodeError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.NewPassword) < minPasswordLen {
|
if utf8.RuneCountInString(req.NewPassword) < minPasswordLen {
|
||||||
h.audit(actorString(p), "password_change", "", "bad_request", ip)
|
h.audit(actorString(p), "password_change", "", "bad_request", ip)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "新密码至少 12 位")
|
httpx.WriteError(w, http.StatusBadRequest, "bad_request", "新密码至少 12 位")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ async function run<T>(fn: () => Promise<T>, silent = false): Promise<T> {
|
|||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!silent && e instanceof ApiError) {
|
if (!silent && e instanceof Error && !(e instanceof ApiError)) {
|
||||||
message.error(e.message);
|
|
||||||
} else if (!silent && e instanceof Error) {
|
|
||||||
message.error(e.message);
|
message.error(e.message);
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -33,9 +33,7 @@ async function run<T>(fn: () => Promise<T>, silent = false): Promise<T> {
|
|||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!silent && e instanceof ApiError) {
|
if (!silent && e instanceof Error && !(e instanceof ApiError)) {
|
||||||
message.error(e.message);
|
|
||||||
} else if (!silent && e instanceof Error) {
|
|
||||||
message.error(e.message);
|
message.error(e.message);
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -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("请求过于频繁,请稍后再试");
|
||||||
|
});
|
||||||
|
});
|
||||||
+69
-7
@@ -27,9 +27,52 @@ export interface RequestOptions {
|
|||||||
contentType?: string | null;
|
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 显示。
|
* 管理接口请求封装:自动带 credentials 与 X-Nixmsg-Request: 1。
|
||||||
* W4 接真实后端时页面不必改,只需让 admin 模块走本函数。
|
* 错误只在这里提示一次;401(登录/改密除外)清空会话并跳转登录页。
|
||||||
*/
|
*/
|
||||||
export async function requestAdmin<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
export async function requestAdmin<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||||
const method = opts.method ?? (opts.body != null || opts.rawBody != null ? "POST" : "GET");
|
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;
|
headers["Content-Type"] = opts.contentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(path, {
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(path, {
|
||||||
method,
|
method,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers,
|
headers,
|
||||||
body: body ?? undefined,
|
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>;
|
let envelope: ApiEnvelope<T>;
|
||||||
try {
|
try {
|
||||||
envelope = (await res.json()) as ApiEnvelope<T>;
|
envelope = (await res.json()) as ApiEnvelope<T>;
|
||||||
} catch {
|
} catch {
|
||||||
const err = new ApiError("internal", `响应不是 JSON(HTTP ${res.status})`, res.status);
|
const err = new ApiError("internal", `响应不是 JSON(HTTP ${res.status})`, res.status);
|
||||||
if (!opts.silent) {
|
if (!opts.silent && res.status !== 401) {
|
||||||
message.error(err.message);
|
message.error(err.message);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -66,7 +122,7 @@ export async function requestAdmin<T>(path: string, opts: RequestOptions = {}):
|
|||||||
|
|
||||||
if (typeof envelope !== "object" || envelope === null) {
|
if (typeof envelope !== "object" || envelope === null) {
|
||||||
const err = new ApiError("internal", "响应格式无效", res.status);
|
const err = new ApiError("internal", "响应格式无效", res.status);
|
||||||
if (!opts.silent) {
|
if (!opts.silent && res.status !== 401) {
|
||||||
message.error(err.message);
|
message.error(err.message);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -77,8 +133,14 @@ export async function requestAdmin<T>(path: string, opts: RequestOptions = {}):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errBody: ApiErrorBody = envelope.error ?? { code: "internal", message: "未知错误" };
|
const errBody: ApiErrorBody = envelope.error ?? { code: "internal", message: "未知错误" };
|
||||||
const err = new ApiError(errBody.code, errBody.message, res.status, "data" in envelope ? envelope.data : undefined);
|
let display = errBody.message;
|
||||||
if (!opts.silent) {
|
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);
|
message.error(err.message);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -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>
|
||||||
+15
-1
@@ -2,8 +2,22 @@ import { createApp } from "vue";
|
|||||||
import { createPinia } from "pinia";
|
import { createPinia } from "pinia";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import { router } from "./router";
|
import { router } from "./router";
|
||||||
|
import { setUnauthorizedHandler } from "./api/http";
|
||||||
|
import { useAuthStore } from "./stores/auth";
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
app.use(createPinia());
|
const pinia = createPinia();
|
||||||
|
app.use(pinia);
|
||||||
app.use(router);
|
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");
|
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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, h, onMounted, reactive, ref } from "vue";
|
import { computed, h, onMounted, reactive, ref } from "vue";
|
||||||
import type { DataTableColumns, DataTableRowKey } from "naive-ui";
|
import type { DataTableColumns, DataTableRowKey } from "naive-ui";
|
||||||
import {
|
import {
|
||||||
|
NAlert,
|
||||||
NButton,
|
NButton,
|
||||||
NDataTable,
|
NDataTable,
|
||||||
NForm,
|
NForm,
|
||||||
@@ -21,6 +22,7 @@ import type { UploadCustomRequestOptions } from "naive-ui";
|
|||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
import HelpTip from "@/components/HelpTip.vue";
|
import HelpTip from "@/components/HelpTip.vue";
|
||||||
import SecretOnceAlert from "@/components/SecretOnceAlert.vue";
|
import SecretOnceAlert from "@/components/SecretOnceAlert.vue";
|
||||||
|
import LoadFailed from "@/components/LoadFailed.vue";
|
||||||
import {
|
import {
|
||||||
batchEndpoints,
|
batchEndpoints,
|
||||||
createEndpoint,
|
createEndpoint,
|
||||||
@@ -41,6 +43,7 @@ import { message } from "@/utils/notify";
|
|||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const loadError = ref("");
|
||||||
const rows = ref<Endpoint[]>([]);
|
const rows = ref<Endpoint[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const checkedKeys = ref<DataTableRowKey[]>([]);
|
const checkedKeys = ref<DataTableRowKey[]>([]);
|
||||||
@@ -77,6 +80,7 @@ const createForm = reactive({
|
|||||||
default_delay_seconds: 0,
|
default_delay_seconds: 0,
|
||||||
});
|
});
|
||||||
const createLoading = ref(false);
|
const createLoading = ref(false);
|
||||||
|
const createError = ref("");
|
||||||
|
|
||||||
const editOpen = ref(false);
|
const editOpen = ref(false);
|
||||||
const editId = ref("");
|
const editId = ref("");
|
||||||
@@ -94,6 +98,7 @@ const talkPassword = ref("");
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
loadError.value = "";
|
||||||
try {
|
try {
|
||||||
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
|
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
|
||||||
const res = await listEndpoints({
|
const res = await listEndpoints({
|
||||||
@@ -105,6 +110,8 @@ async function load() {
|
|||||||
});
|
});
|
||||||
rows.value = res.items;
|
rows.value = res.items;
|
||||||
total.value = res.total;
|
total.value = res.total;
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e instanceof Error ? e.message : "加载失败";
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -187,14 +194,12 @@ function openCreate() {
|
|||||||
talk_password: "",
|
talk_password: "",
|
||||||
default_delay_seconds: 0,
|
default_delay_seconds: 0,
|
||||||
});
|
});
|
||||||
|
createError.value = "";
|
||||||
createOpen.value = true;
|
createOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
if (!createForm.name.trim()) {
|
createError.value = "";
|
||||||
message.error("名称不能为空");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createLoading.value = true;
|
createLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await createEndpoint({
|
const res = await createEndpoint({
|
||||||
@@ -212,6 +217,8 @@ async function submitCreate() {
|
|||||||
filename: `${res.id}-password.txt`,
|
filename: `${res.id}-password.txt`,
|
||||||
};
|
};
|
||||||
await load();
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
createError.value = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "开通失败";
|
||||||
} finally {
|
} finally {
|
||||||
createLoading.value = false;
|
createLoading.value = false;
|
||||||
}
|
}
|
||||||
@@ -252,10 +259,23 @@ function openTalk(r: Endpoint) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitTalk() {
|
async function submitTalk() {
|
||||||
|
const save = async () => {
|
||||||
await setTalkPassword(talkId.value, talkPassword.value);
|
await setTalkPassword(talkId.value, talkPassword.value);
|
||||||
talkOpen.value = false;
|
talkOpen.value = false;
|
||||||
message.success(talkPassword.value ? "已设置对话密码" : "已清除对话密码");
|
message.success(talkPassword.value ? "已设置对话密码" : "已清除对话密码");
|
||||||
await load();
|
await load();
|
||||||
|
};
|
||||||
|
if (!talkPassword.value) {
|
||||||
|
dialog.warning({
|
||||||
|
title: "清除对话密码",
|
||||||
|
content: "留空保存将清除该端的对话密码,确定继续?",
|
||||||
|
positiveText: "清除",
|
||||||
|
negativeText: "取消",
|
||||||
|
onPositiveClick: () => save(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await save();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onResetPwd(r: Endpoint) {
|
async function onResetPwd(r: Endpoint) {
|
||||||
@@ -360,6 +380,8 @@ function onPageSizeChange(s: number) {
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div class="page-body">
|
<div class="page-body">
|
||||||
|
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
|
||||||
|
<template v-else>
|
||||||
<SecretOnceAlert
|
<SecretOnceAlert
|
||||||
v-if="onceSecret"
|
v-if="onceSecret"
|
||||||
:title="onceSecret.title"
|
:title="onceSecret.title"
|
||||||
@@ -403,16 +425,18 @@ function onPageSizeChange(s: number) {
|
|||||||
}"
|
}"
|
||||||
@update:checked-row-keys="(k) => (checkedKeys = k)"
|
@update:checked-row-keys="(k) => (checkedKeys = k)"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-modal v-model:show="createOpen" preset="card" title="开通端" style="width: 520px" :mask-closable="false">
|
<n-modal v-model:show="createOpen" preset="card" title="开通端" style="width: 520px" :mask-closable="false">
|
||||||
<n-scrollbar style="max-height: 360px">
|
<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-form-item label="编号">
|
||||||
<n-input v-model:value="createForm.id" placeholder="留空则生成" data-testid="create-id" />
|
<n-input v-model:value="createForm.id" placeholder="留空则生成" data-testid="create-id" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="名称">
|
<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>
|
||||||
<n-form-item label="登录密码">
|
<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" />
|
||||||
@@ -464,7 +488,7 @@ function onPageSizeChange(s: number) {
|
|||||||
<n-modal v-model:show="talkOpen" preset="card" title="设置对话密码" style="width: 420px">
|
<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 label-placement="left" label-width="90" :show-feedback="false">
|
||||||
<n-form-item label="对话密码">
|
<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-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
useDialog,
|
useDialog,
|
||||||
} from "naive-ui";
|
} from "naive-ui";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import LoadFailed from "@/components/LoadFailed.vue";
|
||||||
import {
|
import {
|
||||||
addGroupMembers,
|
addGroupMembers,
|
||||||
createGroup,
|
createGroup,
|
||||||
@@ -31,6 +32,7 @@ import { message } from "@/utils/notify";
|
|||||||
|
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const loadError = ref("");
|
||||||
const rows = ref<GroupSummary[]>([]);
|
const rows = ref<GroupSummary[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
@@ -48,11 +50,14 @@ const addMembers = ref("");
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
loadError.value = "";
|
||||||
try {
|
try {
|
||||||
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
|
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
|
||||||
const res = await listGroups(query.value.trim() || undefined, cursor, pageSize.value);
|
const res = await listGroups(query.value.trim() || undefined, cursor, pageSize.value);
|
||||||
rows.value = res.items;
|
rows.value = res.items;
|
||||||
total.value = res.total;
|
total.value = res.total;
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e instanceof Error ? e.message : "加载失败";
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -241,6 +246,8 @@ async function onTransfer(endpointId: string) {
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div class="page-body">
|
<div class="page-body">
|
||||||
|
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
|
||||||
|
<template v-else>
|
||||||
<n-space style="margin-bottom: 12px">
|
<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" @keyup.enter="page = 1; load()" />
|
||||||
<n-button @click="page = 1; load()">查询</n-button>
|
<n-button @click="page = 1; load()">查询</n-button>
|
||||||
@@ -262,6 +269,7 @@ async function onTransfer(endpointId: string) {
|
|||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-modal v-model:show="createOpen" preset="card" title="新建群" style="width: 480px">
|
<n-modal v-model:show="createOpen" preset="card" title="新建群" style="width: 480px">
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import {
|
|||||||
} from "naive-ui";
|
} from "naive-ui";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
import HelpTip from "@/components/HelpTip.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 { getMessage, listMessages, type MessageDelivery, type MessageDetail, type MessageSummary } from "@/api/admin";
|
||||||
import { formatLocalMs } from "@/utils/time";
|
import { formatLocalMs } from "@/utils/time";
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const loadError = ref("");
|
||||||
const rows = ref<MessageSummary[]>([]);
|
const rows = ref<MessageSummary[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
@@ -41,6 +43,7 @@ const stateOptions = [
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
loadError.value = "";
|
||||||
try {
|
try {
|
||||||
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
|
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
|
||||||
const res = await listMessages({
|
const res = await listMessages({
|
||||||
@@ -53,6 +56,8 @@ async function load() {
|
|||||||
});
|
});
|
||||||
rows.value = res.items;
|
rows.value = res.items;
|
||||||
total.value = res.total;
|
total.value = res.total;
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e instanceof Error ? e.message : "加载失败";
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -128,6 +133,8 @@ async function openDetail(seq: number) {
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<PageHeader title="投递记录" help="只显示投递痕迹,不显示正文;接口响应也不含 body 字段。" />
|
<PageHeader title="投递记录" help="只显示投递痕迹,不显示正文;接口响应也不含 body 字段。" />
|
||||||
<div class="page-body">
|
<div class="page-body">
|
||||||
|
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
|
||||||
|
<template v-else>
|
||||||
<n-space style="margin-bottom: 12px" align="center">
|
<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.sender_id" placeholder="发送方" style="width: 120px" />
|
||||||
<n-input v-model:value="filters.endpoint_id" placeholder="接收端" style="width: 120px" />
|
<n-input v-model:value="filters.endpoint_id" placeholder="接收端" style="width: 120px" />
|
||||||
@@ -164,6 +171,7 @@ async function openDetail(seq: number) {
|
|||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-modal v-model:show="detailOpen" preset="card" title="投递详情" style="width: 720px">
|
<n-modal v-model:show="detailOpen" preset="card" title="投递详情" style="width: 720px">
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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()).not.toContain("加载失败");
|
||||||
|
w.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,18 +3,28 @@ import { onMounted, ref } from "vue";
|
|||||||
import { NDescriptions, NDescriptionsItem, NSpin } from "naive-ui";
|
import { NDescriptions, NDescriptionsItem, NSpin } from "naive-ui";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
import HelpTip from "@/components/HelpTip.vue";
|
import HelpTip from "@/components/HelpTip.vue";
|
||||||
|
import LoadFailed from "@/components/LoadFailed.vue";
|
||||||
import { fetchOverview, type Overview } from "@/api/admin";
|
import { fetchOverview, type Overview } from "@/api/admin";
|
||||||
import { formatLocalMs } from "@/utils/time";
|
import { formatLocalMs } from "@/utils/time";
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const loadError = ref("");
|
||||||
const data = ref<Overview | null>(null);
|
const data = ref<Overview | null>(null);
|
||||||
|
|
||||||
onMounted(async () => {
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
loadError.value = "";
|
||||||
try {
|
try {
|
||||||
data.value = await fetchOverview();
|
data.value = await fetchOverview();
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e instanceof Error ? e.message : "加载失败";
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -23,7 +33,8 @@ onMounted(async () => {
|
|||||||
<PageHeader title="概览" help="汇总当前实例的端、群与未完成投递数量,不含正文与编号明细。" />
|
<PageHeader title="概览" help="汇总当前实例的端、群与未完成投递数量,不含正文与编号明细。" />
|
||||||
<div class="page-body">
|
<div class="page-body">
|
||||||
<n-spin :show="loading">
|
<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 label="版本">{{ data.version }}</n-descriptions-item>
|
||||||
<n-descriptions-item>
|
<n-descriptions-item>
|
||||||
<template #label>
|
<template #label>
|
||||||
|
|||||||
@@ -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">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
import type { FormInst, FormRules } from "naive-ui";
|
||||||
import {
|
import {
|
||||||
|
NAlert,
|
||||||
NButton,
|
NButton,
|
||||||
NDescriptions,
|
NDescriptions,
|
||||||
NDescriptionsItem,
|
NDescriptionsItem,
|
||||||
@@ -15,29 +17,65 @@ import {
|
|||||||
} from "naive-ui";
|
} from "naive-ui";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
import HelpTip from "@/components/HelpTip.vue";
|
import HelpTip from "@/components/HelpTip.vue";
|
||||||
|
import LoadFailed from "@/components/LoadFailed.vue";
|
||||||
import { changePassword, getSettings, type RuntimeSettings } from "@/api/admin";
|
import { changePassword, getSettings, type RuntimeSettings } from "@/api/admin";
|
||||||
|
import { ApiError } from "@/api/http";
|
||||||
import { message } from "@/utils/notify";
|
import { message } from "@/utils/notify";
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const loadError = ref("");
|
||||||
const settings = ref<RuntimeSettings | null>(null);
|
const settings = ref<RuntimeSettings | null>(null);
|
||||||
const pwd = reactive({ old_password: "", new_password: "", confirm: "" });
|
const pwd = reactive({ old_password: "", new_password: "", confirm: "" });
|
||||||
const pwdLoading = ref(false);
|
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 {
|
try {
|
||||||
settings.value = await getSettings();
|
settings.value = await getSettings();
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e instanceof Error ? e.message : "加载失败";
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function submitPassword() {
|
async function submitPassword() {
|
||||||
if (pwd.new_password.length < 12) {
|
pwdError.value = "";
|
||||||
message.error("新密码至少 12 位");
|
try {
|
||||||
return;
|
await formRef.value?.validate();
|
||||||
}
|
} catch {
|
||||||
if (pwd.new_password !== pwd.confirm) {
|
|
||||||
message.error("两次输入的新密码不一致");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pwdLoading.value = true;
|
pwdLoading.value = true;
|
||||||
@@ -47,6 +85,8 @@ async function submitPassword() {
|
|||||||
pwd.old_password = "";
|
pwd.old_password = "";
|
||||||
pwd.new_password = "";
|
pwd.new_password = "";
|
||||||
pwd.confirm = "";
|
pwd.confirm = "";
|
||||||
|
} catch (e) {
|
||||||
|
pwdError.value = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "修改失败";
|
||||||
} finally {
|
} finally {
|
||||||
pwdLoading.value = false;
|
pwdLoading.value = false;
|
||||||
}
|
}
|
||||||
@@ -59,18 +99,26 @@ async function submitPassword() {
|
|||||||
<div class="page-body">
|
<div class="page-body">
|
||||||
<n-tabs type="line" animated>
|
<n-tabs type="line" animated>
|
||||||
<n-tab-pane name="password" tab="管理员密码">
|
<n-tab-pane name="password" tab="管理员密码">
|
||||||
<n-form label-placement="left" label-width="100" :show-feedback="false" style="max-width: 480px">
|
<n-alert v-if="pwdError" type="error" style="margin-bottom: 12px; max-width: 480px" :title="pwdError" />
|
||||||
<n-form-item label="原密码">
|
<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-input v-model:value="pwd.old_password" type="password" show-password-on="click" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item>
|
<n-form-item path="new_password">
|
||||||
<template #label>
|
<template #label>
|
||||||
新密码
|
新密码
|
||||||
<HelpTip>至少 12 位,且不能以 nst_ 开头(与端登录密码规则一致的安全要求)。</HelpTip>
|
<HelpTip>按字符数计至少 12 位。后端不检查 nst_ 前缀(那是端登录密码规则)。</HelpTip>
|
||||||
</template>
|
</template>
|
||||||
<n-input v-model:value="pwd.new_password" type="password" show-password-on="click" />
|
<n-input v-model:value="pwd.new_password" type="password" show-password-on="click" />
|
||||||
</n-form-item>
|
</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-input v-model:value="pwd.confirm" type="password" show-password-on="click" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-space>
|
<n-space>
|
||||||
@@ -80,7 +128,8 @@ async function submitPassword() {
|
|||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="settings" tab="运行参数(只读)">
|
<n-tab-pane name="settings" tab="运行参数(只读)">
|
||||||
<n-spin :show="loading">
|
<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 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="listen">{{ settings.listen }}</n-descriptions-item>
|
||||||
<n-descriptions-item label="admin_listen">{{ settings.admin_listen || "(空,与端共用)" }}</n-descriptions-item>
|
<n-descriptions-item label="admin_listen">{{ settings.admin_listen || "(空,与端共用)" }}</n-descriptions-item>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
useDialog,
|
useDialog,
|
||||||
} from "naive-ui";
|
} from "naive-ui";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import LoadFailed from "@/components/LoadFailed.vue";
|
||||||
import SecretOnceAlert from "@/components/SecretOnceAlert.vue";
|
import SecretOnceAlert from "@/components/SecretOnceAlert.vue";
|
||||||
import { createToken, deleteToken, listTokens, patchToken, type ApiToken } from "@/api/admin";
|
import { createToken, deleteToken, listTokens, patchToken, type ApiToken } from "@/api/admin";
|
||||||
import { formatLocalMs } from "@/utils/time";
|
import { formatLocalMs } from "@/utils/time";
|
||||||
@@ -19,6 +20,7 @@ import { message } from "@/utils/notify";
|
|||||||
|
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const loadError = ref("");
|
||||||
const rows = ref<ApiToken[]>([]);
|
const rows = ref<ApiToken[]>([]);
|
||||||
const createOpen = ref(false);
|
const createOpen = ref(false);
|
||||||
const createName = ref("");
|
const createName = ref("");
|
||||||
@@ -27,9 +29,12 @@ const onceToken = ref<string | null>(null);
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
loadError.value = "";
|
||||||
try {
|
try {
|
||||||
const res = await listTokens();
|
const res = await listTokens();
|
||||||
rows.value = res.items;
|
rows.value = res.items;
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e instanceof Error ? e.message : "加载失败";
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -136,6 +141,8 @@ function onDelete(r: ApiToken) {
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div class="page-body">
|
<div class="page-body">
|
||||||
|
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
|
||||||
|
<template v-else>
|
||||||
<SecretOnceAlert
|
<SecretOnceAlert
|
||||||
v-if="onceToken"
|
v-if="onceToken"
|
||||||
title="令牌只显示一次"
|
title="令牌只显示一次"
|
||||||
@@ -151,6 +158,7 @@ function onDelete(r: ApiToken) {
|
|||||||
:row-key="(r: ApiToken) => r.id"
|
:row-key="(r: ApiToken) => r.id"
|
||||||
:max-height="480"
|
:max-height="480"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-modal v-model:show="createOpen" preset="card" title="创建 API 令牌" style="width: 420px">
|
<n-modal v-model:show="createOpen" preset="card" title="创建 API 令牌" style="width: 420px">
|
||||||
|
|||||||
Reference in New Issue
Block a user