feat: wire superadmin p1 data

This commit is contained in:
2026-01-15 09:35:16 +08:00
parent bb4c5b39d2
commit 235a216b0c
21 changed files with 3188 additions and 28 deletions

View File

@@ -0,0 +1,44 @@
import { requestJson } from './apiClient';
function normalizeItems(items) {
if (Array.isArray(items)) return items;
if (items && typeof items === 'object') return [items];
return [];
}
export const CouponService = {
async listCoupons({ page, limit, id, tenant_id, tenant_code, tenant_name, keyword, type, status, 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,
tenant_id,
tenant_code,
tenant_name,
keyword,
type,
status,
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;
}
const data = await requestJson('/super/v1/coupons', { query });
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
}
};

View File

@@ -0,0 +1,42 @@
import { requestJson } from './apiClient';
function normalizeItems(items) {
if (Array.isArray(items)) return items;
if (items && typeof items === 'object') return [items];
return [];
}
export const CreatorService = {
async listCreators({ page, limit, id, user_id, name, code, status, 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,
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;
}
const data = await requestJson('/super/v1/creators', { query });
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
}
};

View File

@@ -0,0 +1,59 @@
import { requestJson } from './apiClient';
function normalizeItems(items) {
if (Array.isArray(items)) return items;
if (items && typeof items === 'object') return [items];
return [];
}
export const FinanceService = {
async listWithdrawals({ page, limit, id, tenant_id, tenant_code, tenant_name, user_id, username, status, created_at_from, created_at_to, paid_at_from, paid_at_to, amount_paid_min, amount_paid_max, 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,
tenant_id,
tenant_code,
tenant_name,
user_id,
username,
status,
created_at_from: iso(created_at_from),
created_at_to: iso(created_at_to),
paid_at_from: iso(paid_at_from),
paid_at_to: iso(paid_at_to),
amount_paid_min,
amount_paid_max
};
if (sortField && sortOrder) {
if (sortOrder === 1) query.asc = sortField;
if (sortOrder === -1) query.desc = sortField;
}
const data = await requestJson('/super/v1/withdrawals', { query });
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
},
async approveWithdrawal(id) {
if (!id) throw new Error('id is required');
return requestJson(`/super/v1/withdrawals/${id}/approve`, { method: 'POST' });
},
async rejectWithdrawal(id, { reason } = {}) {
if (!id) throw new Error('id is required');
return requestJson(`/super/v1/withdrawals/${id}/reject`, {
method: 'POST',
body: { reason }
});
}
};

View File

@@ -0,0 +1,39 @@
import { requestJson } from './apiClient';
export const ReportService = {
async getOverview({ tenant_id, start_at, end_at, granularity } = {}) {
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 = {
tenant_id,
start_at: iso(start_at),
end_at: iso(end_at),
granularity
};
return requestJson('/super/v1/reports/overview', { query });
},
async exportReport({ tenant_id, start_at, end_at, granularity, format } = {}) {
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 requestJson('/super/v1/reports/export', {
method: 'POST',
body: {
tenant_id,
start_at: iso(start_at),
end_at: iso(end_at),
granularity,
format: format || 'csv'
}
});
}
};

View File

