feat: 实现管理后台 W1-W3 假数据界面
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from "vue";
|
||||
import type { DataTableColumns, DataTableRowKey } from "naive-ui";
|
||||
import {
|
||||
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 {
|
||||
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 { message } from "@/utils/notify";
|
||||
|
||||
const dialog = useDialog();
|
||||
|
||||
const loading = ref(false);
|
||||
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 } | 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 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;
|
||||
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;
|
||||
} 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,
|
||||
});
|
||||
createOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!createForm.name.trim()) {
|
||||
message.error("名称不能为空");
|
||||
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;
|
||||
onceSecret.value = {
|
||||
title: "开通成功,登录密码只显示一次",
|
||||
secret: `${res.id}\t${res.login_password}`,
|
||||
filename: `${res.id}-password.txt`,
|
||||
};
|
||||
await load();
|
||||
} 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() {
|
||||
await setTalkPassword(talkId.value, talkPassword.value);
|
||||
talkOpen.value = false;
|
||||
message.success(talkPassword.value ? "已设置对话密码" : "已清除对话密码");
|
||||
await load();
|
||||
}
|
||||
|
||||
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;
|
||||
onceSecret.value = {
|
||||
title: "CSV 导入成功,密码只显示一次",
|
||||
secret: res.items.map((i) => `${i.id},${i.name},${i.login_password}`).join("\n"),
|
||||
filename: "import-passwords.csv",
|
||||
};
|
||||
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 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">
|
||||
<SecretOnceAlert
|
||||
v-if="onceSecret"
|
||||
:title="onceSecret.title"
|
||||
:secret="onceSecret.secret"
|
||||
:filename="onceSecret.filename"
|
||||
@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)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="createOpen" preset="card" title="开通端" style="width: 520px" :mask-closable="false">
|
||||
<n-scrollbar style="max-height: 360px">
|
||||
<n-form label-placement="left" label-width="110" :show-feedback="false">
|
||||
<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" />
|
||||
</n-form-item>
|
||||
<n-form-item label="登录密码">
|
||||
<n-input v-model:value="createForm.login_password" placeholder="留空则生成" type="password" show-password-on="click" />
|
||||
</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="留空表示清除" />
|
||||
</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="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>
|
||||
Reference in New Issue
Block a user