Compare commits
2
Commits
adbb6adb93
...
b808be324d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b808be324d | ||
|
|
b16ddb1acb |
@@ -1,16 +1,22 @@
|
|||||||
import type { MemberItem } from "@new-api-key-usage/core";
|
import type { MemberItem } from "@new-api-key-usage/core";
|
||||||
import { adminHttp } from "../http.ts";
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export type MemberWithToken = {
|
||||||
|
member: MemberItem;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchMembers(): Promise<MemberItem[]> {
|
export async function fetchMembers(): Promise<MemberItem[]> {
|
||||||
const { data } = await adminHttp.get<MemberItem[]>("/members");
|
const { data } = await adminHttp.get<MemberItem[]>("/members");
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 开户:响应含令牌明文(仅此一次) */
|
||||||
export async function createMember(body: {
|
export async function createMember(body: {
|
||||||
name: string;
|
name: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
}): Promise<MemberItem> {
|
}): Promise<MemberWithToken> {
|
||||||
const { data } = await adminHttp.post<MemberItem>("/members", body);
|
const { data } = await adminHttp.post<MemberWithToken>("/members", body);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +28,7 @@ export async function patchMember(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 重发:旧令牌即刻失效,明文仅此一次 */
|
||||||
export async function issueMemberToken(id: number): Promise<{ token: string }> {
|
export async function issueMemberToken(id: number): Promise<{ token: string }> {
|
||||||
const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`);
|
const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 令牌明文对话框:仅签发/重发后出现一次(交互-成员管理 §3)
|
||||||
|
*/
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import { message } from "antdv-next";
|
||||||
|
import { CopyOutlined, WarningOutlined } from "@antdv-next/icons";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean;
|
||||||
|
token: string;
|
||||||
|
memberName?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:open", v: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const confirmClose = ref(false);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(v) => {
|
||||||
|
if (v) {
|
||||||
|
confirmClose.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function onCopy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(props.token);
|
||||||
|
message.success("已复制");
|
||||||
|
} catch {
|
||||||
|
message.error("复制失败,请手动选中复制");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryClose() {
|
||||||
|
if (!confirmClose.value) {
|
||||||
|
confirmClose.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("update:open", false);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:title="memberName ? `访问令牌 · ${memberName}` : '访问令牌'"
|
||||||
|
:mask-closable="false"
|
||||||
|
:closable="false"
|
||||||
|
:keyboard="false"
|
||||||
|
:footer="null"
|
||||||
|
>
|
||||||
|
<a-alert
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
message="关闭后将无法再次查看——请立即复制并通过你信任的渠道发给成员"
|
||||||
|
/>
|
||||||
|
<div class="token-plain">
|
||||||
|
<code class="token-plain__text">{{ token }}</code>
|
||||||
|
<a-button @click="onCopy">
|
||||||
|
<CopyOutlined />
|
||||||
|
复制
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<div class="token-plain__footer">
|
||||||
|
<template v-if="!confirmClose">
|
||||||
|
<a-button @click="tryClose">关闭</a-button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="token-plain__confirm">
|
||||||
|
<WarningOutlined style="color: #faad14" />
|
||||||
|
确认已保存并发送?
|
||||||
|
</span>
|
||||||
|
<a-space>
|
||||||
|
<a-button @click="confirmClose = false">再看看</a-button>
|
||||||
|
<a-button type="primary" @click="tryClose">我已保存并发送</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.token-plain {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-plain__text {
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-plain__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-plain__confirm {
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,6 +6,7 @@ import { computed, reactive, ref, watch } from "vue";
|
|||||||
import type { MemberItem } from "@new-api-key-usage/core";
|
import type { MemberItem } from "@new-api-key-usage/core";
|
||||||
import type { ApiError } from "@/api/http.ts";
|
import type { ApiError } from "@/api/http.ts";
|
||||||
import { useCreateMember, useRegisterKey } from "@/queries/admin.ts";
|
import { useCreateMember, useRegisterKey } from "@/queries/admin.ts";
|
||||||
|
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -30,6 +31,9 @@ const resultMsg = ref<{ type: "success" | "error" | "warning"; text: string } |
|
|||||||
const showNewMember = ref(false);
|
const showNewMember = ref(false);
|
||||||
const newMemberName = ref("");
|
const newMemberName = ref("");
|
||||||
const newMemberRemark = ref("");
|
const newMemberRemark = ref("");
|
||||||
|
const tokenOpen = ref(false);
|
||||||
|
const plainToken = ref("");
|
||||||
|
const tokenMemberName = ref("");
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
@@ -106,15 +110,22 @@ async function onCreateMember() {
|
|||||||
const name = newMemberName.value.trim();
|
const name = newMemberName.value.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
try {
|
try {
|
||||||
const m = await createMember.mutateAsync({
|
const res = await createMember.mutateAsync({
|
||||||
name,
|
name,
|
||||||
remark: newMemberRemark.value.trim(),
|
remark: newMemberRemark.value.trim(),
|
||||||
});
|
});
|
||||||
form.member_id = m.id;
|
form.member_id = res.member.id;
|
||||||
showNewMember.value = false;
|
showNewMember.value = false;
|
||||||
newMemberName.value = "";
|
newMemberName.value = "";
|
||||||
newMemberRemark.value = "";
|
newMemberRemark.value = "";
|
||||||
resultMsg.value = { type: "success", text: `已新建成员「${m.name}」并选为归属` };
|
// POST /members 必回明文令牌——立刻展示,避免遗失
|
||||||
|
plainToken.value = res.token;
|
||||||
|
tokenMemberName.value = res.member.name;
|
||||||
|
tokenOpen.value = true;
|
||||||
|
resultMsg.value = {
|
||||||
|
type: "success",
|
||||||
|
text: `已新建成员「${res.member.name}」并选为归属,请保存下方令牌`,
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e as ApiError;
|
const err = e as ApiError;
|
||||||
resultMsg.value = { type: "error", text: err.message || "新建成员失败" };
|
resultMsg.value = { type: "error", text: err.message || "新建成员失败" };
|
||||||
@@ -187,6 +198,12 @@ async function onCreateMember() {
|
|||||||
:message="resultMsg.text"
|
:message="resultMsg.text"
|
||||||
/>
|
/>
|
||||||
</a-form>
|
</a-form>
|
||||||
|
<MemberTokenModal
|
||||||
|
v-model:open="tokenOpen"
|
||||||
|
:token="plainToken"
|
||||||
|
:member-name="tokenMemberName"
|
||||||
|
@update:open="(v: boolean) => { if (!v) { plainToken = ''; tokenMemberName = ''; } }"
|
||||||
|
/>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export function useKeys(filters: () => KeyListFilters) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: () => qk.admin.keys(filters()),
|
queryKey: () => qk.admin.keys(filters()),
|
||||||
queryFn: () => fetchKeys(filters()),
|
queryFn: () => fetchKeys(filters()),
|
||||||
|
// 切页回列表时立即反映最新归属/状态
|
||||||
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ export function useRegisterKey() {
|
|||||||
mutationFn: registerKey,
|
mutationFn: registerKey,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -69,6 +72,8 @@ export function usePatchKey() {
|
|||||||
}) => patchKey(id, body),
|
}) => patchKey(id, body),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||||
|
// 归属变更会改成员「名下 Key 数」
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -122,6 +127,7 @@ export function useMembers() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: qk.admin.members(),
|
queryKey: qk.admin.members(),
|
||||||
queryFn: fetchMembers,
|
queryFn: fetchMembers,
|
||||||
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +137,7 @@ export function useCreateMember() {
|
|||||||
mutationFn: createMember,
|
mutationFn: createMember,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,227 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 成员管理(交互设计-成员管理):清单表 · 开户 · 令牌明文/重发 · 启停
|
||||||
|
*/
|
||||||
|
import { computed, h, reactive, ref } from "vue";
|
||||||
|
import { Tooltip, message } from "antdv-next";
|
||||||
|
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
||||||
|
import type { MemberItem } from "@new-api-key-usage/core";
|
||||||
|
import type { ApiError } from "@/api/http.ts";
|
||||||
import PageHeader from "@/components/common/page-header.vue";
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
|
||||||
|
import {
|
||||||
|
useCreateMember,
|
||||||
|
useIssueMemberToken,
|
||||||
|
useMembers,
|
||||||
|
usePatchMember,
|
||||||
|
} from "@/queries/admin.ts";
|
||||||
|
import { formatMinute } from "@/utils/time.ts";
|
||||||
|
|
||||||
|
const membersQuery = useMembers();
|
||||||
|
const createMember = useCreateMember();
|
||||||
|
const patchMember = usePatchMember();
|
||||||
|
const issueToken = useIssueMemberToken();
|
||||||
|
|
||||||
|
const createOpen = ref(false);
|
||||||
|
const tokenOpen = ref(false);
|
||||||
|
const plainToken = ref("");
|
||||||
|
const tokenMemberName = ref("");
|
||||||
|
|
||||||
|
const createForm = reactive({ name: "", remark: "" });
|
||||||
|
const createError = ref("");
|
||||||
|
|
||||||
|
function tipTitle(label: string, tip: string) {
|
||||||
|
return h("span", { class: "overview-col-title" }, [
|
||||||
|
label,
|
||||||
|
h(
|
||||||
|
Tooltip,
|
||||||
|
{ title: tip },
|
||||||
|
() =>
|
||||||
|
h(QuestionCircleOutlined, {
|
||||||
|
class: "overview-col-title__icon",
|
||||||
|
"aria-hidden": "true",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: "名称", key: "name", dataIndex: "name", width: 140 },
|
||||||
|
{ title: "备注", key: "remark" },
|
||||||
|
{ title: "状态", key: "status", width: 100 },
|
||||||
|
{
|
||||||
|
title: tipTitle("令牌签发", "明文已不可找回,如需新令牌请重发"),
|
||||||
|
key: "token_issued_at",
|
||||||
|
width: 170,
|
||||||
|
},
|
||||||
|
{ title: "名下 Key 数", key: "key_count", width: 110 },
|
||||||
|
{ title: "操作", key: "actions", width: 200 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = computed(() => membersQuery.data.value ?? []);
|
||||||
|
|
||||||
|
async function onCreate() {
|
||||||
|
createError.value = "";
|
||||||
|
const name = createForm.name.trim();
|
||||||
|
if (!name) {
|
||||||
|
createError.value = "请填写成员名称";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await createMember.mutateAsync({
|
||||||
|
name,
|
||||||
|
remark: createForm.remark.trim(),
|
||||||
|
});
|
||||||
|
createOpen.value = false;
|
||||||
|
createForm.name = "";
|
||||||
|
createForm.remark = "";
|
||||||
|
plainToken.value = res.token;
|
||||||
|
tokenMemberName.value = res.member.name;
|
||||||
|
tokenOpen.value = true;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as ApiError;
|
||||||
|
createError.value = err.message || "开户失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onReissue(record: MemberItem) {
|
||||||
|
try {
|
||||||
|
const res = await issueToken.mutateAsync(record.id);
|
||||||
|
plainToken.value = res.token;
|
||||||
|
tokenMemberName.value = record.name;
|
||||||
|
tokenOpen.value = true;
|
||||||
|
} catch (e) {
|
||||||
|
message.error((e as ApiError).message || "重发失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggle(record: MemberItem) {
|
||||||
|
const next = record.status === "active" ? "disabled" : "active";
|
||||||
|
try {
|
||||||
|
await patchMember.mutateAsync({
|
||||||
|
id: record.id,
|
||||||
|
body: { status: next },
|
||||||
|
});
|
||||||
|
message.success(
|
||||||
|
next === "disabled"
|
||||||
|
? "已停用——该成员视图立即不可访问,数据保留"
|
||||||
|
: "已启用——下一请求即恢复",
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
message.error((e as ApiError).message || "状态更新失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<PageHeader
|
<PageHeader title="成员管理" subtitle="开户、访问令牌与启停">
|
||||||
title="成员管理"
|
<template #actions>
|
||||||
subtitle="开户、令牌、停用(信息结构见交互设计-成员管理)"
|
<a-button type="primary" @click="createOpen = true">新建成员</a-button>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<a-empty
|
||||||
|
v-if="!membersQuery.isPending.value && rows.length === 0"
|
||||||
|
description="还没有成员——为每位分摊朋友开户并发放访问令牌"
|
||||||
|
>
|
||||||
|
<a-button @click="createOpen = true">新建成员</a-button>
|
||||||
|
</a-empty>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
v-else
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="rows"
|
||||||
|
:row-key="(r: MemberItem) => r.id"
|
||||||
|
:loading="membersQuery.isPending.value || membersQuery.isFetching.value"
|
||||||
|
:pagination="false"
|
||||||
|
size="middle"
|
||||||
|
:scroll="{ x: 900 }"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'remark'">
|
||||||
|
{{ record.remark || "—" }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'status'">
|
||||||
|
<a-badge
|
||||||
|
:status="record.status === 'active' ? 'success' : 'default'"
|
||||||
|
:text="record.status === 'active' ? '启用中' : '已停用'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'token_issued_at'">
|
||||||
|
{{ formatMinute(record.token_issued_at) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'key_count'">
|
||||||
|
{{ record.key_count }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'actions'">
|
||||||
|
<a-space>
|
||||||
|
<a-popconfirm
|
||||||
|
title="重发后旧令牌立即失效"
|
||||||
|
description="该成员正在用的访问将中断"
|
||||||
|
ok-text="重发"
|
||||||
|
cancel-text="取消"
|
||||||
|
@confirm="() => onReissue(record)"
|
||||||
|
>
|
||||||
|
<a-button size="small">重发令牌</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
<a-popconfirm
|
||||||
|
v-if="record.status === 'active'"
|
||||||
|
title="停用后该成员的视图立即不可访问"
|
||||||
|
description="令牌验证含状态检查;数据保留"
|
||||||
|
ok-text="停用"
|
||||||
|
cancel-text="取消"
|
||||||
|
@confirm="() => onToggle(record)"
|
||||||
|
>
|
||||||
|
<a-button size="small" danger>停用</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
<a-button v-else size="small" @click="() => onToggle(record)">
|
||||||
|
启用
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<a-modal
|
||||||
|
v-model:open="createOpen"
|
||||||
|
title="新建成员"
|
||||||
|
:confirm-loading="createMember.isPending.value"
|
||||||
|
ok-text="开户并签发令牌"
|
||||||
|
cancel-text="取消"
|
||||||
|
@ok="onCreate"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="名称" required>
|
||||||
|
<a-input v-model:value="createForm.name" placeholder="成员名称" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="备注">
|
||||||
|
<a-input v-model:value="createForm.remark" placeholder="备注(可选)" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-alert v-if="createError" type="error" show-icon :message="createError" />
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<MemberTokenModal
|
||||||
|
v-model:open="tokenOpen"
|
||||||
|
:token="plainToken"
|
||||||
|
:member-name="tokenMemberName"
|
||||||
|
@update:open="(v: boolean) => { if (!v) { plainToken = ''; tokenMemberName = ''; } }"
|
||||||
/>
|
/>
|
||||||
<a-skeleton active :paragraph="{ rows: 4 }" />
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.overview-col-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-col-title__icon {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user