feat: extend superadmin navigation

This commit is contained in:
2026-01-14 16:25:18 +08:00
parent 820ee5ba10
commit 788ba4c53a
21 changed files with 248 additions and 330 deletions

View File

@@ -0,0 +1,36 @@
<script setup>
const props = defineProps({
title: { type: String, required: true },
description: { type: String, default: '' },
badge: { type: String, default: 'Pending' },
endpoints: { type: Array, default: () => [] },
notes: { type: Array, default: () => [] }
});
</script>
<template>
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">{{ props.title }}</h4>
<Tag severity="warning" :value="props.badge" />
</div>
<Message v-if="props.description" severity="warn" icon="pi pi-exclamation-triangle">
{{ props.description }}
</Message>
<div v-if="props.endpoints.length" class="mt-4">
<div class="font-medium mb-2">Suggested APIs</div>
<ul class="list-disc pl-5 text-sm text-muted-color">
<li v-for="item in props.endpoints" :key="item">{{ item }}</li>
</ul>
</div>
<div v-if="props.notes.length" class="mt-4">
<div class="font-medium mb-2">Notes</div>
<ul class="list-disc pl-5 text-sm text-muted-color">
<li v-for="item in props.notes" :key="item">{{ item }}</li>
</ul>
</div>
</div>
</template>

View File

@@ -14,7 +14,13 @@ const model = ref([
{ label: 'Tenants', icon: 'pi pi-fw pi-building', to: '/superadmin/tenants' },
{ label: 'Users', icon: 'pi pi-fw pi-users', to: '/superadmin/users' },
{ label: 'Orders', icon: 'pi pi-fw pi-shopping-cart', to: '/superadmin/orders' },
{ label: 'Contents', icon: 'pi pi-fw pi-file', to: '/superadmin/contents' }
{ label: 'Contents', icon: 'pi pi-fw pi-file', to: '/superadmin/contents' },
{ label: 'Creators', icon: 'pi pi-fw pi-star', to: '/superadmin/creators' },
{ label: 'Coupons', icon: 'pi pi-fw pi-ticket', to: '/superadmin/coupons' },
{ label: 'Finance', icon: 'pi pi-fw pi-wallet', to: '/superadmin/finance' },
{ label: 'Reports', icon: 'pi pi-fw pi-chart-line', to: '/superadmin/reports' },
{ label: 'Assets', icon: 'pi pi-fw pi-folder', to: '/superadmin/assets' },
{ label: 'Notifications', icon: 'pi pi-fw pi-bell', to: '/superadmin/notifications' }
]
}
]);

View File

