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

429 lines
12 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 } 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) {
message.warning(`部分失败:${formatFailed(res.failed)}`);
} else {
message.success("已加人");
}
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>