feat: add superadmin member review and coupon ops

This commit is contained in:
2026-01-15 11:53:52 +08:00
parent 56082bad4f
commit 8419ddede7
11 changed files with 1254 additions and 96 deletions

View File

@@ -40,5 +40,55 @@ export const CouponService = {
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
},
async createCoupon(tenantID, form = {}) {
if (!tenantID) throw new Error('tenantID is required');
return requestJson(`/super/v1/tenants/${tenantID}/coupons`, {
method: 'POST',
body: normalizeCouponForm(form)
});
},
async getCoupon(tenantID, couponID) {
if (!tenantID) throw new Error('tenantID is required');
if (!couponID) throw new Error('couponID is required');
return requestJson(`/super/v1/tenants/${tenantID}/coupons/${couponID}`);
},
async updateCoupon(tenantID, couponID, form = {}) {
if (!tenantID) throw new Error('tenantID is required');
if (!couponID) throw new Error('couponID is required');
return requestJson(`/super/v1/tenants/${tenantID}/coupons/${couponID}`, {
method: 'PUT',
body: normalizeCouponForm(form)
});
},
async grantCoupon(tenantID, couponID, userIDs = []) {
if (!tenantID) throw new Error('tenantID is required');
if (!couponID) throw new Error('couponID is required');
if (!Array.isArray(userIDs) || userIDs.length === 0) throw new Error('userIDs is required');
return requestJson(`/super/v1/tenants/${tenantID}/coupons/${couponID}/grant`, {
method: 'POST',
body: { user_ids: userIDs }
});
}
};
function normalizeCouponForm(form) {
const iso = (d) => {
if (!d) return undefined;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
};
return {
title: form.title,
description: form.description,
type: form.type,
value: form.value,
min_order_amount: form.min_order_amount,
max_discount: form.max_discount,
total_quantity: form.total_quantity,
start_at: iso(form.start_at),
end_at: iso(form.end_at)
};
}

View File

@@ -38,5 +38,52 @@ export const CreatorService = {
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
},
async listJoinRequests({ page, limit, tenant_id, tenant_code, tenant_name, user_id, username, status, created_at_from, created_at_to } = {}) {
const iso = (d) => {
if (!d) return undefined;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
};
const query = {
page,
limit,
tenant_id,
tenant_code,
tenant_name,
user_id,
username,
status,
created_at_from: iso(created_at_from),
created_at_to: iso(created_at_to)
};
const data = await requestJson('/super/v1/tenant-join-requests', { query });
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
},
async reviewJoinRequest(requestID, { action, reason } = {}) {
if (!requestID) throw new Error('requestID is required');
return requestJson(`/super/v1/tenant-join-requests/${requestID}/review`, {
method: 'POST',
body: { action, reason }
});
},
async createInvite(tenantID, { max_uses, expires_at, remark } = {}) {
if (!tenantID) throw new Error('tenantID is required');
return requestJson(`/super/v1/tenants/${tenantID}/invites`, {
method: 'POST',
body: {
max_uses,
expires_at,
remark
}
});
}
};

View File