@@ -1,11 +1,251 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import { CouponService } from '@/service/CouponService';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue';
const endpoints = ['GET /super/v1/coupons', 'PATCH /super/v1/coupons/:id/status', 'GET /super/v1/coupon-grants'];
const toast = useToast();
const notes = ['Current coupon CRUD endpoints are tenant-scoped and tied to creator ownership.', 'Expose cross-tenant coupon listing before adding bulk actions.'];
const coupons = ref([]);
const loading = ref(false);
const totalRecords = ref(0);
const page = ref(1);
const rows = ref(10);
const couponID = ref(null);
const tenantID = ref(null);
const tenantCode = ref('');
const tenantName = ref('');
const keyword = ref('');
const type = ref('');
const status = ref('');
const createdAtFrom = ref(null);
const createdAtTo = ref(null);
const sortField = ref('created_at');
const sortOrder = ref(-1);
const typeOptions = [
{ label: '全部', value: '' },
{ label: '固定金额', value: 'fix_amount' },
{ label: '折扣', value: 'discount' }
];
const statusOptions = [
{ label: '全部', value: '' },
{ label: '生效中', value: 'active' },
{ label: '未开始', value: 'upcoming' },
{ label: '已过期', value: 'expired' }
];
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(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(value) {
switch (value) {
case 'active':
return 'success';
case 'upcoming':
return 'warn';
case 'expired':
return 'danger';
default:
return 'secondary';
}
}
async function loadCoupons() {
loading.value = true;
try {
const result = await CouponService.listCoupons({
page: page.value,
limit: rows.value,
id: couponID.value || undefined,
tenant_id: tenantID.value || undefined,
tenant_code: tenantCode.value,
tenant_name: tenantName.value,
keyword: keyword.value,
type: type.value,
status: status.value,
created_at_from: createdAtFrom.value || undefined,
created_at_to: createdAtTo.value || undefined,
sortField: sortField.value,
sortOrder: sortOrder.value
});
coupons.value = result.items;
totalRecords.value = result.total;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载优惠券列表', life: 4000 });
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
loadCoupons();
}
function onReset() {
couponID.value = null;
tenantID.value = null;
tenantCode.value = '';
tenantName.value = '';
keyword.value = '';
type.value = '';
status.value = '';
createdAtFrom.value = null;
createdAtTo.value = null;
sortField.value = 'created_at';
sortOrder.value = -1;
page.value = 1;
rows.value = 10;
loadCoupons();
}
function onPage(event) {
page.value = (event.page ?? 0) + 1;
rows.value = event.rows ?? rows.value;
loadCoupons();
}
function onSort(event) {
sortField.value = event.sortField ?? sortField.value;
sortOrder.value = event.sortOrder ?? sortOrder.value;
loadCoupons();
}
onMounted(() => {
loadCoupons();
});
</script>
<template>
<PendingPanel title="Coupons" description="Coupon management needs a super admin aggregation layer." :endpoints="endpoints" :notes="notes" />
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">优惠券</h4>
</div>
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
<SearchField label="CouponID">
<InputNumber v-model="couponID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="TenantID">
<InputNumber v-model="tenantID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="Tenant Code">
<InputText v-model="tenantCode" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="Tenant Name">
<InputText v-model="tenantName" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="关键词">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="keyword" placeholder="标题/描述" class="w-full" @keyup.enter="onSearch" />
</IconField>
</SearchField>
<SearchField label="类型">
<Select v-model="type" :options="typeOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
</SearchField>
<SearchField label="状态">
<Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" 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
:value="coupons"
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 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 || '-' }}</span>
</div>
</template>
</Column>
<Column field="title" header="标题" sortable 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="min_order_amount" header="门槛" style="min-width: 10rem">
<template #body="{ data }">
{{ formatCny(data.min_order_amount) }}
</template>
</Column>
<Column header="使用情况" style="min-width: 10rem">
<template #body="{ data }">
<span v-if="data.total_quantity === 0">不限量</span>
<span v-else>{{ data.used_quantity ?? 0 }} / {{ data.total_quantity ?? 0 }}</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)" />
</template>
</Column>
<Column field="start_at" header="开始时间" sortable style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.start_at) }}
</template>
</Column>
<Column field="end_at" header="结束时间" sortable style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.end_at) }}
</template>
</Column>
<Column field="created_at" header="创建时间" sortable style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
</DataTable>
</div>
</template>

View File

