fix: 修复端列表解锁、批量选择与编辑提交
This commit is contained in:
@@ -1012,6 +1012,15 @@
|
|||||||
- 备选方案:密码列也加公式前缀;未采用,避免改写密码。
|
- 备选方案:密码列也加公式前缀;未采用,避免改写密码。
|
||||||
- 影响:导入下载文件可被 Excel 正确打开。
|
- 影响:导入下载文件可被 Excel 正确打开。
|
||||||
|
|
||||||
|
### 复审修复 W-05
|
||||||
|
|
||||||
|
- 日期:2026-09-30
|
||||||
|
- 原条款:PRD F01/F17 解锁与批量清理;审查 #55;H-03/H-04/H-05 界面部分。
|
||||||
|
- 实际做法:解锁始终可点,锁定列 HelpTip 说明只反映编号锁。筛选/翻页清空选择;批量确认列出前 20 个编号,删除说明级联,结果列出失败项。编辑只提交变更字段;启用改停用弹确认。导入中禁用按钮并显示预估秒数,校验错误用可滚动表格。默认延迟 `:max` 取自 `limits.max_schedule_seconds`。
|
||||||
|
- 原因:仅 IP 锁定时无法解锁;跨页多选会误删;PATCH 总带 enabled 可能误停用。
|
||||||
|
- 备选方案:扩展 LoginLocks 查询任一锁定;留给 P 线。
|
||||||
|
- 影响:管理员可解除 IP 锁定;停用需二次确认。
|
||||||
|
|
||||||
### S1.1 传输层可注入假实现(Go / JS)
|
### S1.1 传输层可注入假实现(Go / JS)
|
||||||
|
|
||||||
- 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。
|
- 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { defineComponent, h, nextTick } from "vue";
|
import { defineComponent, h, nextTick } from "vue";
|
||||||
import EndpointsView from "./EndpointsView.vue";
|
import EndpointsView from "./EndpointsView.vue";
|
||||||
import { mockApi } from "@/api/mock";
|
import { mockApi } from "@/api/mock";
|
||||||
|
import * as admin from "@/api/admin";
|
||||||
|
|
||||||
vi.mock("@/api/admin", async () => import("@/api/admin-mock"));
|
vi.mock("@/api/admin", async () => import("@/api/admin-mock"));
|
||||||
|
|
||||||
@@ -94,3 +95,82 @@ describe("EndpointsView 开通端", () => {
|
|||||||
w.unmount();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+179
-34
@@ -26,6 +26,7 @@ import LoadFailed from "@/components/LoadFailed.vue";
|
|||||||
import {
|
import {
|
||||||
batchEndpoints,
|
batchEndpoints,
|
||||||
createEndpoint,
|
createEndpoint,
|
||||||
|
getSettings,
|
||||||
importEndpoints,
|
importEndpoints,
|
||||||
kickEndpoint,
|
kickEndpoint,
|
||||||
listEndpoints,
|
listEndpoints,
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
setTalkPassword,
|
setTalkPassword,
|
||||||
unlockEndpoint,
|
unlockEndpoint,
|
||||||
type Endpoint,
|
type Endpoint,
|
||||||
|
type EndpointPatchRequest,
|
||||||
type ImportItem,
|
type ImportItem,
|
||||||
} from "@/api/admin";
|
} from "@/api/admin";
|
||||||
import { ApiError } from "@/api/http";
|
import { ApiError } from "@/api/http";
|
||||||
@@ -70,6 +72,10 @@ const onlineOptions = [
|
|||||||
|
|
||||||
const onceSecret = ref<{ title: string; secret: string; filename: string; mime?: string } | null>(null);
|
const onceSecret = ref<{ title: string; secret: string; filename: string; mime?: string } | null>(null);
|
||||||
const importResult = ref<ImportItem[] | 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 createOpen = ref(false);
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
@@ -92,6 +98,12 @@ const editForm = reactive({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
const editLoading = ref(false);
|
const editLoading = ref(false);
|
||||||
|
const editOrig = reactive({
|
||||||
|
name: "",
|
||||||
|
remark: "",
|
||||||
|
default_delay_seconds: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
const talkOpen = ref(false);
|
const talkOpen = ref(false);
|
||||||
const talkId = ref("");
|
const talkId = ref("");
|
||||||
@@ -120,8 +132,21 @@ async function load() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void load();
|
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) {
|
function sourceLabel(s: string) {
|
||||||
return s === "self" ? "自助注册" : "后台开通";
|
return s === "self" ? "自助注册" : "后台开通";
|
||||||
}
|
}
|
||||||
@@ -149,9 +174,16 @@ const columns = computed<DataTableColumns<Endpoint>>(() => [
|
|||||||
render: (r) => (r.enabled ? "是" : "否"),
|
render: (r) => (r.enabled ? "是" : "否"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "锁定",
|
title() {
|
||||||
|
return h("span", [
|
||||||
|
"锁定",
|
||||||
|
h(HelpTip, null, {
|
||||||
|
default: () => "列表只显示按编号的总数锁定,解锁会同时清除按 IP 的锁定",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
},
|
||||||
key: "login_locked",
|
key: "login_locked",
|
||||||
width: 70,
|
width: 90,
|
||||||
render: (r) => (r.login_locked ? "是" : "否"),
|
render: (r) => (r.login_locked ? "是" : "否"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -178,7 +210,11 @@ const columns = computed<DataTableColumns<Endpoint>>(() => [
|
|||||||
h(NButton, { size: "tiny", onClick: () => openTalk(r) }, () => "对话密码"),
|
h(NButton, { size: "tiny", onClick: () => openTalk(r) }, () => "对话密码"),
|
||||||
h(
|
h(
|
||||||
NButton,
|
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) }, () => "踢下线"),
|
h(NButton, { size: "tiny", onClick: () => void onKick(r) }, () => "踢下线"),
|
||||||
@@ -201,6 +237,10 @@ function openCreate() {
|
|||||||
|
|
||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
createError.value = "";
|
createError.value = "";
|
||||||
|
if ((createForm.default_delay_seconds ?? 0) > maxDelaySeconds.value) {
|
||||||
|
createError.value = `默认延迟不能超过 ${maxDelaySeconds.value} 秒`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
createLoading.value = true;
|
createLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await createEndpoint({
|
const res = await createEndpoint({
|
||||||
@@ -237,24 +277,51 @@ function openEdit(r: Endpoint) {
|
|||||||
default_delay_seconds: Math.round(r.default_delay_ms / 1000),
|
default_delay_seconds: Math.round(r.default_delay_ms / 1000),
|
||||||
enabled: r.enabled,
|
enabled: r.enabled,
|
||||||
});
|
});
|
||||||
|
Object.assign(editOrig, editForm);
|
||||||
editOpen.value = true;
|
editOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitEdit() {
|
function buildEditPatch(): EndpointPatchRequest {
|
||||||
editLoading.value = true;
|
const body: EndpointPatchRequest = {};
|
||||||
try {
|
if (editForm.name !== editOrig.name) body.name = editForm.name;
|
||||||
await patchEndpoint(editId.value, {
|
if (editForm.remark !== editOrig.remark) body.remark = editForm.remark;
|
||||||
name: editForm.name,
|
if (editForm.default_delay_seconds !== editOrig.default_delay_seconds) {
|
||||||
remark: editForm.remark,
|
body.default_delay_seconds = editForm.default_delay_seconds;
|
||||||
default_delay_seconds: editForm.default_delay_seconds,
|
|
||||||
enabled: editForm.enabled,
|
|
||||||
});
|
|
||||||
editOpen.value = false;
|
|
||||||
message.success("已保存");
|
|
||||||
await load();
|
|
||||||
} finally {
|
|
||||||
editLoading.value = false;
|
|
||||||
}
|
}
|
||||||
|
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) {
|
function openTalk(r: Endpoint) {
|
||||||
@@ -318,14 +385,28 @@ async function onBatch(action: "disable" | "delete") {
|
|||||||
message.warning("请先多选");
|
message.warning("请先多选");
|
||||||
return;
|
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({
|
dialog.warning({
|
||||||
title: action === "disable" ? "批量停用" : "批量删除",
|
title: action === "disable" ? "批量停用" : "批量删除",
|
||||||
content: `对 ${ids.length} 个端执行${action === "disable" ? "停用" : "删除"}?`,
|
content,
|
||||||
positiveText: "确定",
|
positiveText: "确定",
|
||||||
negativeText: "取消",
|
negativeText: "取消",
|
||||||
onPositiveClick: async () => {
|
onPositiveClick: async () => {
|
||||||
const res = await batchEndpoints(ids, action);
|
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 = [];
|
checkedKeys.value = [];
|
||||||
await load();
|
await load();
|
||||||
},
|
},
|
||||||
@@ -338,8 +419,13 @@ async function onImport(options: UploadCustomRequestOptions) {
|
|||||||
options.onError();
|
options.onError();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
importing.value = true;
|
||||||
|
importErrors.value = null;
|
||||||
try {
|
try {
|
||||||
const text = await raw.text();
|
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);
|
const res = await importEndpoints(text);
|
||||||
importResult.value = res.items;
|
importResult.value = res.items;
|
||||||
const pwdRows = res.items.filter((i): i is ImportItem & { login_password: string } => Boolean(i.login_password));
|
const pwdRows = res.items.filter((i): i is ImportItem & { login_password: string } => Boolean(i.login_password));
|
||||||
@@ -356,11 +442,12 @@ async function onImport(options: UploadCustomRequestOptions) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
options.onError();
|
options.onError();
|
||||||
if (e instanceof ApiError && e.data && typeof e.data === "object" && "errors" in (e.data as object)) {
|
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;
|
importErrors.value = (e.data as { errors: { line: number; reason: string }[] }).errors;
|
||||||
message.error(errors.map((x) => `第 ${x.line} 行:${x.reason}`).join(";") || e.message);
|
|
||||||
} else if (e instanceof Error) {
|
} else if (e instanceof Error) {
|
||||||
message.error(e.message);
|
message.error(e.message);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
importing.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,12 +461,20 @@ function downloadImportCsv() {
|
|||||||
downloadText("import-passwords.csv", buildPasswordCsv(pwdRows), "text/csv;charset=utf-8");
|
downloadText("import-passwords.csv", buildPasswordCsv(pwdRows), "text/csv;charset=utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reloadFirst() {
|
||||||
|
checkedKeys.value = [];
|
||||||
|
page.value = 1;
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
|
||||||
function onPageChange(p: number) {
|
function onPageChange(p: number) {
|
||||||
|
checkedKeys.value = [];
|
||||||
page.value = p;
|
page.value = p;
|
||||||
void load();
|
void load();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPageSizeChange(s: number) {
|
function onPageSizeChange(s: number) {
|
||||||
|
checkedKeys.value = [];
|
||||||
pageSize.value = s;
|
pageSize.value = s;
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
void load();
|
void load();
|
||||||
@@ -391,8 +486,8 @@ function onPageSizeChange(s: number) {
|
|||||||
<PageHeader title="端" help="可按来源与在线筛选;开通/重置/导入生成的密码只显示一次。">
|
<PageHeader title="端" help="可按来源与在线筛选;开通/重置/导入生成的密码只显示一次。">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<n-button type="primary" @click="openCreate">开通</n-button>
|
<n-button type="primary" @click="openCreate">开通</n-button>
|
||||||
<n-upload :show-file-list="false" accept=".csv,text/csv" :custom-request="onImport">
|
<n-upload :show-file-list="false" accept=".csv,text/csv" :disabled="importing" :custom-request="onImport">
|
||||||
<n-button>CSV 导入</n-button>
|
<n-button :disabled="importing" data-testid="endpoint-import">CSV 导入</n-button>
|
||||||
</n-upload>
|
</n-upload>
|
||||||
<n-button @click="onBatch('disable')">批量停用</n-button>
|
<n-button @click="onBatch('disable')">批量停用</n-button>
|
||||||
<n-button type="error" secondary @click="onBatch('delete')">批量删除</n-button>
|
<n-button type="error" secondary @click="onBatch('delete')">批量删除</n-button>
|
||||||
@@ -409,17 +504,24 @@ function onPageSizeChange(s: number) {
|
|||||||
:mime="onceSecret.mime"
|
:mime="onceSecret.mime"
|
||||||
@dismiss="onceSecret = null"
|
@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-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.source" :options="sourceOptions" style="width: 140px" @update:value="reloadFirst" />
|
||||||
<n-select v-model:value="filters.online" :options="onlineOptions" style="width: 120px" @update:value="page = 1; load()" />
|
<n-select v-model:value="filters.online" :options="onlineOptions" style="width: 120px" @update:value="reloadFirst" />
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="filters.query"
|
v-model:value="filters.query"
|
||||||
clearable
|
clearable
|
||||||
placeholder="编号/名称"
|
placeholder="编号/名称"
|
||||||
style="width: 180px"
|
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>
|
<span>
|
||||||
来源筛选
|
来源筛选
|
||||||
<HelpTip>admin=后台开通,self=自助注册。</HelpTip>
|
<HelpTip>admin=后台开通,self=自助注册。</HelpTip>
|
||||||
@@ -464,8 +566,19 @@ function onPageSizeChange(s: number) {
|
|||||||
<n-form-item label="对话密码">
|
<n-form-item label="对话密码">
|
||||||
<n-input v-model:value="createForm.talk_password" placeholder="可选" type="password" show-password-on="click" />
|
<n-input v-model:value="createForm.talk_password" placeholder="可选" type="password" show-password-on="click" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="默认延迟(秒)">
|
<n-form-item>
|
||||||
<n-input-number v-model:value="createForm.default_delay_seconds" :min="0" style="width: 100%" />
|
<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>
|
||||||
<n-form-item label="备注" label-placement="top">
|
<n-form-item label="备注" label-placement="top">
|
||||||
<n-input v-model:value="createForm.remark" type="textarea" :rows="3" />
|
<n-input v-model:value="createForm.remark" type="textarea" :rows="3" />
|
||||||
@@ -484,13 +597,22 @@ function onPageSizeChange(s: number) {
|
|||||||
<n-scrollbar style="max-height: 320px">
|
<n-scrollbar style="max-height: 320px">
|
||||||
<n-form label-placement="left" label-width="110" :show-feedback="false">
|
<n-form label-placement="left" label-width="110" :show-feedback="false">
|
||||||
<n-form-item label="名称">
|
<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>
|
||||||
<n-form-item label="默认延迟(秒)">
|
<n-form-item>
|
||||||
<n-input-number v-model:value="editForm.default_delay_seconds" :min="0" style="width: 100%" />
|
<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>
|
||||||
<n-form-item label="启用">
|
<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>
|
||||||
<n-form-item label="备注" label-placement="top">
|
<n-form-item label="备注" label-placement="top">
|
||||||
<n-input v-model:value="editForm.remark" type="textarea" :rows="3" />
|
<n-input v-model:value="editForm.remark" type="textarea" :rows="3" />
|
||||||
@@ -500,7 +622,7 @@ function onPageSizeChange(s: number) {
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<n-space justify="end">
|
<n-space justify="end">
|
||||||
<n-button @click="editOpen = false">取消</n-button>
|
<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>
|
</n-space>
|
||||||
</template>
|
</template>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
@@ -531,6 +653,29 @@ function onPageSizeChange(s: number) {
|
|||||||
</n-space>
|
</n-space>
|
||||||
</template>
|
</template>
|
||||||
</n-modal>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user