Compare commits

..

3 Commits

Author SHA1 Message Date
freeboss
9ed3b79c34 feat(manage): 新增推荐页配置模块(7 槽位整屏选片 + zip 导出) 2026-09-26 14:23:37 +08:00
freeboss
e39b88d1db feat: 优化日志页面/支付配置/设备列表/菜单详情数据解析
- 支付配置: privateKeyPath改为privateKey,支持textarea粘贴和pem文件上传
- 审计日志: 优化列表展示(head字段/result解析)和详情弹窗
- 橘视厅调用日志: 接口路径截断显示/JSON格式化/耗时颜色分级
- 第三方设备列表: 新增来源/WiFi MAC/UID/心跳时间列,状态改为已激活/未激活
- 菜单/角色/用户详情: 统一用 data.{entity} 提取业务对象
- 设备权益: 去除双层data嵌套,适配后端统一响应结构
2026-08-11 16:07:10 +08:00
freeboss
630dfddc59 feat(manage): 完善设备管理/支付配置/订单/审计日志模块
- 设备管理: 自营设备固定 source=DIRECT, 第三方设备固定 source=PARTNER, 门店终端管理跳转带 storeOrgId 筛选
- 支付配置: 支持平台全局/渠道/代理商/门店四个层级, 列表显示层级标签与所属组织
- 订单管理: 显示渠道/代理商/门店名称, 设备列显示MAC地址
- 审计日志: 新增审计日志和橘视厅调用日志页面及菜单图标SQL
- 门店管理: 兼容 manage 接口统一返回格式 data:{page:{list}}
2026-08-01 14:48:22 +08:00
20 changed files with 1956 additions and 192 deletions

50
db/add-menu-icons.sql Normal file
View File

@@ -0,0 +1,50 @@
-- ============================================================
-- 为所有菜单补齐图标(只更新 icon 为空的,已有的保留)
-- 图标使用 Element Plus 图标名(PascalCase),侧边栏通过 iconMap[icon] 渲染
-- 执行前建议先跑:SELECT id,name,icon FROM sys_menu WHERE type IN(0,1) ORDER BY parent_id,order_num;
-- 若菜单 name 与下方不一致,请按实际 name 调整 WHERE 条件
-- ============================================================
-- ===== 一级目录 =====
UPDATE `sys_menu` SET `icon`='Briefcase' WHERE `name`='业务管理' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Connection' WHERE `name`='第三方接口管理' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='VideoCamera' WHERE `name`='媒资管理' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Setting' WHERE `name`='系统管理' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='OfficeBuilding' WHERE `name`='组织管理' AND (`icon` IS NULL OR `icon`='');
-- ===== 业务管理 下 =====
UPDATE `sys_menu` SET `icon`='Share' WHERE `name`='渠道管理' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Shop' WHERE `name`='门店管理' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Monitor' WHERE `name`='设备管理' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='PriceTag' WHERE `name`='定价策略' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Wallet' WHERE `name`='支付配置' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Tickets' WHERE `name`='天卡池管理' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Document' WHERE `name`='天卡订单' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Key' WHERE `name`='天卡激活码' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Coin' WHERE `name`='分润规则' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Wallet' WHERE `name`='分账记录' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='RefreshLeft' WHERE `name`='退款记录' AND (`icon` IS NULL OR `icon`='');
-- ===== 第三方接口管理 下 =====
UPDATE `sys_menu` SET `icon`='UserFilled' WHERE `name`='合作伙伴管理' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Files' WHERE `name`='内容包配置' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='ShoppingCart' WHERE `name`='天卡库存' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Document' WHERE `name`='审计日志' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Monitor' WHERE `name`='设备列表' AND (`icon` IS NULL OR `icon`='');
-- ===== 媒资管理 下 =====
UPDATE `sys_menu` SET `icon`='Film' WHERE `name`='橘视厅媒资' AND (`icon` IS NULL OR `icon`='');
-- ===== 系统管理 下 =====
UPDATE `sys_menu` SET `icon`='User' WHERE `name`='用户管理' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='UserFilled'WHERE `name`='角色管理' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Menu' WHERE `name`='菜单管理' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Tools' WHERE `name`='参数配置' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Memo' WHERE `name`='日志管理' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Memo' WHERE `name`='操作日志' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Memo' WHERE `name`='审计日志记录' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Timer' WHERE `name`='定时任务' AND (`icon` IS NULL OR `icon`='');
UPDATE `sys_menu` SET `icon`='Document' WHERE `name`='定时日志' AND (`icon` IS NULL OR `icon`='');
-- ===== 通用兜底:仍为空的菜单统一给一个默认图标 =====
UPDATE `sys_menu` SET `icon`='Menu' WHERE (`icon` IS NULL OR `icon`='') AND `type` IN (0,1);

View File

@@ -0,0 +1,54 @@
import http from '@/utils/httpRequest'
import type { ManageListResponse, ManagePageParams } from './types'
/**
* 审计日志(管理后台操作审计)
*/
export interface AuditLogItem {
id: number
/** 操作人ID */
actorUserId: number
/** 操作人名 */
name?: string
/** 操作描述(中文,如"创建天卡订单(设备端)") */
head?: string
/** 请求方法枚举(如 DAYCARD_ORDER_CREATE) */
method?: string
/** 接口路径 */
api?: string
/** 请求参数 */
param?: string
/** 中文描述(旧字段,多为空) */
paramDesc?: string
/** 操作结果(完整返回字符串,含 code=/msg=) */
result?: string
/** 变更前 */
before?: string
/** 变更后 */
after?: string
/** 操作原因 */
reason?: string
ip?: string
/** 设备信息 */
deviceInfo?: string
/** 工单号 */
ticketNo?: string
createdAt: string
}
/**
* 审计日志查询参数
*/
export interface AuditLogListParams extends ManagePageParams {
actorUserId?: number
api?: string
method?: string
head?: string
ip?: string
ticketNo?: string
}
/** 审计日志列表 */
export function getAuditLogList(params?: AuditLogListParams): Promise<ManageListResponse<AuditLogItem>> {
return http.get(http.adornUrl('/manage/audit-log/list'), { params: http.adornParams(params || {}) }).then(res => res.data)
}

View File

@@ -114,6 +114,13 @@ export interface DayCardOrderItem {
/** 非表字段 */
partnerName?: string
deviceMac?: string
/** 设备MAC(deviceMac 为空时用此字段) */
mac?: string
deviceSn?: string
agentOrgId?: number
/** 组织路径快照 */
orgPathSnapshot?: string
channelOrderNo?: string
}
/** 订单列表参数 */

View File

