feat: add superadmin member review and coupon ops
This commit is contained in:
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user