@@ -1,18 +1,269 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import { CreatorService } from '@/service/CreatorService';
import { TenantService } from '@/service/TenantService';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from '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 toast = useToast();
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.'];
const creators = ref([]);
const loading = ref(false);
const totalRecords = ref(0);
const page = ref(1);
const rows = ref(10);
const tenantID = ref(null);
const ownerUserID = ref(null);
const nameKeyword = ref('');
const codeKeyword = ref('');
const status = ref('');
const createdAtFrom = ref(null);
const createdAtTo = ref(null);
const sortField = ref('id');
const sortOrder = ref(-1);
const statusDialogVisible = ref(false);
const statusOptionsLoading = ref(false);
const statusOptions = ref([]);
const statusUpdating = ref(false);
const statusTenant = ref(null);
const statusValue = ref(null);
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
function getStatusSeverity(value) {
switch (value) {
case 'verified':
return 'success';
case 'pending_verify':
return 'warn';
case 'banned':
return 'danger';
default:
return 'secondary';
}
}
async function ensureStatusOptionsLoaded() {
if (statusOptions.value.length > 0) return;
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);
} finally {
statusOptionsLoading.value = false;
}
}
async function loadCreators() {
loading.value = true;
try {
const result = await CreatorService.listCreators({
page: page.value,
limit: rows.value,
id: tenantID.value || undefined,
user_id: ownerUserID.value || undefined,
name: nameKeyword.value,
code: codeKeyword.value,
status: status.value,
created_at_from: createdAtFrom.value || undefined,
created_at_to: createdAtTo.value || undefined,
sortField: sortField.value,
sortOrder: sortOrder.value
});
creators.value = result.items;
totalRecords.value = result.total;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载创作者列表', life: 4000 });
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
loadCreators();
}
function onReset() {
tenantID.value = null;
ownerUserID.value = null;
nameKeyword.value = '';
codeKeyword.value = '';
status.value = '';
createdAtFrom.value = null;
createdAtTo.value = null;
sortField.value = 'id';
sortOrder.value = -1;
page.value = 1;
rows.value = 10;
loadCreators();
}
function onPage(event) {
page.value = (event.page ?? 0) + 1;
rows.value = event.rows ?? rows.value;
loadCreators();
}
function onSort(event) {
sortField.value = event.sortField ?? sortField.value;
sortOrder.value = event.sortOrder ?? sortOrder.value;
loadCreators();
}
async function openStatusDialog(tenant) {
statusTenant.value = tenant;
statusValue.value = tenant?.status ?? null;
statusDialogVisible.value = true;
try {
await ensureStatusOptionsLoaded();
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载状态选项', life: 4000 });
}
}
async function confirmUpdateStatus() {
const id = statusTenant.value?.id;
if (!id || !statusValue.value) return;
statusUpdating.value = true;
try {
await TenantService.updateTenantStatus({ tenantID: id, status: statusValue.value });
toast.add({ severity: 'success', summary: '更新成功', detail: `TenantID: ${id}`, life: 3000 });
statusDialogVisible.value = false;
await loadCreators();
} catch (error) {
toast.add({ severity: 'error', summary: '更新失败', detail: error?.message || '无法更新状态', life: 4000 });
} finally {
statusUpdating.value = false;
}
}
onMounted(() => {
loadCreators();
ensureStatusOptionsLoaded().catch(() => {});
});
</script>
<template>
<PendingPanel title="Creators" description="Super admin creator operations require cross-tenant APIs." :endpoints="endpoints" :notes="notes" />
<div class="card">
<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>
<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>
</div>
<Dialog v-model:visible="statusDialogVisible" :modal="true" :style="{ width: '420px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">更新创作者状态</span>
<span class="text-muted-color truncate max-w-[240px]">{{ statusTenant?.name ?? '-' }}</span>
</div>
</template>
<div class="flex flex-col gap-4">
<div>
<label class="block font-medium mb-2">状态</label>
<Select v-model="statusValue" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="选择状态" :disabled="statusUpdating" :loading="statusOptionsLoading" fluid />
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="statusDialogVisible = false" :disabled="statusUpdating" />
<Button label="确认" icon="pi pi-check" @click="confirmUpdateStatus" :loading="statusUpdating" :disabled="!statusValue" />
</template>
</Dialog>
</template>

View File

@@ -1,11 +1,341 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import { FinanceService } from '@/service/FinanceService';
import { useToast } from 'primevue/usetoast';
import { ref } from '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 toast = useToast();
const notes = ['Withdrawals currently exist only in tenant creator APIs.', 'Add a super admin ledger view before exposing approvals.'];
const withdrawals = ref([]);
const loading = ref(false);
const totalRecords = ref(0);
const page = ref(1);
const rows = ref(10);
const orderID = ref(null);
const tenantID = ref(null);
const tenantCode = ref('');
const tenantName = ref('');
const userID = ref(null);
const username = ref('');
const status = ref('');
const createdAtFrom = ref(null);
const createdAtTo = ref(null);
const paidAtFrom = ref(null);
const paidAtTo = ref(null);
const amountPaidMin = ref(null);
const amountPaidMax = ref(null);
const sortField = ref('id');
const sortOrder = ref(-1);
const statusOptions = [
{ label: '全部', value: '' },
{ label: 'created', value: 'created' },
{ label: 'paid', value: 'paid' },
{ label: 'failed', value: 'failed' }
];
const approveDialogVisible = ref(false);
const approveLoading = ref(false);
const approveOrder = ref(null);
const rejectDialogVisible = ref(false);
const rejectLoading = ref(false);
const rejectOrder = ref(null);
const rejectReason = ref('');
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(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(value) {
switch (value) {
case 'paid':
return 'success';
case 'created':
return 'warn';
case 'failed':
return 'danger';
default:
return 'secondary';
}
}
async function loadWithdrawals() {
loading.value = true;
try {
const result = await FinanceService.listWithdrawals({
page: page.value,
limit: rows.value,
id: orderID.value || undefined,
tenant_id: tenantID.value || undefined,
tenant_code: tenantCode.value,
tenant_name: tenantName.value,
user_id: userID.value || undefined,
username: username.value,
status: status.value,
created_at_from: createdAtFrom.value || undefined,
created_at_to: createdAtTo.value || undefined,
paid_at_from: paidAtFrom.value || undefined,
paid_at_to: paidAtTo.value || undefined,
amount_paid_min: amountPaidMin.value || undefined,
amount_paid_max: amountPaidMax.value || undefined,
sortField: sortField.value,
sortOrder: sortOrder.value
});
withdrawals.value = result.items;
totalRecords.value = result.total;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载提现列表', life: 4000 });
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
loadWithdrawals();
}
function onReset() {
orderID.value = null;
tenantID.value = null;
tenantCode.value = '';
tenantName.value = '';
userID.value = null;
username.value = '';
status.value = '';
createdAtFrom.value = null;
createdAtTo.value = null;
paidAtFrom.value = null;
paidAtTo.value = null;
amountPaidMin.value = null;
amountPaidMax.value = null;
sortField.value = 'id';
sortOrder.value = -1;
page.value = 1;
rows.value = 10;
loadWithdrawals();
}
function onPage(event) {
page.value = (event.page ?? 0) + 1;
rows.value = event.rows ?? rows.value;
loadWithdrawals();
}
function onSort(event) {
sortField.value = event.sortField ?? sortField.value;
sortOrder.value = event.sortOrder ?? sortOrder.value;
loadWithdrawals();
}
function openApproveDialog(order) {
approveOrder.value = order;
approveDialogVisible.value = true;
}
async function confirmApprove() {
const id = approveOrder.value?.id;
if (!id) return;
approveLoading.value = true;
try {
await FinanceService.approveWithdrawal(id);
toast.add({ severity: 'success', summary: '已批准', detail: `订单ID: ${id}`, life: 3000 });
approveDialogVisible.value = false;
await loadWithdrawals();
} catch (error) {
toast.add({ severity: 'error', summary: '操作失败', detail: error?.message || '无法批准提现', life: 4000 });
} finally {
approveLoading.value = false;
}
}
function openRejectDialog(order) {
rejectOrder.value = order;
rejectReason.value = '';
rejectDialogVisible.value = true;
}
async function confirmReject() {
const id = rejectOrder.value?.id;
if (!id || !rejectReason.value.trim()) return;
rejectLoading.value = true;
try {
await FinanceService.rejectWithdrawal(id, { reason: rejectReason.value.trim() });
toast.add({ severity: 'success', summary: '已驳回', detail: `订单ID: ${id}`, life: 3000 });
rejectDialogVisible.value = false;
await loadWithdrawals();
} catch (error) {
toast.add({ severity: 'error', summary: '操作失败', detail: error?.message || '无法驳回提现', life: 4000 });
} finally {
rejectLoading.value = false;
}
}
loadWithdrawals();
</script>
<template>
<PendingPanel title="Finance" description="Withdrawals and wallet visibility require super admin endpoints." :endpoints="endpoints" :notes="notes" />
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">提现审核</h4>
</div>
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
<SearchField label="OrderID">
<InputNumber v-model="orderID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="TenantID">
<InputNumber v-model="tenantID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="Tenant Code">
<InputText v-model="tenantCode" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="Tenant Name">
<InputText v-model="tenantName" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="UserID">
<InputNumber v-model="userID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="用户名">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="username" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</IconField>
</SearchField>
<SearchField label="状态">
<Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" 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>
<SearchField label="支付时间 From">
<DatePicker v-model="paidAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="支付时间 To">
<DatePicker v-model="paidAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
<SearchField label="金额 Min">
<InputNumber v-model="amountPaidMin" :min="0" placeholder=">= 0" class="w-full" />
</SearchField>
<SearchField label="金额 Max">
<InputNumber v-model="amountPaidMax" :min="0" placeholder=">= 0" class="w-full" />
</SearchField>
</SearchPanel>
<DataTable
:value="withdrawals"
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: 7rem" />
<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 || '-' }}</span>
</div>
</template>
</Column>
<Column header="申请人" style="min-width: 12rem">
<template #body="{ data }">
<span v-if="data.buyer?.username">{{ data.buyer.username }}</span>
<span v-else class="text-muted-color">{{ data.buyer?.id ?? '-' }}</span>
</template>
</Column>
<Column field="amount_paid" header="金额" sortable style="min-width: 10rem">
<template #body="{ data }">
{{ formatCny(data.amount_paid) }}
</template>
</Column>
<Column field="status" header="状态" style="min-width: 10rem">
<template #body="{ data }">
<Tag :value="data.status_description || data.status || '-'" :severity="getStatusSeverity(data.status)" />
</template>
</Column>
<Column field="created_at" header="创建时间" sortable style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
<Column field="paid_at" header="支付时间" sortable style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.paid_at) }}
</template>
</Column>
<Column header="操作" style="min-width: 12rem">
<template #body="{ data }">
<div class="flex items-center gap-2">
<Button label="通过" icon="pi pi-check" size="small" severity="success" :disabled="data.status !== 'created'" @click="openApproveDialog(data)" />
<Button label="驳回" icon="pi pi-times" size="small" severity="danger" :disabled="data.status !== 'created'" @click="openRejectDialog(data)" />
</div>
</template>
</Column>
</DataTable>
</div>
<Dialog v-model:visible="approveDialogVisible" :modal="true" :style="{ width: '420px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">确认通过提现吗</span>
</div>
</template>
<div class="text-sm text-muted-color">确认后将标记为已支付请确保外部打款已完成</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="approveDialogVisible = false" :disabled="approveLoading" />
<Button label="确认通过" icon="pi pi-check" severity="success" @click="confirmApprove" :loading="approveLoading" />
</template>
</Dialog>
<Dialog v-model:visible="rejectDialogVisible" :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">驳回原因</label>
<InputText v-model="rejectReason" placeholder="请输入驳回原因" class="w-full" />
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="rejectDialogVisible = false" :disabled="rejectLoading" />
<Button label="确认驳回" icon="pi pi-check" severity="danger" @click="confirmReject" :loading="rejectLoading" :disabled="!rejectReason.trim()" />
</template>
</Dialog>
</template>

