feat(member): 档案修改与空壳删除

可改 name/remark;key_count=0 时硬删,名下有 key 仅可停用。PATCH 扩档案字段并落审计。
This commit is contained in:
2026-10-12 13:57:19 +08:00
parent e293a8e5a9
commit f4f5ed0b0f
9 changed files with 243 additions and 16 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ export function createApp(opts: CreateAppOptions) {
{ name: "引导与会话", description: "三态判定 / 登录 / 改密" },
{ name: "引擎", description: "同步引擎状态" },
{ name: "Key 管理", description: "登记 / 启停 / 归属" },
{ name: "成员管理", description: "开户 / 令牌 / 启停" },
{ name: "成员管理", description: "开户 / 档案 / 令牌 / 启停 / 空壳删除" },
{ name: "成员视图", description: "令牌自助查询" },
],
});
+106 -9
View File
@@ -135,7 +135,8 @@ export function adminMemberRoutes() {
createRoute({
method: "patch",
path: "/members/{id}",
summary: "停用 / 启用",
summary: "档案修改 / 停用 / 启用",
description: "部分更新 name/remark/status;审计按实际变更字段各落一行",
tags: ["成员管理"],
security: [{ adminSession: [] }],
request: {
@@ -152,27 +153,123 @@ export function adminMemberRoutes() {
},
401: err401,
404: err404,
409: err409,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = await c.req.json().catch(() => null);
const status = body?.status;
if (status !== "active" && status !== "disabled") {
return jsonError(c, 422, ERR.validation_failed({ status: "非法状态" }));
}
const repos = c.get("repos");
const before = await repos.members.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
const row = await repos.members.updateStatus(id, status);
const profile: { name?: string; remark?: string } = {};
const actions: Array<{ action: string; summary: string }> = [];
if (typeof body?.name === "string") {
const name = body.name.trim();
if (!name || name.length > 64) {
return jsonError(
c,
422,
ERR.validation_failed({ name: "名称无效" }),
);
}
if (name !== before.name) {
const taken = await repos.members.findByName(name);
if (taken && taken.id !== id) {
return jsonError(c, 409, ERR.member_name_taken());
}
profile.name = name;
actions.push({
action: "member.update",
summary: `成员 ${before.name} · 改名为 ${name}`,
});
}
}
if (typeof body?.remark === "string") {
if (body.remark !== before.remark) {
profile.remark = body.remark;
actions.push({
action: "member.update",
summary: `成员 ${before.name} · 修改备注`,
});
}
}
let nextStatus: "active" | "disabled" | null = null;
if (body?.status === "active" || body?.status === "disabled") {
if (body.status !== before.status) {
nextStatus = body.status;
actions.push({
action: body.status === "active" ? "member.enable" : "member.disable",
summary: `成员 ${before.name} · ${body.status === "active" ? "启用" : "停用"}`,
});
}
}
if (Object.keys(profile).length === 0 && !nextStatus) {
return jsonError(
c,
422,
ERR.validation_failed({ body: "没有需要更新的字段" }),
);
}
let row = before;
if (Object.keys(profile).length > 0) {
row = await repos.members.updateProfile(id, profile);
}
if (nextStatus) {
row = await repos.members.updateStatus(id, nextStatus);
}
for (const a of actions) {
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: a.action,
summary: a.summary,
});
}
return c.json(toMemberItem(row, await repos.members.countKeys(id)), 200);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/members/{id}",
summary: "空壳删除",
description: "仅 key_count = 0 可硬删;名下有 key 返回 409 member_has_keys",
tags: ["成员管理"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
},
responses: {
204: { description: "已删除" },
401: err401,
404: err404,
409: err409,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const repos = c.get("repos");
const before = await repos.members.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
const keyCount = await repos.members.countKeys(id);
if (keyCount > 0) {
return jsonError(c, 409, ERR.member_has_keys(keyCount));
}
await repos.members.deleteById(id);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: status === "active" ? "member.enable" : "member.disable",
summary: `成员 ${row.name} · ${status === "active" ? "启用" : "停用"}`,
action: "member.delete",
summary: `删除成员 ${before.name}`,
});
return c.json(toMemberItem(row, 0), 200);
return c.body(null, 204);
},
);
+10 -1
View File
@@ -22,12 +22,21 @@ export async function createMember(body: {
export async function patchMember(
id: number,
body: { status: "active" | "disabled" },
body: {
name?: string;
remark?: string;
status?: "active" | "disabled";
},
): Promise<MemberItem> {
const { data } = await adminHttp.patch<MemberItem>(`/members/${id}`, body);
return data;
}
/** 空壳删除:仅 key_count = 0 */
export async function deleteMember(id: number): Promise<void> {
await adminHttp.delete(`/members/${id}`);
}
/** 重发:旧令牌即刻失效,明文仅此一次 */
export async function issueMemberToken(id: number): Promise<{ token: string }> {
const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`);
+18 -1
View File
@@ -23,6 +23,7 @@ import {
} from "@/api/admin/topups.ts";
import {
createMember,
deleteMember,
fetchMembers,
issueMemberToken,
patchMember,
@@ -207,10 +208,26 @@ export function usePatchMember() {
body,
}: {
id: number;
body: { status: "active" | "disabled" };
body: {
name?: string;
remark?: string;
status?: "active" | "disabled";
};
}) => patchMember(id, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.members() });
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
},
});
}
export function useDeleteMember() {
const qc = useQueryClient();
return useMutation({
mutationFn: deleteMember,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.members() });
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
},
});
}
+82 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* 成员管理(交互设计-成员管理):清单表 · 开户 · 令牌明文/重发 · 启停
* 成员管理(交互设计-成员管理):清单表 · 开户 · 档案修改 · 令牌明文/重发 · 启停 · 空壳删除
*/
import { computed, h, reactive, ref, watch } from "vue";
import { Tooltip, message } from "antdv-next";
@@ -16,6 +16,7 @@ import TableRefresh from "@/components/common/table-refresh.vue";
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
import {
useCreateMember,
useDeleteMember,
useIssueMemberToken,
useMembers,
usePatchMember,
@@ -25,9 +26,11 @@ import { formatMinute } from "@/utils/time.ts";
const membersQuery = useMembers();
const createMember = useCreateMember();
const patchMember = usePatchMember();
const deleteMember = useDeleteMember();
const issueToken = useIssueMemberToken();
const createOpen = ref(false);
const editOpen = ref(false);
const tokenOpen = ref(false);
const plainToken = ref("");
const tokenMemberName = ref("");
@@ -35,6 +38,9 @@ const tokenMemberName = ref("");
const createForm = reactive({ name: "", remark: "" });
const createError = ref("");
const editForm = reactive({ id: 0, name: "", remark: "" });
const editError = ref("");
const filterName = ref("");
const filterStatus = ref<"active" | "disabled" | null>(null);
@@ -83,7 +89,7 @@ const columns = [
width: 170,
},
{ title: "名下 Key 数", key: "key_count", width: 110 },
{ title: "操作", key: "actions", width: 200 },
{ title: "操作", key: "actions", width: 280 },
];
const rows = computed(() => {
@@ -166,11 +172,48 @@ async function onToggle(record: MemberItem) {
message.error((e as ApiError).message || "状态更新失败");
}
}
function openEdit(record: MemberItem) {
editForm.id = record.id;
editForm.name = record.name;
editForm.remark = record.remark ?? "";
editError.value = "";
editOpen.value = true;
}
async function onEdit() {
editError.value = "";
const name = editForm.name.trim();
if (!name) {
editError.value = "请填写成员名称";
return;
}
try {
await patchMember.mutateAsync({
id: editForm.id,
body: { name, remark: editForm.remark },
});
editOpen.value = false;
message.success("已保存");
} catch (e) {
const err = e as ApiError;
editError.value = err.message || "保存失败";
}
}
async function onDelete(record: MemberItem) {
try {
await deleteMember.mutateAsync(record.id);
message.success("已删除——仅清理未挂 key 的空壳");
} catch (e) {
message.error((e as ApiError).message || "删除失败");
}
}
</script>
<template>
<section>
<PageHeader title="成员管理" subtitle="开户、访问令牌与启停">
<PageHeader title="成员管理" subtitle="开户、档案修改、访问令牌与启停">
<template #actions>
<a-button type="primary" @click="createOpen = true">新建成员</a-button>
</template>
@@ -242,6 +285,7 @@ async function onToggle(record: MemberItem) {
>
查调用
</a-button>
<a-button size="small" @click="() => openEdit(record)">编辑</a-button>
<a-popconfirm
title="重发后旧令牌立即失效"
description="该成员正在用的访问将中断"
@@ -264,11 +308,46 @@ async function onToggle(record: MemberItem) {
<a-button v-else size="small" @click="() => onToggle(record)">
启用
</a-button>
<a-tooltip
v-if="record.key_count > 0"
title="名下仍有 key,只能停用"
>
<a-button size="small" danger disabled>删除</a-button>
</a-tooltip>
<a-popconfirm
v-else
title="删除后不可恢复(名称与令牌一并作废)"
description="仅清理未挂 key 的空壳"
ok-text="删除"
cancel-text="取消"
@confirm="() => onDelete(record)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<a-modal
v-model:open="editOpen"
title="编辑成员"
:confirm-loading="patchMember.isPending.value"
ok-text="保存"
cancel-text="取消"
@ok="onEdit"
>
<a-form layout="vertical">
<a-form-item label="名称" required>
<a-input v-model:value="editForm.name" placeholder="成员名称" />
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="editForm.remark" placeholder="备注(可选)" />
</a-form-item>
<a-alert v-if="editError" type="error" show-icon :message="editError" />
</a-form>
</a-modal>
<a-modal
v-model:open="createOpen"
title="新建成员"
+4
View File
@@ -36,6 +36,10 @@ export const ERR = {
errorBody("key_already_registered", "这枚 key 已经登记过", { key_id }),
member_name_taken: () =>
errorBody("member_name_taken", "成员名称已被占用"),
member_has_keys: (key_count: number) =>
errorBody("member_has_keys", `名下仍有 ${key_count} 枚 key,只能停用`, {
fields: { key_count: String(key_count) },
}),
upstream_unreachable: () =>
errorBody("upstream_unreachable", "上游站点暂时不可达,请稍后重试"),
not_found: () => errorBody("not_found", "请求的资源不存在"),
+3 -1
View File
@@ -115,7 +115,9 @@ export const createMemberBody = z.object({
});
export const patchMemberBody = z.object({
status: z.enum(["active", "disabled"]),
name: z.string().min(1).max(64).optional(),
remark: z.string().optional(),
status: z.enum(["active", "disabled"]).optional(),
});
export const memberWithToken = z.object({
+2
View File
@@ -138,10 +138,12 @@ export type AuditAction =
| "payment.update"
| "payment.delete"
| "member.create"
| "member.update"
| "member.enable"
| "member.disable"
| "member.token_issue"
| "member.token_reissue"
| "member.delete"
| "account.password_change"
| "account.init";
+17
View File
@@ -294,6 +294,23 @@ export function createRepos(db: Db) {
.returning();
return row!;
},
async updateProfile(
id: number,
values: { name?: string; remark?: string },
): Promise<MemberRow> {
const patch: { name?: string; remark?: string } = {};
if (values.name !== undefined) patch.name = values.name;
if (values.remark !== undefined) patch.remark = values.remark;
const [row] = await db
.update(members)
.set(patch)
.where(eq(members.id, id))
.returning();
return row!;
},
async deleteById(id: number): Promise<void> {
await db.delete(members).where(eq(members.id, id));
},
async reissueToken(
id: number,
token_hash: string,