435 lines
12 KiB
Vue
435 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { computed, h, onMounted, reactive, ref } from "vue";
|
||
import type { DataTableColumns } from "naive-ui";
|
||
import {
|
||
NButton,
|
||
NDataTable,
|
||
NForm,
|
||
NFormItem,
|
||
NInput,
|
||
NModal,
|
||
NScrollbar,
|
||
NSpace,
|
||
NText,
|
||
useDialog,
|
||
} from "naive-ui";
|
||
import PageHeader from "@/components/PageHeader.vue";
|
||
import LoadFailed from "@/components/LoadFailed.vue";
|
||
import HelpTip from "@/components/HelpTip.vue";
|
||
import {
|
||
addGroupMembers,
|
||
createGroup,
|
||
deleteGroup,
|
||
getGroup,
|
||
listGroups,
|
||
removeGroupMember,
|
||
renameGroup,
|
||
transferGroup,
|
||
type GroupDetail,
|
||
type GroupMember,
|
||
type GroupSummary,
|
||
} from "@/api/admin";
|
||
import { formatLocalMs } from "@/utils/time";
|
||
import { message } from "@/utils/notify";
|
||
|
||
const dialog = useDialog();
|
||
const loading = ref(false);
|
||
const loadError = ref("");
|
||
const rows = ref<GroupSummary[]>([]);
|
||
const total = ref(0);
|
||
const page = ref(1);
|
||
const pageSize = ref(50);
|
||
const query = ref("");
|
||
|
||
const createOpen = ref(false);
|
||
const createForm = reactive({ name: "", owner_id: "", member_ids: "" });
|
||
const createLoading = ref(false);
|
||
|
||
const detailOpen = ref(false);
|
||
const detailId = ref("");
|
||
const detail = ref<GroupDetail | null>(null);
|
||
const memberPage = ref(1);
|
||
const memberPageSize = ref(50);
|
||
const renameName = ref("");
|
||
const addMembers = ref("");
|
||
|
||
async function load() {
|
||
loading.value = true;
|
||
loadError.value = "";
|
||
try {
|
||
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
|
||
const res = await listGroups(query.value.trim() || undefined, cursor, pageSize.value);
|
||
rows.value = res.items;
|
||
total.value = res.total;
|
||
} catch (e) {
|
||
loadError.value = e instanceof Error ? e.message : "加载失败";
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
void load();
|
||
});
|
||
|
||
const columns = computed<DataTableColumns<GroupSummary>>(() => [
|
||
{ title: "编号", key: "id", width: 140 },
|
||
{ title: "名称", key: "name", width: 140 },
|
||
{ title: "群主", key: "owner_id", width: 120 },
|
||
{ title: "成员数", key: "member_count", width: 80 },
|
||
{
|
||
title: "创建时间",
|
||
key: "created_at_ms",
|
||
width: 160,
|
||
render: (r) => formatLocalMs(r.created_at_ms),
|
||
},
|
||
{
|
||
title: "操作",
|
||
key: "actions",
|
||
width: 200,
|
||
render: (r) =>
|
||
h(NSpace, { size: 4 }, () => [
|
||
h(NButton, { size: "tiny", onClick: () => void openDetail(r.id) }, () => "详情"),
|
||
h(
|
||
NButton,
|
||
{
|
||
size: "tiny",
|
||
type: "error",
|
||
secondary: true,
|
||
onClick: () => onDelete(r),
|
||
},
|
||
() => "解散",
|
||
),
|
||
]),
|
||
},
|
||
]);
|
||
|
||
const memberColumns = computed<DataTableColumns<GroupMember>>(() => [
|
||
{ title: "编号", key: "id" },
|
||
{ title: "名称", key: "name" },
|
||
{ title: "在线", key: "online", render: (r) => (r.online ? "是" : "否") },
|
||
{
|
||
title: "加入时间",
|
||
key: "joined_at_ms",
|
||
render: (r) => formatLocalMs(r.joined_at_ms),
|
||
},
|
||
{
|
||
title: "操作",
|
||
key: "actions",
|
||
render: (r) =>
|
||
h(NSpace, { size: 4 }, () => [
|
||
h(
|
||
NButton,
|
||
{
|
||
size: "tiny",
|
||
disabled: detail.value?.owner_id === r.id,
|
||
onClick: () => void onTransfer(r.id),
|
||
},
|
||
() => "设为群主",
|
||
),
|
||
h(
|
||
NButton,
|
||
{
|
||
size: "tiny",
|
||
type: "error",
|
||
secondary: true,
|
||
disabled: detail.value?.owner_id === r.id,
|
||
onClick: () => void onRemove(r.id),
|
||
},
|
||
() => "移除",
|
||
),
|
||
]),
|
||
},
|
||
]);
|
||
|
||
async function loadDetail(id: string) {
|
||
const cursor = memberPage.value > 1 ? String((memberPage.value - 1) * memberPageSize.value) : "";
|
||
detail.value = await getGroup(id, cursor, memberPageSize.value);
|
||
renameName.value = detail.value.name;
|
||
}
|
||
|
||
async function openDetail(id: string) {
|
||
detailId.value = id;
|
||
memberPage.value = 1;
|
||
addMembers.value = "";
|
||
await loadDetail(id);
|
||
detailOpen.value = true;
|
||
}
|
||
|
||
function failLabel(code: string): string {
|
||
switch (code) {
|
||
case "not_found":
|
||
case "invalid_target":
|
||
return "端不存在";
|
||
case "endpoint_disabled":
|
||
return "端已停用";
|
||
case "group_full":
|
||
return "群已满";
|
||
default:
|
||
return code;
|
||
}
|
||
}
|
||
|
||
function formatFailed(failed: { id: string; code: string }[]): string {
|
||
return failed.map((f) => `${f.id}(${failLabel(f.code)})`).join("、");
|
||
}
|
||
|
||
async function submitCreate() {
|
||
if (!createForm.name.trim() || !createForm.owner_id.trim()) {
|
||
message.error("名称与群主必填");
|
||
return;
|
||
}
|
||
createLoading.value = true;
|
||
try {
|
||
const member_ids = createForm.member_ids
|
||
.split(/[,,\s]+/)
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
const res = await createGroup({
|
||
name: createForm.name.trim(),
|
||
owner_id: createForm.owner_id.trim(),
|
||
member_ids,
|
||
});
|
||
createOpen.value = false;
|
||
if (res.failed.length) {
|
||
message.warning(`已创建,部分成员失败:${formatFailed(res.failed)}`);
|
||
} else {
|
||
message.success("已创建");
|
||
}
|
||
await load();
|
||
} finally {
|
||
createLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function onDelete(r: GroupSummary) {
|
||
dialog.warning({
|
||
title: "解散群",
|
||
content: `确定解散「${r.name}」?`,
|
||
positiveText: "解散",
|
||
negativeText: "取消",
|
||
onPositiveClick: async () => {
|
||
await deleteGroup(r.id);
|
||
message.success("已解散");
|
||
await load();
|
||
},
|
||
});
|
||
}
|
||
|
||
async function onRename() {
|
||
if (!detail.value) return;
|
||
await renameGroup(detail.value.id, renameName.value.trim());
|
||
message.success("已改名");
|
||
await loadDetail(detail.value.id);
|
||
await load();
|
||
}
|
||
|
||
async function onAddMembers() {
|
||
if (!detail.value) return;
|
||
const ids = addMembers.value
|
||
.split(/[,,\s]+/)
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
if (!ids.length) return;
|
||
const res = await addGroupMembers(detail.value.id, ids);
|
||
if (res.failed.length) {
|
||
if (res.added > 0) {
|
||
message.warning(`已加入 ${res.added} 人,部分失败:${formatFailed(res.failed)}`);
|
||
} else {
|
||
message.warning(`部分失败:${formatFailed(res.failed)}`);
|
||
}
|
||
} else if (res.added === 0) {
|
||
message.info("没有新成员加入");
|
||
} else {
|
||
message.success(`已加人 ${res.added} 人`);
|
||
}
|
||
await loadDetail(detail.value.id);
|
||
await load();
|
||
}
|
||
|
||
async function onRemove(endpointId: string) {
|
||
if (!detail.value) return;
|
||
dialog.warning({
|
||
title: "移除成员",
|
||
content: `确定将 ${endpointId} 移出本群?`,
|
||
positiveText: "移除",
|
||
negativeText: "取消",
|
||
onPositiveClick: async () => {
|
||
await removeGroupMember(detail.value!.id, endpointId);
|
||
message.success("已移除");
|
||
await loadDetail(detail.value!.id);
|
||
await load();
|
||
},
|
||
});
|
||
}
|
||
|
||
async function onTransfer(endpointId: string) {
|
||
if (!detail.value) return;
|
||
dialog.warning({
|
||
title: "转让群主",
|
||
content: `确定将群主转让给 ${endpointId}?`,
|
||
positiveText: "转让",
|
||
negativeText: "取消",
|
||
onPositiveClick: async () => {
|
||
await transferGroup(detail.value!.id, endpointId);
|
||
message.success("已转让群主");
|
||
await loadDetail(detail.value!.id);
|
||
await load();
|
||
},
|
||
});
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page">
|
||
<PageHeader title="群" help="后台建群与加人不必填对话密码。">
|
||
<template #actions>
|
||
<n-button
|
||
type="primary"
|
||
data-testid="group-create-open"
|
||
@click="
|
||
Object.assign(createForm, { name: '', owner_id: '', member_ids: '' });
|
||
createOpen = true;
|
||
"
|
||
>
|
||
新建群
|
||
</n-button>
|
||
</template>
|
||
</PageHeader>
|
||
<div class="page-body">
|
||
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
|
||
<template v-else>
|
||
<div class="page-filters">
|
||
<n-space style="margin-bottom: 12px">
|
||
<n-input v-model:value="query" placeholder="群名称" style="width: 200px" data-testid="group-query" @keyup.enter="page = 1; load()" />
|
||
<n-button @click="page = 1; load()">查询</n-button>
|
||
</n-space>
|
||
</div>
|
||
<div class="page-table">
|
||
<n-data-table
|
||
remote
|
||
flex-height
|
||
:loading="loading"
|
||
:columns="columns"
|
||
:data="rows"
|
||
:row-key="(r: GroupSummary) => r.id"
|
||
:pagination="{
|
||
page,
|
||
pageSize,
|
||
itemCount: total,
|
||
onChange: (p: number) => {
|
||
page = p;
|
||
load();
|
||
},
|
||
}"
|
||
/>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<n-modal v-model:show="createOpen" preset="card" title="新建群" style="width: 480px">
|
||
<n-scrollbar style="max-height: 320px">
|
||
<n-form label-placement="left" label-width="90" :show-feedback="false">
|
||
<n-form-item>
|
||
<template #label>
|
||
编号
|
||
<HelpTip>由服务器生成,后台不能自定。</HelpTip>
|
||
</template>
|
||
<n-text depth="3">创建后自动分配</n-text>
|
||
</n-form-item>
|
||
<n-form-item label="名称">
|
||
<n-input v-model:value="createForm.name" data-testid="group-name" />
|
||
</n-form-item>
|
||
<n-form-item label="群主编号">
|
||
<n-input v-model:value="createForm.owner_id" data-testid="group-owner" />
|
||
</n-form-item>
|
||
<n-form-item label="成员" label-placement="top">
|
||
<n-input
|
||
v-model:value="createForm.member_ids"
|
||
type="textarea"
|
||
:rows="3"
|
||
placeholder="多个编号用逗号或空格分隔"
|
||
data-testid="group-members"
|
||
/>
|
||
</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="group-create-submit" @click="submitCreate">
|
||
创建
|
||
</n-button>
|
||
</n-space>
|
||
</template>
|
||
</n-modal>
|
||
|
||
<n-modal v-model:show="detailOpen" preset="card" title="群详情" style="width: 720px">
|
||
<n-scrollbar v-if="detail" style="max-height: 420px">
|
||
<n-form label-placement="left" label-width="80" :show-feedback="false" style="margin-bottom: 12px">
|
||
<n-form-item label="编号">{{ detail.id }}</n-form-item>
|
||
<n-form-item label="群主">{{ detail.owner_id }}</n-form-item>
|
||
<n-form-item label="名称">
|
||
<n-space>
|
||
<n-input v-model:value="renameName" style="width: 200px" />
|
||
<n-button size="small" @click="onRename">改名</n-button>
|
||
</n-space>
|
||
</n-form-item>
|
||
<n-form-item label="加人" label-placement="top">
|
||
<n-space vertical style="width: 100%">
|
||
<n-input v-model:value="addMembers" type="textarea" :rows="2" placeholder="成员编号,逗号分隔" />
|
||
<n-button size="small" @click="onAddMembers">加人</n-button>
|
||
</n-space>
|
||
</n-form-item>
|
||
</n-form>
|
||
<n-data-table
|
||
remote
|
||
:columns="memberColumns"
|
||
:data="detail.members"
|
||
:row-key="(r: GroupMember) => r.id"
|
||
size="small"
|
||
:max-height="280"
|
||
:pagination="{
|
||
page: memberPage,
|
||
pageSize: memberPageSize,
|
||
itemCount: detail.member_total ?? detail.members.length,
|
||
onChange: (p: number) => {
|
||
memberPage = p;
|
||
loadDetail(detailId);
|
||
},
|
||
}"
|
||
/>
|
||
</n-scrollbar>
|
||
<template #footer>
|
||
<n-space justify="end">
|
||
<n-button @click="detailOpen = false">关闭</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>
|