feat(admin): U10 限曝光/导出/经营看板/赋余额订阅 运营台接线

新增 3 个 wanxiang 运营页 + 营收报表导出按钮,全部接 dev/2.0.0 真实 admin-api,mock 兜底齐备:
- 限曝光控制(exposureLimit): POST /feed/exposure-limit + 复用 featured/page 列表 + featured/export-excel 导出;perm feed:featured:update / feed:featured:export
- 经营看板(operationDashboard): GET /telemetry/dashboard 跨模块聚合(DAU/GMV/生成量/发布量) + 区间选择 + 指标卡 + dashboard/export-excel;perm telemetry:dashboard:query(区别于按游戏维度的 dashboard 页)
- 赋余额/订阅(grant): POST /trade/account/grant + /trade/subscription/grant 2-Tab;资金操作=二次确认 + bizNo 幂等键(自动生成可改) + 元↔分换算;perm trade:account:grant / trade:subscription:grant
- 提现页平台营收 Tab 追加 GET /trade/report/revenue/export-excel 导出;perm trade:report:export

接线对齐后端 ReqVO 实证(GrantBalanceReqVO/GrantSubscriptionReqVO/ExposureLimitReqVO):
bizNo @NotEmpty max64、amount 分>0、plan 1月/2季/3年、durationDays>0、exposureLimit Float>=0(>=9999 硬剔除)。
路由注册 remaining.ts wanxiang.children(5→8),菜单继承 route meta;导出走 request.download(blob)+download.excel。

gate: vite build --mode prod GREEN(3 新页+新端点均编入 bundle 实证)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-18 13:11:57 -07:00
parent 9b96433bc2
commit 7219b613f9
8 changed files with 924 additions and 13 deletions

View File

