feat: expand superadmin user detail views
This commit is contained in:
@@ -117,5 +117,68 @@ export const UserService = {
|
||||
total: data?.total ?? 0,
|
||||
items: normalizeItems(data?.items)
|
||||
};
|
||||
},
|
||||
async getUserRealName(userID) {
|
||||
if (!userID) throw new Error('userID is required');
|
||||
return requestJson(`/super/v1/users/${userID}/realname`);
|
||||
},
|
||||
async listUserNotifications(userID, { page, limit, tenant_id, type, read, created_at_from, created_at_to } = {}) {
|
||||
if (!userID) throw new Error('userID is required');
|
||||
|
||||
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,
|
||||
type,
|
||||
read,
|
||||
created_at_from: iso(created_at_from),
|
||||
created_at_to: iso(created_at_to)
|
||||
};
|
||||
|
||||
const data = await requestJson(`/super/v1/users/${userID}/notifications`, { query });
|
||||
return {
|
||||
page: data?.page ?? page ?? 1,
|
||||
limit: data?.limit ?? limit ?? 10,
|
||||
total: data?.total ?? 0,
|
||||
items: normalizeItems(data?.items)
|
||||
};
|
||||
},
|
||||
async listUserCoupons(userID, { page, limit, tenant_id, tenant_code, tenant_name, status, type, keyword, created_at_from, created_at_to } = {}) {
|
||||
if (!userID) throw new Error('userID is required');
|
||||
|
||||
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,
|
||||
status,
|
||||
type,
|
||||
keyword,
|
||||
created_at_from: iso(created_at_from),
|
||||
created_at_to: iso(created_at_to)
|
||||
};
|
||||
|
||||
const data = await requestJson(`/super/v1/users/${userID}/coupons`, { query });
|
||||
return {
|
||||
page: data?.page ?? page ?? 1,
|
||||
limit: data?.limit ?? limit ?? 10,
|
||||
total: data?.total ?? 0,
|
||||
items: normalizeItems(data?.items)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import SearchField from '@/components/SearchField.vue';
|
||||
import SearchPanel from '@/components/SearchPanel.vue';
|
||||
import { OrderService } from '@/service/OrderService';
|
||||
import { TenantService } from '@/service/TenantService';
|
||||
import { UserService } from '@/service/UserService';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
@@ -18,6 +19,27 @@ const user = ref(null);
|
||||
const wallet = ref(null);
|
||||
const walletLoading = ref(false);
|
||||
|
||||
const realName = ref(null);
|
||||
const realNameLoading = ref(false);
|
||||
|
||||
const notifications = ref([]);
|
||||
const notificationsLoading = ref(false);
|
||||
const notificationsTotal = ref(0);
|
||||
const notificationsPage = ref(1);
|
||||
const notificationsRows = ref(10);
|
||||
|
||||
const coupons = ref([]);
|
||||
const couponsLoading = ref(false);
|
||||
const couponsTotal = ref(0);
|
||||
const couponsPage = ref(1);
|
||||
const couponsRows = ref(10);
|
||||
|
||||
const rechargeOrders = ref([]);
|
||||
const rechargeOrdersLoading = ref(false);
|
||||
const rechargeOrdersTotal = ref(0);
|
||||
const rechargeOrdersPage = ref(1);
|
||||
const rechargeOrdersRows = ref(10);
|
||||
|
||||
const tabValue = ref('owned');
|
||||
|
||||
function formatDate(value) {
|
||||
@@ -69,6 +91,42 @@ function getStatusSeverity(status) {
|
||||
}
|
||||
}
|
||||
|
||||
function getOrderStatusSeverity(value) {
|
||||
switch (value) {
|
||||
case 'paid':
|
||||
return 'success';
|
||||
case 'created':
|
||||
case 'refunding':
|
||||
return 'warn';
|
||||
case 'failed':
|
||||
case 'canceled':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function getNotificationReadSeverity(value) {
|
||||
return value ? 'secondary' : 'warn';
|
||||
}
|
||||
|
||||
function getUserCouponSeverity(value) {
|
||||
switch (value) {
|
||||
case 'unused':
|
||||
return 'success';
|
||||
case 'used':
|
||||
return 'secondary';
|
||||
case 'expired':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function getRealNameSeverity(value) {
|
||||
return value ? 'success' : 'secondary';
|
||||
}
|
||||
|
||||
function hasRole(role) {
|
||||
const roles = user.value?.roles || [];
|
||||
return Array.isArray(roles) && roles.includes(role);
|
||||
@@ -100,6 +158,77 @@ async function loadWallet() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRealName() {
|
||||
const id = userID.value;
|
||||
if (!id || Number.isNaN(id)) return;
|
||||
realNameLoading.value = true;
|
||||
try {
|
||||
realName.value = await UserService.getUserRealName(id);
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载实名认证信息', life: 4000 });
|
||||
} finally {
|
||||
realNameLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNotifications() {
|
||||
const id = userID.value;
|
||||
if (!id || Number.isNaN(id)) return;
|
||||
notificationsLoading.value = true;
|
||||
try {
|
||||
const result = await UserService.listUserNotifications(id, {
|
||||
page: notificationsPage.value,
|
||||
limit: notificationsRows.value
|
||||
});
|
||||
notifications.value = result.items;
|
||||
notificationsTotal.value = result.total;
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载通知列表', life: 4000 });
|
||||
} finally {
|
||||
notificationsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCoupons() {
|
||||
const id = userID.value;
|
||||
if (!id || Number.isNaN(id)) return;
|
||||
couponsLoading.value = true;
|
||||
try {
|
||||
const result = await UserService.listUserCoupons(id, {
|
||||
page: couponsPage.value,
|
||||
limit: couponsRows.value
|
||||
});
|
||||
coupons.value = result.items;
|
||||
couponsTotal.value = result.total;
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载优惠券列表', life: 4000 });
|
||||
} finally {
|
||||
couponsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRechargeOrders() {
|
||||
const id = userID.value;
|
||||
if (!id || Number.isNaN(id)) return;
|
||||
rechargeOrdersLoading.value = true;
|
||||
try {
|
||||
const result = await OrderService.listOrders({
|
||||
page: rechargeOrdersPage.value,
|
||||
limit: rechargeOrdersRows.value,
|
||||
user_id: id,
|
||||
type: 'recharge',
|
||||
sortField: 'created_at',
|
||||
sortOrder: -1
|
||||
});
|
||||
rechargeOrders.value = result.items;
|
||||
rechargeOrdersTotal.value = result.total;
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载充值记录', life: 4000 });
|
||||
} finally {
|
||||
rechargeOrdersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const statusDialogVisible = ref(false);
|
||||
const statusLoading = ref(false);
|
||||
const statusOptionsLoading = ref(false);
|
||||
@@ -266,15 +395,40 @@ function onJoinedTenantsPage(event) {
|
||||
loadJoinedTenants();
|
||||
}
|
||||
|
||||
function onNotificationsPage(event) {
|
||||
notificationsPage.value = (event.page ?? 0) + 1;
|
||||
notificationsRows.value = event.rows ?? notificationsRows.value;
|
||||
loadNotifications();
|
||||
}
|
||||
|
||||
function onCouponsPage(event) {
|
||||
couponsPage.value = (event.page ?? 0) + 1;
|
||||
couponsRows.value = event.rows ?? couponsRows.value;
|
||||
loadCoupons();
|
||||
}
|
||||
|
||||
function onRechargeOrdersPage(event) {
|
||||
rechargeOrdersPage.value = (event.page ?? 0) + 1;
|
||||
rechargeOrdersRows.value = event.rows ?? rechargeOrdersRows.value;
|
||||
loadRechargeOrders();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => userID.value,
|
||||
() => {
|
||||
ownedTenantsPage.value = 1;
|
||||
joinedTenantsPage.value = 1;
|
||||
notificationsPage.value = 1;
|
||||
couponsPage.value = 1;
|
||||
rechargeOrdersPage.value = 1;
|
||||
loadUser();
|
||||
loadWallet();
|
||||
loadRealName();
|
||||
loadOwnedTenants();
|
||||
loadJoinedTenants();
|
||||
loadNotifications();
|
||||
loadCoupons();
|
||||
loadRechargeOrders();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
@@ -343,6 +497,10 @@ onMounted(() => {
|
||||
<Tab value="owned">拥有的租户</Tab>
|
||||
<Tab value="joined">加入的租户</Tab>
|
||||
<Tab value="wallet">钱包</Tab>
|
||||
<Tab value="recharge">充值记录</Tab>
|
||||
<Tab value="coupons">优惠券</Tab>
|
||||
<Tab value="notifications">通知</Tab>
|
||||
<Tab value="realname">实名认证</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel value="owned">
|
||||
@@ -538,6 +696,213 @@ onMounted(() => {
|
||||
</DataTable>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel value="recharge">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-color">共 {{ rechargeOrdersTotal }} 条</span>
|
||||
<Button label="刷新" icon="pi pi-refresh" severity="secondary" @click="loadRechargeOrders" :disabled="rechargeOrdersLoading" />
|
||||
</div>
|
||||
<DataTable
|
||||
:value="rechargeOrders"
|
||||
dataKey="id"
|
||||
:loading="rechargeOrdersLoading"
|
||||
lazy
|
||||
:paginator="true"
|
||||
:rows="rechargeOrdersRows"
|
||||
:totalRecords="rechargeOrdersTotal"
|
||||
:first="(rechargeOrdersPage - 1) * rechargeOrdersRows"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
@page="onRechargeOrdersPage"
|
||||
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 field="status" header="状态" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data?.status_description || data?.status || '-'" :severity="getOrderStatusSeverity(data?.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="amount_paid" header="实付金额" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatCny(data.amount_paid) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="租户" style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ data?.tenant?.name || '平台/系统' }}</span>
|
||||
<span class="text-xs text-muted-color">Code: {{ data?.tenant?.code || '-' }} / ID: {{ data?.tenant?.id ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="paid_at" header="支付时间" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.paid_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="created_at" header="创建时间" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.created_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel value="coupons">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-color">共 {{ couponsTotal }} 条</span>
|
||||
<Button label="刷新" icon="pi pi-refresh" severity="secondary" @click="loadCoupons" :disabled="couponsLoading" />
|
||||
</div>
|
||||
<DataTable
|
||||
:value="coupons"
|
||||
dataKey="id"
|
||||
:loading="couponsLoading"
|
||||
lazy
|
||||
:paginator="true"
|
||||
:rows="couponsRows"
|
||||
:totalRecords="couponsTotal"
|
||||
:first="(couponsPage - 1) * couponsRows"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
@page="onCouponsPage"
|
||||
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 field="coupon_id" header="券ID" style="min-width: 8rem" />
|
||||
<Column header="租户" style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ data.tenant_name || '-' }}</span>
|
||||
<span class="text-xs text-muted-color">Code: {{ data.tenant_code || '-' }} / ID: {{ data.tenant_id ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="title" header="标题" style="min-width: 16rem" />
|
||||
<Column header="类型" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ data.type_description || data.type || '-' }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="value" header="面额/折扣" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<span v-if="data.type === 'discount'">{{ data.value ?? '-' }}%</span>
|
||||
<span v-else>{{ formatCny(data.value) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="状态" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status_description || data.status || '-'" :severity="getUserCouponSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="order_id" header="使用订单" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ data.order_id ? data.order_id : '-' }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="used_at" header="使用时间" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.used_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="created_at" header="领取时间" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.created_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel value="notifications">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-color">共 {{ notificationsTotal }} 条</span>
|
||||
<Button label="刷新" icon="pi pi-refresh" severity="secondary" @click="loadNotifications" :disabled="notificationsLoading" />
|
||||
</div>
|
||||
<DataTable
|
||||
:value="notifications"
|
||||
dataKey="id"
|
||||
:loading="notificationsLoading"
|
||||
lazy
|
||||
:paginator="true"
|
||||
:rows="notificationsRows"
|
||||
:totalRecords="notificationsTotal"
|
||||
:first="(notificationsPage - 1) * notificationsRows"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
@page="onNotificationsPage"
|
||||
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 }">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ data.tenant_name || '平台/系统' }}</span>
|
||||
<span class="text-xs text-muted-color">Code: {{ data.tenant_code || '-' }} / ID: {{ data.tenant_id ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="type" header="类型" style="min-width: 10rem" />
|
||||
<Column field="title" header="标题" style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<span class="block max-w-[240px] truncate">{{ data.title || '-' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="content" header="内容" style="min-width: 20rem">
|
||||
<template #body="{ data }">
|
||||
<span class="block max-w-[320px] truncate">{{ data.content || '-' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="read" header="状态" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.read ? '已读' : '未读'" :severity="getNotificationReadSeverity(data.read)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="created_at" header="发送时间" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.created_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel value="realname">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag :value="realName?.is_real_name_verified ? '已认证' : '未认证'" :severity="getRealNameSeverity(realName?.is_real_name_verified)" />
|
||||
<span class="text-muted-color">实名认证信息</span>
|
||||
</div>
|
||||
<Button label="刷新" icon="pi pi-refresh" severity="secondary" @click="loadRealName" :disabled="realNameLoading" />
|
||||
</div>
|
||||
<div v-if="realNameLoading" class="flex items-center justify-center py-10">
|
||||
<ProgressSpinner style="width: 36px; height: 36px" strokeWidth="6" />
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">真实姓名</div>
|
||||
<div class="font-medium">{{ realName?.real_name || '-' }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">身份证号</div>
|
||||
<div class="font-medium">{{ realName?.id_card_masked || '-' }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-sm text-muted-color">认证时间</div>
|
||||
<div class="font-medium">{{ formatDate(realName?.verified_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user