@@ -144,6 +144,36 @@ const router = createRouter({
name: 'superadmin-contents',
component: () => import('@/views/superadmin/Contents.vue')
},
{
path: '/superadmin/creators',
name: 'superadmin-creators',
component: () => import('@/views/superadmin/Creators.vue')
},
{
path: '/superadmin/coupons',
name: 'superadmin-coupons',
component: () => import('@/views/superadmin/Coupons.vue')
},
{
path: '/superadmin/finance',
name: 'superadmin-finance',
component: () => import('@/views/superadmin/Finance.vue')
},
{
path: '/superadmin/reports',
name: 'superadmin-reports',
component: () => import('@/views/superadmin/Reports.vue')
},
{
path: '/superadmin/assets',
name: 'superadmin-assets',
component: () => import('@/views/superadmin/Assets.vue')
},
{
path: '/superadmin/notifications',
name: 'superadmin-notifications',
component: () => import('@/views/superadmin/Notifications.vue')
},
{
path: '/superadmin/orders/:orderID',
name: 'superadmin-order-detail',

View File

@@ -67,23 +67,7 @@ export const ContentService = {
items: normalizeItems(data?.items)
};
},
async listTenantContents(
tenantID,
{
page,
limit,
keyword,
status,
visibility,
user_id,
published_at_from,
published_at_to,
created_at_from,
created_at_to,
sortField,
sortOrder
} = {}
) {
async listTenantContents(tenantID, { page, limit, keyword, status, visibility, user_id, published_at_from, published_at_to, created_at_from, created_at_to, sortField, sortOrder } = {}) {
if (!tenantID) throw new Error('tenantID is required');
const iso = (d) => {
@@ -117,8 +101,7 @@ export const ContentService = {
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
}
,
},
async updateTenantContentStatus(tenantID, contentID, { status } = {}) {
if (!tenantID) throw new Error('tenantID is required');
if (!contentID) throw new Error('contentID is required');

View File

@@ -7,21 +7,7 @@ function normalizeItems(items) {
}
export const TenantService = {
async listTenants({
page,
limit,
id,
user_id,
name,
code,
status,
expired_at_from,
expired_at_to,
created_at_from,
created_at_to,
sortField,
sortOrder
} = {}) {
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);

View File

@@ -7,21 +7,7 @@ function normalizeItems(items) {
}
export const UserService = {
async listUsers({
page,
limit,
id,
tenant_id,
username,
status,
role,
created_at_from,
created_at_to,
verified_at_from,
verified_at_to,
sortField,
sortOrder
} = {}) {
async listUsers({ page, limit, id, tenant_id, username, status, role, created_at_from, created_at_to, verified_at_from, verified_at_to, sortField, sortOrder } = {}) {
const iso = (d) => {
if (!d) return undefined;
const date = d instanceof Date ? d : new Date(d);
@@ -98,10 +84,7 @@ export const UserService = {
if (!userID) throw new Error('userID is required');
return requestJson(`/super/v1/users/${userID}`);
},
async listUserTenants(
userID,
{ page, limit, tenant_id, code, name, role, status, created_at_from, created_at_to } = {}
) {
async listUserTenants(userID, { page, limit, tenant_id, code, name, role, status, created_at_from, created_at_to } = {}) {
if (!userID) throw new Error('userID is required');
const iso = (d) => {

View File

@@ -39,4 +39,3 @@ export async function refreshSuperToken() {
if (token) setSuperAuthToken(token);
return token;
}

View File

@@ -0,0 +1,11 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
const endpoints = ['GET /super/v1/assets', 'DELETE /super/v1/assets/:id', 'GET /super/v1/assets/usage'];
const notes = ['Upload and storage endpoints are tenant-scoped today.', 'Add asset inventory before enabling cleanup actions.'];
</script>
<template>
<PendingPanel title="Assets" description="Asset governance requires a super admin inventory API." :endpoints="endpoints" :notes="notes" />
</template>

View File

@@ -292,11 +292,7 @@ onMounted(() => {
</Column>
<Column header="租户" sortable sortField="tenant_id" style="min-width: 18rem">
<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}`"
>
<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-[220px]">{{ data?.tenant?.name ?? data?.tenant?.code ?? '-' }}</span>
<i class="pi pi-external-link text-xs" />
</router-link>
@@ -309,11 +305,7 @@ onMounted(() => {
</Column>
<Column header="Owner" sortable sortField="user_id" style="min-width: 14rem">
<template #body="{ data }">
<router-link
v-if="(data?.owner?.id ?? data?.content?.user_id) > 0"
class="inline-flex items-center gap-1 font-medium text-primary hover:underline"
:to="`/superadmin/users/${data?.owner?.id ?? data?.content?.user_id}`"
>
<router-link v-if="(data?.owner?.id ?? data?.content?.user_id) > 0" class="inline-flex items-center gap-1 font-medium text-primary hover:underline" :to="`/superadmin/users/${data?.owner?.id ?? data?.content?.user_id}`">
<span class="truncate max-w-[200px]">{{ data?.owner?.username ?? `ID:${data?.content?.user_id ?? '-'}` }}</span>
<i class="pi pi-external-link text-xs" />
</router-link>
@@ -353,16 +345,7 @@ onMounted(() => {
</Column>
<Column header="操作" style="min-width: 10rem">
<template #body="{ data }">
<Button
v-if="data?.content?.status === 'published'"
label="下架"
icon="pi pi-ban"
severity="danger"
text
size="small"
class="p-0"
@click="openUnpublishDialog(data)"
/>
<Button v-if="data?.content?.status === 'published'" label="下架" icon="pi pi-ban" severity="danger" text size="small" class="p-0" @click="openUnpublishDialog(data)" />
<span v-else class="text-muted-color">-</span>
</template>
</Column>

View File

@@ -0,0 +1,11 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
const endpoints = ['GET /super/v1/coupons', 'PATCH /super/v1/coupons/:id/status', 'GET /super/v1/coupon-grants'];
const notes = ['Current coupon CRUD endpoints are tenant-scoped and tied to creator ownership.', 'Expose cross-tenant coupon listing before adding bulk actions.'];
</script>
<template>
<PendingPanel title="Coupons" description="Coupon management needs a super admin aggregation layer." :endpoints="endpoints" :notes="notes" />
</template>

View File

@@ -0,0 +1,18 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
const endpoints = [
'GET /super/v1/creators',
'GET /super/v1/creator-applications',
'POST /super/v1/creator-applications/:id/review',
'GET /super/v1/creator-members',
'POST /super/v1/creator-members/:id/review',
'POST /super/v1/creator-members/invite'
];
const notes = ['Tenant-level creator endpoints require the tenant owner and are not usable from super admin today.', 'Keep creator approvals in the tenant admin portal until super admin APIs are added.'];
</script>
<template>
<PendingPanel title="Creators" description="Super admin creator operations require cross-tenant APIs." :endpoints="endpoints" :notes="notes" />
</template>

View File

@@ -0,0 +1,11 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
const endpoints = ['GET /super/v1/withdrawals', 'POST /super/v1/withdrawals/:id/approve', 'POST /super/v1/withdrawals/:id/reject', 'GET /super/v1/wallet-ledgers'];
const notes = ['Withdrawals currently exist only in tenant creator APIs.', 'Add a super admin ledger view before exposing approvals.'];
</script>
<template>
<PendingPanel title="Finance" description="Withdrawals and wallet visibility require super admin endpoints." :endpoints="endpoints" :notes="notes" />
</template>

View File

@@ -0,0 +1,11 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
const endpoints = ['GET /super/v1/notifications', 'POST /super/v1/notifications/broadcast', 'POST /super/v1/notifications/templates', 'GET /super/v1/notifications/templates'];
const notes = ['The current notification API is user-scoped only.', 'Add super admin send and template endpoints before enabling operations.'];
</script>
<template>
<PendingPanel title="Notifications" description="Notification management is pending super admin APIs." :endpoints="endpoints" :notes="notes" />
</template>

View File

@@ -176,9 +176,7 @@ watch(
</div>
</template>
<div class="flex flex-col gap-4">
<div class="text-sm text-muted-color">
该操作会将订单从 <span class="font-medium">paid</span> 推进到 <span class="font-medium">refunding</span> 并提交异步退款任务
</div>
<div class="text-sm text-muted-color">该操作会将订单从 <span class="font-medium">paid</span> 推进到 <span class="font-medium">refunding</span> 并提交异步退款任务</div>
<div class="flex items-center gap-2">
<Checkbox v-model="refundForce" inputId="refundForce" binary />
<label for="refundForce" class="cursor-pointer">强制退款绕过默认时间窗</label>
@@ -194,4 +192,3 @@ watch(
</template>
</Dialog>
</template>

View File

@@ -328,15 +328,7 @@ loadOrders();
</Column>
<Column header="操作" style="min-width: 10rem">
<template #body="{ data }">
<Button
label="退款"
icon="pi pi-replay"
text
size="small"
class="p-0"
:disabled="data?.status !== 'paid'"
@click="openRefundDialog(data)"
/>
<Button label="退款" icon="pi pi-replay" text size="small" class="p-0" :disabled="data?.status !== 'paid'" @click="openRefundDialog(data)" />
</template>
</Column>
</DataTable>
@@ -352,9 +344,7 @@ loadOrders();
</div>
</template>
<div class="flex flex-col gap-4">
<div class="text-sm text-muted-color">
该操作会将订单从 <span class="font-medium">paid</span> 推进到 <span class="font-medium">refunding</span> 并提交异步退款任务
</div>
<div class="text-sm text-muted-color">该操作会将订单从 <span class="font-medium">paid</span> 推进到 <span class="font-medium">refunding</span> 并提交异步退款任务</div>
<div class="flex items-center gap-2">
<Checkbox v-model="refundForce" inputId="refundForce" binary />
<label for="refundForce" class="cursor-pointer">强制退款绕过默认时间窗</label>
@@ -366,14 +356,7 @@ loadOrders();
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="refundDialogVisible = false" :disabled="refundLoading" />
<Button
label="确认退款"
icon="pi pi-check"
severity="danger"
@click="confirmRefund"
:loading="refundLoading"
:disabled="refundOrder?.status !== 'paid'"
/>
<Button label="确认退款" icon="pi pi-check" severity="danger" @click="confirmRefund" :loading="refundLoading" :disabled="refundOrder?.status !== 'paid'" />
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,11 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
const endpoints = ['GET /super/v1/reports/overview', 'GET /super/v1/reports/series', 'POST /super/v1/reports/export'];
const notes = ['Current report APIs are scoped to creators in tenant context.', 'Add cross-tenant aggregation before wiring charts and exports.'];
</script>
<template>
<PendingPanel title="Reports" description="Platform reporting needs aggregated super admin APIs." :endpoints="endpoints" :notes="notes" />
</template>

View File

@@ -111,9 +111,7 @@ async function ensureStatusOptionsLoaded() {
statusOptionsLoading.value = true;
try {
const list = await TenantService.getTenantStatuses();
statusOptions.value = (list || [])
.map((kv) => ({ label: kv?.value ?? kv?.key ?? '-', value: kv?.key ?? '' }))
.filter((item) => item.value);
statusOptions.value = (list || []).map((kv) => ({ label: kv?.value ?? kv?.key ?? '-', value: kv?.key ?? '' })).filter((item) => item.value);
} finally {
statusOptionsLoading.value = false;
}
@@ -642,14 +640,7 @@ onMounted(() => {
<Select v-model="contentsStatus" :options="contentStatusOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
</SearchField>
<SearchField label="可见性">
<Select
v-model="contentsVisibility"
:options="contentVisibilityOptions"
optionLabel="label"
optionValue="value"
placeholder="请选择"
class="w-full"
/>
<Select v-model="contentsVisibility" :options="contentVisibilityOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
</SearchField>
<SearchField label="OwnerUserID">
<InputNumber v-model="contentsOwnerUserID" :min="1" placeholder="精确匹配" class="w-full" />
@@ -717,10 +708,7 @@ onMounted(() => {
</Column>
<Column header="可见性" sortable sortField="visibility" style="min-width: 12rem">
<template #body="{ data }">
<Tag
:value="data?.visibility_description || data?.content?.visibility || '-'"
:severity="getContentVisibilitySeverity(data?.content?.visibility)"
/>
<Tag :value="data?.visibility_description || data?.content?.visibility || '-'" :severity="getContentVisibilitySeverity(data?.content?.visibility)" />
</template>
</Column>
<Column header="价格" style="min-width: 10rem">
@@ -743,16 +731,7 @@ onMounted(() => {
</Column>
<Column header="操作" style="min-width: 10rem">
<template #body="{ data }">
<Button
v-if="data?.content?.status === 'published'"
label="下架"
icon="pi pi-ban"
severity="danger"
text
size="small"
class="p-0"
@click="openUnpublishDialog(data)"
/>
<Button v-if="data?.content?.status === 'published'" label="下架" icon="pi pi-ban" severity="danger" text size="small" class="p-0" @click="openUnpublishDialog(data)" />
<span v-else class="text-muted-color">-</span>
</template>
</Column>

View File

@@ -448,15 +448,7 @@ onMounted(() => {
<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}`"
/>
<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="user_id" header="Owner" sortable style="min-width: 12rem">
@@ -480,14 +472,7 @@ onMounted(() => {
</Column>
<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)"
/>
<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">

View File

@@ -78,9 +78,7 @@ async function ensureStatusOptionsLoaded() {
statusOptionsLoading.value = true;
try {
const list = await UserService.getUserStatuses();
statusOptions.value = (list || [])
.map((kv) => ({ label: kv?.value ?? kv?.key ?? '-', value: kv?.key ?? '' }))
.filter((item) => item.value);
statusOptions.value = (list || []).map((kv) => ({ label: kv?.value ?? kv?.key ?? '-', value: kv?.key ?? '' })).filter((item) => item.value);
} finally {
statusOptionsLoading.value = false;
}
@@ -336,15 +334,7 @@ onMounted(() => {
<Column field="code" header="Code" style="min-width: 10rem" />
<Column field="name" header="名称" style="min-width: 14rem">
<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}`"
/>
<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="status_description" header="状态" style="min-width: 10rem" />
@@ -393,15 +383,7 @@ onMounted(() => {
/>
</SearchField>
<SearchField label="成员状态">
<Select
v-model="joinedTenantsStatus"
:options="statusFilterOptions"
optionLabel="label"
optionValue="value"
placeholder="请选择"
:loading="statusOptionsLoading"
class="w-full"
/>
<Select v-model="joinedTenantsStatus" :options="statusFilterOptions" optionLabel="label" optionValue="value" placeholder="请选择" :loading="statusOptionsLoading" class="w-full" />
</SearchField>
<SearchField label="加入时间 From">
<DatePicker v-model="joinedTenantsJoinedAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
@@ -432,15 +414,7 @@ onMounted(() => {
<Column field="code" header="Code" style="min-width: 10rem" />
<Column field="name" header="名称" style="min-width: 14rem">
<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.tenant_id}`"
/>
<Button :label="data.name || '-'" icon="pi pi-external-link" text size="small" class="p-0" as="router-link" :to="`/superadmin/tenants/${data.tenant_id}`" />
</template>
</Column>
<Column header="Owner" style="min-width: 12rem">

View File

@@ -475,15 +475,7 @@ onMounted(() => {
<Column field="id" header="ID" sortable style="min-width: 6rem" />
<Column field="username" header="用户名" sortable style="min-width: 16rem">
<template #body="{ data }">
<Button
:label="data.username || '-'"
icon="pi pi-external-link"
text
size="small"
class="p-0"
as="router-link"
:to="`/superadmin/users/${data.id}`"
/>
<Button :label="data.username || '-'" icon="pi pi-external-link" text size="small" class="p-0" as="router-link" :to="`/superadmin/users/${data.id}`" />
</template>
</Column>
<Column field="status" header="状态" sortable style="min-width: 10rem">
@@ -501,14 +493,7 @@ onMounted(() => {
</Column>
<Column header="超管" style="min-width: 9rem">
<template #body="{ data }">
<Button
:label="hasRole(data, 'super_admin') ? '是' : '否'"
icon="pi pi-user-edit"
text
size="small"
class="p-0"
@click="openRolesDialog(data)"
/>
<Button :label="hasRole(data, 'super_admin') ? '是' : '否'" icon="pi pi-user-edit" text size="small" class="p-0" @click="openRolesDialog(data)" />
</template>
</Column>
<Column field="balance" header="余额" sortable style="min-width: 10rem">
@@ -523,28 +508,12 @@ onMounted(() => {
</Column>
<Column header="拥有租户" style="min-width: 10rem">
<template #body="{ data }">
<Button
:label="String(data.owned_tenant_count ?? 0)"
icon="pi pi-building"
text
size="small"
class="p-0"
:disabled="(data.owned_tenant_count ?? 0) === 0"
@click="openOwnedTenantsDialog(data)"
/>
<Button :label="String(data.owned_tenant_count ?? 0)" icon="pi pi-building" text size="small" class="p-0" :disabled="(data.owned_tenant_count ?? 0) === 0" @click="openOwnedTenantsDialog(data)" />
</template>
</Column>
<Column header="加入租户" style="min-width: 10rem">
<template #body="{ data }">
<Button
:label="String(data.joined_tenant_count ?? 0)"
icon="pi pi-users"
text
size="small"
class="p-0"
:disabled="(data.joined_tenant_count ?? 0) === 0"
@click="openJoinedTenantsDialog(data)"
/>
<Button :label="String(data.joined_tenant_count ?? 0)" icon="pi pi-users" text size="small" class="p-0" :disabled="(data.joined_tenant_count ?? 0) === 0" @click="openJoinedTenantsDialog(data)" />
</template>
</Column>
<Column field="verified_at" header="认证时间" sortable style="min-width: 14rem">