feat: 更新租户和订单相关功能,添加租户成员列表接口,优化数据处理和前端展示

This commit is contained in:
2025-12-23 23:38:05 +08:00
parent bcb8c822f1
commit 26e4279f1e
10 changed files with 750 additions and 34 deletions

View File

@@ -7,8 +7,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sakai Vue</title>
<link href="https://fonts.cdnfonts.com/css/lato" rel="stylesheet">
<script type="module" crossorigin src="./assets/index-Brwtp57n.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Ba8sjR1v.css">
<script type="module" crossorigin src="./assets/index-CpopCMB_.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BJeWLZjR.css">
</head>
<body>

View File

@@ -7,8 +7,41 @@ function normalizeItems(items) {
}
export const TenantService = {
async listTenants({ page, limit, name, code, status, sortField, sortOrder } = {}) {
const query = { page, limit, name, code, status };
async listTenants({
page,
limit,
id,
user_id,
name,
code,
status,
expired_at_from,
expired_at_to,
created_at_from,
created_at_to,
sortField,
sortOrder
} = {}) {
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,
id,
user_id,
name,
code,
status,
expired_at_from: iso(expired_at_from),
expired_at_to: iso(expired_at_to),
created_at_from: iso(created_at_from),
created_at_to: iso(created_at_to)
};
if (sortField && sortOrder) {
if (sortOrder === 1) query.asc = sortField;
if (sortOrder === -1) query.desc = sortField;
@@ -33,6 +66,11 @@ export const TenantService = {
}
});
},
async listTenantUsers(tenantID, { page, limit, user_id, username, role, status } = {}) {
return requestJson(`/super/v1/tenants/${tenantID}/users`, {
query: { page, limit, user_id, username, role, status }
});
},
async renewTenantExpire({ tenantID, duration }) {
return requestJson(`/super/v1/tenants/${tenantID}`, {
method: 'PATCH',

View File

@@ -15,11 +15,30 @@ const totalRecords = ref(0);
const page = ref(1);
const rows = ref(10);
const keyword = ref('');
const nameKeyword = ref('');
const codeKeyword = ref('');
const status = ref('');
const tenantID = ref(null);
const ownerUserID = ref(null);
const expiredAtFrom = ref(null);
const expiredAtTo = ref(null);
const createdAtFrom = ref(null);
const createdAtTo = ref(null);
const sortField = ref('id');
const sortOrder = ref(-1);
const tenantUsersDialogVisible = ref(false);
const tenantUsersLoading = ref(false);
const tenantUsersTenant = ref(null);
const tenantUsers = ref([]);
const tenantUsersTotal = ref(0);
const tenantUsersPage = ref(1);
const tenantUsersRows = ref(10);
const tenantUsersUsername = ref('');
const tenantUsersUserID = ref(null);
const tenantUsersRole = ref('');
const tenantUsersStatus = ref('');
const createDialogVisible = ref(false);
const creating = ref(false);
const createCode = ref('');
@@ -38,6 +57,12 @@ function formatDate(value) {
return date.toLocaleString();
}
function formatCny(amountInCents) {
const amount = Number(amountInCents) / 100;
if (!Number.isFinite(amount)) return '-';
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
}
function getStatusSeverity(status) {
switch (status) {
case 'verified':
@@ -76,9 +101,15 @@ async function loadTenants() {
const result = await TenantService.listTenants({
page: page.value,
limit: rows.value,
name: keyword.value,
code: keyword.value,
id: tenantID.value || undefined,
user_id: ownerUserID.value || undefined,
name: nameKeyword.value,
code: codeKeyword.value,
status: status.value,
expired_at_from: expiredAtFrom.value || undefined,
expired_at_to: expiredAtTo.value || undefined,
created_at_from: createdAtFrom.value || undefined,
created_at_to: createdAtTo.value || undefined,
sortField: sortField.value,
sortOrder: sortOrder.value
});
@@ -102,8 +133,15 @@ function onSearch() {
}
function onReset() {
keyword.value = '';
nameKeyword.value = '';
codeKeyword.value = '';
status.value = '';
tenantID.value = null;
ownerUserID.value = null;
expiredAtFrom.value = null;
expiredAtTo.value = null;
createdAtFrom.value = null;
createdAtTo.value = null;
sortField.value = 'id';
sortOrder.value = -1;
page.value = 1;
@@ -123,6 +161,70 @@ function onSort(event) {
loadTenants();
}
function openTenantUsersDialog(tenant) {
tenantUsersTenant.value = tenant;
tenantUsersDialogVisible.value = true;
tenantUsersPage.value = 1;
tenantUsersRows.value = 10;
tenantUsersUsername.value = '';
tenantUsersUserID.value = null;
tenantUsersRole.value = '';
tenantUsersStatus.value = '';
loadTenantUsers();
}
async function loadTenantUsers() {
const tenant = tenantUsersTenant.value;
const id = tenant?.id;
if (!id) return;
tenantUsersLoading.value = true;
try {
const data = await TenantService.listTenantUsers(id, {
page: tenantUsersPage.value,
limit: tenantUsersRows.value,
username: tenantUsersUsername.value,
user_id: tenantUsersUserID.value || undefined,
role: tenantUsersRole.value || undefined,
status: tenantUsersStatus.value || undefined
});
const normalizeItems = (items) => {
if (Array.isArray(items)) return items;
if (items && typeof items === 'object') return [items];
return [];
};
tenantUsers.value = normalizeItems(data?.items);
tenantUsersTotal.value = data?.total ?? 0;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载租户成员列表', life: 4000 });
} finally {
tenantUsersLoading.value = false;
}
}
function onTenantUsersSearch() {
tenantUsersPage.value = 1;
loadTenantUsers();
}
function onTenantUsersReset() {
tenantUsersUsername.value = '';
tenantUsersUserID.value = null;
tenantUsersRole.value = '';
tenantUsersStatus.value = '';
tenantUsersPage.value = 1;
tenantUsersRows.value = 10;
loadTenantUsers();
}
function onTenantUsersPageChange(event) {
tenantUsersPage.value = (event.page ?? 0) + 1;
tenantUsersRows.value = event.rows ?? tenantUsersRows.value;
loadTenantUsers();
}
const renewDialogVisible = ref(false);
const renewing = ref(false);
const renewTenant = ref(null);
@@ -282,17 +384,43 @@ onMounted(() => {
</div>
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
<SearchField label="名称 / Code">
<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="keyword" placeholder="请输入" class="w-full" @keyup.enter="onSearch" />
<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="tenantStatusOptions" optionLabel="label" optionValue="value" placeholder="请选择" :loading="tenantStatusOptionsLoading" class="w-full" />
</SearchField>
<SearchField label="过期时间 From">
<DatePicker v-model="expiredAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="过期时间 To">
<DatePicker v-model="expiredAtTo" showIcon showButtonBar placeholder="结束时间" 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
@@ -319,13 +447,42 @@ onMounted(() => {
<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: 14rem" />
<Column field="user_id" header="Owner" sortable 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 header="管理员" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex flex-wrap gap-1">
<Tag v-for="u in data.admin_users || []" :key="u.id" :value="u.username || String(u.id)" severity="info" />
<span v-if="!data.admin_users || data.admin_users.length === 0" class="text-muted-color">-</span>
</div>
</template>
</Column>
<Column field="status_description" header="状态" style="min-width: 10rem">
<template #body="{ data }">
<Tag :value="data.status_description || data.status || '-'" :severity="getStatusSeverity(data.status)" class="cursor-pointer" @click="openTenantStatusDialog(data)" />
</template>
</Column>
<Column field="user_count" header="用户数" sortable style="min-width: 8rem" />
<Column field="user_balance" header="余额" sortable style="min-width: 8rem" />
<Column field="user_count" header="用户数" sortable style="min-width: 8rem">
<template #body="{ data }">
<Button
:label="String(data.user_count ?? 0)"
text
size="small"
icon="pi pi-users"
class="p-0"
@click="openTenantUsersDialog(data)"
/>
</template>
</Column>
<Column field="income_amount_paid_sum" header="累计收入" sortable style="min-width: 10rem">
<template #body="{ data }">
{{ formatCny(data.income_amount_paid_sum) }}
</template>
</Column>
<Column field="expired_at" header="过期时间" sortable style="min-width: 14rem">
<template #body="{ data }">
<span v-if="data.expired_at" v-tooltip="getExpiryDaysInfo(data.expired_at).tooltipText" :class="getExpiryDaysInfo(data.expired_at).textClass">
@@ -444,5 +601,96 @@ onMounted(() => {
<Button label="确认创建" icon="pi pi-check" @click="confirmCreateTenant" :loading="creating" :disabled="!canCreateTenant" />
</template>
</Dialog>
<Dialog v-model:visible="tenantUsersDialogVisible" :modal="true" :style="{ width: '980px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">租户成员</span>
<span class="text-muted-color truncate max-w-[420px]">{{ tenantUsersTenant?.name ?? tenantUsersTenant?.code ?? '-' }}</span>
</div>
</template>
<div class="flex flex-col gap-4">
<SearchPanel :loading="tenantUsersLoading" @search="onTenantUsersSearch" @reset="onTenantUsersReset">
<SearchField label="UserID">
<InputNumber v-model="tenantUsersUserID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="用户名">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="tenantUsersUsername" placeholder="模糊匹配" class="w-full" @keyup.enter="onTenantUsersSearch" />
</IconField>
</SearchField>
<SearchField label="角色">
<Select
v-model="tenantUsersRole"
:options="[
{ label: '全部', value: '' },
{ label: 'member', value: 'member' },
{ label: 'tenant_admin', value: 'tenant_admin' }
]"
optionLabel="label"
optionValue="value"
placeholder="请选择"
class="w-full"
/>
</SearchField>
<SearchField label="状态">
<Select
v-model="tenantUsersStatus"
:options="[
{ label: '全部', value: '' },
{ label: 'pending_verify', value: 'pending_verify' },
{ label: 'verified', value: 'verified' },
{ label: 'banned', value: 'banned' }
]"
optionLabel="label"
optionValue="value"
placeholder="请选择"
class="w-full"
/>
</SearchField>
</SearchPanel>
<DataTable
:value="tenantUsers"
dataKey="tenant_user.id"
:loading="tenantUsersLoading"
lazy
:paginator="true"
:rows="tenantUsersRows"
:totalRecords="tenantUsersTotal"
:first="(tenantUsersPage - 1) * tenantUsersRows"
:rowsPerPageOptions="[10, 20, 50, 100]"
@page="onTenantUsersPageChange"
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
scrollable
scrollHeight="420px"
responsiveLayout="scroll"
>
<Column field="tenant_user.user_id" header="UserID" style="min-width: 7rem" />
<Column field="user.username" header="用户名" style="min-width: 14rem" />
<Column field="tenant_user.role" header="角色" style="min-width: 12rem">
<template #body="{ data }">
<div class="flex flex-wrap gap-1">
<Tag v-for="r in data?.tenant_user?.role || []" :key="r" :value="r" severity="secondary" />
<span v-if="!data?.tenant_user?.role || data.tenant_user.role.length === 0" class="text-muted-color">-</span>
</div>
</template>
</Column>
<Column field="user.status_description" header="状态" style="min-width: 10rem" />
<Column field="tenant_user.created_at" header="加入时间" style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data?.tenant_user?.created_at) }}
</template>
</Column>
</DataTable>
</div>
<template #footer>
<Button label="关闭" icon="pi pi-times" text @click="tenantUsersDialogVisible = false" />
</template>
</Dialog>
</div>
</template>