View File

@@ -1,11 +1,149 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import StatisticsStrip from '@/components/StatisticsStrip.vue';
import { ReportService } from '@/service/ReportService';
import { useToast } from 'primevue/usetoast';
import { computed, ref } from 'vue';
const endpoints = ['GET /super/v1/reports/overview', 'GET /super/v1/reports/series', 'POST /super/v1/reports/export'];
const toast = useToast();
const notes = ['Current report APIs are scoped to creators in tenant context.', 'Add cross-tenant aggregation before wiring charts and exports.'];
const overview = ref(null);
const loading = ref(false);
const tenantID = ref(null);
const startAt = ref(null);
const endAt = ref(null);
const granularity = ref('day');
const granularityOptions = [{ label: '按天', value: 'day' }];
function formatPercent(value) {
const rate = Number(value);
if (!Number.isFinite(rate)) return '-';
return `${(rate * 100).toFixed(2)}%`;
}
function formatCnyFromYuan(amount) {
const value = Number(amount);
if (!Number.isFinite(value)) return '-';
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(value);
}
const summaryItems = computed(() => {
const summary = overview.value?.summary;
if (!summary) return [];
return [
{ key: 'views', label: '累计曝光:', value: summary.total_views ?? 0, icon: 'pi-eye' },
{ key: 'paid-orders', label: '已支付订单:', value: summary.paid_orders ?? 0, icon: 'pi-shopping-cart' },
{ key: 'paid-amount', label: '已支付金额:', value: formatCnyFromYuan(summary.paid_amount), icon: 'pi-wallet' },
{ key: 'refund-orders', label: '退款订单:', value: summary.refund_orders ?? 0, icon: 'pi-undo' },
{ key: 'refund-amount', label: '退款金额:', value: formatCnyFromYuan(summary.refund_amount), icon: 'pi-replay' },
{ key: 'conversion', label: '转化率:', value: formatPercent(summary.conversion_rate), icon: 'pi-percentage' }
];
});
async function loadOverview() {
loading.value = true;
try {
overview.value = await ReportService.getOverview({
tenant_id: tenantID.value || undefined,
start_at: startAt.value || undefined,
end_at: endAt.value || undefined,
granularity: granularity.value
});
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载报表数据', life: 4000 });
} finally {
loading.value = false;
}
}
function onSearch() {
loadOverview();
}
function onReset() {
tenantID.value = null;
startAt.value = null;
endAt.value = null;
granularity.value = 'day';
loadOverview();
}
async function exportReport() {
try {
const res = await ReportService.exportReport({
tenant_id: tenantID.value || undefined,
start_at: startAt.value || undefined,
end_at: endAt.value || undefined,
granularity: granularity.value,
format: 'csv'
});
const content = res?.content ?? '';
const filename = res?.filename || 'report.csv';
const blob = new Blob([content], { type: res?.mime_type || 'text/csv' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
toast.add({ severity: 'success', summary: '导出成功', detail: filename, life: 3000 });
} catch (error) {
toast.add({ severity: 'error', summary: '导出失败', detail: error?.message || '无法导出报表', life: 4000 });
}
}
loadOverview();
</script>
<template>
<PendingPanel title="Reports" description="Platform reporting needs aggregated super admin APIs." :endpoints="endpoints" :notes="notes" />
<div>
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">运营报表</h4>
<Button label="导出 CSV" icon="pi pi-download" severity="secondary" :loading="loading" @click="exportReport" />
</div>
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
<SearchField label="TenantID">
<InputNumber v-model="tenantID" :min="1" placeholder="不填则全平台" class="w-full" />
</SearchField>
<SearchField label="开始时间">
<DatePicker v-model="startAt" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="结束时间">
<DatePicker v-model="endAt" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
<SearchField label="统计粒度">
<Select v-model="granularity" :options="granularityOptions" optionLabel="label" optionValue="value" class="w-full" />
</SearchField>
</SearchPanel>
</div>
<StatisticsStrip v-if="summaryItems.length" :items="summaryItems" containerClass="card mb-4" />
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">趋势明细</h4>
</div>
<DataTable :value="overview?.items || []" :loading="loading" scrollable scrollHeight="420px" responsiveLayout="scroll">
<Column field="date" header="日期" style="min-width: 10rem" />
<Column field="paid_orders" header="已支付订单" style="min-width: 10rem" />
<Column field="paid_amount" header="已支付金额" style="min-width: 12rem">
<template #body="{ data }">
{{ formatCnyFromYuan(data.paid_amount) }}
</template>
</Column>
<Column field="refund_orders" header="退款订单" style="min-width: 10rem" />
<Column field="refund_amount" header="退款金额" style="min-width: 12rem">
<template #body="{ data }">
{{ formatCnyFromYuan(data.refund_amount) }}
</template>
</Column>
</DataTable>
</div>
</div>
</template>