Compare commits
1 Commits
e39b88d1db
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ed3b79c34 |
@@ -24,6 +24,7 @@ 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'
|
||||
|
||||
107
src/api/manage/recommend-page.ts
Normal file
107
src/api/manage/recommend-page.ts
Normal 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
|
||||
})
|
||||
}
|
||||
835
src/views/modules/manage/recommend-page.vue
Normal file
835
src/views/modules/manage/recommend-page.vue
Normal 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>
|
||||
Reference in New Issue
Block a user