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

551 lines
17 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,
importEndpoints,
kickEndpoint,
listEndpoints,
patchEndpoint,
resetLoginPassword,
setTalkPassword,
unlockEndpoint,
type Endpoint,
type ImportItem,
} from "@/api/admin";
import { ApiError } from "@/api/http";
import { formatLocalMs } from "@/utils/time";
import { 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 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 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();
});
function sourceLabel(s: string) {
return s === "self" ? "自助注册" : "后台开通";
}
const columns = computed<DataTableColumns<Endpoint>>(() => [
{ type: "selection" },
{ title: "编号", key: "id", width: 120, ellipsis: { tooltip: true } },
{ title: "名称", key: "name", width: 120, ellipsis: { tooltip: true } },
{
title: "来源",
key: "source",
width: 90,
render: (r) => sourceLabel(r.source),
},
{
title: "在线",
key: "online",
width: 70,
render: (r) => (r.online ? "是" : "否"),
},
{
title: "启用",
key: "enabled",
width: 70,
render: (r) => (r.enabled ? "是" : "否"),
},
{
title: "锁定",
key: "login_locked",
width: 70,
render: (r) => (r.login_locked ? "是" : "否"),
},
{
title: "最近在线",
key: "online_since_ms",
width: 160,
render: (r) => formatLocalMs(r.online ? r.online_since_ms : r.offline_since_ms),
},
{
title: "创建时间",
key: "created_at_ms",
width: 160,
render: (r) => formatLocalMs(r.created_at_ms),
},
{
title: "操作",
key: "actions",
width: 360,
fixed: "right",
render: (r) =>
h(NSpace, { size: 4, wrap: false }, () => [
h(NButton, { size: "tiny", onClick: () => openEdit(r) }, () => "编辑"),
h(NButton, { size: "tiny", onClick: () => void onResetPwd(r) }, () => "重置密码"),
h(NButton, { size: "tiny", onClick: () => openTalk(r) }, () => "对话密码"),
h(
NButton,
{ size: "tiny", disabled: !r.login_locked, onClick: () => void onUnlock(r) },
() => "解锁",
),
h(NButton, { size: "tiny", onClick: () => void onKick(r) }, () => "踢下线"),
]),
},
]);
function openCreate() {
Object.assign(createForm, {
id: "",
name: "",
remark: "",
login_password: "",
talk_password: "",
default_delay_seconds: 0,
});
createError.value = "";
createOpen.value = true;
}
async function submitCreate() {
createError.value = "";
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,
});
editOpen.value = true;
}
async function submitEdit() {
editLoading.value = true;
try {
await patchEndpoint(editId.value, {
name: editForm.name,
remark: editForm.remark,
default_delay_seconds: editForm.default_delay_seconds,
enabled: editForm.enabled,
});
editOpen.value = false;
message.success("已保存");
await load();
} finally {
editLoading.value = false;
}
}
function openTalk(r: Endpoint) {
talkId.value = r.id;
talkPassword.value = "";
talkOpen.value = true;
}
async function submitTalk() {
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;
}
dialog.warning({
title: action === "disable" ? "批量停用" : "批量删除",
content: `对 ${ids.length} 个端执行${action === "disable" ? "停用" : "删除"}?`,
positiveText: "确定",
negativeText: "取消",
onPositiveClick: async () => {
const res = await batchEndpoints(ids, action);
message.success(`成功 ${res.ok_ids.length},失败 ${res.failed.length}`);
checkedKeys.value = [];
await load();
},
});
}
async function onImport(options: UploadCustomRequestOptions) {
const raw = options.file.file;
if (!raw) {
options.onError();
return;
}
try {
const text = await raw.text();
const res = await importEndpoints(text);
importResult.value = res.items;
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);
} else if (e instanceof Error) {
message.error(e.message);
}
}
}
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 onPageChange(p: number) {
page.value = p;
void load();
}
function onPageSizeChange(s: number) {
pageSize.value = s;
page.value = 1;
void load();
}
</script>
<template>
<div class="page">
<PageHeader title="端" help="可按来源与在线筛选;开通/重置/导入生成的密码只显示一次。">
<template #actions>
<n-button type="primary" @click="openCreate">开通</n-button>
<n-upload :show-file-list="false" accept=".csv,text/csv" :custom-request="onImport">
<n-button>CSV 导入</n-button>
</n-upload>
<n-button @click="onBatch('disable')">批量停用</n-button>
<n-button type="error" secondary @click="onBatch('delete')">批量删除</n-button>
</template>
</PageHeader>
<div class="page-body">
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
<template v-else>
<SecretOnceAlert
v-if="onceSecret"
:title="onceSecret.title"
:secret="onceSecret.secret"
:filename="onceSecret.filename"
:mime="onceSecret.mime"
@dismiss="onceSecret = null"
/>
<n-space style="margin-bottom: 12px" align="center">
<n-select v-model:value="filters.source" :options="sourceOptions" style="width: 140px" @update:value="page = 1; load()" />
<n-select v-model:value="filters.online" :options="onlineOptions" style="width: 120px" @update:value="page = 1; load()" />
<n-input
v-model:value="filters.query"
clearable
placeholder="编号/名称"
style="width: 180px"
@keyup.enter="page = 1; load()"
/>
<n-button @click="page = 1; load()">查询</n-button>
<span>
来源筛选
<HelpTip>admin=后台开通,self=自助注册。</HelpTip>
</span>
</n-space>
<n-data-table
remote
:loading="loading"
:columns="columns"
:data="rows"
:row-key="(r: Endpoint) => r.id"
:checked-row-keys="checkedKeys"
:max-height="480"
:scroll-x="1200"
:pagination="{
page,
pageSize,
itemCount: total,
showSizePicker: true,
pageSizes: [20, 50, 100],
onChange: onPageChange,
onUpdatePageSize: onPageSizeChange,
}"
@update:checked-row-keys="(k) => (checkedKeys = k)"
/>
</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 label="默认延迟(秒)">
<n-input-number v-model:value="createForm.default_delay_seconds" :min="0" style="width: 100%" />
</n-form-item>
<n-form-item label="备注" label-placement="top">
<n-input v-model:value="createForm.remark" type="textarea" :rows="3" />
</n-form-item>
</n-form>
</n-scrollbar>
<template #footer>
<n-space justify="end">
<n-button @click="createOpen = false">取消</n-button>
<n-button type="primary" :loading="createLoading" data-testid="create-submit" @click="submitCreate">开通</n-button>
</n-space>
</template>
</n-modal>
<n-modal v-model:show="editOpen" preset="card" title="编辑端" style="width: 480px" :mask-closable="false">
<n-scrollbar style="max-height: 320px">
<n-form label-placement="left" label-width="110" :show-feedback="false">
<n-form-item label="名称">
<n-input v-model:value="editForm.name" />
</n-form-item>
<n-form-item label="默认延迟(秒)">
<n-input-number v-model:value="editForm.default_delay_seconds" :min="0" style="width: 100%" />
</n-form-item>
<n-form-item label="启用">
<n-switch v-model:value="editForm.enabled" />
</n-form-item>
<n-form-item label="备注" label-placement="top">
<n-input v-model:value="editForm.remark" type="textarea" :rows="3" />
</n-form-item>
</n-form>
</n-scrollbar>
<template #footer>
<n-space justify="end">
<n-button @click="editOpen = false">取消</n-button>
<n-button type="primary" :loading="editLoading" @click="submitEdit">保存</n-button>
</n-space>
</template>
</n-modal>
<n-modal v-model:show="talkOpen" preset="card" title="设置对话密码" style="width: 420px">
<n-form label-placement="left" label-width="90" :show-feedback="false">
<n-form-item label="对话密码">
<n-input v-model:value="talkPassword" type="password" show-password-on="click" placeholder="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>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.page-body {
flex: 1;
overflow: auto;
padding: 16px;
min-height: 0;
}
</style>