@@ -39,6 +39,25 @@ const statusOptions = [
{ label: '已过期', value: 'expired' }
];
const editDialogVisible = ref(false);
const couponSubmitting = ref(false);
const editingCoupon = ref(null);
const formTenantID = ref(null);
const formTitle = ref('');
const formDescription = ref('');
const formType = ref('fix_amount');
const formValue = ref(0);
const formMinOrderAmount = ref(0);
const formMaxDiscount = ref(0);
const formTotalQuantity = ref(0);
const formStartAt = ref(null);
const formEndAt = ref(null);
const grantDialogVisible = ref(false);
const grantSubmitting = ref(false);
const grantCoupon = ref(null);
const grantUserIDsText = ref('');
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
@@ -47,6 +66,13 @@ function formatDate(value) {
return date.toLocaleString();
}
function parseDateValue(value) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date;
}
function formatCny(amountInCents) {
const amount = Number(amountInCents) / 100;
if (!Number.isFinite(amount)) return '-';
@@ -66,6 +92,111 @@ function getStatusSeverity(value) {
}
}
function resetCouponForm() {
formTenantID.value = null;
formTitle.value = '';
formDescription.value = '';
formType.value = 'fix_amount';
formValue.value = 0;
formMinOrderAmount.value = 0;
formMaxDiscount.value = 0;
formTotalQuantity.value = 0;
formStartAt.value = null;
formEndAt.value = null;
}
function openCreateDialog() {
editingCoupon.value = null;
resetCouponForm();
editDialogVisible.value = true;
}
function openEditDialog(row) {
editingCoupon.value = row;
formTenantID.value = row?.tenant_id ?? null;
formTitle.value = row?.title ?? '';
formDescription.value = row?.description ?? '';
formType.value = row?.type ?? 'fix_amount';
formValue.value = row?.value ?? 0;
formMinOrderAmount.value = row?.min_order_amount ?? 0;
formMaxDiscount.value = row?.max_discount ?? 0;
formTotalQuantity.value = row?.total_quantity ?? 0;
formStartAt.value = parseDateValue(row?.start_at);
formEndAt.value = parseDateValue(row?.end_at);
editDialogVisible.value = true;
}
async function confirmSaveCoupon() {
const tenantValue = formTenantID.value;
if (!tenantValue) {
toast.add({ severity: 'warn', summary: '缺少租户', detail: '请填写 TenantID', life: 3000 });
return;
}
if (!formTitle.value.trim()) {
toast.add({ severity: 'warn', summary: '缺少标题', detail: '优惠券标题不能为空', life: 3000 });
return;
}
couponSubmitting.value = true;
try {
const payload = {
title: formTitle.value,
description: formDescription.value,
type: formType.value,
value: formValue.value,
min_order_amount: formMinOrderAmount.value,
max_discount: formMaxDiscount.value,
total_quantity: formTotalQuantity.value,
start_at: formStartAt.value,
end_at: formEndAt.value
};
if (editingCoupon.value?.id) {
await CouponService.updateCoupon(tenantValue, editingCoupon.value.id, payload);
toast.add({ severity: 'success', summary: '更新成功', detail: `CouponID: ${editingCoupon.value.id}`, life: 3000 });
} else {
await CouponService.createCoupon(tenantValue, payload);
toast.add({ severity: 'success', summary: '创建成功', detail: `TenantID: ${tenantValue}`, life: 3000 });
}
editDialogVisible.value = false;
await loadCoupons();
} catch (error) {
toast.add({ severity: 'error', summary: '保存失败', detail: error?.message || '无法保存优惠券', life: 4000 });
} finally {
couponSubmitting.value = false;
}
}
function openGrantDialog(row) {
grantCoupon.value = row;
grantUserIDsText.value = '';
grantDialogVisible.value = true;
}
async function confirmGrant() {
const coupon = grantCoupon.value;
if (!coupon?.id || !coupon?.tenant_id) return;
const ids = grantUserIDsText.value
.split(/[\s,]+/g)
.map((item) => Number(item))
.filter((value) => Number.isFinite(value) && value > 0);
if (ids.length === 0) {
toast.add({ severity: 'warn', summary: '请输入用户ID', detail: '至少填写 1 个用户ID', life: 3000 });
return;
}
grantSubmitting.value = true;
try {
const res = await CouponService.grantCoupon(coupon.tenant_id, coupon.id, ids);
toast.add({ severity: 'success', summary: '发放成功', detail: `已发放 ${res?.granted ?? ids.length}`, life: 3000 });
grantDialogVisible.value = false;
await loadCoupons();
} catch (error) {
toast.add({ severity: 'error', summary: '发放失败', detail: error?.message || '无法发放优惠券', life: 4000 });
} finally {
grantSubmitting.value = false;
}
}
async function loadCoupons() {
loading.value = true;
try {
@@ -136,6 +267,7 @@ onMounted(() => {
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">优惠券</h4>
<Button label="新建优惠券" icon="pi pi-plus" @click="openCreateDialog" />
</div>
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
@@ -246,6 +378,96 @@ onMounted(() => {
{{ formatDate(data.created_at) }}
</template>
</Column>
<Column header="操作" style="min-width: 12rem">
<template #body="{ data }">
<Button label="编辑" icon="pi pi-pencil" text size="small" class="p-0 mr-3" @click="openEditDialog(data)" />
<Button label="发放" icon="pi pi-send" text size="small" class="p-0" @click="openGrantDialog(data)" />
</template>
</Column>
</DataTable>
</div>
<Dialog v-model:visible="editDialogVisible" :modal="true" :style="{ width: '600px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">{{ editingCoupon ? '编辑优惠券' : '新建优惠券' }}</span>
<span v-if="editingCoupon?.id" class="text-muted-color">ID: {{ editingCoupon.id }}</span>
</div>
</template>
<div class="flex flex-col gap-4">
<div>
<label class="block font-medium mb-2">TenantID</label>
<InputNumber v-model="formTenantID" :min="1" placeholder="必填" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">标题</label>
<InputText v-model="formTitle" placeholder="请输入标题" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">描述</label>
<InputText v-model="formDescription" placeholder="可选" class="w-full" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block font-medium mb-2">类型</label>
<Select v-model="formType" :options="typeOptions" optionLabel="label" optionValue="value" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">面额/折扣</label>
<InputNumber v-model="formValue" :min="0" class="w-full" />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block font-medium mb-2">最低门槛()</label>
<InputNumber v-model="formMinOrderAmount" :min="0" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">最高折扣()</label>
<InputNumber v-model="formMaxDiscount" :min="0" class="w-full" />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block font-medium mb-2">发行量</label>
<InputNumber v-model="formTotalQuantity" :min="0" class="w-full" />
</div>
<div class="text-sm text-muted-color flex items-center">0 表示不限量</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block font-medium mb-2">开始时间</label>
<DatePicker v-model="formStartAt" showIcon showButtonBar placeholder="可选" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">结束时间</label>
<DatePicker v-model="formEndAt" showIcon showButtonBar placeholder="可选" class="w-full" />
</div>
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="editDialogVisible = false" :disabled="couponSubmitting" />
<Button label="确认保存" icon="pi pi-check" @click="confirmSaveCoupon" :loading="couponSubmitting" />
</template>
</Dialog>
<Dialog v-model:visible="grantDialogVisible" :modal="true" :style="{ width: '520px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">发放优惠券</span>
<span class="text-muted-color">ID: {{ grantCoupon?.id ?? '-' }}</span>
</div>
</template>
<div class="flex flex-col gap-4">
<div class="text-sm text-muted-color">输入用户ID使用逗号或空格分隔</div>
<div>
<label class="block font-medium mb-2">用户ID列表</label>
<InputText v-model="grantUserIDsText" placeholder="例如1,2,3" class="w-full" />
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="grantDialogVisible = false" :disabled="grantSubmitting" />
<Button label="确认发放" icon="pi pi-send" @click="confirmGrant" :loading="grantSubmitting" />
</template>
</Dialog>
</template>

View File

@@ -8,6 +8,8 @@ import { onMounted, ref } from 'vue';
const toast = useToast();
const tabValue = ref('creators');
const creators = ref([]);
const loading = ref(false);
@@ -32,6 +34,39 @@ const statusUpdating = ref(false);
const statusTenant = ref(null);
const statusValue = ref(null);
const joinRequests = ref([]);
const joinRequestsLoading = ref(false);
const joinRequestsTotal = ref(0);
const joinRequestsPage = ref(1);
const joinRequestsRows = ref(10);
const joinTenantID = ref(null);
const joinTenantCode = ref('');
const joinTenantName = ref('');
const joinUserID = ref(null);
const joinUsername = ref('');
const joinStatus = ref('pending');
const joinCreatedAtFrom = ref(null);
const joinCreatedAtTo = ref(null);
const joinStatusOptions = [
{ label: '全部', value: '' },
{ label: '待审核', value: 'pending' },
{ label: '已通过', value: 'approved' },
{ label: '已驳回', value: 'rejected' }
];
const reviewDialogVisible = ref(false);
const reviewSubmitting = ref(false);
const reviewAction = ref('approve');
const reviewReason = ref('');
const reviewTarget = ref(null);
const inviteDialogVisible = ref(false);
const inviteSubmitting = ref(false);
const inviteTenantID = ref(null);
const inviteMaxUses = ref(1);
const inviteExpiresAt = ref(null);
const inviteRemark = ref('');
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
@@ -53,6 +88,19 @@ function getStatusSeverity(value) {
}
}
function getJoinStatusSeverity(value) {
switch (value) {
case 'pending':
return 'warn';
case 'approved':
return 'success';
case 'rejected':
return 'danger';
default:
return 'secondary';
}
}
async function ensureStatusOptionsLoaded() {
if (statusOptions.value.length > 0) return;
statusOptionsLoading.value = true;
@@ -94,6 +142,30 @@ async function loadCreators() {
}
}
async function loadJoinRequests() {
joinRequestsLoading.value = true;
try {
const result = await CreatorService.listJoinRequests({
page: joinRequestsPage.value,
limit: joinRequestsRows.value,
tenant_id: joinTenantID.value || undefined,
tenant_code: joinTenantCode.value,
tenant_name: joinTenantName.value,
user_id: joinUserID.value || undefined,
username: joinUsername.value,
status: joinStatus.value || undefined,
created_at_from: joinCreatedAtFrom.value || undefined,
created_at_to: joinCreatedAtTo.value || undefined
});
joinRequests.value = result.items;
joinRequestsTotal.value = result.total;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载成员申请', life: 4000 });
} finally {
joinRequestsLoading.value = false;
}
}
function onSearch() {
page.value = 1;
loadCreators();
@@ -126,6 +198,31 @@ function onSort(event) {
loadCreators();
}
function onJoinSearch() {
joinRequestsPage.value = 1;
loadJoinRequests();
}
function onJoinReset() {
joinTenantID.value = null;
joinTenantCode.value = '';
joinTenantName.value = '';
joinUserID.value = null;
joinUsername.value = '';
joinStatus.value = 'pending';
joinCreatedAtFrom.value = null;
joinCreatedAtTo.value = null;
joinRequestsPage.value = 1;
joinRequestsRows.value = 10;
loadJoinRequests();
}
function onJoinPage(event) {
joinRequestsPage.value = (event.page ?? 0) + 1;
joinRequestsRows.value = event.rows ?? joinRequestsRows.value;
loadJoinRequests();
}
async function openStatusDialog(tenant) {
statusTenant.value = tenant;
statusValue.value = tenant?.status ?? null;
@@ -154,98 +251,271 @@ async function confirmUpdateStatus() {
}
}
function openReviewDialog(row, action) {
reviewTarget.value = row;
reviewAction.value = action || 'approve';
reviewReason.value = '';
reviewDialogVisible.value = true;
}
async function confirmReview() {
const targetID = reviewTarget.value?.id;
if (!targetID) return;
const reason = reviewReason.value.trim();
if (reviewAction.value === 'reject' && !reason) {
toast.add({ severity: 'warn', summary: '请输入原因', detail: '驳回时需填写原因', life: 3000 });
return;
}
reviewSubmitting.value = true;
try {
await CreatorService.reviewJoinRequest(targetID, { action: reviewAction.value, reason });
toast.add({ severity: 'success', summary: '审核完成', detail: `申请ID: ${targetID}`, life: 3000 });
reviewDialogVisible.value = false;
await loadJoinRequests();
} catch (error) {
toast.add({ severity: 'error', summary: '审核失败', detail: error?.message || '无法审核申请', life: 4000 });
} finally {
reviewSubmitting.value = false;
}
}
function openInviteDialog(row) {
inviteTenantID.value = Number(row?.tenant_id ?? row?.tenant?.id ?? 0) || null;
inviteMaxUses.value = 1;
inviteExpiresAt.value = null;
inviteRemark.value = '';
inviteDialogVisible.value = true;
}
async function confirmInvite() {
const tenantIDValue = inviteTenantID.value;
if (!tenantIDValue) {
toast.add({ severity: 'warn', summary: '请输入租户ID', detail: '租户ID不能为空', life: 3000 });
return;
}
inviteSubmitting.value = true;
try {
const result = await CreatorService.createInvite(tenantIDValue, {
max_uses: inviteMaxUses.value,
expires_at: inviteExpiresAt.value instanceof Date ? inviteExpiresAt.value.toISOString() : inviteExpiresAt.value || undefined,
remark: inviteRemark.value
});
toast.add({ severity: 'success', summary: '邀请已创建', detail: `邀请码: ${result?.code || '-'}`, life: 4000 });
inviteDialogVisible.value = false;
} catch (error) {
toast.add({ severity: 'error', summary: '创建失败', detail: error?.message || '无法创建邀请', life: 4000 });
} finally {
inviteSubmitting.value = false;
}
}
onMounted(() => {
loadCreators();
ensureStatusOptionsLoaded().catch(() => {});
loadJoinRequests();
});
</script>
<template>
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">创作者列表</h4>
</div>
<Tabs v-model:value="tabValue" value="creators">
<TabList>
<Tab value="creators">创作者列表</Tab>
<Tab value="members">成员审核</Tab>
</TabList>
<TabPanels>
<TabPanel value="creators">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">创作者列表</h4>
</div>
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
<SearchField label="TenantID">
<InputNumber v-model="tenantID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="Owner UserID">
<InputNumber v-model="ownerUserID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="名称">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="nameKeyword" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</IconField>
</SearchField>
<SearchField label="Code">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="codeKeyword" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</IconField>
</SearchField>
<SearchField label="状态">
<Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="请选择" :loading="statusOptionsLoading" class="w-full" />
</SearchField>
<SearchField label="创建时间 From">
<DatePicker v-model="createdAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="创建时间 To">
<DatePicker v-model="createdAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
</SearchPanel>
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
<SearchField label="TenantID">
<InputNumber v-model="tenantID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="Owner UserID">
<InputNumber v-model="ownerUserID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="名称">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="nameKeyword" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</IconField>
</SearchField>
<SearchField label="Code">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="codeKeyword" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</IconField>
</SearchField>
<SearchField label="状态">
<Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="请选择" :loading="statusOptionsLoading" class="w-full" />
</SearchField>
<SearchField label="创建时间 From">
<DatePicker v-model="createdAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="创建时间 To">
<DatePicker v-model="createdAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
</SearchPanel>
<DataTable
:value="creators"
dataKey="id"
:loading="loading"
lazy
:paginator="true"
:rows="rows"
:totalRecords="totalRecords"
:first="(page - 1) * rows"
:rowsPerPageOptions="[10, 20, 50, 100]"
sortMode="single"
:sortField="sortField"
:sortOrder="sortOrder"
@page="onPage"
@sort="onSort"
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
scrollable
scrollHeight="flex"
responsiveLayout="scroll"
>
<Column field="id" header="ID" sortable style="min-width: 6rem" />
<Column field="code" header="Code" style="min-width: 10rem" />
<Column field="name" header="名称" sortable style="min-width: 16rem">
<template #body="{ data }">
<Button :label="data.name || '-'" icon="pi pi-external-link" text size="small" class="p-0" as="router-link" :to="`/superadmin/tenants/${data.id}`" />
</template>
</Column>
<Column field="owner.username" header="Owner" style="min-width: 12rem">
<template #body="{ data }">
<span v-if="data.owner?.username">{{ data.owner.username }}</span>
<span v-else class="text-muted-color">{{ data.user_id ?? '-' }}</span>
</template>
</Column>
<Column field="status" header="状态" style="min-width: 10rem">
<template #body="{ data }">
<Tag :value="data.status_description || data.status || '-'" :severity="getStatusSeverity(data.status)" class="cursor-pointer" @click="openStatusDialog(data)" />
</template>
</Column>
<Column field="user_count" header="成员数" style="min-width: 8rem" />
<Column field="created_at" header="创建时间" sortable style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
</DataTable>
<DataTable
:value="creators"
dataKey="id"
:loading="loading"
lazy
:paginator="true"
:rows="rows"
:totalRecords="totalRecords"
:first="(page - 1) * rows"
:rowsPerPageOptions="[10, 20, 50, 100]"
sortMode="single"
:sortField="sortField"
:sortOrder="sortOrder"
@page="onPage"
@sort="onSort"
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
scrollable
scrollHeight="flex"
responsiveLayout="scroll"
>
<Column field="id" header="ID" sortable style="min-width: 6rem" />
<Column field="code" header="Code" style="min-width: 10rem" />
<Column field="name" header="名称" sortable style="min-width: 16rem">
<template #body="{ data }">
<Button :label="data.name || '-'" icon="pi pi-external-link" text size="small" class="p-0" as="router-link" :to="`/superadmin/tenants/${data.id}`" />
</template>
</Column>
<Column field="owner.username" header="Owner" style="min-width: 12rem">
<template #body="{ data }">
<span v-if="data.owner?.username">{{ data.owner.username }}</span>
<span v-else class="text-muted-color">{{ data.user_id ?? '-' }}</span>
</template>
</Column>
<Column field="status" header="状态" style="min-width: 10rem">
<template #body="{ data }">
<Tag :value="data.status_description || data.status || '-'" :severity="getStatusSeverity(data.status)" class="cursor-pointer" @click="openStatusDialog(data)" />
</template>
</Column>
<Column field="user_count" header="成员数" style="min-width: 8rem" />
<Column field="created_at" header="创建时间" sortable style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
</DataTable>
</TabPanel>
<TabPanel value="members">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<h4 class="m-0">成员申请</h4>
<span class="text-muted-color">跨租户审核与邀请</span>
</div>
<Button label="创建邀请" icon="pi pi-link" severity="secondary" @click="openInviteDialog()" />
</div>
<SearchPanel :loading="joinRequestsLoading" @search="onJoinSearch" @reset="onJoinReset">
<SearchField label="TenantID">
<InputNumber v-model="joinTenantID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="TenantCode">
<InputText v-model="joinTenantCode" placeholder="模糊匹配" class="w-full" @keyup.enter="onJoinSearch" />
</SearchField>
<SearchField label="TenantName">
<InputText v-model="joinTenantName" placeholder="模糊匹配" class="w-full" @keyup.enter="onJoinSearch" />
</SearchField>
<SearchField label="UserID">
<InputNumber v-model="joinUserID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="Username">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="joinUsername" placeholder="模糊匹配" class="w-full" @keyup.enter="onJoinSearch" />
</IconField>
</SearchField>
<SearchField label="状态">
<Select v-model="joinStatus" :options="joinStatusOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
</SearchField>
<SearchField label="申请时间 From">
<DatePicker v-model="joinCreatedAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="申请时间 To">
<DatePicker v-model="joinCreatedAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
</SearchPanel>
<DataTable
:value="joinRequests"
dataKey="id"
:loading="joinRequestsLoading"
lazy
:paginator="true"
:rows="joinRequestsRows"
:totalRecords="joinRequestsTotal"
:first="(joinRequestsPage - 1) * joinRequestsRows"
:rowsPerPageOptions="[10, 20, 50, 100]"
@page="onJoinPage"
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
scrollable
scrollHeight="420px"
responsiveLayout="scroll"
>
<Column field="id" header="申请ID" style="min-width: 8rem" />
<Column header="租户" style="min-width: 16rem">
<template #body="{ data }">
<router-link v-if="data.tenant_id" class="inline-flex items-center gap-1 font-medium text-primary hover:underline" :to="`/superadmin/tenants/${data.tenant_id}`">
<span class="truncate max-w-[200px]">{{ data.tenant_name || data.tenant_code || '-' }}</span>
<i class="pi pi-external-link text-xs" />
</router-link>
<div class="text-xs text-muted-color">Code: {{ data.tenant_code || '-' }} / ID: {{ data.tenant_id ?? '-' }}</div>
</template>
</Column>
<Column header="申请用户" style="min-width: 14rem">
<template #body="{ data }">
<router-link v-if="data.user_id" class="inline-flex items-center gap-1 font-medium text-primary hover:underline" :to="`/superadmin/users/${data.user_id}`">
<span class="truncate max-w-[200px]">{{ data.username || `ID:${data.user_id}` }}</span>
<i class="pi pi-external-link text-xs" />
</router-link>
<div class="text-xs text-muted-color">ID: {{ data.user_id ?? '-' }}</div>
</template>
</Column>
<Column field="status" header="状态" style="min-width: 10rem">
<template #body="{ data }">
<Tag :value="data.status_description || data.status || '-'" :severity="getJoinStatusSeverity(data.status)" />
</template>
</Column>
<Column field="reason" header="申请说明" style="min-width: 16rem" />
<Column field="created_at" header="申请时间" style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
<Column field="decided_at" header="处理时间" style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.decided_at) }}
</template>
</Column>
<Column header="操作" style="min-width: 12rem">
<template #body="{ data }">
<Button v-if="data.status === 'pending'" label="通过" icon="pi pi-check" text size="small" class="p-0 mr-3" @click="openReviewDialog(data, 'approve')" />
<Button v-if="data.status === 'pending'" label="驳回" icon="pi pi-times" severity="danger" text size="small" class="p-0 mr-3" @click="openReviewDialog(data, 'reject')" />
<Button label="邀请" icon="pi pi-link" text size="small" class="p-0" @click="openInviteDialog(data)" />
</template>
</Column>
</DataTable>
</TabPanel>
</TabPanels>
</Tabs>
</div>
<Dialog v-model:visible="statusDialogVisible" :modal="true" :style="{ width: '420px' }">
@@ -266,4 +536,67 @@ onMounted(() => {
<Button label="确认" icon="pi pi-check" @click="confirmUpdateStatus" :loading="statusUpdating" :disabled="!statusValue" />
</template>
</Dialog>
<Dialog v-model:visible="reviewDialogVisible" :modal="true" :style="{ width: '520px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">成员申请审核</span>
<span class="text-muted-color">申请ID: {{ reviewTarget?.id ?? '-' }}</span>
</div>
</template>
<div class="flex flex-col gap-4">
<div class="text-sm text-muted-color">审核租户成员申请请确认处理动作与备注</div>
<div>
<label class="block font-medium mb-2">审核动作</label>
<Select
v-model="reviewAction"
:options="[
{ label: '通过', value: 'approve' },
{ label: '驳回', value: 'reject' }
]"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
<div>
<label class="block font-medium mb-2">审核说明</label>
<InputText v-model="reviewReason" placeholder="驳回时建议填写原因" class="w-full" />
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="reviewDialogVisible = false" :disabled="reviewSubmitting" />
<Button label="确认审核" icon="pi pi-check" severity="success" @click="confirmReview" :loading="reviewSubmitting" :disabled="reviewSubmitting || (reviewAction === 'reject' && !reviewReason.trim())" />
</template>
</Dialog>
<Dialog v-model:visible="inviteDialogVisible" :modal="true" :style="{ width: '520px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">创建成员邀请</span>
</div>
</template>
<div class="flex flex-col gap-4">
<div>
<label class="block font-medium mb-2">TenantID</label>
<InputNumber v-model="inviteTenantID" :min="1" placeholder="请输入租户ID" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">最大使用次数</label>
<InputNumber v-model="inviteMaxUses" :min="1" placeholder="默认 1 次" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">过期时间</label>
<DatePicker v-model="inviteExpiresAt" showIcon showButtonBar placeholder="默认 7 天" class="w-full" />
</div>
<div>
<label class="block font-medium mb-2">备注</label>
<InputText v-model="inviteRemark" placeholder="可选备注" class="w-full" />
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="inviteDialogVisible = false" :disabled="inviteSubmitting" />
<Button label="确认创建" icon="pi pi-check" @click="confirmInvite" :loading="inviteSubmitting" />
</template>
</Dialog>
</template>