feat: wire superadmin p1 data
This commit is contained in:
44
frontend/superadmin/src/service/CouponService.js
Normal file
44
frontend/superadmin/src/service/CouponService.js
Normal 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)
|
||||
};
|
||||
}
|
||||
};
|
||||
42
frontend/superadmin/src/service/CreatorService.js
Normal file
42
frontend/superadmin/src/service/CreatorService.js
Normal 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)
|
||||
};
|
||||
}
|
||||
};
|
||||
59
frontend/superadmin/src/service/FinanceService.js
Normal file
59
frontend/superadmin/src/service/FinanceService.js
Normal 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 }
|
||||
});
|
||||
}
|
||||
};
|
||||
39
frontend/superadmin/src/service/ReportService.js
Normal file
39
frontend/superadmin/src/service/ReportService.js
Normal 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'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user