Files
NixMsg/web/src/views/EndpointsView.vue
T

715 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from "vue";
import type { DataTableColumns, DataTableRowKey } from "naive-ui";
import {
NAlert,
NButton,
NDataTable,
NForm,
NFormItem,
NInput,
NInputNumber,
NModal,
NScrollbar,
NSelect,
NSpace,
NSwitch,
NText,
NUpload,
useDialog,
} from "naive-ui";
import type { UploadCustomRequestOptions } from "naive-ui";
import PageHeader from "@/components/PageHeader.vue";
import HelpTip from "@/components/HelpTip.vue";
import SecretOnceAlert from "@/components/SecretOnceAlert.vue";
import LoadFailed from "@/components/LoadFailed.vue";
import {
batchEndpoints,
createEndpoint,
getSettings,
importEndpoints,
kickEndpoint,
listEndpoints,
patchEndpoint,
resetLoginPassword,
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[]>([]);
const page = ref(1);
const pageSize = ref(50);
const filters = reactive({
source: "" as "" | "admin" | "self",
online: "" as "" | "true" | "false",
query: "",
});
const sourceOptions = [
{ label: "全部来源", value: "" },
{ label: "后台开通", value: "admin" },
{ label: "自助注册", value: "self" },
];
const onlineOptions = [
{ label: "全部状态", value: "" },
{ label: "在线", value: "true" },
{ label: "离线", value: "false" },
];
const onceSecret = ref<{ title: string; secret: string; filename: string; 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({
id: "",
name: "",
remark: "",
login_password: "",
talk_password: "",
default_delay_seconds: 0,
});
const createLoading = ref(false);
const createError = ref("");
const editOpen = ref(false);
const editId = ref("");
const editForm = reactive({
name: "",
remark: "",
default_delay_seconds: 0,
enabled: true,
});
const editLoading = ref(false);
const editOrig = reactive({
name: "",
remark: "",
default_delay_seconds: 0,
enabled: true,
});
const talkOpen = ref(false);
const talkId = ref("");
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({
cursor,
limit: pageSize.value,
source: filters.source || undefined,
online: filters.online === "" ? undefined : filters.online === "true",
query: filters.query.trim() || undefined,
});
rows.value = res.items;
total.value = res.total;
} catch (e) {
loadError.value = e instanceof Error ? e.message : "加载失败";
} finally {
loading.value = false;
}
}
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" ? "自助注册" : "后台开通";
}
const columns = computed<DataTableColumns<Endpoint>>(() => [
{ type: "selection" },
{ title: "编号", key: "id", width: 120, ellipsis: { tooltip: true } },
{ title: "名称", key: "name", width: 120, ellipsis: { tooltip: true } },
{
title: "来源",
key: "source",
width: 90,
render: (r) => sourceLabel(r.source),
},
{
title: "在线",
key: "online",
width: 70,
render: (r) => (r.online ? "是" : "否"),
},
{
title: "启用",
key: "enabled",
width: 70,
render: (r) => (r.enabled ? "是" : "否"),
},
{
title() {
return h("span", [
"锁定",
h(HelpTip, null, {
default: () => "列表只显示按编号的总数锁定,解锁会同时清除按 IP 的锁定",
}),
]);
},
key: "login_locked",
width: 90,
render: (r) => (r.login_locked ? "是" : "否"),
},
{
title: "最近上线",
key: "online_since_ms",
width: 160,
render: (r) => formatLocalMs(r.online_since_ms),
},
{
title: "最近离线",
key: "offline_since_ms",
width: 160,
render: (r) => formatLocalMs(r.offline_since_ms),
},
{
title: "创建时间",
key: "created_at_ms",
width: 160,
render: (r) => formatLocalMs(r.created_at_ms),
},
{
title: "操作",
key: "actions",
width: 360,
fixed: "right",
render: (r) =>
h(NSpace, { size: 4, wrap: false }, () => [
h(NButton, { size: "tiny", onClick: () => openEdit(r) }, () => "编辑"),
h(NButton, { size: "tiny", onClick: () => void onResetPwd(r) }, () => "重置密码"),
h(NButton, { size: "tiny", onClick: () => openTalk(r) }, () => "对话密码"),
h(
NButton,
{
size: "tiny",
"data-testid": "endpoint-unlock",
onClick: () => void onUnlock(r),
},
() => "解锁",
),
h(NButton, { size: "tiny", onClick: () => void onKick(r) }, () => "踢下线"),
]),
},
]);
function openCreate() {
Object.assign(createForm, {
id: "",
name: "",
remark: "",
login_password: "",
talk_password: "",
default_delay_seconds: 0,
});
createError.value = "";
createOpen.value = true;
}
async function submitCreate() {
createError.value = "";
if ((createForm.default_delay_seconds ?? 0) > maxDelaySeconds.value) {
createError.value = `默认延迟不能超过 ${maxDelaySeconds.value} 秒`;
return;
}
createLoading.value = true;
try {
const res = await createEndpoint({
id: createForm.id.trim() || undefined,
name: createForm.name.trim(),
remark: createForm.remark,
login_password: createForm.login_password || undefined,
talk_password: createForm.talk_password || undefined,
default_delay_seconds: createForm.default_delay_seconds,
});
createOpen.value = false;
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;
}
}
function openEdit(r: Endpoint) {
editId.value = r.id;
Object.assign(editForm, {
name: r.name,
remark: r.remark,
default_delay_seconds: Math.round(r.default_delay_ms / 1000),
enabled: r.enabled,
});
Object.assign(editOrig, editForm);
editOpen.value = true;
}
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) {
talkId.value = r.id;
talkPassword.value = "";
talkOpen.value = true;
}
async function submitTalk() {
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) {
dialog.warning({
title: "重置登录密码",
content: `确定重置「${r.id}」的登录密码?当前会话将失效。`,
positiveText: "重置",
negativeText: "取消",
onPositiveClick: async () => {
const res = await resetLoginPassword(r.id);
onceSecret.value = {
title: "新登录密码只显示一次",
secret: res.login_password,
filename: `${r.id}-new-password.txt`,
};
},
});
}
async function onUnlock(r: Endpoint) {
await unlockEndpoint(r.id);
message.success("已解除锁定");
await load();
}
async function onKick(r: Endpoint) {
const res = await kickEndpoint(r.id);
message.success(res.kicked ? "已踢下线" : "当时不在线");
await load();
}
async function onBatch(action: "disable" | "delete") {
const ids = checkedKeys.value.map(String);
if (!ids.length) {
message.warning("请先多选");
return;
}
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,
positiveText: "确定",
negativeText: "取消",
onPositiveClick: async () => {
const res = await batchEndpoints(ids, action);
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();
},
});
}
async function onImport(options: UploadCustomRequestOptions) {
const raw = options.file.file;
if (!raw) {
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;
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)) {
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();
}
</script>
<template>
<div class="page">
<PageHeader title="端" help="可按来源与在线筛选;开通/重置/导入生成的密码只显示一次。">
<template #actions>
<n-button type="primary" @click="openCreate">开通</n-button>
<n-upload :show-file-list="false" accept=".csv,text/csv" :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="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="reloadFirst"
/>
<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"
:scroll-x="1400"
:pagination="{
page,
pageSize,
itemCount: total,
showSizePicker: true,
pageSizes: [20, 50, 100],
onChange: onPageChange,
onUpdatePageSize: onPageSizeChange,
}"
@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-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" placeholder="可选" />
</n-form-item>
<n-form-item label="登录密码">
<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>
<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" />
</n-form-item>
</n-form>
</n-scrollbar>
<template #footer>
<n-space justify="end">
<n-button @click="createOpen = false">取消</n-button>
<n-button type="primary" :loading="createLoading" data-testid="create-submit" @click="submitCreate">开通</n-button>
</n-space>
</template>
</n-modal>
<n-modal v-model:show="editOpen" preset="card" title="编辑端" style="width: 480px" :mask-closable="false">
<n-scrollbar style="max-height: 320px">
<n-form label-placement="left" label-width="110" :show-feedback="false">
<n-form-item label="名称">
<n-input v-model:value="editForm.name" data-testid="edit-name" />
</n-form-item>
<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" 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" />
</n-form-item>
</n-form>
</n-scrollbar>
<template #footer>
<n-space justify="end">
<n-button @click="editOpen = false">取消</n-button>
<n-button type="primary" :loading="editLoading" data-testid="edit-submit" @click="submitEdit">保存</n-button>
</n-space>
</template>
</n-modal>
<n-modal v-model:show="talkOpen" preset="card" title="设置对话密码" style="width: 420px">
<n-form label-placement="left" label-width="90" :show-feedback="false">
<n-form-item label="对话密码">
<n-input v-model:value="talkPassword" type="password" show-password-on="click" placeholder="4–64 字符,留空表示清除" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="talkOpen = false">取消</n-button>
<n-button type="primary" @click="submitTalk">确定</n-button>
</n-space>
</template>
</n-modal>
<n-modal v-if="importResult" :show="true" preset="card" title="导入结果" style="width: 560px" @update:show="(v) => !v && (importResult = null)">
<n-scrollbar style="max-height: 280px">
<n-text depth="3">以下密码仅本次可见,请下载保存。</n-text>
<pre style="font-size: 12px; white-space: pre-wrap">{{ importResult.map((i) => `${i.id}\t${i.name}\t${i.login_password ?? ""}`).join("\n") }}</pre>
</n-scrollbar>
<template #footer>
<n-space justify="end">
<n-button @click="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>
<style scoped>
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.page-body {
flex: 1;
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>