@@ -32,21 +32,22 @@ export interface DeviceInfo {
channelOrgId?: number
channelName?: string
agentOrgName?: string
/** 设备来源:PLATFORM-平台 / PARTNER-第三方接口注册 */
/** 设备来源:DIRECT-自营 / PARTNER-第三方接口注册 */
source?: string
/** 合作伙伴UUID(source=PARTNER 时) */
partnerId?: string
}
/**
* 终端列表查询参数
* 终端保存/更新数据
*/
export interface DeviceListParams extends ManagePageParams {
storeOrgId?: number
status?: string
deviceName?: string
mac?: string
/** 设备来源:PLATFORM / PARTNER */
deviceSn?: string
/** 设备来源:DIRECT / PARTNER */
source?: string
/** 合作伙伴UUID(source=PARTNER 时筛选) */
partnerId?: string
@@ -68,9 +69,25 @@ export interface DeviceSaveData {
remark?: string
}
/** 下划线转驼峰(兼容后端返回 snake_case 字段,统一为前端驼峰访问) */
function toCamelCase (obj: any): any {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj
const result: any = {}
for (const key in obj) {
result[key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = obj[key]
}
return result
}
/** 终端分页列表 */
export function getDeviceList(params?: DeviceListParams): Promise<ManageListResponse<DeviceInfo>> {
return http.get(http.adornUrl('/manage/device/list'), { params: http.adornParams(params || {}) }).then(res => res.data)
return http.get(http.adornUrl('/manage/device/list'), { params: http.adornParams(params || {}) }).then(res => {
const body = res.data as any
if (body && body.data && body.data.page && Array.isArray(body.data.page.list)) {
body.data.page.list = body.data.page.list.map(toCamelCase)
}
return body
})
}
/** 终端详情 */

View File

@@ -24,5 +24,8 @@ export * from './profitSharing'
export * from './refund'
export * from './partner'
export * from './content-package'
export * from './recommend-page'
export * from './partner-card'
export * from './openapiCallLog'
export * from './auditLog'
export * from './jstCallLog'

View File

@@ -0,0 +1,64 @@
import http from '@/utils/httpRequest'
import type { ManageListResponse, ManagePageParams } from './types'
/**
* 橘视厅调用日志(设备调用橘视厅 API 的日志)
*/
export interface JstCallLogItem {
id: number
/** 请求URL(可能带反引号和query参数) */
apiPath: string
/** 请求方法 */
requestMethod?: string
/** 请求体 */
requestBody?: string
/** 响应体 */
responseBody?: string
/** HTTP状态码 */
responseCode?: number
/** 是否成功(1=成功,0=失败) */
success: number
/** 耗时(ms) */
duration?: number
/** 关联数据ID */
dataId?: number
/** 请求头(JSON字符串) */
requestHeaders?: string
/** 客户端IP */
clientIp?: string
/** 错误信息 */
errorMessage?: string
/** 关联设备MAC */
mac?: string
/** 关联设备SN */
deviceSn?: string
/** 关联设备UID */
deviceUid?: string
/** 门店组织ID */
storeOrgId?: number
/** 设备所属门店名称 */
storeOrgName?: string
/** 门店编码 */
storeOrgCode?: string
/** 门店类型 */
storeOrgType?: string
createTime: string
}
/**
* 橘视厅调用日志查询参数
*/
export interface JstCallLogListParams extends ManagePageParams {
mac?: string
beginTime?: string
endTime?: string
apiPath?: string
requestBody?: string
/** 结果筛选:1=成功,0=失败 */
success?: number
}
/** 橘视厅调用日志列表 */
export function getJstCallLogList(params?: JstCallLogListParams): Promise<ManageListResponse<JstCallLogItem>> {
return http.get(http.adornUrl('/manage/jst/call-log/list'), { params: http.adornParams(params || {}) }).then(res => res.data)
}

View File

@@ -2,40 +2,56 @@ import http from '@/utils/httpRequest'
import type { ManageListResponse, ManageResponse, ManagePageParams } from './types'
/**
* 微信支付配置信息
* 微信支付配置(按层级:平台全局/渠道/代理商/门店,三选一留空=全局)
*/
export interface PayConfigInfo {
id: number
partnerId: number | null
/** 渠道组织ID(渠道级配置) */
channelOrgId?: number
/** 代理商组织ID(代理商级配置) */
agentOrgId?: number
/** 门店组织ID(门店级配置) */
storeOrgId?: number
appId: string
mchId: string
apiV3Key: string
certSerialNo: string
privateKeyPath: string
/** 商户 API 私钥(apiclient_key.pem 的 PEM 内容) */
privateKey: string
notifyUrl: string
status: string
remark: string
/** join 名称(后端返回) */
channelName?: string
agentOrgName?: string
storeName?: string
}
/**
* 支付配置列表查询参数
*/
export interface PayConfigListParams extends ManagePageParams {
partnerId?: number
channelOrgId?: number
agentOrgId?: number
storeOrgId?: number
status?: string
}
/**
* 支付配置保存/更新数据
* 配置层级三选一:channelOrgId/agentOrgId/storeOrgId,都留空=平台全局
*/
export interface PayConfigSaveData {
id?: number
partnerId?: number
channelOrgId?: number
agentOrgId?: number
storeOrgId?: number
appId: string
mchId: string
apiV3Key: string
certSerialNo?: string
privateKeyPath?: string
/** 商户 API 私钥(apiclient_key.pem 的 PEM 内容) */
privateKey?: string
notifyUrl: string
status?: string
remark?: string
@@ -47,7 +63,7 @@ export function getPayConfigList(params?: PayConfigListParams): Promise<ManageLi
}
/** 支付配置详情 */
export function getPayConfigInfo(id: number): Promise<ManageResponse<{ config?: PayConfigInfo }>> {
export function getPayConfigInfo(id: number): Promise<ManageResponse<PayConfigInfo>> {
return http.get(http.adornUrl(`/manage/payconfig/info/${id}`), { params: http.adornParams() }).then(res => res.data)
}

View File

@@ -0,0 +1,107 @@
import http from '@/utils/httpRequest'
import type { ManageListResponse, ManageResponse, ManagePageParams } from './types'
/**
* 推荐页配置(设备厂商 1920×1080 整屏电影推荐页)
* 后端文档:/manage/recommend-page
*/
/** 推荐页期次(列表项) */
export interface RecommendPageItem {
id: number
name: string
remark?: string
deleteFlag?: string
createdAt?: string
updatedAt?: string
}
/** 槽位定义(/slot-definitions 返回,固定 7 个) */
export interface RecommendSlotDefinition {
slotCode: string
/** MAIN-主推 / PORTRAIT-竖图 / LANDSCAPE-横图 / MORE-更多入口 */
slotType: 'MAIN' | 'PORTRAIT' | 'LANDSCAPE' | 'MORE'
width: number
height: number
defaultActionType?: string
mediaRequired?: boolean
posterField?: string
}
/** 槽位详情(/info/{id} 返回的解析后 slots 数组项) */
export interface RecommendSlotInfo {
slotCode: string
slotType: string
width: number
height: number
contentcode?: string | null
mediaName?: string
posterUrl?: string
customPoster?: boolean
actionType?: string
actionParam?: string
rawMedia?: any
}
/** 推荐页详情(slots 已解析为数组,前端渲染用 data.slots) */
export interface RecommendPageDetail {
recommendPage: RecommendPageItem
slots: RecommendSlotInfo[]
}
/** 保存时的槽位数据(只传必要字段,影片名/海报由后端从橘视厅同步) */
export interface RecommendSlotSaveData {
slotCode: string
contentcode?: string
/** 自定义海报 URL;slot_07 必填,其余槽位留空用默认 */
posterUrl?: string
/** 自定义拉起命令(一般不填,用后端默认值) */
actionParam?: string
}
/** 保存推荐页请求体(有 id 更新,无 id 新建) */
export interface RecommendPageSaveData {
id?: number | null
name: string
remark?: string
slots: RecommendSlotSaveData[]
}
/** 列表查询参数 */
export interface RecommendPageListParams extends ManagePageParams {
name?: string
}
/** 分页列表 */
export function getRecommendPageList(params?: RecommendPageListParams): Promise<ManageListResponse<RecommendPageItem>> {
return http.get(http.adornUrl('/manage/recommend-page/list'), { params: http.adornParams(params || {}) }).then(res => res.data)
}
/** 槽位定义(渲染线框用) */
export function getRecommendSlotDefinitions(): Promise<ManageResponse<{ slots: Record<string, RecommendSlotDefinition> }>> {
return http.get(http.adornUrl('/manage/recommend-page/slot-definitions'), { params: http.adornParams() }).then(res => res.data)
}
/** 详情(slots 已解析为数组) */
export function getRecommendPageInfo(id: number): Promise<ManageResponse<RecommendPageDetail>> {
return http.get(http.adornUrl(`/manage/recommend-page/info/${id}`), { params: http.adornParams() }).then(res => res.data)
}
/** 新建或更新(有 id 更新,无 id 新建) */
export function saveRecommendPage(data: RecommendPageSaveData): Promise<ManageResponse<{ recommendPage: RecommendPageItem }>> {
return http.post(http.adornUrl('/manage/recommend-page/save'), http.adornData(data)).then(res => res.data)
}
/** 软删除(body 为 id 数组) */
export function deleteRecommendPage(ids: number[]): Promise<ManageResponse> {
return http.post(http.adornUrl('/manage/recommend-page/delete'), http.adornData(ids, false)).then(res => res.data)
}
/** 导出 zip 交付包(blob 下载;后端实时下载裁剪 7 张海报,耗时约 5~15 秒) */
export function exportRecommendPage(id: number) {
return http.get(http.adornUrl(`/manage/recommend-page/export/${id}`), {
params: http.adornParams(),
responseType: 'blob',
timeout: 60 * 1000
})
}

View File

@@ -0,0 +1,225 @@
<template>
<div class="mod-audit-log">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.api" placeholder="接口路径" clearable style="width:220px"></el-input>
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.method" placeholder="方法枚举" clearable style="width:180px"></el-input>
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.head" placeholder="操作描述" clearable style="width:180px"></el-input>
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.ip" placeholder="IP" clearable style="width:150px"></el-input>
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.resultStatus" placeholder="结果" clearable style="width:120px">
<el-option label="成功" value="SUCCESS"></el-option>
<el-option label="失败" value="FAIL"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%">
<el-table-column type="index" header-align="center" align="center" width="55" label="序号" />
<el-table-column header-align="center" align="center" min-width="160" label="操作描述">
<template #default="scope">
<span>{{ scope.row.head || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="method" header-align="center" align="center" width="170" label="方法" show-overflow-tooltip />
<el-table-column prop="api" header-align="center" align="center" min-width="200" label="接口" show-overflow-tooltip />
<el-table-column header-align="center" align="center" width="100" label="操作人">
<template #default="scope">
<span v-if="scope.row.name">{{ scope.row.name }}</span>
<span v-else-if="scope.row.actorUserId">用户#{{ scope.row.actorUserId }}</span>
<el-tag v-else size="small" type="info">系统</el-tag>
</template>
</el-table-column>
<el-table-column header-align="center" align="center" width="90" label="结果">
<template #default="scope">
<el-tag :type="resultTag(scope.row).type" size="small">{{ resultTag(scope.row).label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip" header-align="center" align="center" width="140" label="IP" show-overflow-tooltip />
<el-table-column prop="createdAt" header-align="center" align="center" width="170" label="操作时间" />
<el-table-column fixed="right" header-align="center" align="center" width="90" label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="viewDetail(scope.row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="审计日志详情" width="820px" top="5vh">
<el-descriptions :column="1" border>
<el-descriptions-item label="操作描述">{{ detailData.head || '-' }}</el-descriptions-item>
<el-descriptions-item label="方法">{{ detailData.method || '-' }}</el-descriptions-item>
<el-descriptions-item label="接口">{{ detailData.api || '-' }}</el-descriptions-item>
<el-descriptions-item label="操作人">
<span v-if="detailData.name">{{ detailData.name }}</span>
<span v-else-if="detailData.actorUserId">用户#{{ detailData.actorUserId }}</span>
<span v-else>系统</span>
</el-descriptions-item>
<el-descriptions-item label="结果">
<el-tag :type="resultTag(detailData).type" size="small">{{ resultTag(detailData).label }}</el-tag>
<span style="margin-left:8px; color:#909399; font-size:12px;">{{ resultSummary(detailData.result) }}</span>
</el-descriptions-item>
<el-descriptions-item label="IP">{{ detailData.ip || '-' }}</el-descriptions-item>
<el-descriptions-item label="设备信息" :content-style="{ wordBreak: 'break-all' }">{{ detailData.deviceInfo || '-' }}</el-descriptions-item>
<el-descriptions-item label="工单号">{{ detailData.ticketNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="原因">{{ detailData.reason || '-' }}</el-descriptions-item>
<el-descriptions-item label="操作时间">{{ detailData.createdAt || '-' }}</el-descriptions-item>
<el-descriptions-item label="请求参数">
<pre class="log-pre">{{ detailData.param || '-' }}</pre>
</el-descriptions-item>
<el-descriptions-item label="返回结果">
<pre class="log-pre">{{ detailData.result || '-' }}</pre>
</el-descriptions-item>
<el-descriptions-item label="变更前">
<pre class="log-pre">{{ detailData.before || '-' }}</pre>
</el-descriptions-item>
<el-descriptions-item label="变更后">
<pre class="log-pre">{{ detailData.after || '-' }}</pre>
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button type="primary" @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { getAuditLogList, type AuditLogItem } from '@/api/manage/auditLog'
interface DataForm {
api: string
method: string
head: string
ip: string
resultStatus: string
}
const dataForm = reactive<DataForm>({ api: '', method: '', head: '', ip: '', resultStatus: '' })
const dataList = ref<AuditLogItem[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
/** 解析 result 字符串,返回标签类型与文案 */
function resultTag (row: Partial<AuditLogItem> | undefined): { type: 'success' | 'danger' | 'warning' | 'info', label: string } {
if (!row) return { type: 'info', label: '-' }
const r = row.result || ''
if (!r) return { type: 'info', label: '-' }
// 异常
if (r.startsWith('exception') || /Exception/i.test(r)) return { type: 'danger', label: '异常' }
// 解析 code=N
const m = r.match(/code=(\d+)/)
if (m) {
const code = Number(m[1])
if (code === 0) return { type: 'success', label: '成功' }
return { type: 'danger', label: `失败(${code})` }
}
// 兜底:msg=success
if (/msg=success/i.test(r)) return { type: 'success', label: '成功' }
if (/msg=/i.test(r) && !/msg=success/i.test(r)) return { type: 'danger', label: '失败' }
return { type: 'warning', label: '未知' }
}
/** 返回结果摘要(取 msg 部分) */
function resultSummary (result?: string): string {
if (!result) return ''
const m = result.match(/msg=([^,}]+)/)
return m ? `msg: ${m[1]}` : ''
}
async function getDataList (): Promise<void> {
dataListLoading.value = true
try {
const params: any = {
page: pageIndex.value,
limit: pageSize.value,
api: dataForm.api || undefined,
method: dataForm.method || undefined,
head: dataForm.head || undefined,
ip: dataForm.ip || undefined
}
// 结果状态由前端筛选(后端无此参数)
const res = await getAuditLogList(params)
if (res && res.code === 0) {
let list = res.data?.page?.list || []
if (dataForm.resultStatus) {
const wantSuccess = dataForm.resultStatus === 'SUCCESS'
list = list.filter((item: AuditLogItem) => {
const tag = resultTag(item)
const isSuccess = tag.type === 'success'
return wantSuccess ? isSuccess : !isSuccess
})
}
dataList.value = list
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
} finally {
dataListLoading.value = false
}
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
const detailVisible = ref<boolean>(false)
const detailData = reactive<Partial<AuditLogItem>>({})
function viewDetail (row: AuditLogItem): void {
Object.assign(detailData, row)
detailVisible.value = true
}
onMounted(() => {
getDataList()
})
</script>
<style scoped>
.mod-audit-log {
padding: 10px;
}
.log-pre {
white-space: pre-wrap;
word-break: break-all;
max-height: 220px;
overflow: auto;
margin: 0;
background: #f5f7fa;
padding: 8px;
border-radius: 4px;
font-size: 12px;
}
</style>

View File

@@ -13,9 +13,6 @@
<el-option label="已取消" value="CANCELLED"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<org-tree-select v-model="dataForm.partnerId" placeholder="合作商" width="160px" />
</el-form-item>
<el-form-item>
<el-date-picker
v-model="dateRange"
@@ -35,11 +32,18 @@
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%;">
<el-table-column type="index" header-align="center" align="center" width="60" label="序号" />
<el-table-column prop="orderNo" header-align="center" align="center" min-width="180" label="订单号" show-overflow-tooltip />
<el-table-column prop="partnerId" header-align="center" align="center" width="100" label="合作商" />
<el-table-column prop="deviceId" header-align="center" align="center" width="90" label="设备" />
<el-table-column prop="deviceMac" header-align="center" align="center" width="140" label="设备MAC" show-overflow-tooltip />
<el-table-column prop="storeOrgId" header-align="center" align="center" width="100" label="门店" />
<el-table-column prop="channelOrgId" header-align="center" align="center" width="100" label="渠道" />
<el-table-column header-align="center" align="center" width="140" label="设备MAC" show-overflow-tooltip>
<template #default="{ row }">{{ row.mac || row.deviceMac || '-' }}</template>
</el-table-column>
<el-table-column header-align="center" align="center" width="110" label="渠道">
<template #default="{ row }">{{ orgNameOf(row.channelOrgId) }}</template>
</el-table-column>
<el-table-column header-align="center" align="center" width="110" label="代理商">
<template #default="{ row }">{{ orgNameOf(row.agentOrgId) }}</template>
</el-table-column>
<el-table-column header-align="center" align="center" width="110" label="门店">
<template #default="{ row }">{{ orgNameOf(row.storeOrgId) }}</template>
</el-table-column>
<el-table-column prop="cardNo" header-align="center" align="center" min-width="160" label="卡密(激活码)" show-overflow-tooltip />
<el-table-column prop="status" header-align="center" align="center" width="100" label="订单状态">
<template #default="{ row }">
@@ -77,11 +81,11 @@
<el-dialog v-model="detailVisible" title="订单详情" width="720px">
<el-descriptions :column="2" border v-loading="detailLoading">
<el-descriptions-item label="订单号">{{ detailData?.orderNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="合作商">{{ detailData?.partnerId ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="设备">{{ detailData?.deviceId ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="设备MAC">{{ detailData?.deviceMac || '-' }}</el-descriptions-item>
<el-descriptions-item label="门店">{{ detailData?.storeOrgId ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="渠道">{{ detailData?.channelOrgId ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="设备MAC">{{ detailData?.mac || detailData?.deviceMac || '-' }}</el-descriptions-item>
<el-descriptions-item label="渠道">{{ orgNameOf(detailData?.channelOrgId) }}</el-descriptions-item>
<el-descriptions-item label="代理商">{{ orgNameOf(detailData?.agentOrgId) }}</el-descriptions-item>
<el-descriptions-item label="门店">{{ orgNameOf(detailData?.storeOrgId) }}</el-descriptions-item>
<el-descriptions-item label="卡密(激活码)">{{ detailData?.cardNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单状态">
<el-tag v-if="detailData?.status" :type="getStatusTagType(detailData.status)" size="small">
@@ -109,17 +113,16 @@
import { reactive, ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getDayCardOrderList, getDayCardOrderInfo, type DayCardOrderItem } from '@/api/manage/daycard'
import { getOrgAllTree } from '@/api/org'
interface DataForm {
orderNo: string
status: string
partnerId: string
}
const dataForm = reactive<DataForm>({
orderNo: '',
status: '',
partnerId: ''
status: ''
})
const dateRange = ref<[string, string] | []>([])
@@ -164,6 +167,37 @@ function formatAmount (amount: number | null | undefined): string {
return '¥' + Number(amount).toFixed(2)
}
/** 组织 ID -> 名称 映射(回显渠道/代理商/门店) */
const orgMap = ref<Map<number, string>>(new Map())
function flattenOrgTree (nodes: any[]): Map<number, string> {
const map = new Map<number, string>()
const walk = (list: any[]) => {
list.forEach(n => {
map.set(n.id, n.name)
if (n.children?.length) walk(n.children)
})
}
walk(nodes)
return map
}
async function loadOrgMap (): Promise<void> {
try {
const res = await getOrgAllTree()
const body = (res as any).data
const list = body?.data?.list || body?.data || []
orgMap.value = flattenOrgTree(Array.isArray(list) ? list : [])
} catch {
// 静默
}
}
function orgNameOf (orgId?: number | null): string {
if (orgId == null) return '-'
return orgMap.value.get(orgId) || `#${orgId}`
}
async function getDataList (): Promise<void> {
dataListLoading.value = true
try {
@@ -173,7 +207,6 @@ async function getDataList (): Promise<void> {
}
if (dataForm.orderNo) params.orderNo = dataForm.orderNo
if (dataForm.status) params.status = dataForm.status
if (dataForm.partnerId) params.partnerId = dataForm.partnerId
if (dateRange.value && dateRange.value.length === 2) {
params.startDate = dateRange.value[0]
params.endDate = dateRange.value[1]
@@ -231,6 +264,7 @@ async function detailHandle (row: DayCardOrderItem): Promise<void> {
}
onMounted(() => {
loadOrgMap()
getDataList()
})
</script>

View File

@@ -33,12 +33,6 @@
width="60"
label="序号">
</el-table-column>
<el-table-column
prop="deviceName"
header-align="center"
align="center"
label="设备名称">
</el-table-column>
<el-table-column
prop="mac"
header-align="center"
@@ -91,12 +85,12 @@
</template>
</el-table-column>
<el-table-column
prop="storeName"
header-align="center"
align="center"
min-width="140"
label="所属门店">
<template #default="scope">
{{ scope.row.storeName || '-' }}
{{ scope.row.storeName || orgNameOf(scope.row.storeOrgId) }}
</template>
</el-table-column>
<el-table-column
@@ -228,16 +222,42 @@
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useRoute } from 'vue-router'
import { getDeviceList, saveOrUpdateDevice, deleteDevice, unbindDeviceOrg, unbindDayCard, type DeviceInfo, type DeviceSaveData } from '@/api/manage/device'
import { getDeviceBenefits, type DeviceBenefits } from '@/api/manage/daycard'
import { getOrgAllTree } from '@/api/org'
import OrgTreeSelect from '@/components/org-tree-select/index.vue'
const route = useRoute()
/** 组织 ID -> 名称(回显门店) */
const orgMap = ref<Record<number, string>>({})
async function loadOrgMap (): Promise<void> {
try {
const res = await getOrgAllTree()
const body = (res as any).data
const list = body?.data?.list || body?.data || []
const walk = (nodes: any[]) => {
nodes.forEach(n => {
orgMap.value[n.id] = n.name
if (n.children?.length) walk(n.children)
})
}
if (Array.isArray(list)) walk(list)
} catch { /* 静默 */ }
}
function orgNameOf (orgId?: number | null): string {
if (orgId == null) return '-'
return orgMap.value[orgId] || `#${orgId}`
}
// ===================== 列表 =====================
const dataForm = reactive({
deviceName: '',
mac: '',
status: ''
status: '',
storeOrgId: undefined as number | undefined
})
const dataList = ref<DeviceInfo[]>([])
const pageIndex = ref<number>(1)
@@ -253,7 +273,9 @@ async function getDataList (): Promise<void> {
limit: pageSize.value,
deviceName: dataForm.deviceName,
mac: dataForm.mac,
status: dataForm.status
status: dataForm.status,
storeOrgId: dataForm.storeOrgId,
source: 'DIRECT'
})
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
@@ -477,10 +499,9 @@ async function showBenefits (row: DeviceInfo): Promise<void> {
}
try {
const res = await getDeviceBenefits(row.id)
if (res && res.code === 0 && res.data) {
benefitsData.value = { ...res.data, deviceName: row.deviceName, deviceId: row.id, mac: row.mac }
} else if (res && res.code === 200 && res.data) {
benefitsData.value = { ...res.data, deviceName: row.deviceName, deviceId: row.id, mac: row.mac }
const benefits = res?.data
if (benefits) {
benefitsData.value = { ...benefits, deviceName: row.deviceName, deviceId: row.id, mac: row.mac }
} else {
ElMessage.error(res?.msg || '查询权益失败')
}
@@ -492,6 +513,12 @@ async function showBenefits (row: DeviceInfo): Promise<void> {
}
onMounted(() => {
// 从门店终端管理跳转来时,带 storeOrgId 筛选该门店设备
const q = route.query.storeOrgId
if (q != null && q !== '') {
dataForm.storeOrgId = Number(q)
}
loadOrgMap()
getDataList()
})
</script>

View File

@@ -0,0 +1,240 @@
<template>
<div class="mod-jst-call-log">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.mac" placeholder="MAC地址" clearable style="width:160px"></el-input>
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.apiPath" placeholder="接口路径" clearable style="width:200px"></el-input>
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.success" placeholder="结果" clearable style="width:120px">
<el-option label="成功" :value="1"></el-option>
<el-option label="失败" :value="0"></el-option>
</el-select>
</el-form-item>
<el-form-item label="开始">
<el-date-picker v-model="dataForm.beginTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="开始时间" style="width:200px" />
</el-form-item>
<el-form-item label="结束">
<el-date-picker v-model="dataForm.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="结束时间" style="width:200px" />
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%">
<el-table-column type="index" header-align="center" align="center" width="55" label="序号" />
<el-table-column header-align="center" align="center" min-width="260" label="接口路径" show-overflow-tooltip>
<template #default="scope">
<span>{{ apiShort(scope.row.apiPath) }}</span>
</template>
</el-table-column>
<el-table-column prop="requestMethod" header-align="center" align="center" width="70" label="方法" />
<el-table-column header-align="center" align="center" width="90" label="结果">
<template #default="scope">
<el-tag v-if="scope.row.success === 1" size="small" type="success">成功</el-tag>
<el-tag v-else size="small" type="danger">失败</el-tag>
</template>
</el-table-column>
<el-table-column header-align="center" align="center" width="90" label="状态码">
<template #default="scope">
<el-tag v-if="scope.row.responseCode && scope.row.responseCode >= 200 && scope.row.responseCode < 300" size="small" type="success">{{ scope.row.responseCode }}</el-tag>
<el-tag v-else-if="scope.row.responseCode" size="small" type="danger">{{ scope.row.responseCode }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column header-align="center" align="center" width="100" label="耗时">
<template #default="scope">
<span :class="durationClass(scope.row.duration)">{{ scope.row.duration ?? '-' }} ms</span>
</template>
</el-table-column>
<el-table-column prop="mac" header-align="center" align="center" width="140" label="MAC" show-overflow-tooltip>
<template #default="scope">{{ scope.row.mac || '-' }}</template>
</el-table-column>
<el-table-column prop="storeOrgName" header-align="center" align="center" min-width="140" label="门店" show-overflow-tooltip>
<template #default="scope">{{ scope.row.storeOrgName || '-' }}</template>
</el-table-column>
<el-table-column prop="createTime" header-align="center" align="center" width="170" label="调用时间" />
<el-table-column fixed="right" header-align="center" align="center" width="90" label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="viewDetail(scope.row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="橘视厅调用详情" width="860px" top="5vh">
<el-descriptions :column="1" border>
<el-descriptions-item label="接口路径">
<div style="word-break: break-all">{{ cleanUrl(detailData.apiPath) }}</div>
</el-descriptions-item>
<el-descriptions-item label="请求方法">{{ detailData.requestMethod || '-' }}</el-descriptions-item>
<el-descriptions-item label="结果">
<el-tag v-if="detailData.success === 1" size="small" type="success">成功</el-tag>
<el-tag v-else size="small" type="danger">失败</el-tag>
<span style="margin-left:8px; color:#909399; font-size:12px;">HTTP {{ detailData.responseCode || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="耗时">{{ detailData.duration ?? '-' }} ms</el-descriptions-item>
<el-descriptions-item label="MAC">{{ detailData.mac || '-' }}</el-descriptions-item>
<el-descriptions-item label="设备SN">{{ detailData.deviceSn || '-' }}</el-descriptions-item>
<el-descriptions-item label="设备UID">{{ detailData.deviceUid || '-' }}</el-descriptions-item>
<el-descriptions-item label="门店">{{ detailData.storeOrgName || '-' }}</el-descriptions-item>
<el-descriptions-item v-if="detailData.clientIp" label="客户端IP">{{ detailData.clientIp }}</el-descriptions-item>
<el-descriptions-item v-if="detailData.errorMessage" label="错误信息">
<pre class="log-pre">{{ detailData.errorMessage }}</pre>
</el-descriptions-item>
<el-descriptions-item label="请求头">
<pre class="log-pre">{{ formatJson(detailData.requestHeaders) }}</pre>
</el-descriptions-item>
<el-descriptions-item label="请求体">
<pre class="log-pre">{{ formatJson(detailData.requestBody) }}</pre>
</el-descriptions-item>
<el-descriptions-item label="响应体">
<pre class="log-pre">{{ formatJson(detailData.responseBody) }}</pre>
</el-descriptions-item>
<el-descriptions-item label="调用时间">{{ detailData.createTime || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button type="primary" @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { getJstCallLogList, type JstCallLogItem } from '@/api/manage/jstCallLog'
interface DataForm {
mac: string
apiPath: string
success: number | ''
beginTime: string
endTime: string
}
const dataForm = reactive<DataForm>({ mac: '', apiPath: '', success: '', beginTime: '', endTime: '' })
const dataList = ref<JstCallLogItem[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
/** 清理 URL 首尾的反引号和空格 */
function cleanUrl (url?: string): string {
if (!url) return '-'
return url.trim().replace(/^`|`$/g, '')
}
/** 列表展示用:仅取 host + path,去掉 query 串 */
function apiShort (url?: string): string {
const clean = cleanUrl(url)
if (clean === '-') return clean
// 去掉 query
const qIdx = clean.indexOf('?')
const noQuery = qIdx > -1 ? clean.substring(0, qIdx) : clean
// 提取 host + path
const m = noQuery.match(/^https?:\/\/[^/]+(.*)$/i)
if (m && m[1]) return m[1]
return noQuery
}
/** 耗时颜色:<300ms 绿,<1000ms 普通,>=1000ms 红 */
function durationClass (duration?: number): string {
if (duration == null) return ''
if (duration < 300) return 'dur-fast'
if (duration >= 1000) return 'dur-slow'
return ''
}
/** 尝试格式化 JSON 字符串,失败则原样返回 */
function formatJson (str?: string): string {
if (!str) return '-'
try {
return JSON.stringify(JSON.parse(str), null, 2)
} catch {
return str
}
}
async function getDataList (): Promise<void> {
dataListLoading.value = true
try {
const res = await getJstCallLogList({
page: pageIndex.value,
limit: pageSize.value,
mac: dataForm.mac || undefined,
apiPath: dataForm.apiPath || undefined,
success: dataForm.success === '' ? undefined : (dataForm.success as number),
beginTime: dataForm.beginTime || undefined,
endTime: dataForm.endTime || undefined
})
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
} finally {
dataListLoading.value = false
}
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
const detailVisible = ref<boolean>(false)
const detailData = reactive<Partial<JstCallLogItem>>({})
function viewDetail (row: JstCallLogItem): void {
Object.assign(detailData, row)
detailVisible.value = true
}
onMounted(() => {
getDataList()
})
</script>
<style scoped>
.mod-jst-call-log {
padding: 10px;
}
.log-pre {
white-space: pre-wrap;
word-break: break-all;
max-height: 240px;
overflow: auto;
margin: 0;
background: #f5f7fa;
padding: 8px;
border-radius: 4px;
font-size: 12px;
}
.dur-fast {
color: #67c23a;
}
.dur-slow {
color: #f56c6c;
}
</style>

View File

@@ -2,15 +2,15 @@
<div class="mod-partner-device">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-select v-model="dataForm.partnerId" placeholder="合作商" clearable filterable style="width:180px">
<el-select v-model="dataForm.partnerId" placeholder="合作伙伴" clearable filterable style="width:180px">
<el-option v-for="p in partnerOptions" :key="p.id" :label="p.name" :value="p.partnerId"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.deviceName" placeholder="设备名称" clearable style="width:160px"></el-input>
<el-input v-model="dataForm.mac" placeholder="MAC地址" clearable style="width:160px"></el-input>
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.mac" placeholder="MAC地址" clearable style="width:160px"></el-input>
<el-input v-model="dataForm.deviceSn" placeholder="设备SN" clearable style="width:160px"></el-input>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
@@ -18,25 +18,37 @@
</el-form>
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%">
<el-table-column type="index" header-align="center" align="center" width="60" label="序号" />
<el-table-column header-align="center" align="center" min-width="140" label="合作商">
<el-table-column type="index" header-align="center" align="center" width="55" label="序号" />
<el-table-column header-align="center" align="center" width="90" label="来源">
<template #default="scope">
<el-tag v-if="scope.row.source === 'PARTNER'" size="small" type="warning">第三方</el-tag>
<el-tag v-else-if="scope.row.source === 'DIRECT'" size="small" type="success">自营</el-tag>
<span v-else>{{ scope.row.source || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="mac" header-align="center" align="center" width="150" label="MAC" show-overflow-tooltip />
<el-table-column prop="wifiMac" header-align="center" align="center" width="150" label="WiFi MAC" show-overflow-tooltip>
<template #default="scope">{{ scope.row.wifiMac || '-' }}</template>
</el-table-column>
<el-table-column prop="deviceSn" header-align="center" align="center" min-width="160" label="SN" show-overflow-tooltip>
<template #default="scope">{{ scope.row.deviceSn || '-' }}</template>
</el-table-column>
<el-table-column prop="deviceUid" header-align="center" align="center" min-width="140" label="设备UID" show-overflow-tooltip>
<template #default="scope">{{ scope.row.deviceUid || '-' }}</template>
</el-table-column>
<el-table-column header-align="center" align="center" min-width="140" label="合作伙伴">
<template #default="scope">{{ partnerNameOf(scope.row.partnerId) }}</template>
</el-table-column>
<el-table-column prop="deviceName" header-align="center" align="center" min-width="140" label="设备名称" />
<el-table-column prop="mac" header-align="center" align="center" width="140" label="MAC" />
<el-table-column prop="deviceSn" header-align="center" align="center" min-width="160" label="设备SN" show-overflow-tooltip />
<el-table-column prop="deviceModel" header-align="center" align="center" width="120" label="型号" />
<el-table-column prop="status" header-align="center" align="center" width="100" label="状态">
<el-table-column prop="status" header-align="center" align="center" width="90" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'ACTIVE'" size="small" type="success">启用</el-tag>
<el-tag v-else-if="scope.row.status === 'INACTIVE'" size="small" type="danger">禁用</el-tag>
<el-tag v-if="scope.row.status === 'ACTIVE'" size="small" type="success">已激活</el-tag>
<el-tag v-else-if="scope.row.status === 'INACTIVE'" size="small" type="info">未激活</el-tag>
<el-tag v-else size="small">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="storeName" header-align="center" align="center" min-width="140" label="绑定门店">
<template #default="scope">{{ scope.row.storeName || '-' }}</template>
<el-table-column prop="lastHeartbeat" header-align="center" align="center" width="170" label="心跳时间">
<template #default="scope">{{ scope.row.lastHeartbeat || '-' }}</template>
</el-table-column>
<el-table-column prop="createdAt" header-align="center" align="center" width="170" label="注册时间" />
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@@ -58,10 +70,10 @@ import { getPartnerList, type PartnerInfo } from '@/api/manage/partner'
interface DataForm {
partnerId: string | null
deviceName: string
mac: string
deviceSn: string
}
const dataForm = reactive<DataForm>({ partnerId: null, deviceName: '', mac: '' })
const dataForm = reactive<DataForm>({ partnerId: null, mac: '', deviceSn: '' })
const dataList = ref<DeviceInfo[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
@@ -94,8 +106,8 @@ async function getDataList (): Promise<void> {
limit: pageSize.value,
source: 'PARTNER',
partnerId: dataForm.partnerId ?? undefined,
deviceName: dataForm.deviceName || undefined,
mac: dataForm.mac || undefined
mac: dataForm.mac || undefined,
deviceSn: dataForm.deviceSn || undefined
})
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []

View File

@@ -2,11 +2,9 @@
<div class="mod-pay-config">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<org-tree-select v-model="dataForm.partnerId" placeholder="合作商" width="180px" />
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.status" placeholder="状态" clearable style="width: 120px;">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
<el-select v-model="dataForm.status" placeholder="状态" clearable style="width: 120px">
<el-option label="启用" value="ACTIVE"></el-option>
<el-option label="禁用" value="INACTIVE"></el-option>
</el-select>
</el-form-item>
<el-form-item>
@@ -16,10 +14,23 @@
<el-button type="primary" @click="addHandle()">新增</el-button>
</el-form-item>
</el-form>
<div style="margin-bottom: 8px; color: #909399; font-size: 13px">
配置优先级:门店 &gt; 代理商 &gt; 渠道 &gt; 平台全局(下单时逐级查找)
</div>
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%;">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%">
<el-table-column type="index" header-align="center" align="center" width="50" label="序号"></el-table-column>
<el-table-column prop="partnerId" header-align="center" align="center" label="合作商"></el-table-column>
<el-table-column header-align="center" align="center" width="100" label="层级">
<template #default="scope">
<el-tag v-if="scope.row.storeOrgId" size="small" type="danger">门店</el-tag>
<el-tag v-else-if="scope.row.agentOrgId" size="small" type="warning">代理商</el-tag>
<el-tag v-else-if="scope.row.channelOrgId" size="small" type="success">渠道</el-tag>
<el-tag v-else size="small" type="info">全局</el-tag>
</template>
</el-table-column>
<el-table-column header-align="center" align="center" min-width="160" label="所属组织">
<template #default="scope">{{ orgNameOf(scope.row) }}</template>
</el-table-column>
<el-table-column prop="appId" header-align="center" align="center" label="应用ID"></el-table-column>
<el-table-column prop="mchId" header-align="center" align="center" label="商户号"></el-table-column>
<el-table-column prop="certSerialNo" header-align="center" align="center" label="证书序列号" show-overflow-tooltip></el-table-column>
@@ -49,10 +60,19 @@
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<el-dialog v-model="addOrUpdateVisible" :title="addOrUpdateTitle" width="500px">
<el-dialog v-model="addOrUpdateVisible" :title="addOrUpdateTitle" width="560px">
<el-form :model="addOrUpdateForm" :rules="addOrUpdateRules" ref="addOrUpdateFormRef" label-width="120px">
<el-form-item label="合作商" prop="partnerId">
<org-tree-select v-model="addOrUpdateForm.partnerId" placeholder="请选择合作商" />
<el-form-item label="配置层级" prop="configLevel">
<el-radio-group v-model="addOrUpdateForm.configLevel" @change="onLevelChange">
<el-radio label="GLOBAL">平台全局</el-radio>
<el-radio label="CHANNEL">渠道</el-radio>
<el-radio label="AGENT">代理商</el-radio>
<el-radio label="STORE">门店</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="addOrUpdateForm.configLevel !== 'GLOBAL'" label="所属组织" prop="orgId">
<org-tree-select v-model="addOrUpdateForm.orgId" :hide-node-types="hideTypesByLevel" placeholder="请选择组织" />
</el-form-item>
<el-form-item label="应用ID" prop="appId">
<el-input v-model="addOrUpdateForm.appId" placeholder="请输入应用ID"></el-input>
@@ -66,20 +86,32 @@
<el-form-item label="证书序列号" prop="certSerialNo">
<el-input v-model="addOrUpdateForm.certSerialNo" placeholder="请输入证书序列号"></el-input>
</el-form-item>
<el-form-item label="私钥路径" prop="privateKeyPath">
<el-input v-model="addOrUpdateForm.privateKeyPath" placeholder="请输入私钥路径"></el-input>
<el-form-item label="商户API私钥" prop="privateKey">
<div style="display:flex; flex-direction:column; gap:8px; width:100%;">
<el-input
v-model="addOrUpdateForm.privateKey"
type="textarea"
:rows="6"
placeholder="请粘贴 apiclient_key.pem 文件内容(-----BEGIN PRIVATE KEY----- 开头)或上传文件">
</el-input>
<el-upload
:auto-upload="false"
:show-file-list="false"
:limit="1"
accept=".pem"
:on-change="onPemFileChange">
<el-button size="small" type="primary" plain>上传 .pem 文件</el-button>
<template #tip>
<span style="margin-left:8px; color:#909399; font-size:12px;">仅读取本地文件内容,不会上传到服务器</span>
</template>
</el-upload>
</div>
</el-form-item>
<el-form-item label="回调地址" prop="notifyUrl">
<el-input v-model="addOrUpdateForm.notifyUrl" placeholder="请输入回调地址"></el-input>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch
v-model="addOrUpdateForm.status"
active-value="ACTIVE"
inactive-value="INACTIVE"
active-text="启用"
inactive-text="禁用">
</el-switch>
<el-switch v-model="addOrUpdateForm.status" active-value="ACTIVE" inactive-value="INACTIVE" active-text="启用" inactive-text="禁用"></el-switch>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="addOrUpdateForm.remark" placeholder="请输入备注"></el-input>
@@ -94,64 +126,24 @@
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { reactive, ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
import { getPayConfigList, saveOrUpdatePayConfig, deletePayConfig, type PayConfigInfo } from '@/api/manage/payConfig'
import OrgTreeSelect from '@/components/org-tree-select/index.vue'
const statusOptions = [
{ label: '启用', value: 'ACTIVE' },
{ label: '禁用', value: 'INACTIVE' }
]
const dataForm = reactive<{
partnerId: string | number
status: string
}>({
partnerId: '',
status: ''
})
const dataForm = reactive<{ status: string }>({ status: '' })
const dataList = ref<PayConfigInfo[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
const addOrUpdateVisible = ref<boolean>(false)
const addOrUpdateTitle = ref<string>('')
const addOrUpdateFormRef = ref<FormInstance>()
interface AddOrUpdateForm {
id: number | null
partnerId: number | null
appId: string
mchId: string
apiV3Key: string
certSerialNo: string
privateKeyPath: string
notifyUrl: string
status: string
remark: string
}
const addOrUpdateForm = reactive<AddOrUpdateForm>({
id: null,
partnerId: null,
appId: '',
mchId: '',
apiV3Key: '',
certSerialNo: '',
privateKeyPath: '',
notifyUrl: '',
status: 'ACTIVE',
remark: ''
})
const addOrUpdateRules: FormRules = {
partnerId: [{ required: true, message: '请选择合作商', trigger: 'change' }],
appId: [{ required: true, message: '应用ID不能为空', trigger: 'blur' }],
mchId: [{ required: true, message: '商户号不能为空', trigger: 'blur' }],
apiV3Key: [{ required: true, message: 'API V3密钥不能为空', trigger: 'blur' }],
notifyUrl: [{ required: true, message: '回调地址不能为空', trigger: 'blur' }]
/** 列表:层级名称回显 */
function orgNameOf (row: PayConfigInfo): string {
if (row.storeOrgId) return row.storeName || `门店#${row.storeOrgId}`
if (row.agentOrgId) return row.agentOrgName || `代理商#${row.agentOrgId}`
if (row.channelOrgId) return row.channelName || `渠道#${row.channelOrgId}`
return '平台全局'
}
function getDataList (): void {
@@ -159,8 +151,7 @@ function getDataList (): void {
getPayConfigList({
page: pageIndex.value,
limit: pageSize.value,
partnerId: dataForm.partnerId ? Number(dataForm.partnerId) : undefined,
status: dataForm.status
status: dataForm.status || undefined
}).then((res) => {
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
@@ -186,14 +177,96 @@ function currentChangeHandle (val: number): void {
getDataList()
}
// ===================== 新增/修改 =====================
const addOrUpdateVisible = ref<boolean>(false)
const addOrUpdateTitle = ref<string>('')
const addOrUpdateFormRef = ref<FormInstance>()
type ConfigLevel = 'GLOBAL' | 'CHANNEL' | 'AGENT' | 'STORE'
interface AddOrUpdateForm {
id: number | null
configLevel: ConfigLevel
orgId: number | string | null
appId: string
mchId: string
apiV3Key: string
certSerialNo: string
privateKey: string
notifyUrl: string
status: string
remark: string
}
const addOrUpdateForm = reactive<AddOrUpdateForm>({
id: null,
configLevel: 'GLOBAL',
orgId: null,
appId: '',
mchId: '',
apiV3Key: '',
certSerialNo: '',
privateKey: '',
notifyUrl: '',
status: 'ACTIVE',
remark: ''
})
/** 按层级过滤组织树:只显示对应类型的节点 */
const hideTypesByLevel = computed<string[]>(() => {
switch (addOrUpdateForm.configLevel) {
case 'CHANNEL': return ['STORE', 'SERVICE_PROVIDER']
case 'AGENT': return ['STORE', 'CHANNEL']
case 'STORE': return ['CHANNEL', 'SERVICE_PROVIDER']
default: return []
}
})
const addOrUpdateRules: FormRules = {
appId: [{ required: true, message: '应用ID不能为空', trigger: 'blur' }],
mchId: [{ required: true, message: '商户号不能为空', trigger: 'blur' }],
apiV3Key: [{ required: true, message: 'API V3密钥不能为空', trigger: 'blur' }],
notifyUrl: [{ required: true, message: '回调地址不能为空', trigger: 'blur' }],
orgId: [{ required: true, message: '请选择所属组织', trigger: 'change' }]
}
function onLevelChange (): void {
addOrUpdateForm.orgId = null
addOrUpdateFormRef.value?.clearValidate('orgId')
}
/** 读取本地 .pem 文件内容到 privateKey(不上传服务器) */
function onPemFileChange (file: UploadFile): void {
if (!file.raw) return
const reader = new FileReader()
reader.onload = () => {
const text = String(reader.result || '').trim()
if (!text) {
ElMessage.warning('文件内容为空')
return
}
if (!text.includes('BEGIN') && !text.includes('PRIVATE KEY')) {
ElMessage.warning('文件内容不像 PEM 私钥,请确认文件正确')
}
addOrUpdateForm.privateKey = text
ElMessage.success('已读取私钥文件内容')
}
reader.onerror = () => {
ElMessage.error('读取文件失败')
}
reader.readAsText(file.raw)
}
function resetForm (): void {
addOrUpdateForm.id = null
addOrUpdateForm.partnerId = null
addOrUpdateForm.configLevel = 'GLOBAL'
addOrUpdateForm.orgId = null
addOrUpdateForm.appId = ''
addOrUpdateForm.mchId = ''
addOrUpdateForm.apiV3Key = ''
addOrUpdateForm.certSerialNo = ''
addOrUpdateForm.privateKeyPath = ''
addOrUpdateForm.privateKey = ''
addOrUpdateForm.notifyUrl = ''
addOrUpdateForm.status = 'ACTIVE'
addOrUpdateForm.remark = ''
@@ -201,74 +274,79 @@ function resetForm (): void {
function addHandle (): void {
resetForm()
addOrUpdateTitle.value = '新增'
addOrUpdateTitle.value = '新增支付配置'
addOrUpdateVisible.value = true
addOrUpdateFormRef.value?.clearValidate()
}
function editHandle (row: PayConfigInfo): void {
resetForm()
addOrUpdateTitle.value = '修改'
addOrUpdateVisible.value = true
addOrUpdateTitle.value = '修改支付配置'
addOrUpdateForm.id = row.id
addOrUpdateForm.partnerId = row.partnerId
addOrUpdateForm.appId = row.appId
addOrUpdateForm.mchId = row.mchId
addOrUpdateForm.apiV3Key = row.apiV3Key
addOrUpdateForm.certSerialNo = row.certSerialNo
addOrUpdateForm.privateKeyPath = row.privateKeyPath
addOrUpdateForm.privateKey = row.privateKey
addOrUpdateForm.notifyUrl = row.notifyUrl
addOrUpdateForm.status = row.status
addOrUpdateForm.remark = row.remark
// 根据现有 orgId 判断层级
if (row.storeOrgId) {
addOrUpdateForm.configLevel = 'STORE'
addOrUpdateForm.orgId = row.storeOrgId
} else if (row.agentOrgId) {
addOrUpdateForm.configLevel = 'AGENT'
addOrUpdateForm.orgId = row.agentOrgId
} else if (row.channelOrgId) {
addOrUpdateForm.configLevel = 'CHANNEL'
addOrUpdateForm.orgId = row.channelOrgId
} else {
addOrUpdateForm.configLevel = 'GLOBAL'
addOrUpdateForm.orgId = null
}
addOrUpdateVisible.value = true
}
function submitForm (): void {
addOrUpdateFormRef.value?.validate((valid) => {
if (valid) {
saveOrUpdatePayConfig({
if (!valid) return
// 根据层级填对应 orgId,其他留空
const payload: any = {
id: addOrUpdateForm.id || undefined,
partnerId: addOrUpdateForm.partnerId ?? undefined,
appId: addOrUpdateForm.appId,
mchId: addOrUpdateForm.mchId,
apiV3Key: addOrUpdateForm.apiV3Key,
certSerialNo: addOrUpdateForm.certSerialNo,
privateKeyPath: addOrUpdateForm.privateKeyPath,
privateKey: addOrUpdateForm.privateKey,
notifyUrl: addOrUpdateForm.notifyUrl,
status: addOrUpdateForm.status,
remark: addOrUpdateForm.remark
}).then((res) => {
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
addOrUpdateVisible.value = false
getDataList()
}
})
if (addOrUpdateForm.configLevel === 'CHANNEL') {
payload.channelOrgId = addOrUpdateForm.orgId
} else if (addOrUpdateForm.configLevel === 'AGENT') {
payload.agentOrgId = addOrUpdateForm.orgId
} else if (addOrUpdateForm.configLevel === 'STORE') {
payload.storeOrgId = addOrUpdateForm.orgId
}
saveOrUpdatePayConfig(payload).then((res) => {
if (res && res.code === 0) {
ElMessage.success({ message: '操作成功', duration: 1500, onClose: () => { addOrUpdateVisible.value = false; getDataList() } })
} else {
ElMessage.error(res.msg)
}
})
}
})
}
function deleteHandle (id: number): void {
ElMessageBox.confirm(`确定对[id=${id}]进行[删除]操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(() => {
deletePayConfig([id]).then((res) => {
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
getDataList()
}
})
ElMessage.success({ message: '操作成功', duration: 1500, onClose: () => getDataList() })
} else {
ElMessage.error(res.msg)
}

View File

@@ -0,0 +1,835 @@
<template>
<div class="mod-recommend-page">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.name" placeholder="期名" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="addHandle()">新建推荐页</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table
:data="dataList"
border
v-loading="dataListLoading"
style="width: 100%;">
<el-table-column
type="index"
header-align="center"
align="center"
width="80"
label="序号">
</el-table-column>
<el-table-column
prop="name"
header-align="center"
align="center"
min-width="160"
label="期名">
</el-table-column>
<el-table-column
prop="remark"
header-align="center"
align="center"
min-width="160"
label="备注">
<template #default="scope">
{{ scope.row.remark || '-' }}
</template>
</el-table-column>
<el-table-column
prop="createdAt"
header-align="center"
align="center"
width="180"
label="创建时间">
</el-table-column>
<el-table-column
fixed="right"
header-align="center"
align="center"
width="320"
label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="editHandle(scope.row)">编辑</el-button>
<el-button link type="primary" size="small" @click="copyHandle(scope.row)">复制新一期</el-button>
<el-button link type="success" size="small" :loading="exportLoading" @click="exportHandle(scope.row.id, scope.row.name)">导出zip</el-button>
<el-button link type="danger" size="small" @click="deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 编辑弹窗:期信息 + 槽位线框 + 槽位配置 -->
<el-dialog v-model="editVisible" :title="editTitle" width="1120px" top="3vh" :close-on-click-modal="false">
<el-form ref="editFormRef" :model="editForm" :rules="editRules" :inline="true" label-width="60px">
<el-form-item label="期名" prop="name">
<el-input v-model="editForm.name" placeholder="如:2026年10月推荐" style="width: 260px" maxlength="50"></el-input>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.remark" placeholder="备注(可选)" style="width: 380px" maxlength="200"></el-input>
</el-form-item>
</el-form>
<div class="edit-layout">
<!-- 左侧:整屏线框(0.25 倍缩放) -->
<div class="wireframe">
<div class="wf-title">整屏预览 1920×1080(0.25x),点击槽位选片/配置</div>
<div class="wf-canvas">
<div class="wf-row" v-for="(row, ri) in wireframeRows" :key="ri">
<div
v-for="slot in row"
:key="slot.slotCode"
class="slot-box"
:class="{ 'is-active': activeSlotCode === slot.slotCode, 'is-more': slot.slotType === 'MORE' }"
:style="{ width: slot.width * SCALE + 'px', height: slot.height * SCALE + 'px' }"
@click="onSlotClick(slot)">
<el-image v-if="displayPoster(slot)" :src="displayPoster(slot)" fit="cover" class="slot-poster"></el-image>
<div v-else class="slot-empty">
<el-icon><component :is="slot.slotType === 'MORE' ? Picture : Plus" /></el-icon>
<span>{{ slot.slotType === 'MORE' ? '设置海报' : '选择影片' }}</span>
</div>
<div class="slot-badge">{{ slot.slotCode }}</div>
<div v-if="slot.mediaName" class="slot-name-bar">{{ slot.mediaName }}</div>
</div>
</div>
</div>
</div>
<!-- 右侧:当前槽位配置 -->
<div class="slot-panel" v-if="activeSlot">
<div class="slot-panel-header">
<span class="slot-panel-code">{{ activeSlot.slotCode }}</span>
<el-tag size="small" :type="tagType(activeSlot.slotType)">{{ slotTypeLabel(activeSlot) }}</el-tag>
<span class="slot-panel-size">{{ activeSlot.width }}×{{ activeSlot.height }}</span>
</div>
<template v-if="activeSlot.slotType !== 'MORE'">
<div class="media-card">
<el-image
v-if="displayPoster(activeSlot)"
:src="displayPoster(activeSlot)"
fit="cover"
class="media-card-img"
:preview-src-list="[displayPoster(activeSlot) as string]"
preview-teleported></el-image>
<div v-else class="media-card-img media-card-img--empty">
<el-icon :size="24"><Film /></el-icon>
</div>
<div class="media-card-info">
<div class="media-card-name" :title="activeSlot.mediaName">{{ activeSlot.mediaName || '未选择影片' }}</div>
<div class="media-card-code">{{ activeSlot.contentcode || '-' }}</div>
<div class="media-card-btns">
<el-button size="small" type="primary" @click="openMediaDialog(activeSlot)">{{ activeSlot.contentcode ? '重新选片' : '选择影片' }}</el-button>
<el-button v-if="activeSlot.contentcode" size="small" @click="clearSlotMedia(activeSlot)">清除</el-button>
</div>
</div>
</div>
<el-form label-position="top" class="slot-form" @submit.prevent>
<el-form-item label="自定义海报 URL(可选)">
<el-input v-model="activeSlot.posterUrl" placeholder="留空使用橘视厅默认海报;横图槽位效果不佳时可填横图 URL 覆盖" clearable></el-input>
</el-form-item>
</el-form>
</template>
<template v-else>
<el-form label-position="top" class="slot-form" @submit.prevent>
<el-form-item label="海报 URL(必填)" required>
<el-input v-model="activeSlot.posterUrl" placeholder="340×300「更多影片」入口海报图 URL" clearable></el-input>
</el-form-item>
</el-form>
</template>
<el-form label-position="top" class="slot-form" @submit.prevent>
<el-form-item label="自定义拉起命令(可选)">
<el-input v-model="activeSlot.actionParam" type="textarea" :rows="2" placeholder="一般不填,留空使用后端默认拉起命令;更换影片后会自动清空"></el-input>
</el-form-item>
</el-form>
</div>
<div v-else class="slot-panel slot-panel--empty">点击左侧槽位进行配置</div>
</div>
<template #footer>
<el-button @click="editVisible = false">取 消</el-button>
<el-button type="primary" :loading="saveLoading" @click="submitEdit()">保 存</el-button>
<el-button type="success" :loading="exportLoading" :disabled="!editForm.id" @click="exportHandle(editForm.id, editForm.name)">导出 zip</el-button>
</template>
</el-dialog>
<!-- 选片弹窗(复用橘视厅媒资搜索接口) -->
<el-dialog v-model="mediaVisible" :title="mediaDialogTitle" width="880px" top="5vh" append-to-body>
<el-form :inline="true" @submit.prevent>
<el-form-item>
<el-input v-model="mediaKeyword" placeholder="输入片名关键字搜索" clearable style="width: 360px" @keyup.enter="searchMedia(1)"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchMedia(1)">搜 索</el-button>
</el-form-item>
</el-form>
<el-table :data="mediaList" border v-loading="mediaLoading" max-height="420" style="width: 100%">
<el-table-column label="海报" width="70" align="center">
<template #default="scope">
<el-image
v-if="scope.row.posterimg"
:src="scope.row.posterimg"
fit="cover"
style="width: 44px; height: 62px; border-radius: 2px"
:preview-src-list="[scope.row.posterimg]"
preview-teleported></el-image>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="name" label="片名" min-width="150" show-overflow-tooltip></el-table-column>
<el-table-column prop="contentcode" label="contentcode" min-width="200" show-overflow-tooltip></el-table-column>
<el-table-column prop="categoryname" label="分类" width="90" align="center"></el-table-column>
<el-table-column prop="score" label="评分" width="70" align="center"></el-table-column>
<el-table-column prop="releaseyear" label="年份" width="80" align="center"></el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="scope">
<el-button link type="primary" size="small" @click="chooseMedia(scope.row)">选择</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="onMediaSizeChange"
@current-change="onMediaPageChange"
:current-page="mediaPageIndex"
:page-sizes="[10, 20, 50]"
:page-size="mediaPageSize"
:total="mediaTotal"
layout="total, sizes, prev, pager, next"
style="margin-top: 8px">
</el-pagination>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Film, Picture, Plus } from '@element-plus/icons-vue'
import {
getRecommendPageList,
getRecommendPageInfo,
getRecommendSlotDefinitions,
saveRecommendPage,
deleteRecommendPage,
exportRecommendPage,
type RecommendPageItem,
type RecommendSlotDefinition,
type RecommendSlotInfo,
type RecommendSlotSaveData
} from '@/api/manage/recommend-page'
import { searchJetsenMedia, type JetsenContentItem } from '@/api/jetsenMedia'
/** 线框缩放比例(编辑页 0.25 倍渲染) */
const SCALE = 0.25
/** 槽位类型中文标签 */
const SLOT_TYPE_LABELS: Record<string, string> = {
MAIN: '主推',
PORTRAIT: '竖图',
LANDSCAPE: '横图',
MORE: '更多入口'
}
/** 线框两行槽位排布 */
const WIREFRAME_LAYOUT: string[][] = [
['slot_01', 'slot_02', 'slot_03', 'slot_04'],
['slot_05', 'slot_06', 'slot_07']
]
/** 内置槽位定义(接口不可用时兜底,与文档一致) */
const DEFAULT_SLOT_DEFS: RecommendSlotDefinition[] = [
{ slotCode: 'slot_01', slotType: 'MAIN', width: 700, height: 400, defaultActionType: 'DETAIL', mediaRequired: true, posterField: 'thumbimg' },
{ slotCode: 'slot_02', slotType: 'PORTRAIT', width: 340, height: 440, defaultActionType: 'DETAIL', mediaRequired: true, posterField: 'posterimg' },
{ slotCode: 'slot_03', slotType: 'PORTRAIT', width: 340, height: 440, defaultActionType: 'DETAIL', mediaRequired: true, posterField: 'posterimg' },
{ slotCode: 'slot_04', slotType: 'PORTRAIT', width: 340, height: 440, defaultActionType: 'DETAIL', mediaRequired: true, posterField: 'posterimg' },
{ slotCode: 'slot_05', slotType: 'LANDSCAPE', width: 700, height: 300, defaultActionType: 'DETAIL', mediaRequired: true, posterField: 'thumbimg' },
{ slotCode: 'slot_06', slotType: 'LANDSCAPE', width: 700, height: 300, defaultActionType: 'DETAIL', mediaRequired: true, posterField: 'thumbimg' },
{ slotCode: 'slot_07', slotType: 'MORE', width: 340, height: 300, defaultActionType: 'LAUNCH', mediaRequired: false }
]
// ===================== 列表 =====================
const dataForm = reactive<{ name: string }>({ name: '' })
const dataList = ref<RecommendPageItem[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
async function getDataList (): Promise<void> {
dataListLoading.value = true
try {
const res = await getRecommendPageList({
page: pageIndex.value,
limit: pageSize.value,
name: dataForm.name.trim() || undefined
})
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
} finally {
dataListLoading.value = false
}
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
function deleteHandle (id: number): void {
ElMessageBox.confirm(`确定对[id=${id}]进行[删除]操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteRecommendPage([id]).then((res) => {
if (res && res.code === 0) {
ElMessage.success({ message: '操作成功', duration: 1500, onClose: () => getDataList() })
} else {
ElMessage.error(res?.msg || '删除失败')
}
})
}).catch(() => {})
}
// ===================== 槽位定义与线框 =====================
const slotDefinitions = ref<RecommendSlotDefinition[]>([])
async function loadSlotDefinitions (): Promise<void> {
try {
const res = await getRecommendSlotDefinitions()
if (res && res.code === 0 && res.data?.slots) {
const defs = Object.values(res.data.slots).sort((a, b) => a.slotCode.localeCompare(b.slotCode))
if (defs.length > 0) {
slotDefinitions.value = defs
return
}
}
} catch (e) {
console.error('加载槽位定义失败,使用内置定义', e)
}
slotDefinitions.value = DEFAULT_SLOT_DEFS
}
const wireframeRows = computed<EditSlotState[][]>(() => {
const byCode = new Map(editSlots.value.map(s => [s.slotCode, s]))
return WIREFRAME_LAYOUT
.map(row => row.map(code => byCode.get(code)).filter(Boolean) as EditSlotState[])
.filter(row => row.length > 0)
})
// ===================== 编辑 =====================
/** 编辑态槽位数据 */
interface EditSlotState {
slotCode: string
slotType: string
width: number
height: number
contentcode: string
mediaName: string
/** 自定义海报 URL(slot_07 即海报本身;其余槽位留空用默认) */
posterUrl: string
/** 默认海报预览(选片/详情回显用,不提交) */
defaultPosterUrl: string
actionParam: string
}
const editVisible = ref<boolean>(false)
const editTitle = ref<string>('')
const editFormRef = ref<FormInstance>()
const editForm = reactive<{ id?: number; name: string; remark: string }>({ id: undefined, name: '', remark: '' })
const editRules: FormRules = {
name: [{ required: true, message: '请输入期名', trigger: 'blur' }]
}
const editSlots = ref<EditSlotState[]>([])
const activeSlotCode = ref<string>('')
const saveLoading = ref<boolean>(false)
const exportLoading = ref<boolean>(false)
const activeSlot = computed<EditSlotState | undefined>(() => editSlots.value.find(s => s.slotCode === activeSlotCode.value))
/** 由槽位定义 + 详情数据构建编辑态(infos 为空 = 新建) */
function buildEditSlots (defs: RecommendSlotDefinition[], infos: RecommendSlotInfo[]): EditSlotState[] {
const infoMap = new Map((infos || []).map(i => [i.slotCode, i]))
return defs.map(d => {
const info = infoMap.get(d.slotCode)
if (d.slotType === 'MORE') {
return {
slotCode: d.slotCode,
slotType: d.slotType,
width: d.width,
height: d.height,
contentcode: '',
mediaName: '更多影片',
posterUrl: info?.posterUrl || '',
defaultPosterUrl: '',
actionParam: info?.actionParam || ''
}
}
const isCustom = !!info?.customPoster
return {
slotCode: d.slotCode,
slotType: d.slotType,
width: d.width,
height: d.height,
contentcode: info?.contentcode || '',
mediaName: info?.mediaName || '',
posterUrl: isCustom ? (info?.posterUrl || '') : '',
defaultPosterUrl: isCustom ? '' : (info?.posterUrl || ''),
actionParam: info?.actionParam || ''
}
})
}
function resetEditForm (): void {
editForm.id = undefined
editForm.name = ''
editForm.remark = ''
nextTick(() => editFormRef.value?.clearValidate())
}
/** 新建 */
function addHandle (): void {
resetEditForm()
editSlots.value = buildEditSlots(slotDefinitions.value, [])
activeSlotCode.value = 'slot_01'
editTitle.value = '新建推荐页'
editVisible.value = true
}
/** 编辑 / 复制新一期(copy=true 时 id 置空,预填原期槽位) */
async function openEdit (row: RecommendPageItem, copy: boolean): Promise<void> {
let res
try {
res = await getRecommendPageInfo(row.id)
} catch (e: any) {
ElMessage.error(e?.message || '查询失败')
return
}
if (!(res && res.code === 0 && res.data)) {
ElMessage.error(res?.msg || '查询失败')
return
}
const { recommendPage, slots } = res.data
resetEditForm()
editForm.id = copy ? undefined : recommendPage.id
editForm.name = recommendPage.name || ''
editForm.remark = recommendPage.remark || ''
editSlots.value = buildEditSlots(slotDefinitions.value, slots || [])
activeSlotCode.value = 'slot_01'
editTitle.value = copy ? `复制新一期(源自 id=${row.id},只需改差异影片)` : `编辑推荐页 [id=${row.id}]`
editVisible.value = true
}
function editHandle (row: RecommendPageItem): void {
openEdit(row, false)
}
function copyHandle (row: RecommendPageItem): void {
openEdit(row, true)
}
function onSlotClick (slot: EditSlotState): void {
activeSlotCode.value = slot.slotCode
if (slot.slotType !== 'MORE') {
openMediaDialog(slot)
}
}
function clearSlotMedia (slot: EditSlotState): void {
slot.contentcode = ''
slot.mediaName = ''
slot.posterUrl = ''
slot.defaultPosterUrl = ''
slot.actionParam = ''
}
/** 槽位展示海报:自定义海报优先,否则默认海报 */
function displayPoster (slot: EditSlotState): string {
if (slot.slotType === 'MORE') return slot.posterUrl
return slot.posterUrl || slot.defaultPosterUrl
}
function slotTypeLabel (slot: EditSlotState): string {
return SLOT_TYPE_LABELS[slot.slotType] || slot.slotType
}
function tagType (slotType: string): 'danger' | 'success' | 'warning' | 'info' {
if (slotType === 'MAIN') return 'danger'
if (slotType === 'PORTRAIT') return 'success'
if (slotType === 'LANDSCAPE') return 'warning'
return 'info'
}
/** 保存(前端预校验:7 槽位齐、slot_01~06 contentcode 齐、slot_07 海报齐) */
async function submitEdit (): Promise<void> {
if (!editFormRef.value) return
await editFormRef.value.validate(async (valid) => {
if (!valid) return
const missingMedia = editSlots.value.filter(s => s.slotType !== 'MORE' && !s.contentcode).map(s => s.slotCode)
if (missingMedia.length > 0) {
activeSlotCode.value = missingMedia[0]
ElMessage.warning(`槽位 ${missingMedia.join('、')} 未选择影片`)
return
}
const more = editSlots.value.find(s => s.slotType === 'MORE')
if (more && !more.posterUrl.trim()) {
activeSlotCode.value = more.slotCode
ElMessage.warning('slot_07 需填写海报 URL')
return
}
const slots: RecommendSlotSaveData[] = editSlots.value.map(s => {
const item: RecommendSlotSaveData = { slotCode: s.slotCode }
if (s.slotType !== 'MORE' && s.contentcode) item.contentcode = s.contentcode
if (s.posterUrl.trim()) item.posterUrl = s.posterUrl.trim()
if (s.actionParam.trim()) item.actionParam = s.actionParam.trim()
return item
})
saveLoading.value = true
try {
const res = await saveRecommendPage({
id: editForm.id ?? null,
name: editForm.name.trim(),
remark: editForm.remark.trim(),
slots
})
if (res && res.code === 0) {
// 保存成功后回填 id,导出按钮可用(保存后才可导出)
const savedId = res.data?.recommendPage?.id
if (savedId) editForm.id = savedId
ElMessage.success('保存成功')
getDataList()
} else {
ElMessage.error(res?.msg || '保存失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '保存异常')
} finally {
saveLoading.value = false
}
})
}
// ===================== 选片弹窗(复用橘视厅媒资搜索) =====================
const mediaVisible = ref<boolean>(false)
const mediaKeyword = ref<string>('')
const mediaList = ref<JetsenContentItem[]>([])
const mediaLoading = ref<boolean>(false)
const mediaPageIndex = ref<number>(1)
const mediaPageSize = ref<number>(20)
const mediaTotal = ref<number>(0)
const mediaDialogTitle = computed<string>(() => {
const s = activeSlot.value
return s ? `选择影片(${s.slotCode} · ${slotTypeLabel(s)})` : '选择影片'
})
function openMediaDialog (slot: EditSlotState): void {
activeSlotCode.value = slot.slotCode
mediaVisible.value = true
}
async function searchMedia (page?: number): Promise<void> {
const keyword = mediaKeyword.value.trim()
if (!keyword) {
ElMessage.warning('请输入搜索关键字')
return
}
if (page) mediaPageIndex.value = page
mediaLoading.value = true
try {
const res = await searchJetsenMedia({
keyword,
pageindex: mediaPageIndex.value,
pagesize: mediaPageSize.value
})
const body = res.data
if (body == null || Number(body.code) !== 0) {
ElMessage.error((body && body.msg) || '搜索失败')
return
}
mediaList.value = (body.data && body.data.list) || []
mediaTotal.value = (body.data && body.data.totalCount) || 0
} catch (e: any) {
ElMessage.error(e?.message || '搜索异常')
} finally {
mediaLoading.value = false
}
}
function onMediaSizeChange (val: number): void {
mediaPageSize.value = val
searchMedia(1)
}
function onMediaPageChange (val: number): void {
searchMedia(val)
}
function chooseMedia (item: JetsenContentItem): void {
const slot = activeSlot.value
if (!slot || !item.contentcode) return
const dup = editSlots.value.find(s => s.slotType !== 'MORE' && s.slotCode !== slot.slotCode && s.contentcode === item.contentcode)
slot.contentcode = item.contentcode
slot.mediaName = item.name
slot.defaultPosterUrl = item.posterimg || ''
// 换片后清除自定义海报/拉起命令,避免与旧影片不匹配
slot.posterUrl = ''
slot.actionParam = ''
mediaVisible.value = false
if (dup) {
ElMessage.warning(`提示:影片「${item.name}」已用于槽位 ${dup.slotCode}`)
}
}
// ===================== 导出 zip(blob 下载) =====================
async function exportHandle (id?: number, name?: string): Promise<void> {
if (!id) {
ElMessage.warning('请先保存后再导出')
return
}
exportLoading.value = true
try {
const res = await exportRecommendPage(id)
const blob = res.data
if (!(blob instanceof Blob)) {
ElMessage.error('导出失败')
return
}
// 后端失败时返回 JSON 错误而非 zip
if (blob.type === 'application/json') {
try {
const j = JSON.parse(await blob.text())
ElMessage.error(j.msg || '导出失败')
} catch (_) {
ElMessage.error('导出失败')
}
return
}
const disposition = (res.headers['content-disposition'] || res.headers['Content-Disposition'] || '') as string
let fileName = `推荐页_${name || id}.zip`
const match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
if (match && match[1]) {
try {
fileName = decodeURIComponent(match[1])
} catch (_) {
// 保留默认名
}
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (e: any) {
ElMessage.error(e?.message || '导出异常')
} finally {
exportLoading.value = false
}
}
onMounted(() => {
loadSlotDefinitions()
getDataList()
})
</script>
<style scoped>
.mod-recommend-page {
padding: 10px;
}
.table-page-wrap {
margin-top: 10px;
}
.edit-layout {
display: flex;
gap: 16px;
align-items: flex-start;
}
.wireframe {
flex-shrink: 0;
}
.wf-title {
font-size: 12px;
color: #909399;
margin-bottom: 6px;
}
.wf-canvas {
width: 480px;
height: 270px;
box-sizing: border-box;
border: 1px dashed #dcdfe6;
border-radius: 6px;
background: #fafafa;
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
padding: 0 17.5px;
}
.wf-row {
display: flex;
gap: 5px;
align-items: center;
}
.slot-box {
position: relative;
box-sizing: border-box;
flex-shrink: 0;
border: 1px dashed #b1b3b8;
border-radius: 4px;
background: #f5f7fa;
cursor: pointer;
overflow: hidden;
transition: border-color 0.2s;
}
.slot-box:hover {
border-color: #409eff;
}
.slot-box.is-active {
border: 1px solid #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.slot-box.is-more {
background: #fdf6ec;
}
.slot-poster {
width: 100%;
height: 100%;
display: block;
}
.slot-empty {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: #909399;
font-size: 12px;
}
.slot-badge {
position: absolute;
top: 2px;
left: 2px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 10px;
line-height: 1.4;
padding: 0 4px;
border-radius: 2px;
}
.slot-name-bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 10px;
line-height: 1.5;
padding: 0 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.slot-panel {
flex: 1;
min-width: 0;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 14px 16px;
}
.slot-panel--empty {
display: flex;
align-items: center;
justify-content: center;
height: 270px;
color: #909399;
}
.slot-panel-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.slot-panel-code {
font-weight: 600;
font-size: 15px;
}
.slot-panel-size {
color: #909399;
font-size: 12px;
}
.media-card {
display: flex;
gap: 12px;
}
.media-card-img {
width: 80px;
height: 112px;
border-radius: 4px;
flex-shrink: 0;
background: #f5f7fa;
}
.media-card-img--empty {
display: flex;
align-items: center;
justify-content: center;
color: #c0c4cc;
border: 1px dashed #dcdfe6;
box-sizing: border-box;
}
.media-card-info {
flex: 1;
min-width: 0;
}
.media-card-name {
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-card-code {
font-size: 12px;
color: #909399;
margin: 4px 0 10px;
word-break: break-all;
}
.slot-form {
margin-top: 4px;
}
</style>

View File

@@ -197,7 +197,7 @@ function goTerminalManage (row: ShopRow) {
ElMessage.warning('无法跳转:缺少门店标识')
return
}
router.push({ name: 'device-device', query: { storeOrgId: String(id) } }).catch(() => {})
router.push({ name: 'manage-device', query: { storeOrgId: String(id) } }).catch(() => {})
}
function onMoreMenuCommand (cmd: string, row: ShopRow) {

View File

@@ -455,12 +455,14 @@ interface StoreSearchResponse {
}
}
function pickStoreSearchPage(data: StoreSearchResponse | undefined): { list: ShopRow[]; total: number } {
function pickStoreSearchPage(data: any): { list: ShopRow[]; total: number } {
if (!data) return { list: [], total: 0 }
const block = data.data ?? data.page ?? {}
const list = Array.isArray(block.list) ? block.list : []
const total = block.totalCount ?? 0
return { list, total }
// 兼容:data.data.page.list(manage统一)/ data.data.list(PageResult)/ data.page.list / data.list
const inner = data.data ?? data.page ?? data
const block = (inner && typeof inner === 'object' && !Array.isArray(inner)) ? (inner.page ?? inner) : inner
const list = Array.isArray(block?.list) ? block.list : (Array.isArray(inner) ? inner : [])
const total = block?.totalCount ?? inner?.totalCount ?? 0
return { list, total: Number(total) || 0 }
}
function getDataList() {

View File

@@ -198,9 +198,8 @@ function init (id?: number): void {
// 修改
getMenuInfo(dataForm.id).then((data) => {
if (!data || data.code !== 0) return
const body = data as unknown as { data?: MenuInfo; menu?: MenuInfo }
const menu = body.data ?? body.menu
if (menu && typeof menu === 'object') {
const menu = data.data?.menu
if (menu && typeof menu === 'object' && menu.id) {
dataForm.id = menu.id
dataForm.type = menu.type ?? 1
dataForm.name = menu.name

View File

@@ -104,13 +104,8 @@ function init (id?: number): void {
if (dataForm.id) {
getRoleInfo(dataForm.id).then((body) => {
if (!body || body.code !== 0) return
const env = body as unknown as {
data?: RoleInfo & { menuIdList?: number[] }
role?: RoleInfo & { menuIdList?: number[] }
}
const rolePayload = env.data ?? env.role
if (!rolePayload || typeof rolePayload !== 'object') return
const role = rolePayload as RoleInfo & { menuIdList: number[] }
const role = body.data?.role as (RoleInfo & { menuIdList?: number[] }) | undefined
if (!role || typeof role !== 'object') return
dataForm.roleName = role.roleName || ''
dataForm.remark = role.remark || ''
const menuIdList = Array.isArray(role.menuIdList) ? role.menuIdList : []

View File

@@ -265,8 +265,7 @@ function init (id?: number | string | null): void {
if (id !== null && id !== undefined && id !== '') {
getUserInfo(Number(id)).then((body) => {
if (!body || body.code !== 0) return
const env = body as unknown as { data?: UserInfo; user?: UserInfo }
const user = env.data ?? env.user
const user = body.data?.user
if (!user || typeof user !== 'object') return
dataForm.id = user.id
dataForm.userName = user.username || ''