551 lines
17 KiB
Vue
551 lines
17 KiB
Vue
<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>
|