Files
quyun-v2/frontend/portal/src/views/creator/MembersView.vue

707 lines
23 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>
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>