feat: add creator member management

This commit is contained in:
2026-01-17 20:42:43 +08:00
parent 984a404b5f
commit 7fca7a40e7
14 changed files with 2915 additions and 81 deletions

View File

@@ -31,6 +31,25 @@ export const creatorApi = {
request(`/creator/coupons/${id}`, { method: "PUT", body: data }),
grantCoupon: (id, data) =>
request(`/creator/coupons/${id}/grant`, { method: "POST", body: data }),
listMembers: (params) => {
const qs = new URLSearchParams(params).toString();
return request(`/creator/members?${qs}`);
},
removeMember: (id) => request(`/creator/members/${id}`, { method: "DELETE" }),
listMemberInvites: (params) => {
const qs = new URLSearchParams(params).toString();
return request(`/creator/members/invites?${qs}`);
},
createMemberInvite: (data) =>
request("/creator/members/invite", { method: "POST", body: data }),
disableMemberInvite: (id) =>
request(`/creator/members/invites/${id}`, { method: "DELETE" }),
listMemberJoinRequests: (params) => {
const qs = new URLSearchParams(params).toString();
return request(`/creator/members/join-requests?${qs}`);
},
reviewMemberJoinRequest: (id, data) =>
request(`/creator/members/${id}/review`, { method: "POST", body: data }),
getSettings: () => request("/creator/settings"),
updateSettings: (data) =>
request("/creator/settings", { method: "PUT", body: data }),

View File

@@ -108,6 +108,16 @@ const isFullWidth = computed(() => {
配置
</div>
<router-link
:to="tenantRoute('/creator/members')"
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group"
>
<i
class="pi pi-users text-lg group-hover:scale-110 transition-transform"
></i>
<span class="font-medium">成员管理</span>
</router-link>
<router-link
:to="tenantRoute('/creator/settings')"
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"

View File

@@ -149,6 +149,11 @@ const router = createRouter({
name: "creator-orders",
component: () => import("../views/creator/OrdersView.vue"),
},
{
path: "members",
name: "creator-members",
component: () => import("../views/creator/MembersView.vue"),
},
{
path: "coupons",
name: "creator-coupons",

View File

@@ -0,0 +1,706 @@
<script setup>
import Dialog from "primevue/dialog";
import Toast from "primevue/toast";
import { useToast } from "primevue/usetoast";
import { computed, onMounted, reactive, ref } from "vue";
import { creatorApi } from "../../api/creator";
const toast = useToast();
const activeTab = ref("members");
const loading = reactive({
members: false,
invites: false,
requests: false,
});
const memberKeyword = ref("");
const memberRole = ref("");
const memberStatus = ref("");
const members = ref([]);
const memberPager = ref({ page: 1, limit: 10, total: 0 });
const inviteStatus = ref("");
const invites = ref([]);
const invitePager = ref({ page: 1, limit: 10, total: 0 });
const inviteDialog = ref(false);
const inviteForm = reactive({
max_uses: 1,
expires_at: "",
remark: "",
});
const requestKeyword = ref("");
const requestStatus = ref("pending");
const requests = ref([]);
const requestPager = ref({ page: 1, limit: 10, total: 0 });
const reviewDialog = ref(false);
const reviewAction = ref("approve");
const reviewReason = ref("");
const reviewTarget = ref(null);
const tabs = [
{ key: "members", label: "成员列表" },
{ key: "requests", label: "加入申请" },
{ key: "invites", label: "邀请记录" },
];
const fetchMembers = async () => {
loading.members = true;
try {
const res = await creatorApi.listMembers({
page: memberPager.value.page,
limit: memberPager.value.limit,
keyword: memberKeyword.value,
role: memberRole.value,
status: memberStatus.value,
});
members.value = res.items || [];
memberPager.value.total = res.total || 0;
} catch (e) {
console.error(e);
} finally {
loading.members = false;
}
};
const fetchInvites = async () => {
loading.invites = true;
try {
const res = await creatorApi.listMemberInvites({
page: invitePager.value.page,
limit: invitePager.value.limit,
status: inviteStatus.value,
});
invites.value = res.items || [];
invitePager.value.total = res.total || 0;
} catch (e) {
console.error(e);
} finally {
loading.invites = false;
}
};
const fetchJoinRequests = async () => {
loading.requests = true;
try {
const res = await creatorApi.listMemberJoinRequests({
page: requestPager.value.page,
limit: requestPager.value.limit,
status: requestStatus.value,
keyword: requestKeyword.value,
});
requests.value = res.items || [];
requestPager.value.total = res.total || 0;
} catch (e) {
console.error(e);
} finally {
loading.requests = false;
}
};
onMounted(() => {
fetchMembers();
fetchInvites();
fetchJoinRequests();
});
const memberStatusStyle = (status) => {
switch (status) {
case "verified":
return { bg: "bg-emerald-50", text: "text-emerald-600" };
case "banned":
return { bg: "bg-rose-50", text: "text-rose-600" };
default:
return { bg: "bg-slate-100", text: "text-slate-500" };
}
};
const inviteStatusStyle = (status) => {
switch (status) {
case "active":
return { bg: "bg-emerald-50", text: "text-emerald-600" };
case "disabled":
return { bg: "bg-slate-100", text: "text-slate-500" };
case "expired":
return { bg: "bg-amber-50", text: "text-amber-700" };
default:
return { bg: "bg-slate-100", text: "text-slate-500" };
}
};
const requestStatusStyle = (status) => {
switch (status) {
case "pending":
return { bg: "bg-amber-50", text: "text-amber-700" };
case "approved":
return { bg: "bg-emerald-50", text: "text-emerald-600" };
case "rejected":
return { bg: "bg-rose-50", text: "text-rose-600" };
default:
return { bg: "bg-slate-100", text: "text-slate-500" };
}
};
const openInviteDialog = () => {
inviteForm.max_uses = 1;
inviteForm.expires_at = "";
inviteForm.remark = "";
inviteDialog.value = true;
};
const createInvite = async () => {
try {
const payload = {
max_uses: Number(inviteForm.max_uses) || 1,
remark: inviteForm.remark,
};
if (inviteForm.expires_at) {
payload.expires_at = new Date(inviteForm.expires_at).toISOString();
}
await creatorApi.createMemberInvite(payload);
inviteDialog.value = false;
toast.add({ severity: "success", summary: "已创建邀请", life: 2000 });
fetchInvites();
} catch (e) {
toast.add({
severity: "error",
summary: "创建失败",
detail: e.message,
life: 3000,
});
}
};
const disableInvite = async (invite) => {
if (!invite || !invite.id) return;
if (!window.confirm("确认撤销该邀请?")) return;
try {
await creatorApi.disableMemberInvite(invite.id);
toast.add({ severity: "success", summary: "已撤销", life: 2000 });
fetchInvites();
} catch (e) {
toast.add({
severity: "error",
summary: "撤销失败",
detail: e.message,
life: 3000,
});
}
};
const removeMember = async (member) => {
if (!member || !member.id) return;
if (!window.confirm("确认移除该成员?")) return;
try {
await creatorApi.removeMember(member.id);
toast.add({ severity: "success", summary: "成员已移除", life: 2000 });
fetchMembers();
} catch (e) {
toast.add({
severity: "error",
summary: "移除失败",
detail: e.message,
life: 3000,
});
}
};
const openReviewDialog = (item, action) => {
reviewTarget.value = item;
reviewAction.value = action;
reviewReason.value = "";
reviewDialog.value = true;
};
const submitReview = async () => {
if (!reviewTarget.value) return;
try {
await creatorApi.reviewMemberJoinRequest(reviewTarget.value.id, {
action: reviewAction.value,
reason: reviewReason.value,
});
reviewDialog.value = false;
toast.add({ severity: "success", summary: "已处理申请", life: 2000 });
fetchJoinRequests();
fetchMembers();
} catch (e) {
toast.add({
severity: "error",
summary: "处理失败",
detail: e.message,
life: 3000,
});
}
};
const tabClass = (key) =>
key === activeTab.value
? "bg-slate-900 text-white"
: "bg-white text-slate-600 border border-slate-200 hover:bg-slate-50";
const pendingRequests = computed(() =>
requests.value.filter((item) => item.status === "pending"),
);
</script>
<template>
<div>
<Toast />
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-slate-900">成员管理</h1>
<div class="flex gap-2">
<button
v-for="tab in tabs"
:key="tab.key"
class="px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
:class="tabClass(tab.key)"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
</div>
<div v-if="activeTab === 'members'" class="space-y-6">
<div
class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 flex flex-wrap gap-4 items-center"
>
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-slate-500">角色:</span>
<select
v-model="memberRole"
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white"
>
<option value="">全部</option>
<option value="member">成员</option>
<option value="tenant_admin">管理员</option>
</select>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-slate-500">状态:</span>
<select
v-model="memberStatus"
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white"
>
<option value="">全部</option>
<option value="verified">已审核</option>
<option value="banned">已封禁</option>
<option value="active">正常</option>
</select>
</div>
<div class="ml-auto relative w-72">
<i
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
></i>
<input
type="text"
v-model="memberKeyword"
@keyup.enter="fetchMembers"
placeholder="搜索昵称/手机号..."
class="w-full h-9 pl-9 pr-4 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
/>
</div>
<button
class="px-4 py-2 rounded-lg bg-slate-900 text-white text-sm font-semibold"
@click="fetchMembers"
>
筛选
</button>
</div>
<div
class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden"
>
<table class="w-full text-left text-sm">
<thead
class="bg-slate-50 text-slate-500 font-bold border-b border-slate-200"
>
<tr>
<th class="px-6 py-4">成员</th>
<th class="px-6 py-4">角色</th>
<th class="px-6 py-4">状态</th>
<th class="px-6 py-4">加入时间</th>
<th class="px-6 py-4 text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-if="loading.members">
<td colspan="5" class="px-6 py-6 text-center text-slate-500">
加载中...
</td>
</tr>
<tr v-else-if="members.length === 0">
<td colspan="5" class="px-6 py-8 text-center text-slate-400">
暂无成员数据
</td>
</tr>
<tr
v-for="member in members"
:key="member.id"
class="hover:bg-slate-50"
>
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<img
:src="
member.user?.avatar ||
`https://api.dicebear.com/7.x/avataaars/svg?seed=${member.user?.id}`
"
class="w-10 h-10 rounded-full object-cover"
/>
<div>
<div class="font-semibold text-slate-900">
{{
member.user?.nickname || member.user?.username || "-"
}}
</div>
<div class="text-xs text-slate-400">
{{ member.user?.phone || "--" }}
</div>
</div>
</div>
</td>
<td class="px-6 py-4 text-slate-700">
{{ member.role_description?.join(" / ") || "成员" }}
</td>
<td class="px-6 py-4">
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
:class="`${memberStatusStyle(member.status).bg} ${memberStatusStyle(member.status).text}`"
>
{{ member.status_description || member.status }}
</span>
</td>
<td class="px-6 py-4 text-slate-500">
{{ member.created_at || "--" }}
</td>
<td class="px-6 py-4 text-right">
<button
class="text-rose-600 text-sm font-semibold hover:text-rose-700"
@click="removeMember(member)"
>
移除
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="activeTab === 'requests'" class="space-y-6">
<div
class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 flex flex-wrap gap-4 items-center"
>
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-slate-500">状态:</span>
<select
v-model="requestStatus"
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white"
>
<option value="pending">待审核</option>
<option value="approved">已通过</option>
<option value="rejected">已拒绝</option>
<option value="">全部</option>
</select>
</div>
<div class="ml-auto relative w-72">
<i
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
></i>
<input
type="text"
v-model="requestKeyword"
@keyup.enter="fetchJoinRequests"
placeholder="搜索昵称/手机号..."
class="w-full h-9 pl-9 pr-4 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
/>
</div>
<button
class="px-4 py-2 rounded-lg bg-slate-900 text-white text-sm font-semibold"
@click="fetchJoinRequests"
>
筛选
</button>
</div>
<div
class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden"
>
<table class="w-full text-left text-sm">
<thead
class="bg-slate-50 text-slate-500 font-bold border-b border-slate-200"
>
<tr>
<th class="px-6 py-4">申请人</th>
<th class="px-6 py-4">申请理由</th>
<th class="px-6 py-4">状态</th>
<th class="px-6 py-4">申请时间</th>
<th class="px-6 py-4 text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-if="loading.requests">
<td colspan="5" class="px-6 py-6 text-center text-slate-500">
加载中...
</td>
</tr>
<tr v-else-if="requests.length === 0">
<td colspan="5" class="px-6 py-8 text-center text-slate-400">
暂无申请记录
</td>
</tr>
<tr v-for="req in requests" :key="req.id" class="hover:bg-slate-50">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<img
:src="
req.user?.avatar ||
`https://api.dicebear.com/7.x/avataaars/svg?seed=${req.user?.id}`
"
class="w-10 h-10 rounded-full object-cover"
/>
<div>
<div class="font-semibold text-slate-900">
{{ req.user?.nickname || req.user?.username || "-" }}
</div>
<div class="text-xs text-slate-400">
{{ req.user?.phone || "--" }}
</div>
</div>
</div>
</td>
<td class="px-6 py-4 text-slate-600">
{{ req.reason || "-" }}
</td>
<td class="px-6 py-4">
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
:class="`${requestStatusStyle(req.status).bg} ${requestStatusStyle(req.status).text}`"
>
{{ req.status_description || req.status }}
</span>
</td>
<td class="px-6 py-4 text-slate-500">
{{ req.created_at || "--" }}
</td>
<td class="px-6 py-4 text-right">
<div
v-if="req.status === 'pending'"
class="flex justify-end gap-2"
>
<button
class="px-3 py-1 text-sm rounded-lg bg-emerald-500 text-white"
@click="openReviewDialog(req, 'approve')"
>
通过
</button>
<button
class="px-3 py-1 text-sm rounded-lg bg-rose-500 text-white"
@click="openReviewDialog(req, 'reject')"
>
拒绝
</button>
</div>
<span v-else class="text-slate-400 text-sm">已处理</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="pendingRequests.length === 0" class="text-sm text-slate-500">
当前暂无待审核申请
</div>
</div>
<div v-else class="space-y-6">
<div class="flex items-center justify-between">
<div
class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 flex gap-4 items-center"
>
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-slate-500">状态:</span>
<select
v-model="inviteStatus"
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white"
>
<option value="">全部</option>
<option value="active">可用</option>
<option value="disabled">已撤销</option>
<option value="expired">已过期</option>
</select>
</div>
<button
class="px-4 py-2 rounded-lg bg-slate-900 text-white text-sm font-semibold"
@click="fetchInvites"
>
筛选
</button>
</div>
<button
class="px-4 py-2 rounded-lg bg-primary-600 text-white text-sm font-semibold shadow-sm"
@click="openInviteDialog"
>
<i class="pi pi-plus mr-1"></i> 新建邀请
</button>
</div>
<div
class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden"
>
<table class="w-full text-left text-sm">
<thead
class="bg-slate-50 text-slate-500 font-bold border-b border-slate-200"
>
<tr>
<th class="px-6 py-4">邀请码</th>
<th class="px-6 py-4">状态</th>
<th class="px-6 py-4">使用情况</th>
<th class="px-6 py-4">过期时间</th>
<th class="px-6 py-4">备注</th>
<th class="px-6 py-4 text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-if="loading.invites">
<td colspan="6" class="px-6 py-6 text-center text-slate-500">
加载中...
</td>
</tr>
<tr v-else-if="invites.length === 0">
<td colspan="6" class="px-6 py-8 text-center text-slate-400">
暂无邀请记录
</td>
</tr>
<tr
v-for="invite in invites"
:key="invite.id"
class="hover:bg-slate-50"
>
<td class="px-6 py-4 font-mono text-slate-700">
{{ invite.code }}
</td>
<td class="px-6 py-4">
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
:class="`${inviteStatusStyle(invite.status).bg} ${inviteStatusStyle(invite.status).text}`"
>
{{ invite.status_description || invite.status }}
</span>
</td>
<td class="px-6 py-4 text-slate-600">
{{ invite.used_count }} / {{ invite.max_uses }}
</td>
<td class="px-6 py-4 text-slate-500">
{{ invite.expires_at || "长期有效" }}
</td>
<td class="px-6 py-4 text-slate-500">
{{ invite.remark || "-" }}
</td>
<td class="px-6 py-4 text-right">
<button
v-if="invite.status === 'active'"
class="text-rose-600 text-sm font-semibold hover:text-rose-700"
@click="disableInvite(invite)"
>
撤销
</button>
<span v-else class="text-slate-400 text-sm">--</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<Dialog
v-model:visible="inviteDialog"
modal
header="新建邀请"
class="w-[420px]"
>
<div class="space-y-4">
<div>
<label class="block text-sm text-slate-600 mb-2">最大使用次数</label>
<input
type="number"
min="1"
v-model="inviteForm.max_uses"
class="w-full h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
/>
</div>
<div>
<label class="block text-sm text-slate-600 mb-2">过期时间</label>
<input
type="datetime-local"
v-model="inviteForm.expires_at"
class="w-full h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
/>
</div>
<div>
<label class="block text-sm text-slate-600 mb-2">备注</label>
<input
type="text"
v-model="inviteForm.remark"
placeholder="可选"
class="w-full h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
/>
</div>
</div>
<template #footer>
<button
class="px-4 py-2 rounded-lg border border-slate-200 text-sm text-slate-600 mr-2"
@click="inviteDialog = false"
>
取消
</button>
<button
class="px-4 py-2 rounded-lg bg-slate-900 text-white text-sm font-semibold"
@click="createInvite"
>
创建
</button>
</template>
</Dialog>
<Dialog
v-model:visible="reviewDialog"
modal
header="审核申请"
class="w-[420px]"
>
<div class="space-y-4">
<div class="text-sm text-slate-600">
当前操作
<span class="font-semibold text-slate-900">{{
reviewAction === "approve" ? "通过" : "拒绝"
}}</span>
</div>
<div>
<label class="block text-sm text-slate-600 mb-2">备注说明</label>
<textarea
v-model="reviewReason"
rows="3"
placeholder="可选"
class="w-full px-3 py-2 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
></textarea>
</div>
</div>
<template #footer>
<button
class="px-4 py-2 rounded-lg border border-slate-200 text-sm text-slate-600 mr-2"
@click="reviewDialog = false"
>
取消
</button>
<button
class="px-4 py-2 rounded-lg bg-slate-900 text-white text-sm font-semibold"
@click="submitReview"
>
确认
</button>
</template>
</Dialog>
</div>
</template>