@ -4,10 +4,12 @@ import { isMock, mockPage, mockResult } from './mock'
/**
* 造梦AI —— feed 模块 admin 接口(游戏流精选池运营)
* 契约:contracts/api-schemas/feed.yaml
* - GET /admin-api/feed/featured/page 精选池列表(可按 zoneId 筛选)
* - POST /admin-api/feed/featured 置顶/取消 + 运营加权(写 game_feed_rank.pinned/boost)
* - GET /admin-api/feed/featured/page 精选池列表(可按 zoneId 筛选)
* - POST /admin-api/feed/featured 置顶/取消 + 运营加权(写 game_feed_rank.pinned/boost)
* - POST /admin-api/feed/exposure-limit 限曝光降权(U5;boost 反向操作,写 game_feed_rank.exposure_limit)
* - GET /admin-api/feed/featured/export-excel 精选池/限曝光排序覆盖层导出 Excel(U5)
* 端与鉴权:admin-api 走 huijing 管理员 Token + RBAC;CommonResult code=0 成功(request 已解包 data)。
* 边界:精选只作用于排序覆盖层(pinned/boost),不改 project 本体;仅 status=4 已发布可入精选。
* 边界:精选/限曝光只作用于排序覆盖层(pinned/boost/exposure_limit),不改 project 本体;仅 status=4 已发布可入精选/限曝光。
*/
/** 精选池单卡(对齐 feed.yaml FeedCardRespVO) */
@ -39,6 +41,16 @@ export interface FeaturedPageReqVO extends PageParam {
zoneId?: number
}
/**
* 限曝光降权入参(对齐后端 ExposureLimitReqVO)
* exposureLimit 取值:0=取消降权 / >0=降权 x 分 / >=9999=硬限曝光(出流剔除);仅 status=4 已发布且已有排序行可设置。
*/
export interface ExposureLimitReqVO {
gameId: number // 目标游戏ID(须已发布)
zoneId?: number // 作用专区;不传=默认混合流(zoneId=0)
exposureLimit: number // 限曝光降权因子(>=0)
}
// ---- mock 数据(对齐 FeedCardRespVO,均为已发布且进入精选池的游戏) ----
const mockFeaturedList: FeedCardRespVO[] = [
{
@ -104,3 +116,19 @@ export const saveFeatured = async (data: FeaturedReqVO) => {
}
return await request.post({ url: '/feed/featured', data })
}
/** 限曝光降权(U5;boost 反向操作,写 exposure_limit;perm feed:featured:update) */
export const setExposureLimit = async (data: ExposureLimitReqVO) => {
if (isMock()) {
return mockResult(true)
}
return await request.post({ url: '/feed/exposure-limit', data })
}
/** 导出精选池/限曝光排序覆盖层 Excel(U5;blob 下载,perm feed:featured:export;mock 下不下载,返回 null) */
export const exportFeaturedExcel = async (params: { zoneId?: number }) => {
if (isMock()) {
return mockResult(null)
}
return await request.download({ url: '/feed/featured/export-excel', params })
}

View File

@ -2,13 +2,16 @@ import request from '@/config/axios'
import { isMock, mockPage, mockResult } from './mock'
/**
* 造梦AI —— telemetry 模块 admin 接口(数据看板数据源)
* 造梦AI —— telemetry 模块 admin 接口(数据看板数据源 + 经营看板聚合)
* 契约:contracts/api-schemas/telemetry.yaml
* - GET /admin-api/telemetry/game-stat/page 游戏维度聚合分页(看板表格数据源)
* - GET /admin-api/telemetry/game-stat/{gameId} 单游戏最新聚合快照
* - GET /admin-api/telemetry/game-stat/page 游戏维度聚合分页(看板表格数据源)
* - GET /admin-api/telemetry/game-stat/{gameId} 单游戏最新聚合快照
* - GET /admin-api/telemetry/dashboard 经营看板聚合(U5;跨模块只读 DAU/GMV/生成量/发布量)
* - GET /admin-api/telemetry/dashboard/export-excel 经营看板导出 Excel(U5)
* 端与鉴权:admin-api 走 huijing 管理员 Token + RBAC;CommonResult code=0 成功(request 已解包 data)。
* 量纲:qualityScore=运营质量分(0-100),≠ aigc 生成质量分(0-1)。
* 量纲:qualityScore=运营质量分(0-100),≠ aigc 生成质量分(0-1);GMV 单位「分」(分账前原始收入)。
* 范围:广告/收益聚合字段为 Wave2 变现范围外(later 补列,本 VO 不含,非遗漏)。
* 经营看板 ≠ game-stat:前者是跨模块(DAU+trade+aigc+project)区间聚合卡片,后者是按游戏维度的明细分页。
*/
/** 游戏维度聚合快照(对齐 telemetry.yaml GameStatRespVO) */
@ -34,6 +37,27 @@ export interface GameStatPageReqVO extends PageParam {
statDate?: string // 统计日;空则取最新一日
}
/**
* 经营看板聚合快照(对齐后端 DashboardRespVO)
* 跨模块只读聚合:DAU(本模块) + GMV(trade,分) + 生成量(aigc) + 发布量(project);缺模块数据兜底 0 + degraded 标记,整体不 500。
*/
export interface DashboardRespVO {
statDateStart?: string
statDateEnd?: string
dau?: number // 统计结束日当天去重活跃身份数
gmv?: number // 区间原始收入合计(分账前,单位:分)
generatedCount?: number // 区间 aigc 成功生成任务数
publishedCount?: number // 区间 project 已发布项目数
degraded?: boolean // true=有跨模块数据源不可用、对应指标已兜底 0
degradedSources?: string[] // 不可用数据源明细(如 ['trade','aigc'])
}
/** 经营看板查询参数(对齐后端 /admin-api/telemetry/dashboard,区间必填) */
export interface DashboardReqVO {
statDateStart: string
statDateEnd: string
}
// ---- mock 数据(对齐 GameStatRespVO,按日聚合) ----
const mockGameStatList: GameStatRespVO[] = [
{
@ -118,3 +142,28 @@ export const getGameStat = async (gameId: number) => {
}
return await request.get({ url: '/telemetry/game-stat/' + gameId })
}
/** 经营看板聚合(U5;跨模块只读,perm telemetry:dashboard:query) */
export const getDashboard = async (params: DashboardReqVO) => {
if (isMock()) {
return mockResult<DashboardRespVO>({
statDateStart: params.statDateStart,
statDateEnd: params.statDateEnd,
dau: 1530,
gmv: 1000000, // 分(=10000元)
generatedCount: 320,
publishedCount: 88,
degraded: false,
degradedSources: []
})
}
return await request.get({ url: '/telemetry/dashboard', params })
}
/** 导出经营看板 Excel(U5;blob 下载,perm telemetry:dashboard:query;mock 下不下载,返回 null) */
export const exportDashboardExcel = async (params: DashboardReqVO) => {
if (isMock()) {
return mockResult(null)
}
return await request.download({ url: '/telemetry/dashboard/export-excel', params })
}

View File

@ -2,14 +2,18 @@ import request from '@/config/axios'
import { isMock, mockPage, mockResult } from './mock'
/**
* 造梦AI —— trade 模块 admin 接口(提现审核 + 收益流水查询 + 平台营收报表)
* 造梦AI —— trade 模块 admin 接口(提现审核 + 收益流水查询 + 平台营收报表 + 赋余额 + 赋订阅)
* 契约:contracts/api-schemas/trade.yaml
* - GET /admin-api/trade/withdraw/page 提现审核队列(默认 status=0 待审核)
* - POST /admin-api/trade/withdraw/audit 审核(通过/驳回,驱动状态机;非法流转服务端拒绝)
* - GET /admin-api/trade/income/page 收益流水查询(跨创作者对账,只读)
* - GET /admin-api/trade/report/revenue 平台营收报表聚合(按日 gross/net/平台分成/提现,只读)
* - GET /admin-api/trade/withdraw/page 提现审核队列(默认 status=0 待审核)
* - POST /admin-api/trade/withdraw/audit 审核(通过/驳回,驱动状态机;非法流转服务端拒绝)
* - GET /admin-api/trade/income/page 收益流水查询(跨创作者对账,只读)
* - GET /admin-api/trade/report/revenue 平台营收报表聚合(按日 gross/net/平台分成/提现,只读)
* - GET /admin-api/trade/report/revenue/export-excel 营收报表导出 Excel(U5)
* - POST /admin-api/trade/account/grant admin 赋余额(U2;幂等键 bizNo,real 入账+流水可对账)
* - POST /admin-api/trade/subscription/grant admin 赋订阅(U2;幂等键 bizNo,续期叠加)
* 端与鉴权:admin-api 走 huijing 管理员 Token + RBAC;CommonResult code=0 成功(request 已解包 data)。
* 资金口径:金额一律「分」(BIGINT),禁浮点;shareRate 为 DECIMAL(如 0.8)。
* ⚠️ 资金操作:赋余额/赋订阅须前端二次确认 + 携带 bizNo 幂等键(后端 @NotEmpty 强校验,重试安全);操作人由 token 解析,非前端入参。
*/
/** 提现状态机(对齐 trade.yaml WithdrawRespVO.status / x-state-machines) */
@ -94,6 +98,36 @@ export interface RevenueReportReqVO {
statDateEnd: string
}
/** 订阅套餐(对齐后端 GrantSubscriptionReqVO.plan) */
export enum SubscriptionPlanEnum {
MONTHLY = 1, // 月度
QUARTERLY = 2, // 季度
YEARLY = 3 // 年度
}
/**
* admin 赋余额入参(对齐后端 GrantBalanceReqVO)
* 资金操作:amount 单位「分」只增不减(>0);bizNo 幂等键(REQUIRED, max64, 调用端生成唯一);操作人由 token 解析非入参。
*/
export interface GrantBalanceReqVO {
userId: number // 被赋余额的创作者用户ID
amount: number // 赋余额金额(分,>0)
bizNo: string // 业务单号=幂等键(REQUIRED)
remark?: string // 赋值原因(可空)
}
/**
* admin 赋订阅入参(对齐后端 GrantSubscriptionReqVO)
* plan=1月度/2季度/3年度;durationDays>0 续期叠加;bizNo 幂等键(REQUIRED, max64);操作人由 token 解析非入参。
*/
export interface GrantSubscriptionReqVO {
userId: number // 订阅用户ID
plan: number // 套餐:1月度/2季度/3年度
durationDays: number // 赋订阅时长(天,>0,续期叠加)
bizNo: string // 业务单号=幂等键(REQUIRED)
remark?: string // 开通原因(可空)
}
// ---- mock 数据 ----
const mockWithdrawList: WithdrawRespVO[] = [
{
@ -200,3 +234,30 @@ export const getRevenueReport = async (params: RevenueReportReqVO) => {
}
return await request.get({ url: '/trade/report/revenue', params })
}
/** 导出平台营收报表 Excel(U5;blob 下载,perm trade:report:export;mock 下不下载,返回 null) */
export const exportRevenueExcel = async (params: RevenueReportReqVO) => {
if (isMock()) {
return mockResult(null)
}
return await request.download({ url: '/trade/report/revenue/export-excel', params })
}
/** admin 赋余额(U2;资金操作,返回赋余额流水 ID;perm trade:account:grant) */
export const grantBalance = async (data: GrantBalanceReqVO) => {
if (isMock()) {
// mock:返回一个伪流水 ID(与真实返回 Long 同构)
return mockResult<number>(Date.now())
}
return await request.post({ url: '/trade/account/grant', data })
}
/** admin 赋订阅(U2;资金操作,返回赋值后到期时间字符串;perm trade:subscription:grant) */
export const grantSubscription = async (data: GrantSubscriptionReqVO) => {
if (isMock()) {
// mock:返回一个伪到期时间(与真实返回 LocalDateTime 序列化同构)
const d = new Date(Date.now() + (data.durationDays ?? 30) * 24 * 3600 * 1000)
return mockResult<string>(d.toISOString().slice(0, 19).replace('T', ' '))
}
return await request.post({ url: '/trade/subscription/grant', data })
}

View File

@ -238,8 +238,9 @@ const remainingRouter: AppRouteRecordRaw[] = [
// 造梦AI(wanxiang) MVP 运营页静态路由:
// - 已裁剪 huijing 无关业务模块(ai/bpm/crm/erp/iot/mall/member/mp/pay/report)的全部静态路由,
// 避免对已删视图的死 import 导致构建失败;
// - 新增造梦AI 5 个 MVP 运营页(wanxiang/*)静态路由:后端 RBAC 菜单未下发时也可路由可见、
// - 新增造梦AI 8 个 MVP 运营页(wanxiang/*)静态路由:后端 RBAC 菜单未下发时也可路由可见、
// 可独立构建(mock 兜底见各 wanxiang api 模块),对接我方 admin-api。
// (U10 追加 3 页:限曝光控制 / 经营看板 / 赋余额+订阅。)
// ===========================================================================
{
path: '/wanxiang',
@ -270,6 +271,16 @@ const remainingRouter: AppRouteRecordRaw[] = [
icon: 'ep:star'
}
},
{
// U10:限曝光控制(boost 反向运营,写排序覆盖层 exposure_limit)
path: 'exposure-limit',
component: () => import('@/views/wanxiang/exposureLimit/index.vue'),
name: 'WanxiangExposureLimit',
meta: {
title: '限曝光控制',
icon: 'ep:bottom'
}
},
{
path: 'dashboard',
component: () => import('@/views/wanxiang/dashboard/index.vue'),
@ -279,6 +290,16 @@ const remainingRouter: AppRouteRecordRaw[] = [
icon: 'ep:data-line'
}
},
{
// U10:经营看板(跨模块只读聚合 DAU/GMV/生成量/发布量;区别于按游戏维度的「数据看板」)
path: 'operation-dashboard',
component: () => import('@/views/wanxiang/operationDashboard/index.vue'),
name: 'WanxiangOperationDashboard',
meta: {
title: '经营看板',
icon: 'ep:pie-chart'
}
},
{
path: 'ad-slot',
component: () => import('@/views/wanxiang/adSlot/index.vue'),
@ -296,6 +317,16 @@ const remainingRouter: AppRouteRecordRaw[] = [
title: '提现审核',
icon: 'ep:wallet'
}
},
{
// U10:赋余额 + 赋订阅(资金操作 2 Tab;二次确认 + bizNo 幂等键)
path: 'grant',
component: () => import('@/views/wanxiang/grant/index.vue'),
name: 'WanxiangGrant',
meta: {
title: '赋余额/订阅',
icon: 'ep:money'
}
}
]
},

View File

@ -0,0 +1,246 @@
<template>
<ContentWrap>
<!-- 搜索工作栏:复用精选池列表作为候选游戏来源(仅 status=4 已发布且已有排序行可限曝光) -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="专区ID" prop="zoneId">
<el-input
v-model.number="queryParams.zoneId"
placeholder="不填=全部"
clearable
class="!w-240px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button
type="warning"
plain
v-hasPermi="['feed:featured:update']"
@click="openForm()"
>
<Icon icon="ep:bottom" class="mr-5px" /> 设置限曝光
</el-button>
<el-button
type="success"
plain
:loading="exportLoading"
v-hasPermi="['feed:featured:export']"
@click="handleExport"
>
<Icon icon="ep:download" class="mr-5px" /> 导出排序覆盖层
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表:精选池(候选限曝光游戏;可直接对某游戏发起限曝光降权) -->
<ContentWrap>
<el-table v-loading="loading" :data="list">
<el-table-column label="游戏ID" align="center" prop="gameId" width="90" />
<el-table-column label="封面" align="center" width="80">
<template #default="scope">
<el-image
v-if="scope.row.coverUrl"
:src="scope.row.coverUrl"
fit="cover"
style="width: 48px; height: 64px; border-radius: 4px"
:preview-src-list="[scope.row.coverUrl]"
preview-teleported
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="标题" align="center" prop="title" min-width="140" show-overflow-tooltip />
<el-table-column label="作者" align="center" prop="authorName" width="120" />
<el-table-column label="专区ID" align="center" prop="zoneId" width="90" />
<el-table-column label="质量分(0-100)" align="center" prop="qualityScore" width="120">
<template #default="scope">{{ scope.row.qualityScore?.toFixed(1) ?? '-' }}</template>
</el-table-column>
<el-table-column label="试玩数" align="center" prop="playCount" width="100" />
<el-table-column label="操作" align="center" width="160" fixed="right">
<template #default="scope">
<el-button
link
type="warning"
v-hasPermi="['feed:featured:update']"
@click="openForm(scope.row)"
>
限曝光降权
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 限曝光降权 表单弹窗 -->
<Dialog v-model="dialogVisible" title="限曝光降权" width="520">
<el-alert
class="mb-12px"
type="warning"
:closable="false"
title="限曝光是 boost 的反向运营操作:写入排序覆盖层降权,仅作用于排序、不改游戏本体。仅已发布且已有排序行的游戏可设置。"
/>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
<el-form-item label="游戏ID" prop="gameId">
<el-input v-model.number="formData.gameId" placeholder="请输入已发布游戏的ID" />
</el-form-item>
<el-form-item label="作用专区ID" prop="zoneId">
<el-input v-model.number="formData.zoneId" placeholder="不填=作用于默认混合流(zoneId=0)" />
</el-form-item>
<el-form-item label="限曝光降权因子" prop="exposureLimit">
<el-input-number
v-model="formData.exposureLimit"
:precision="1"
:step="1"
:min="0"
class="!w-240px"
/>
<div class="text-12px text-gray-400 mt-4px">
0=取消降权 / &gt;0=降权 x 分 / &ge;9999=硬限曝光(出流剔除)
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="submitLoading" type="primary" @click="submitForm">确 定</el-button>
<el-button @click="dialogVisible = false">取 消</el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import download from '@/utils/download'
import * as FeedApi from '@/api/wanxiang/feed'
defineOptions({ name: 'WanxiangExposureLimit' })
const message = useMessage() // 消息弹窗
const loading = ref(true) // 列表加载中
const total = ref(0) // 总条数
const list = ref<FeedApi.FeedCardRespVO[]>([]) // 列表数据(候选限曝光游戏)
const queryParams = reactive<FeedApi.FeaturedPageReqVO>({
pageNo: 1,
pageSize: 10,
zoneId: undefined
})
const queryFormRef = ref()
const exportLoading = ref(false) // 导出加载中
/** 查询候选限曝光游戏(复用精选池列表) */
const getList = async () => {
loading.value = true
try {
const data = await FeedApi.getFeaturedPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置 */
const resetQuery = () => {
queryParams.zoneId = undefined
queryFormRef.value?.resetFields?.()
handleQuery()
}
/** 导出排序覆盖层(精选/限曝光)Excel */
const handleExport = async () => {
try {
await message.exportConfirm()
exportLoading.value = true
const data = await FeedApi.exportFeaturedExcel({ zoneId: queryParams.zoneId })
if (data) download.excel(data as Blob, '精选池限曝光.xls')
} catch {
} finally {
exportLoading.value = false
}
}
// ---- 限曝光降权 表单弹窗 ----
const dialogVisible = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const formData = reactive<FeedApi.ExposureLimitReqVO>({
gameId: undefined as unknown as number,
zoneId: undefined,
exposureLimit: 0
})
const formRules = reactive({
gameId: [{ required: true, message: '游戏ID不能为空', trigger: 'blur' }],
exposureLimit: [{ required: true, message: '限曝光降权因子不能为空', trigger: 'blur' }]
})
/** 打开弹窗:传 row=对该游戏限曝光;不传=手填游戏ID限曝光 */
const openForm = (row?: FeedApi.FeedCardRespVO) => {
dialogVisible.value = true
if (row) {
formData.gameId = row.gameId
formData.zoneId = row.zoneId
} else {
formData.gameId = undefined as unknown as number
formData.zoneId = undefined
}
formData.exposureLimit = 0
formRef.value?.resetFields?.()
}
/** 提交限曝光降权(写排序覆盖层 exposure_limit) */
const submitForm = async () => {
if (!formRef.value) return
const valid = await formRef.value.validate()
if (!valid) return
// 资金外的运营降权操作:二次确认(>=9999 会出流剔除,影响较大)
try {
const tip =
formData.exposureLimit >= 9999
? `确认对游戏 #${formData.gameId} 设置「硬限曝光(出流剔除)」?`
: formData.exposureLimit > 0
? `确认对游戏 #${formData.gameId} 降权 ${formData.exposureLimit} 分?`
: `确认取消游戏 #${formData.gameId} 的限曝光降权?`
await message.confirm(tip)
} catch {
return
}
submitLoading.value = true
try {
await FeedApi.setExposureLimit({
gameId: formData.gameId,
zoneId: formData.zoneId,
exposureLimit: formData.exposureLimit
})
message.success('限曝光设置成功')
dialogVisible.value = false
await getList()
} finally {
submitLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getList()
})
</script>

View File

@ -0,0 +1,292 @@
<template>
<el-tabs v-model="activeTab" class="wanxiang-grant-tabs">
<!-- ============ Tab1:赋余额 ============ -->
<el-tab-pane label="赋余额" name="balance">
<ContentWrap>
<el-alert
class="mb-16px"
type="warning"
:closable="false"
title="资金操作:给创作者账户充值(活动奖励/客诉补偿),金额只增不减。业务单号(bizNo)为幂等键,同一单号重复提交不会重复入账,重试安全。操作人由登录态记录、可审计追溯。"
/>
<el-form
ref="balanceFormRef"
:model="balanceForm"
:rules="balanceRules"
label-width="120px"
class="!w-640px"
>
<el-form-item label="创作者用户ID" prop="userId">
<el-input v-model.number="balanceForm.userId" placeholder="被赋余额的创作者用户ID" />
</el-form-item>
<el-form-item label="赋余额金额(元)" prop="amountYuan">
<el-input-number
v-model="balanceForm.amountYuan"
:precision="2"
:step="1"
:min="0.01"
class="!w-240px"
/>
<div class="text-12px text-gray-400 mt-4px">
提交按「分」入账({{ yuan2fen(balanceForm.amountYuan) }} 分),只增不减
</div>
</el-form-item>
<el-form-item label="业务单号" prop="bizNo">
<el-input v-model="balanceForm.bizNo" maxlength="64" show-word-limit placeholder="幂等键,调用端生成唯一" class="!w-360px" />
<el-button class="ml-8px" @click="balanceForm.bizNo = genBizNo('GR')">重新生成</el-button>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="balanceForm.remark"
type="textarea"
:rows="3"
maxlength="255"
show-word-limit
placeholder="赋值原因(可空),如:活动奖励"
class="!w-360px"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="balanceLoading"
v-hasPermi="['trade:account:grant']"
@click="submitBalance"
>
确认赋余额
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
</el-tab-pane>
<!-- ============ Tab2:赋订阅 ============ -->
<el-tab-pane label="赋订阅" name="subscription">
<ContentWrap>
<el-alert
class="mb-16px"
type="warning"
:closable="false"
title="资金操作:给用户开通会员订阅(运营/B 端定制开通,MVP 不接真实支付收单)。业务单号(bizNo)为幂等键,同一单号不重复延长;续期叠加不丢未用时长。操作人由登录态记录、可审计追溯。"
/>
<el-form
ref="subFormRef"
:model="subForm"
:rules="subRules"
label-width="120px"
class="!w-640px"
>
<el-form-item label="订阅用户ID" prop="userId">
<el-input v-model.number="subForm.userId" placeholder="开通会员的用户ID" />
</el-form-item>
<el-form-item label="订阅套餐" prop="plan">
<el-select v-model="subForm.plan" placeholder="请选择套餐" class="!w-240px" @change="onPlanChange">
<el-option v-for="p in planOptions" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
</el-form-item>
<el-form-item label="赋订阅时长(天)" prop="durationDays">
<el-input-number
v-model="subForm.durationDays"
:precision="0"
:step="30"
:min="1"
class="!w-240px"
/>
<div class="text-12px text-gray-400 mt-4px">续期叠加;选套餐自动带默认天数,可手改</div>
</el-form-item>
<el-form-item label="业务单号" prop="bizNo">
<el-input v-model="subForm.bizNo" maxlength="64" show-word-limit placeholder="幂等键,调用端生成唯一" class="!w-360px" />
<el-button class="ml-8px" @click="subForm.bizNo = genBizNo('SUB')">重新生成</el-button>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="subForm.remark"
type="textarea"
:rows="3"
maxlength="255"
show-word-limit
placeholder="开通原因(可空),如:B 端定制开通"
class="!w-360px"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="subLoading"
v-hasPermi="['trade:subscription:grant']"
@click="submitSubscription"
>
确认赋订阅
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
</el-tab-pane>
</el-tabs>
</template>
<script lang="ts" setup>
import * as TradeApi from '@/api/wanxiang/trade'
import { SubscriptionPlanEnum } from '@/api/wanxiang/trade'
defineOptions({ name: 'WanxiangGrant' })
const message = useMessage() // 消息弹窗
const activeTab = ref('balance') // 当前 Tab
/** 元 → 分(资金一律以分入账;四舍五入避免浮点误差) */
const yuan2fen = (yuan?: number): number => Math.round((yuan ?? 0) * 100)
/**
* 生成幂等键 bizNo:前缀 + 时间戳(到毫秒) + 4 位随机。
* 唯一性靠时间戳+随机;如运营需自定义可手改/重新生成(后端按 bizNo 幂等去重)。
*/
const genBizNo = (prefix: string): string => {
const now = new Date()
const pad = (n: number, w = 2) => String(n).padStart(w, '0')
const ts =
now.getFullYear().toString() +
pad(now.getMonth() + 1) +
pad(now.getDate()) +
pad(now.getHours()) +
pad(now.getMinutes()) +
pad(now.getSeconds()) +
pad(now.getMilliseconds(), 3)
const rand = Math.floor(Math.random() * 9000 + 1000)
return `${prefix}${ts}${rand}`
}
// ---- Tab1:赋余额 ----
const balanceLoading = ref(false)
const balanceFormRef = ref()
const balanceForm = reactive<{
userId?: number
amountYuan?: number
bizNo: string
remark?: string
}>({
userId: undefined,
amountYuan: undefined,
bizNo: genBizNo('GR'),
remark: ''
})
const balanceRules = reactive({
userId: [{ required: true, message: '创作者用户ID不能为空', trigger: 'blur' }],
amountYuan: [{ required: true, message: '赋余额金额不能为空', trigger: 'blur' }],
bizNo: [{ required: true, message: '业务单号(幂等键)不能为空', trigger: 'blur' }]
})
/** 提交赋余额(资金操作:二次确认 + 携带 bizNo 幂等键) */
const submitBalance = async () => {
if (!balanceFormRef.value) return
const valid = await balanceFormRef.value.validate()
if (!valid) return
const amount = yuan2fen(balanceForm.amountYuan)
if (amount <= 0) {
message.warning('赋余额金额必须大于 0')
return
}
try {
await message.confirm(
`确认给创作者 #${balanceForm.userId} 赋余额 ${(balanceForm.amountYuan ?? 0).toFixed(2)} 元(${amount} 分)?业务单号:${balanceForm.bizNo}`
)
} catch {
return
}
balanceLoading.value = true
try {
const grantId = await TradeApi.grantBalance({
userId: balanceForm.userId as number,
amount,
bizNo: balanceForm.bizNo,
remark: balanceForm.remark
})
message.success(`赋余额成功,流水 ID:${grantId}`)
// 成功后重置表单 + 换新幂等键,避免误重复提交同一单号
balanceForm.userId = undefined
balanceForm.amountYuan = undefined
balanceForm.remark = ''
balanceForm.bizNo = genBizNo('GR')
balanceFormRef.value?.clearValidate?.()
} finally {
balanceLoading.value = false
}
}
// ---- Tab2:赋订阅 ----
const subLoading = ref(false)
const subFormRef = ref()
// 套餐选项与默认天数(月度30/季度90/年度365;可手改)
const planOptions = [
{ label: '月度(30天)', value: SubscriptionPlanEnum.MONTHLY, days: 30 },
{ label: '季度(90天)', value: SubscriptionPlanEnum.QUARTERLY, days: 90 },
{ label: '年度(365天)', value: SubscriptionPlanEnum.YEARLY, days: 365 }
]
const subForm = reactive<{
userId?: number
plan?: number
durationDays?: number
bizNo: string
remark?: string
}>({
userId: undefined,
plan: undefined,
durationDays: undefined,
bizNo: genBizNo('SUB'),
remark: ''
})
const subRules = reactive({
userId: [{ required: true, message: '订阅用户ID不能为空', trigger: 'blur' }],
plan: [{ required: true, message: '订阅套餐不能为空', trigger: 'change' }],
durationDays: [{ required: true, message: '订阅时长不能为空', trigger: 'blur' }],
bizNo: [{ required: true, message: '业务单号(幂等键)不能为空', trigger: 'blur' }]
})
/** 选套餐时自动带默认天数(仅当用户未手填时) */
const onPlanChange = (plan: number) => {
const opt = planOptions.find((p) => p.value === plan)
if (opt && subForm.durationDays == null) subForm.durationDays = opt.days
}
/** 提交赋订阅(资金操作:二次确认 + 携带 bizNo 幂等键) */
const submitSubscription = async () => {
if (!subFormRef.value) return
const valid = await subFormRef.value.validate()
if (!valid) return
const planLabel = planOptions.find((p) => p.value === subForm.plan)?.label ?? subForm.plan
try {
await message.confirm(
`确认给用户 #${subForm.userId} 开通【${planLabel}】订阅 ${subForm.durationDays} 天?业务单号:${subForm.bizNo}`
)
} catch {
return
}
subLoading.value = true
try {
const expireTime = await TradeApi.grantSubscription({
userId: subForm.userId as number,
plan: subForm.plan as number,
durationDays: subForm.durationDays as number,
bizNo: subForm.bizNo,
remark: subForm.remark
})
message.success(`赋订阅成功,到期时间:${expireTime}`)
// 成功后重置 + 换新幂等键
subForm.userId = undefined
subForm.plan = undefined
subForm.durationDays = undefined
subForm.remark = ''
subForm.bizNo = genBizNo('SUB')
subFormRef.value?.clearValidate?.()
} finally {
subLoading.value = false
}
}
</script>
<style lang="scss" scoped>
.wanxiang-grant-tabs {
padding: 0 8px;
}
</style>

View File

@ -0,0 +1,169 @@
<template>
<!-- 经营看板:跨模块只读聚合(DAU + GMV + 生成量 + 发布量),区间维度 -->
<ContentWrap>
<el-form class="-mb-15px" :inline="true" label-width="68px">
<el-form-item label="统计区间">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日"
end-placeholder="结束日"
@change="loadDashboard"
/>
</el-form-item>
<el-form-item>
<el-button @click="loadDashboard"><Icon icon="ep:search" class="mr-5px" /> 查询</el-button>
<el-button
type="success"
plain
:loading="exportLoading"
v-hasPermi="['telemetry:dashboard:query']"
@click="handleExport"
>
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 降级提示:有跨模块数据源不可用时(对应指标已兜底 0) -->
<el-alert
v-if="dashboard.degraded"
class="mb-12px"
type="warning"
:closable="false"
:title="`部分数据源不可用,对应指标已兜底为 0:${(dashboard.degradedSources || []).join('、') || '未知'}`"
/>
<!-- 关键指标卡 -->
<el-row :gutter="16" v-loading="loading">
<el-col :span="6">
<el-card shadow="never">
<div class="metric-label">DAU 日活(结束日当天)</div>
<div class="metric-value">{{ formatNumber(dashboard.dau) }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never">
<div class="metric-label">GMV 区间营收(元)</div>
<div class="metric-value">{{ fen2yuan(dashboard.gmv) }}</div>
<div class="metric-sub">分账前原始收入</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never">
<div class="metric-label">区间生成量</div>
<div class="metric-value">{{ formatNumber(dashboard.generatedCount) }}</div>
<div class="metric-sub">aigc 成功生成任务数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never">
<div class="metric-label">区间发布量</div>
<div class="metric-value">{{ formatNumber(dashboard.publishedCount) }}</div>
<div class="metric-sub">project 已发布项目数</div>
</el-card>
</el-col>
</el-row>
</template>
<script lang="ts" setup>
import download from '@/utils/download'
import * as TelemetryApi from '@/api/wanxiang/telemetry'
defineOptions({ name: 'WanxiangOperationDashboard' })
const message = useMessage() // 消息弹窗
const loading = ref(false) // 看板加载中
const exportLoading = ref(false) // 导出加载中
const dateRange = ref<string[]>([]) // 统计区间 [start, end]
const dashboard = reactive<TelemetryApi.DashboardRespVO>({
dau: 0,
gmv: 0,
generatedCount: 0,
publishedCount: 0,
degraded: false,
degradedSources: []
})
/** 千分位格式化 */
const formatNumber = (n?: number): string => (n ?? 0).toLocaleString('zh-CN')
/** 金额:分 → 元(GMV 单位为分;展示转元,千分位 2 位小数) */
const fen2yuan = (fen?: number): string =>
((fen ?? 0) / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
/** 区间默认值:未选时取近 7 日(含今日) */
const ensureRange = (): [string, string] => {
let start = dateRange.value?.[0]
let end = dateRange.value?.[1]
if (!start || !end) {
const today = new Date()
const ago = new Date(today.getTime() - 6 * 24 * 3600 * 1000)
const fmt = (d: Date) => d.toISOString().slice(0, 10)
start = fmt(ago)
end = fmt(today)
dateRange.value = [start, end]
}
return [start, end]
}
/** 加载经营看板聚合 */
const loadDashboard = async () => {
const [start, end] = ensureRange()
loading.value = true
try {
const data = await TelemetryApi.getDashboard({ statDateStart: start, statDateEnd: end })
dashboard.statDateStart = data.statDateStart
dashboard.statDateEnd = data.statDateEnd
dashboard.dau = data.dau
dashboard.gmv = data.gmv
dashboard.generatedCount = data.generatedCount
dashboard.publishedCount = data.publishedCount
dashboard.degraded = data.degraded
dashboard.degradedSources = data.degradedSources
} finally {
loading.value = false
}
}
/** 导出经营看板 Excel(区间快照) */
const handleExport = async () => {
const [start, end] = ensureRange()
try {
await message.exportConfirm()
exportLoading.value = true
const data = await TelemetryApi.exportDashboardExcel({ statDateStart: start, statDateEnd: end })
if (data) download.excel(data as Blob, '经营看板.xls')
} catch {
} finally {
exportLoading.value = false
}
}
/** 初始化:默认加载近 7 日看板 */
onMounted(() => {
loadDashboard()
})
</script>
<style lang="scss" scoped>
.metric-label {
font-size: 14px;
color: var(--el-text-color-secondary);
}
.metric-value {
margin-top: 8px;
font-size: 28px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.metric-sub {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
</style>

View File

@ -128,6 +128,15 @@
</el-form-item>
<el-form-item>
<el-button @click="loadRevenueReport"><Icon icon="ep:search" class="mr-5px" /> 查询</el-button>
<el-button
type="success"
plain
:loading="revExportLoading"
v-hasPermi="['trade:report:export']"
@click="handleRevenueExport"
>
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
@ -187,6 +196,7 @@
<script lang="ts" setup>
import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
import * as TradeApi from '@/api/wanxiang/trade'
import { WithdrawStatusEnum, WithdrawAuditDecisionEnum } from '@/api/wanxiang/trade'
@ -324,6 +334,7 @@ const handleIncomeQuery = () => {
// ---- Tab3:平台营收报表(只读) ----
const revLoading = ref(false)
const revExportLoading = ref(false) // 营收报表导出加载中
const revenueRange = ref<string[]>([])
const revenue = reactive<TradeApi.RevenueReportVO>({
totalGross: 0,
@ -357,6 +368,30 @@ const loadRevenueReport = async () => {
}
}
/** 导出平台营收报表 Excel(区间单行聚合;口径同 /report/revenue) */
const handleRevenueExport = async () => {
// 复用区间默认逻辑:未选区间先补近 7 日
let start = revenueRange.value?.[0]
let end = revenueRange.value?.[1]
if (!start || !end) {
const today = new Date()
const ago = new Date(today.getTime() - 6 * 24 * 3600 * 1000)
const fmt = (d: Date) => d.toISOString().slice(0, 10)
start = fmt(ago)
end = fmt(today)
revenueRange.value = [start, end]
}
try {
await message.exportConfirm()
revExportLoading.value = true
const data = await TradeApi.exportRevenueExcel({ statDateStart: start, statDateEnd: end })
if (data) download.excel(data as Blob, '平台营收报表.xls')
} catch {
} finally {
revExportLoading.value = false
}
}
// Tab 切换时懒加载对应数据
watch(activeTab, (tab) => {
if (tab === 'income' && !incLoaded.value) getIncomeList()