diff --git a/frontend/superadmin/src/components/StatisticsStrip.vue b/frontend/superadmin/src/components/StatisticsStrip.vue index fcd17a7..de937a6 100644 --- a/frontend/superadmin/src/components/StatisticsStrip.vue +++ b/frontend/superadmin/src/components/StatisticsStrip.vue @@ -14,7 +14,15 @@ const props = defineProps({ - + @@ -23,7 +31,7 @@ const props = defineProps({ {{ item.value }} - + diff --git a/frontend/superadmin/src/views/superadmin/Contents.vue b/frontend/superadmin/src/views/superadmin/Contents.vue index 2b0b6e9..7bdb169 100644 --- a/frontend/superadmin/src/views/superadmin/Contents.vue +++ b/frontend/superadmin/src/views/superadmin/Contents.vue @@ -3,9 +3,11 @@ import SearchField from '@/components/SearchField.vue'; import SearchPanel from '@/components/SearchPanel.vue'; import { ContentService } from '@/service/ContentService'; import { useToast } from 'primevue/usetoast'; -import { computed, onMounted, ref } from 'vue'; +import { computed, ref, watch } from 'vue'; +import { useRoute } from 'vue-router'; const toast = useToast(); +const route = useRoute(); const loading = ref(false); const contents = ref([]); @@ -47,6 +49,24 @@ const visibilityOptions = [ { label: 'private', value: 'private' } ]; +function getQueryValue(value) { + if (Array.isArray(value)) return value[0]; + return value ?? null; +} + +function parseNumber(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return null; + return parsed; +} + +function parseDate(value) { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date; +} + function formatDate(value) { if (!value) return '-'; if (String(value).startsWith('0001-01-01')) return '-'; @@ -55,6 +75,56 @@ function formatDate(value) { return date.toLocaleString(); } +function resetFilters() { + contentID.value = null; + tenantID.value = null; + tenantCode.value = ''; + tenantName.value = ''; + ownerUserID.value = null; + ownerUsername.value = ''; + keyword.value = ''; + status.value = ''; + visibility.value = ''; + publishedAtFrom.value = null; + publishedAtTo.value = null; + createdAtFrom.value = null; + createdAtTo.value = null; + priceAmountMin.value = null; + priceAmountMax.value = null; + sortField.value = 'id'; + sortOrder.value = -1; +} + +function applyRouteQuery(query) { + resetFilters(); + + const idValue = getQueryValue(query?.id); + const tenantValue = getQueryValue(query?.tenant_id); + const userValue = getQueryValue(query?.user_id); + + if (idValue) contentID.value = parseNumber(idValue); + if (tenantValue) tenantID.value = parseNumber(tenantValue); + if (userValue) ownerUserID.value = parseNumber(userValue); + + const statusValue = getQueryValue(query?.status); + const visibilityValue = getQueryValue(query?.visibility); + const keywordValue = getQueryValue(query?.keyword); + + if (statusValue !== null) status.value = String(statusValue); + if (visibilityValue !== null) visibility.value = String(visibilityValue); + if (keywordValue !== null) keyword.value = String(keywordValue); + + const publishedFromValue = getQueryValue(query?.published_at_from); + const publishedToValue = getQueryValue(query?.published_at_to); + const createdFromValue = getQueryValue(query?.created_at_from); + const createdToValue = getQueryValue(query?.created_at_to); + + if (publishedFromValue) publishedAtFrom.value = parseDate(publishedFromValue); + if (publishedToValue) publishedAtTo.value = parseDate(publishedToValue); + if (createdFromValue) createdAtFrom.value = parseDate(createdFromValue); + if (createdToValue) createdAtTo.value = parseDate(createdToValue); +} + function formatCny(amountInCents) { const amount = Number(amountInCents) / 100; if (!Number.isFinite(amount)) return '-'; @@ -128,23 +198,7 @@ function onSearch() { } function onReset() { - contentID.value = null; - tenantID.value = null; - tenantCode.value = ''; - tenantName.value = ''; - ownerUserID.value = null; - ownerUsername.value = ''; - keyword.value = ''; - status.value = ''; - visibility.value = ''; - publishedAtFrom.value = null; - publishedAtTo.value = null; - createdAtFrom.value = null; - createdAtTo.value = null; - priceAmountMin.value = null; - priceAmountMax.value = null; - sortField.value = 'id'; - sortOrder.value = -1; + resetFilters(); page.value = 1; rows.value = 10; loadContents(); @@ -192,9 +246,16 @@ async function confirmUnpublish() { } } -onMounted(() => { - loadContents(); -}); +watch( + () => route.query, + (query) => { + applyRouteQuery(query); + page.value = 1; + rows.value = 10; + loadContents(); + }, + { immediate: true } +); diff --git a/frontend/superadmin/src/views/superadmin/Orders.vue b/frontend/superadmin/src/views/superadmin/Orders.vue index 6988979..e007839 100644 --- a/frontend/superadmin/src/views/superadmin/Orders.vue +++ b/frontend/superadmin/src/views/superadmin/Orders.vue @@ -3,9 +3,11 @@ import SearchField from '@/components/SearchField.vue'; import SearchPanel from '@/components/SearchPanel.vue'; import { OrderService } from '@/service/OrderService'; import { useToast } from 'primevue/usetoast'; -import { ref } from 'vue'; +import { ref, watch } from 'vue'; +import { useRoute } from 'vue-router'; const toast = useToast(); +const route = useRoute(); const orders = ref([]); const loading = ref(false); @@ -54,6 +56,24 @@ const typeOptions = [ { label: 'content_purchase', value: 'content_purchase' } ]; +function getQueryValue(value) { + if (Array.isArray(value)) return value[0]; + return value ?? null; +} + +function parseNumber(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return null; + return parsed; +} + +function parseDate(value) { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date; +} + function formatDate(value) { if (!value) return '-'; if (String(value).startsWith('0001-01-01')) return '-'; @@ -62,6 +82,56 @@ function formatDate(value) { return date.toLocaleString(); } +function resetFilters() { + orderID.value = null; + tenantID.value = null; + tenantCode.value = ''; + tenantName.value = ''; + buyerUserID.value = null; + buyerUsername.value = ''; + contentID.value = null; + contentTitle.value = ''; + status.value = ''; + type.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; +} + +function applyRouteQuery(query) { + resetFilters(); + + const idValue = getQueryValue(query?.id); + const tenantValue = getQueryValue(query?.tenant_id); + const userValue = getQueryValue(query?.user_id); + const contentValue = getQueryValue(query?.content_id); + + if (idValue) orderID.value = parseNumber(idValue); + if (tenantValue) tenantID.value = parseNumber(tenantValue); + if (userValue) buyerUserID.value = parseNumber(userValue); + if (contentValue) contentID.value = parseNumber(contentValue); + + const statusValue = getQueryValue(query?.status); + const typeValue = getQueryValue(query?.type); + if (statusValue !== null) status.value = String(statusValue); + if (typeValue !== null) type.value = String(typeValue); + + const createdFromValue = getQueryValue(query?.created_at_from); + const createdToValue = getQueryValue(query?.created_at_to); + const paidFromValue = getQueryValue(query?.paid_at_from); + const paidToValue = getQueryValue(query?.paid_at_to); + + if (createdFromValue) createdAtFrom.value = parseDate(createdFromValue); + if (createdToValue) createdAtTo.value = parseDate(createdToValue); + if (paidFromValue) paidAtFrom.value = parseDate(paidFromValue); + if (paidToValue) paidAtTo.value = parseDate(paidToValue); +} + function formatCny(amountInCents) { const amount = Number(amountInCents) / 100; if (!Number.isFinite(amount)) return '-'; @@ -147,24 +217,7 @@ function onSearch() { } function onReset() { - orderID.value = null; - tenantID.value = null; - tenantCode.value = ''; - tenantName.value = ''; - buyerUserID.value = null; - buyerUsername.value = ''; - contentID.value = null; - contentTitle.value = ''; - status.value = ''; - type.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; + resetFilters(); page.value = 1; rows.value = 10; loadOrders(); @@ -182,7 +235,16 @@ function onSort(event) { loadOrders(); } -loadOrders(); +watch( + () => route.query, + (query) => { + applyRouteQuery(query); + page.value = 1; + rows.value = 10; + loadOrders(); + }, + { immediate: true } +); diff --git a/frontend/superadmin/src/views/superadmin/Reports.vue b/frontend/superadmin/src/views/superadmin/Reports.vue index f077f22..a260c78 100644 --- a/frontend/superadmin/src/views/superadmin/Reports.vue +++ b/frontend/superadmin/src/views/superadmin/Reports.vue @@ -2,11 +2,15 @@ import SearchField from '@/components/SearchField.vue'; import SearchPanel from '@/components/SearchPanel.vue'; import StatisticsStrip from '@/components/StatisticsStrip.vue'; +import { useLayout } from '@/layout/composables/layout'; import { ReportService } from '@/service/ReportService'; import { useToast } from 'primevue/usetoast'; -import { computed, ref } from 'vue'; +import { computed, ref, watch } from 'vue'; +import { useRouter } from 'vue-router'; const toast = useToast(); +const router = useRouter(); +const { getPrimary, getSurface, isDarkTheme } = useLayout(); const overview = ref(null); const loading = ref(false); @@ -16,6 +20,11 @@ const startAt = ref(null); const endAt = ref(null); const granularity = ref('day'); +const orderChartData = ref(null); +const amountChartData = ref(null); +const orderChartOptions = ref(null); +const amountChartOptions = ref(null); + const granularityOptions = [{ label: '按天', value: 'day' }]; function formatPercent(value) { @@ -30,16 +39,165 @@ function formatCnyFromYuan(amount) { return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(value); } +function formatDateParam(value) { + if (!value) return undefined; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); +} + +function buildOrdersQuery({ status, usePaidAt } = {}) { + const query = {}; + if (tenantID.value) query.tenant_id = tenantID.value; + if (status) query.status = status; + const start = formatDateParam(startAt.value); + const end = formatDateParam(endAt.value); + if (usePaidAt) { + if (start) query.paid_at_from = start; + if (end) query.paid_at_to = end; + } + return query; +} + +function goToOrders(status, usePaidAt) { + router.push({ name: 'superadmin-orders', query: buildOrdersQuery({ status, usePaidAt }) }); +} + +function goToContents() { + const query = {}; + if (tenantID.value) query.tenant_id = tenantID.value; + router.push({ name: 'superadmin-contents', query }); +} + +function buildOrderChartData(items) { + const documentStyle = getComputedStyle(document.documentElement); + const labels = items.map((item) => item.date); + const paidOrders = items.map((item) => Number(item.paid_orders ?? 0)); + const refundOrders = items.map((item) => Number(item.refund_orders ?? 0)); + + return { + labels, + datasets: [ + { + label: '已支付订单', + data: paidOrders, + fill: true, + backgroundColor: documentStyle.getPropertyValue('--p-primary-200'), + borderColor: documentStyle.getPropertyValue('--p-primary-500'), + tension: 0.35 + }, + { + label: '退款订单', + data: refundOrders, + fill: true, + backgroundColor: documentStyle.getPropertyValue('--p-orange-200'), + borderColor: documentStyle.getPropertyValue('--p-orange-500'), + tension: 0.35 + } + ] + }; +} + +function buildAmountChartData(items) { + const documentStyle = getComputedStyle(document.documentElement); + const labels = items.map((item) => item.date); + const paidAmounts = items.map((item) => Number(item.paid_amount ?? 0)); + const refundAmounts = items.map((item) => Number(item.refund_amount ?? 0)); + + return { + labels, + datasets: [ + { + label: '已支付金额', + data: paidAmounts, + fill: true, + backgroundColor: documentStyle.getPropertyValue('--p-emerald-200'), + borderColor: documentStyle.getPropertyValue('--p-emerald-500'), + tension: 0.35 + }, + { + label: '退款金额', + data: refundAmounts, + fill: true, + backgroundColor: documentStyle.getPropertyValue('--p-rose-200'), + borderColor: documentStyle.getPropertyValue('--p-rose-500'), + tension: 0.35 + } + ] + }; +} + +function buildChartOptions({ amountMode } = {}) { + const documentStyle = getComputedStyle(document.documentElement); + const textColor = documentStyle.getPropertyValue('--text-color'); + const textMutedColor = documentStyle.getPropertyValue('--text-color-secondary'); + const borderColor = documentStyle.getPropertyValue('--surface-border'); + + const options = { + maintainAspectRatio: false, + aspectRatio: 2.2, + plugins: { + legend: { + labels: { + color: textColor + } + } + }, + scales: { + x: { + ticks: { + color: textMutedColor + }, + grid: { + color: 'transparent', + borderColor: 'transparent' + } + }, + y: { + ticks: { + color: textMutedColor + }, + grid: { + color: borderColor, + borderColor: 'transparent', + drawTicks: false + } + } + } + }; + + if (amountMode) { + options.plugins.tooltip = { + callbacks: { + label(context) { + const label = context.dataset?.label ? `${context.dataset.label}: ` : ''; + return `${label}${formatCnyFromYuan(context.parsed?.y ?? 0)}`; + } + } + }; + } + + return options; +} + +function updateCharts() { + const items = overview.value?.items ?? []; + orderChartData.value = buildOrderChartData(items); + amountChartData.value = buildAmountChartData(items); + orderChartOptions.value = buildChartOptions({ amountMode: false }); + amountChartOptions.value = buildChartOptions({ amountMode: true }); +} + 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: 'views', label: '累计曝光:', value: summary.total_views ?? 0, icon: 'pi-eye', onClick: goToContents }, + { key: 'paid-orders', label: '已支付订单:', value: summary.paid_orders ?? 0, icon: 'pi-shopping-cart', onClick: () => goToOrders('paid', true) }, + { key: 'paid-amount', label: '已支付金额:', value: formatCnyFromYuan(summary.paid_amount), icon: 'pi-wallet', onClick: () => goToOrders('paid', true) }, + { key: 'refund-orders', label: '退款订单:', value: summary.refund_orders ?? 0, icon: 'pi-undo', onClick: () => goToOrders('refunded', false) }, + { key: 'refund-amount', label: '退款金额:', value: formatCnyFromYuan(summary.refund_amount), icon: 'pi-replay', onClick: () => goToOrders('refunded', false) }, { key: 'conversion', label: '转化率:', value: formatPercent(summary.conversion_rate), icon: 'pi-percentage' } ]; }); @@ -53,6 +211,7 @@ async function loadOverview() { end_at: endAt.value || undefined, granularity: granularity.value }); + updateCharts(); } catch (error) { toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载报表数据', life: 4000 }); } finally { @@ -97,6 +256,10 @@ async function exportReport() { } loadOverview(); + +watch([getPrimary, getSurface, isDarkTheme], () => { + updateCharts(); +}); @@ -125,6 +288,31 @@ loadOverview(); + + + + 趋势概览 + 按 {{ granularity === 'day' ? '天' : granularity }} 汇总 + + + + + + 订单趋势 + 已支付 vs 退款 + + + + + + 金额趋势 + 单位:元 + + + + + + 趋势明细