Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88db96c374 | ||
|
|
96b213354b | ||
|
|
0d862dfe1d | ||
|
|
8535305185 | ||
|
|
837266ce7e | ||
|
|
71ee469563 | ||
|
|
3c730c7d33 | ||
|
|
4055b7a2a9 | ||
|
|
bbff52f324 |
No files matched your search
@@ -98,6 +98,11 @@ export const pageSubmissionsApi = (params: Record<string, unknown>) => {
|
||||
return request.get<PageResult<SubmissionItem>>({ url: '/admin/submissions/page', params })
|
||||
}
|
||||
|
||||
/** 内容提交详情:附带短剧预览 + 分集列表,审核弹窗展示用 */
|
||||
export const detailSubmissionApi = (id: number) => {
|
||||
return request.get<SubmissionItem>({ url: `/admin/submissions/${id}` })
|
||||
}
|
||||
|
||||
/** action: APPROVE / REJECT / WITHDRAW */
|
||||
export const auditSubmissionApi = (id: number, data: Record<string, unknown>) => {
|
||||
return request.put<SubmissionItem>({ url: `/admin/submissions/${id}/audit`, data })
|
||||
|
||||
@@ -109,6 +109,35 @@ export interface CreatorDramaEditData {
|
||||
language?: string
|
||||
}
|
||||
|
||||
/** 一键发布的分集入参:assetId 来自已完成的分片上传 */
|
||||
export interface PublishEpisodeItem {
|
||||
assetId: number
|
||||
title?: string
|
||||
durationSec?: number
|
||||
cover?: string
|
||||
}
|
||||
|
||||
export interface PublishDramaPayload {
|
||||
title: string
|
||||
subtitle?: string
|
||||
/** 短剧分类 id(下拉选择) */
|
||||
categoryId?: number
|
||||
/** 题材标签(自由文本,可选) */
|
||||
genre?: string
|
||||
description?: string
|
||||
cover?: string
|
||||
language?: string
|
||||
/** 默认 true:创建后立即提交审核 */
|
||||
submit?: boolean
|
||||
episodes: PublishEpisodeItem[]
|
||||
}
|
||||
|
||||
export interface PublishDramaResult {
|
||||
dramaId: number
|
||||
submissionId?: number | null
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface PriceEpisodeConfig {
|
||||
episodeId: number
|
||||
number: number
|
||||
@@ -176,6 +205,11 @@ export const createDramaApi = (data: CreatorDramaEditData) => {
|
||||
return request.post<number>({ url: '/admin/creator-center/dramas', data })
|
||||
}
|
||||
|
||||
/** 一键发布:一个事务里建作品 + 建分集 + 绑定视频(可选提交审核) */
|
||||
export const publishDramaApi = (data: PublishDramaPayload) => {
|
||||
return request.post<PublishDramaResult>({ url: '/admin/creator-center/publish', data })
|
||||
}
|
||||
|
||||
export const updateDramaApi = (id: number, data: CreatorDramaEditData) => {
|
||||
return request.put<void>({ url: `/admin/creator-center/dramas/${id}`, data })
|
||||
}
|
||||
@@ -205,6 +239,11 @@ export const deleteEpisodeApi = (dramaId: number, episodeId: number) => {
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除作品(草稿 / 已下线 / 审核中可删,已上线需先下线) */
|
||||
export const deleteDramaApi = (dramaId: number) => {
|
||||
return request.delete<void>({ url: `/admin/creator-center/dramas/${dramaId}` })
|
||||
}
|
||||
|
||||
export const sortEpisodesApi = (dramaId: number, ids: number[]) => {
|
||||
return request.put<void>({
|
||||
url: `/admin/creator-center/dramas/${dramaId}/episodes/sort`,
|
||||
|
||||
@@ -40,9 +40,10 @@ export const deleteDramaApi = (id: number) => {
|
||||
return request.delete<void>({ url: `/admin/dramas/${id}` })
|
||||
}
|
||||
|
||||
/** 审核与发布:submit / approve / reject / publish / offline */
|
||||
/** 审核与发布:submit / approve / reject / publish / offline / approvePublish */
|
||||
export const changeDramaStatusApi = (id: number, action: DramaReviewAction, reason?: string) => {
|
||||
return request.post<void>({ url: `/admin/dramas/${id}/${action}`, data: { reason } })
|
||||
const path = action === 'approvePublish' ? 'approve-publish' : action
|
||||
return request.post<void>({ url: `/admin/dramas/${id}/${path}`, data: { reason } })
|
||||
}
|
||||
|
||||
/** 下拉选项(短剧表单用),只返回 id/name */
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
import type { PageResult } from './types'
|
||||
|
||||
export interface RewardTaskItem {
|
||||
id: string
|
||||
code: string
|
||||
sectionCode: string
|
||||
type: string
|
||||
verifyMode: string
|
||||
targetValue: number
|
||||
rewardCoin: number
|
||||
cycleType: string
|
||||
cycleLimit: number
|
||||
actionType: string
|
||||
actionUrl: string
|
||||
iconUrl: string
|
||||
titleId: string
|
||||
titleEn: string
|
||||
titleZh: string
|
||||
subtitleId: string
|
||||
subtitleEn: string
|
||||
subtitleZh: string
|
||||
ctaId: string
|
||||
ctaEn: string
|
||||
ctaZh: string
|
||||
sectionTitleId: string
|
||||
sectionTitleEn: string
|
||||
sectionTitleZh: string
|
||||
configJson: string
|
||||
sort: number
|
||||
status: number
|
||||
statusText: string
|
||||
startAt?: string
|
||||
endAt?: string
|
||||
}
|
||||
|
||||
export interface RewardTaskPageParams {
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
type?: string
|
||||
sectionCode?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
export type RewardTaskSaveData = Omit<RewardTaskItem, 'id' | 'status' | 'statusText'>
|
||||
|
||||
export interface RewardRuleDay {
|
||||
dayIndex: number
|
||||
rewardCoin: number
|
||||
}
|
||||
|
||||
export interface RewardGrantItem {
|
||||
id: string
|
||||
userId: string
|
||||
taskId: string
|
||||
cycleKey: string
|
||||
amount: number
|
||||
status: string
|
||||
idempotentKey: string
|
||||
coinLedgerId: string
|
||||
source: string
|
||||
operatorId: string
|
||||
failureReason: string
|
||||
grantedAt?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export const pageRewardTasksApi = (params: RewardTaskPageParams) =>
|
||||
request.get<PageResult<RewardTaskItem>>({ url: '/admin/rewards/tasks/page', params })
|
||||
|
||||
export const createRewardTaskApi = (data: RewardTaskSaveData) =>
|
||||
request.post<string>({ url: '/admin/rewards/tasks', data })
|
||||
|
||||
export const updateRewardTaskApi = (id: string, data: RewardTaskSaveData) =>
|
||||
request.put<void>({ url: `/admin/rewards/tasks/${id}`, data })
|
||||
|
||||
export const updateRewardTaskStatusApi = (id: string, status: number) =>
|
||||
request.put<void>({ url: `/admin/rewards/tasks/${id}/status`, params: { status } })
|
||||
|
||||
export const getRewardRulesApi = () =>
|
||||
request.get<RewardRuleDay[]>({ url: '/admin/rewards/check-in/rules' })
|
||||
|
||||
export const saveRewardRulesApi = (days: RewardRuleDay[]) =>
|
||||
request.put<void>({ url: '/admin/rewards/check-in/rules', data: { days } })
|
||||
|
||||
export const pageRewardGrantsApi = (params: Record<string, unknown>) =>
|
||||
request.get<PageResult<RewardGrantItem>>({ url: '/admin/rewards/grants/page', params })
|
||||
|
||||
export const manualRewardGrantApi = (data: Record<string, unknown>) =>
|
||||
request.post<RewardGrantItem>({ url: '/admin/rewards/grants/manual', data })
|
||||
@@ -73,7 +73,8 @@ export interface DramaSaveData {
|
||||
sort?: number
|
||||
}
|
||||
|
||||
export type DramaReviewAction = 'submit' | 'approve' | 'reject' | 'publish' | 'offline'
|
||||
export type DramaReviewAction =
|
||||
'submit' | 'approve' | 'reject' | 'publish' | 'offline' | 'approvePublish'
|
||||
|
||||
export interface EpisodeItem {
|
||||
id: number
|
||||
|
||||
@@ -40,10 +40,19 @@ const dialogVisible = ref<boolean>(false)
|
||||
const lockScreen = () => {
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 点击整行菜单项都能触发(之前 click 绑在内层文字 div 上,点到菜单项左右内边距没反应)
|
||||
const handleCommand = (command: string) => {
|
||||
if (command === 'lock') {
|
||||
lockScreen()
|
||||
} else if (command === 'logout') {
|
||||
loginOut()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDropdown class="custom-hover" :class="prefixCls" trigger="click">
|
||||
<ElDropdown class="custom-hover" :class="prefixCls" trigger="click" @command="handleCommand">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
v-if="avatarUrl"
|
||||
@@ -63,12 +72,8 @@ const lockScreen = () => {
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem>
|
||||
<div @click="lockScreen">{{ t('lock.lockScreen') }}</div>
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem>
|
||||
<div @click="loginOut">{{ t('common.loginOut') }}</div>
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem command="lock">{{ t('lock.lockScreen') }}</ElDropdownItem>
|
||||
<ElDropdownItem command="logout">{{ t('common.loginOut') }}</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
|
||||
@@ -10,6 +10,7 @@ import TranscodeNoticeButton from './TranscodeNoticeButton.vue'
|
||||
import { Screenfull } from '@/components/Screenfull'
|
||||
import { Breadcrumb } from '@/components/Breadcrumb'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
const { getPrefixCls, variables } = useDesign()
|
||||
@@ -18,6 +19,16 @@ const prefixCls = getPrefixCls('tool-header')
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 转码通知接口要求 drama:list,创作者账号没有该权限。
|
||||
// 不给入口,避免顶栏每 30 秒轮询打一次 403。
|
||||
const canSeeTranscodeNotice = computed(() => {
|
||||
const permissions = userStore.getUserInfo?.permissions
|
||||
if (Array.isArray(permissions)) return permissions.includes('drama:list')
|
||||
return typeof permissions === 'string' && permissions.split(',').includes('drama:list')
|
||||
})
|
||||
|
||||
// 面包屑
|
||||
const breadcrumb = computed(() => appStore.getBreadcrumb)
|
||||
|
||||
@@ -74,7 +85,9 @@ export default defineComponent({
|
||||
color="var(--top-header-text-color)"
|
||||
></LocaleDropdown>
|
||||
) : undefined}
|
||||
{canSeeTranscodeNotice.value ? (
|
||||
<TranscodeNoticeButton color="var(--top-header-text-color)"></TranscodeNoticeButton>
|
||||
) : undefined}
|
||||
<UserInfo></UserInfo>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,6 +108,19 @@ export default defineComponent({
|
||||
:deep(.v-user-info) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 顶栏图标的点击热区原来只有 18px 图标本身,容易点偏导致「点了没反应」。
|
||||
// 统一撑满顶栏高度并左右留出内边距。
|
||||
:deep(.v-screenfull),
|
||||
:deep(.v-size-dropdown),
|
||||
:deep(.v-locale-dropdown),
|
||||
:deep(.v-user-info) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
|
||||
@@ -83,7 +83,9 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.notice-badge :deep(.el-badge__content.is-fixed) {
|
||||
top: 8px;
|
||||
right: 2px;
|
||||
/* 角标中心骑在铃铛右上角:right 9px + 右移 50%,与位数宽度无关 */
|
||||
top: calc(50% - 9px);
|
||||
right: 9px;
|
||||
transform: translate(50%, -50%);
|
||||
}
|
||||
</style>
|
||||
+8
-1
@@ -25,13 +25,20 @@ router.beforeEach(async (to, from, next) => {
|
||||
const userStore = useUserStoreWithOut()
|
||||
if (userStore.getUserInfo) {
|
||||
if (to.path === '/login') {
|
||||
next({ path: '/' })
|
||||
// 已登录再访问登录页:按当前账号的菜单落地,不能回 '/'(静态路由会 redirect 到 /dashboard/index,
|
||||
// 创作者没有该路由,直接掉 404)
|
||||
next({ path: landingPath(), replace: true })
|
||||
} else {
|
||||
if (permissionStore.getIsAddRouters) {
|
||||
// 已加过路由:只有还是同一个账号才能直接放行。
|
||||
// 换账号时(例如超管退出后创作者登录)必须重建,否则侧边栏/路由会残留上一个账号的
|
||||
const username = userStore.getUserInfo?.username || ''
|
||||
if (permissionStore.getRoutersOwner === username) {
|
||||
// 访问根路径时同样按账号菜单落地,避免走静态 redirect 掉进 /dashboard/index
|
||||
if (to.path === '/') {
|
||||
next({ path: landingPath(), replace: true })
|
||||
return
|
||||
}
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
+2
-1
@@ -8,9 +8,10 @@ import { AppIcons } from '@/constants/icons'
|
||||
|
||||
export const constantRouterMap: AppRouteRecordRaw[] = [
|
||||
{
|
||||
// 不写死 redirect:vue-router 会在守卫之前就把 '/' 解析成 redirect 目标,
|
||||
// 创作者没有 /dashboard/index 路由会直接 404。落地页交给 permission.ts 按账号菜单决定。
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/dashboard/index',
|
||||
name: 'Root',
|
||||
meta: {
|
||||
hidden: true
|
||||
|
||||
@@ -298,6 +298,11 @@ html.dark .creator-guide-title {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.creator-switch-hint {
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
html.dark .creator-profile-name,
|
||||
html.dark .creator-profile-meta b,
|
||||
html.dark .creator-drama-title,
|
||||
@@ -316,3 +321,144 @@ html.dark .creator-episode-cover {
|
||||
border-color: var(--el-border-color-light);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
/* ---------- 移动端(创作者多手机上传) ---------- */
|
||||
.creator-mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 作品表格:列宽合计超过容器时由外层横向滚动,
|
||||
不依赖 el-table 内部的 scrollbar(其 header/body 会被各自裁切)。 */
|
||||
.creator-desktop-only {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.creator-desktop-only .el-table.drama-el-table {
|
||||
min-width: 1150px;
|
||||
}
|
||||
|
||||
/* 作品编辑页:分集 / 提交记录表格由外层容器横向滚动(宽度见各表格 inline min-width) */
|
||||
.creator-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* 操作列按钮放不下时换行,避免「删除」被表格裁掉 */
|
||||
.creator-table-scroll .table-actions {
|
||||
flex-wrap: wrap;
|
||||
white-space: normal;
|
||||
row-gap: 6px;
|
||||
}
|
||||
|
||||
.creator-video-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.creator-desktop-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.creator-mobile-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.creator-profile-meta {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.console-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.console-toolbar .console-keyword,
|
||||
.console-toolbar .console-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.creator-drama-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.creator-drama-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e8eb;
|
||||
border-radius: var(--app-radius-md);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.creator-drama-card-cover {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
place-content: center;
|
||||
width: 60px;
|
||||
height: 80px;
|
||||
overflow: hidden;
|
||||
color: #9a97a8;
|
||||
font-size: 16px;
|
||||
background: #eef0f5;
|
||||
border-radius: var(--app-radius-sm);
|
||||
}
|
||||
|
||||
.creator-drama-card-cover img,
|
||||
.creator-drama-card-cover .creator-drama-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.creator-drama-card-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.creator-drama-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.creator-drama-card-head .creator-drama-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.creator-drama-card-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
color: #727983;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.creator-drama-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
html.dark .creator-drama-card {
|
||||
border-color: var(--el-border-color-light);
|
||||
background: var(--el-bg-color-overlay);
|
||||
}
|
||||
|
||||
html.dark .creator-drama-card-cover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Author: fruit.com.cn
|
||||
/** 从本地视频文件截取封面(3:4 居中裁切),抖音式自动封面用 */
|
||||
|
||||
export interface CapturedCover {
|
||||
file: File
|
||||
previewUrl: string
|
||||
}
|
||||
|
||||
function loadVideo(file: File): Promise<HTMLVideoElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video')
|
||||
video.preload = 'metadata'
|
||||
video.muted = true
|
||||
video.playsInline = true
|
||||
const src = URL.createObjectURL(file)
|
||||
const fail = (message: string) => {
|
||||
URL.revokeObjectURL(src)
|
||||
reject(new Error(message))
|
||||
}
|
||||
video.onloadedmetadata = () => {
|
||||
URL.revokeObjectURL(src)
|
||||
resolve(video)
|
||||
}
|
||||
video.onerror = () => fail('当前浏览器无法解析该视频格式')
|
||||
video.src = src
|
||||
})
|
||||
}
|
||||
|
||||
/** 截取第 atSeconds 秒画面,按 3:4 居中裁切输出 720×960 JPEG */
|
||||
export async function captureVideoCover(file: File, atSeconds = 1): Promise<CapturedCover> {
|
||||
const video = await loadVideo(file)
|
||||
const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0
|
||||
// 太短的视频从开头截,正常视频取第 1 秒,避免黑场
|
||||
const target = duration ? Math.min(atSeconds, Math.max(0.1, duration * 0.2)) : 0
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => reject(new Error('截取封面超时')), 8000)
|
||||
video.onseeked = () => {
|
||||
window.clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
video.currentTime = target
|
||||
})
|
||||
|
||||
const width = video.videoWidth
|
||||
const height = video.videoHeight
|
||||
if (!width || !height) throw new Error('无法读取视频尺寸')
|
||||
|
||||
const ratio = 3 / 4
|
||||
let sw = width
|
||||
let sh = height
|
||||
if (width / height > ratio) {
|
||||
sw = height * ratio
|
||||
} else {
|
||||
sh = width / ratio
|
||||
}
|
||||
const sx = (width - sw) / 2
|
||||
const sy = (height - sh) / 2
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 720
|
||||
canvas.height = 960
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('当前浏览器不支持 canvas')
|
||||
context.drawImage(video, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(result) => (result ? resolve(result) : reject(new Error('封面生成失败'))),
|
||||
'image/jpeg',
|
||||
0.85
|
||||
)
|
||||
})
|
||||
const baseName = file.name.replace(/\.[^.]+$/, '') || 'cover'
|
||||
return {
|
||||
file: new File([blob], `${baseName}-cover.jpg`, { type: 'image/jpeg' }),
|
||||
previewUrl: URL.createObjectURL(blob)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import PageHeading from '@/views/components/PageHeading.vue'
|
||||
import { useRbac } from '@/stores/rbac'
|
||||
import { dateCell, formatCount } from '@/utils/format'
|
||||
import {
|
||||
createRewardTaskApi,
|
||||
getRewardRulesApi,
|
||||
manualRewardGrantApi,
|
||||
pageRewardGrantsApi,
|
||||
pageRewardTasksApi,
|
||||
saveRewardRulesApi,
|
||||
updateRewardTaskApi,
|
||||
updateRewardTaskStatusApi
|
||||
} from '@/api/admin/reward'
|
||||
|
||||
const { can } = useRbac()
|
||||
const activeTab = ref('tasks')
|
||||
const typeOptions = [
|
||||
['DAILY_CHECK_IN', '每日签到'],
|
||||
['WATCH_DURATION', '观看时长'],
|
||||
['WATCH_EPISODES', '观看集数'],
|
||||
['FIRST_LOGIN', '首次登录'],
|
||||
['COMPLETE_PROFILE', '完善资料'],
|
||||
['FIRST_PURCHASE', '首次购买'],
|
||||
['FOLLOW_SOCIAL', '关注社交账号'],
|
||||
['INVITE_FRIEND', '邀请好友'],
|
||||
['CUSTOM_LINK', '自定义任务']
|
||||
]
|
||||
const cycleOptions = [
|
||||
['ONCE', '一次性'],
|
||||
['DAILY', '每日'],
|
||||
['WEEKLY', '每周']
|
||||
]
|
||||
const sectionOptions = [
|
||||
['CHECK_IN', '签到奖励'],
|
||||
['DAILY', '每日任务'],
|
||||
['NEWBIE', '新手任务'],
|
||||
['ACTIVITY', '活动任务']
|
||||
]
|
||||
const actionOptions = [
|
||||
['NONE', '无动作'],
|
||||
['OPEN_HOME', '打开首页'],
|
||||
['OPEN_PROFILE', '打开个人资料'],
|
||||
['OPEN_WALLET', '打开钱包'],
|
||||
['OPEN_URL', '打开链接']
|
||||
]
|
||||
|
||||
const tasks = reactive({ rows: [], total: 0, page: 1, size: 10, loading: false })
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
type: undefined,
|
||||
sectionCode: undefined,
|
||||
status: undefined
|
||||
})
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref(null)
|
||||
const emptyForm = () => ({
|
||||
code: '',
|
||||
sectionCode: 'DAILY',
|
||||
type: 'WATCH_DURATION',
|
||||
verifyMode: 'SERVER',
|
||||
targetValue: 1,
|
||||
rewardCoin: 10,
|
||||
cycleType: 'DAILY',
|
||||
cycleLimit: 1,
|
||||
actionType: 'OPEN_HOME',
|
||||
actionUrl: '',
|
||||
iconUrl: '',
|
||||
titleId: '',
|
||||
titleEn: '',
|
||||
titleZh: '',
|
||||
subtitleId: '',
|
||||
subtitleEn: '',
|
||||
subtitleZh: '',
|
||||
ctaId: 'Mulai',
|
||||
ctaEn: 'Start',
|
||||
ctaZh: '去完成',
|
||||
sectionTitleId: 'Tugas harian',
|
||||
sectionTitleEn: 'Daily tasks',
|
||||
sectionTitleZh: '每日任务',
|
||||
configJson: '',
|
||||
sort: 0,
|
||||
startAt: null,
|
||||
endAt: null
|
||||
})
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
async function loadTasks() {
|
||||
tasks.loading = true
|
||||
try {
|
||||
const res = await pageRewardTasksApi({
|
||||
pageIndex: tasks.page,
|
||||
pageSize: tasks.size,
|
||||
keyword: filters.keyword.trim() || undefined,
|
||||
type: filters.type,
|
||||
sectionCode: filters.sectionCode,
|
||||
status: filters.status
|
||||
})
|
||||
tasks.rows = res.data?.list || []
|
||||
tasks.total = res.data?.total || 0
|
||||
} finally {
|
||||
tasks.loading = false
|
||||
}
|
||||
}
|
||||
function searchTasks() {
|
||||
tasks.page = 1
|
||||
loadTasks()
|
||||
}
|
||||
function resetFilters() {
|
||||
Object.assign(filters, {
|
||||
keyword: '',
|
||||
type: undefined,
|
||||
sectionCode: undefined,
|
||||
status: undefined
|
||||
})
|
||||
searchTasks()
|
||||
}
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
Object.assign(form, emptyForm(), { sort: tasks.total + 1 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
function openEdit(row) {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, emptyForm(), row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
function validateTask() {
|
||||
if (!form.code.trim() || !form.titleId.trim() || !form.sectionTitleId.trim())
|
||||
return '请填写编码、印尼语标题和分组标题'
|
||||
if (form.targetValue <= 0 || form.rewardCoin < 0) return '目标值必须大于 0,奖励金币不能小于 0'
|
||||
return ''
|
||||
}
|
||||
async function saveTask() {
|
||||
const invalid = validateTask()
|
||||
if (invalid) return ElMessage.warning(invalid)
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
code: form.code.trim(),
|
||||
titleId: form.titleId.trim(),
|
||||
sectionTitleId: form.sectionTitleId.trim()
|
||||
}
|
||||
if (editingId.value) await updateRewardTaskApi(editingId.value, payload)
|
||||
else await createRewardTaskApi(payload)
|
||||
ElMessage.success(editingId.value ? '任务已更新' : '任务已创建,默认下线')
|
||||
dialogVisible.value = false
|
||||
await loadTasks()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
async function toggleTask(row) {
|
||||
await updateRewardTaskStatusApi(row.id, row.status === 1 ? 0 : 1)
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
const rules = ref([])
|
||||
const rulesLoading = ref(false)
|
||||
const rulesSaving = ref(false)
|
||||
async function loadRules() {
|
||||
rulesLoading.value = true
|
||||
try {
|
||||
rules.value = (await getRewardRulesApi()).data || []
|
||||
} finally {
|
||||
rulesLoading.value = false
|
||||
}
|
||||
}
|
||||
async function saveRules() {
|
||||
rulesSaving.value = true
|
||||
try {
|
||||
await saveRewardRulesApi(
|
||||
rules.value.map(({ dayIndex, rewardCoin }) => ({ dayIndex, rewardCoin }))
|
||||
)
|
||||
ElMessage.success('签到规则已保存')
|
||||
} finally {
|
||||
rulesSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const grants = reactive({
|
||||
rows: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 10,
|
||||
loading: false,
|
||||
userId: '',
|
||||
status: undefined
|
||||
})
|
||||
const grantVisible = ref(false)
|
||||
const grantSaving = ref(false)
|
||||
const grantForm = reactive({ userId: '', taskId: '', amount: 10, idempotentKey: '', reason: '' })
|
||||
async function loadGrants() {
|
||||
grants.loading = true
|
||||
try {
|
||||
const res = await pageRewardGrantsApi({
|
||||
pageIndex: grants.page,
|
||||
pageSize: grants.size,
|
||||
userId: grants.userId || undefined,
|
||||
status: grants.status
|
||||
})
|
||||
grants.rows = res.data?.list || []
|
||||
grants.total = res.data?.total || 0
|
||||
} finally {
|
||||
grants.loading = false
|
||||
}
|
||||
}
|
||||
function queryGrants() {
|
||||
grants.page = 1
|
||||
loadGrants()
|
||||
}
|
||||
function openGrant() {
|
||||
Object.assign(grantForm, {
|
||||
userId: grants.userId,
|
||||
taskId: '',
|
||||
amount: 10,
|
||||
idempotentKey: '',
|
||||
reason: ''
|
||||
})
|
||||
grantVisible.value = true
|
||||
}
|
||||
async function confirmGrant() {
|
||||
if (!grantForm.userId || grantForm.amount <= 0)
|
||||
return ElMessage.warning('请填写用户 ID 和补发金币')
|
||||
grantSaving.value = true
|
||||
try {
|
||||
await manualRewardGrantApi({
|
||||
...grantForm,
|
||||
taskId: grantForm.taskId || undefined,
|
||||
idempotentKey: grantForm.idempotentKey || undefined
|
||||
})
|
||||
ElMessage.success('奖励已通过钱包流水补发')
|
||||
grantVisible.value = false
|
||||
await loadGrants()
|
||||
} finally {
|
||||
grantSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleTab(name) {
|
||||
if (name === 'rules' && !rules.value.length) loadRules()
|
||||
if (name === 'grants' && !grants.rows.length) loadGrants()
|
||||
}
|
||||
function optionLabel(options, value) {
|
||||
return options.find((item) => item[0] === value)?.[1] || value || '-'
|
||||
}
|
||||
onMounted(loadTasks)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeading
|
||||
eyebrow="订单与权益"
|
||||
title="奖励任务"
|
||||
description="配置奖励页任务、签到规则和发放记录,所有金币变动均写钱包流水。"
|
||||
>
|
||||
<el-button
|
||||
v-if="activeTab === 'tasks' && can('reward:update')"
|
||||
type="primary"
|
||||
@click="openCreate"
|
||||
>新建任务</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="activeTab === 'grants' && can('reward:grant')"
|
||||
type="primary"
|
||||
@click="openGrant"
|
||||
>人工补发</el-button
|
||||
>
|
||||
</PageHeading>
|
||||
|
||||
<section class="panel reward-panel">
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTab">
|
||||
<el-tab-pane label="任务配置" name="tasks">
|
||||
<div class="reward-toolbar">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
clearable
|
||||
placeholder="任务编码 / 标题"
|
||||
@keyup.enter="searchTasks"
|
||||
/>
|
||||
<el-select v-model="filters.type" clearable placeholder="全部类型" @change="searchTasks"
|
||||
><el-option
|
||||
v-for="item in typeOptions"
|
||||
:key="item[0]"
|
||||
:label="item[1]"
|
||||
:value="item[0]"
|
||||
/></el-select>
|
||||
<el-select
|
||||
v-model="filters.sectionCode"
|
||||
clearable
|
||||
placeholder="全部分组"
|
||||
@change="searchTasks"
|
||||
><el-option
|
||||
v-for="item in sectionOptions"
|
||||
:key="item[0]"
|
||||
:label="item[1]"
|
||||
:value="item[0]"
|
||||
/></el-select>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="searchTasks"
|
||||
><el-option label="已上线" :value="1" /><el-option label="已下线" :value="0"
|
||||
/></el-select>
|
||||
<el-button @click="searchTasks">查询</el-button
|
||||
><el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="tasks.loading"
|
||||
:data="tasks.rows"
|
||||
row-key="id"
|
||||
empty-text="暂无奖励任务"
|
||||
>
|
||||
<el-table-column prop="code" label="任务编码" min-width="150" />
|
||||
<el-table-column label="标题" min-width="180"
|
||||
><template #default="{ row }"
|
||||
><strong>{{ row.titleId }}</strong
|
||||
><div class="sub-copy">{{ row.titleZh || row.titleEn }}</div></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="类型" width="130"
|
||||
><template #default="{ row }">{{
|
||||
optionLabel(typeOptions, row.type)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="分组" width="110"
|
||||
><template #default="{ row }">{{
|
||||
optionLabel(sectionOptions, row.sectionCode)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="目标 / 奖励" width="130"
|
||||
><template #default="{ row }"
|
||||
>{{ formatCount(row.targetValue) }} / +{{ formatCount(row.rewardCoin) }}</template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="周期" width="100"
|
||||
><template #default="{ row }">{{
|
||||
optionLabel(cycleOptions, row.cycleType)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="状态" width="90"
|
||||
><template #default="{ row }"
|
||||
><el-tag :type="row.status === 1 ? 'success' : 'info'">{{
|
||||
row.statusText
|
||||
}}</el-tag></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="操作" width="170" fixed="right"
|
||||
><template #default="{ row }"
|
||||
><el-button v-if="can('reward:update')" link type="primary" @click="openEdit(row)"
|
||||
>编辑</el-button
|
||||
><el-button
|
||||
v-if="can('reward:update')"
|
||||
link
|
||||
:type="row.status === 1 ? 'danger' : 'success'"
|
||||
@click="toggleTask(row)"
|
||||
>{{ row.status === 1 ? '下线' : '上线' }}</el-button
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-model:current-page="tasks.page"
|
||||
v-model:page-size="tasks.size"
|
||||
:total="tasks.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadTasks"
|
||||
@size-change="searchTasks"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="签到规则" name="rules">
|
||||
<div v-loading="rulesLoading" class="rule-grid"
|
||||
><div v-for="day in rules" :key="day.dayIndex" class="rule-card"
|
||||
><span>第 {{ day.dayIndex }} 天</span
|
||||
><el-input-number v-model="day.rewardCoin" :min="0" /><small>金币</small></div
|
||||
></div
|
||||
>
|
||||
<el-button
|
||||
v-if="can('reward:checkin')"
|
||||
type="primary"
|
||||
:loading="rulesSaving"
|
||||
@click="saveRules"
|
||||
>保存签到规则</el-button
|
||||
>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="发放记录" name="grants">
|
||||
<div class="reward-toolbar"
|
||||
><el-input
|
||||
v-model="grants.userId"
|
||||
clearable
|
||||
placeholder="用户 ID"
|
||||
@keyup.enter="queryGrants"
|
||||
/><el-select
|
||||
v-model="grants.status"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
@change="queryGrants"
|
||||
><el-option label="已发放" value="GRANTED" /><el-option
|
||||
label="待处理"
|
||||
value="PENDING" /><el-option label="失败" value="FAILED" /></el-select
|
||||
><el-button @click="queryGrants">查询</el-button></div
|
||||
>
|
||||
<el-table
|
||||
v-loading="grants.loading"
|
||||
:data="grants.rows"
|
||||
row-key="id"
|
||||
empty-text="暂无发放记录"
|
||||
>
|
||||
<el-table-column prop="userId" label="用户 ID" min-width="160" /><el-table-column
|
||||
prop="taskId"
|
||||
label="任务 ID"
|
||||
min-width="160"
|
||||
/><el-table-column label="金币" width="100"
|
||||
><template #default="{ row }">+{{ formatCount(row.amount) }}</template></el-table-column
|
||||
><el-table-column prop="source" label="来源" width="90" /><el-table-column
|
||||
prop="status"
|
||||
label="状态"
|
||||
width="100"
|
||||
/><el-table-column
|
||||
prop="idempotentKey"
|
||||
label="幂等键"
|
||||
min-width="240"
|
||||
show-overflow-tooltip
|
||||
/><el-table-column label="发放时间" width="180"
|
||||
><template #default="{ row }">{{
|
||||
dateCell(row.grantedAt || row.createdAt)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-model:current-page="grants.page"
|
||||
v-model:page-size="grants.size"
|
||||
:total="grants.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadGrants"
|
||||
@size-change="queryGrants"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="editingId ? '编辑奖励任务' : '新建奖励任务'"
|
||||
width="780px"
|
||||
>
|
||||
<el-form label-position="top" class="task-form"
|
||||
><el-row :gutter="16"
|
||||
><el-col :span="8"
|
||||
><el-form-item label="任务编码"
|
||||
><el-input v-model="form.code" :disabled="Boolean(editingId)" /></el-form-item></el-col
|
||||
><el-col :span="8"
|
||||
><el-form-item label="任务类型"
|
||||
><el-select v-model="form.type"
|
||||
><el-option
|
||||
v-for="item in typeOptions"
|
||||
:key="item[0]"
|
||||
:label="item[1]"
|
||||
:value="item[0]" /></el-select></el-form-item></el-col
|
||||
><el-col :span="8"
|
||||
><el-form-item label="分组"
|
||||
><el-select v-model="form.sectionCode"
|
||||
><el-option
|
||||
v-for="item in sectionOptions"
|
||||
:key="item[0]"
|
||||
:label="item[1]"
|
||||
:value="item[0]" /></el-select></el-form-item></el-col
|
||||
></el-row>
|
||||
<el-row :gutter="16"
|
||||
><el-col :span="6"
|
||||
><el-form-item label="目标值"
|
||||
><el-input-number v-model="form.targetValue" :min="1" /></el-form-item></el-col
|
||||
><el-col :span="6"
|
||||
><el-form-item label="奖励金币"
|
||||
><el-input-number v-model="form.rewardCoin" :min="0" /></el-form-item></el-col
|
||||
><el-col :span="6"
|
||||
><el-form-item label="周期"
|
||||
><el-select v-model="form.cycleType"
|
||||
><el-option
|
||||
v-for="item in cycleOptions"
|
||||
:key="item[0]"
|
||||
:label="item[1]"
|
||||
:value="item[0]" /></el-select></el-form-item></el-col
|
||||
><el-col :span="6"
|
||||
><el-form-item label="排序"
|
||||
><el-input-number v-model="form.sort" :min="0" /></el-form-item></el-col
|
||||
></el-row>
|
||||
<el-divider content-position="left">多语言文案</el-divider
|
||||
><el-row :gutter="16"
|
||||
><el-col :span="8"
|
||||
><el-form-item label="印尼语标题"
|
||||
><el-input v-model="form.titleId" /></el-form-item></el-col
|
||||
><el-col :span="8"
|
||||
><el-form-item label="英文标题"><el-input v-model="form.titleEn" /></el-form-item></el-col
|
||||
><el-col :span="8"
|
||||
><el-form-item label="中文标题"><el-input v-model="form.titleZh" /></el-form-item></el-col
|
||||
></el-row>
|
||||
<el-row :gutter="16"
|
||||
><el-col :span="8"
|
||||
><el-form-item label="印尼语说明"
|
||||
><el-input v-model="form.subtitleId" /></el-form-item></el-col
|
||||
><el-col :span="8"
|
||||
><el-form-item label="英文说明"
|
||||
><el-input v-model="form.subtitleEn" /></el-form-item></el-col
|
||||
><el-col :span="8"
|
||||
><el-form-item label="中文说明"
|
||||
><el-input v-model="form.subtitleZh" /></el-form-item></el-col
|
||||
></el-row>
|
||||
<el-row :gutter="16"
|
||||
><el-col :span="8"
|
||||
><el-form-item label="印尼语分组标题"
|
||||
><el-input v-model="form.sectionTitleId" /></el-form-item></el-col
|
||||
><el-col :span="8"
|
||||
><el-form-item label="英文分组标题"
|
||||
><el-input v-model="form.sectionTitleEn" /></el-form-item></el-col
|
||||
><el-col :span="8"
|
||||
><el-form-item label="中文分组标题"
|
||||
><el-input v-model="form.sectionTitleZh" /></el-form-item></el-col
|
||||
></el-row>
|
||||
<el-divider content-position="left">动作与扩展</el-divider
|
||||
><el-row :gutter="16"
|
||||
><el-col :span="8"
|
||||
><el-form-item label="完成按钮"><el-input v-model="form.ctaId" /></el-form-item></el-col
|
||||
><el-col :span="8"
|
||||
><el-form-item label="动作"
|
||||
><el-select v-model="form.actionType"
|
||||
><el-option
|
||||
v-for="item in actionOptions"
|
||||
:key="item[0]"
|
||||
:label="item[1]"
|
||||
:value="item[0]" /></el-select></el-form-item></el-col
|
||||
><el-col :span="8"
|
||||
><el-form-item label="动作链接"
|
||||
><el-input v-model="form.actionUrl" /></el-form-item></el-col></el-row
|
||||
><el-form-item label="扩展配置 JSON"
|
||||
><el-input v-model="form.configJson" type="textarea" :rows="3"
|
||||
/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer
|
||||
><el-button @click="dialogVisible = false">取消</el-button
|
||||
><el-button type="primary" :loading="saving" @click="saveTask">保存</el-button></template
|
||||
>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="grantVisible" title="人工补发奖励" width="520px"
|
||||
><el-form label-position="top"
|
||||
><el-form-item label="用户 ID"><el-input v-model="grantForm.userId" /></el-form-item
|
||||
><el-form-item label="任务 ID(可选)"><el-input v-model="grantForm.taskId" /></el-form-item
|
||||
><el-form-item label="补发金币"
|
||||
><el-input-number v-model="grantForm.amount" :min="1" /></el-form-item
|
||||
><el-form-item label="幂等键(重复补发时必须相同)"
|
||||
><el-input v-model="grantForm.idempotentKey" /></el-form-item
|
||||
><el-form-item label="原因"><el-input v-model="grantForm.reason" /></el-form-item></el-form
|
||||
><template #footer
|
||||
><el-button @click="grantVisible = false">取消</el-button
|
||||
><el-button type="primary" :loading="grantSaving" @click="confirmGrant"
|
||||
>确认补发</el-button
|
||||
></template
|
||||
></el-dialog
|
||||
>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reward-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
.reward-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.reward-toolbar .el-input,
|
||||
.reward-toolbar .el-select {
|
||||
width: 180px;
|
||||
}
|
||||
.sub-copy {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.rule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(100px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 8px 0 20px;
|
||||
}
|
||||
.rule-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 12px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
.rule-card small {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.task-form :deep(.el-select),
|
||||
.task-form :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
.el-pagination {
|
||||
justify-content: flex-end;
|
||||
margin-top: 18px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.rule-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from '@/views/contentManagement/upload/imagePresets'
|
||||
|
||||
const router = useRouter()
|
||||
const { can, hasAny } = useRbac()
|
||||
const { can } = useRbac()
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
@@ -110,7 +110,8 @@ const actionMeta = {
|
||||
approve: { label: '审核通过', done: '已通过审核' },
|
||||
reject: { label: '驳回', done: '已驳回' },
|
||||
publish: { label: '上线', done: '已上线' },
|
||||
offline: { label: '下线', done: '已下线' }
|
||||
offline: { label: '下线', done: '已下线' },
|
||||
approvePublish: { label: '审核通过并上线', done: '已通过审核并上线' }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -435,9 +436,9 @@ async function runAction(row, action) {
|
||||
function reviewActions(row) {
|
||||
const canReview = can('drama:review')
|
||||
if (row.status === 'draft' || row.status === 'offline') {
|
||||
return hasAny('drama:update', 'drama:review') ? ['submit'] : []
|
||||
return canReview ? ['approvePublish'] : []
|
||||
}
|
||||
if (row.status === 'reviewing') return canReview ? ['approve', 'reject'] : []
|
||||
if (row.status === 'reviewing') return canReview ? ['approvePublish', 'reject'] : []
|
||||
if (row.status === 'approved') return canReview ? ['publish'] : []
|
||||
if (row.status === 'published') return canReview ? ['offline'] : []
|
||||
return []
|
||||
|
||||
@@ -1,39 +1,315 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
<!-- 创作者数据看板:布局与视觉对齐管理端首页(BusinessDashboardView),指标口径复用 /admin/creator-center/dashboard。 -->
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import PageHeading from '@/views/components/PageHeading.vue'
|
||||
import CreatorCenterPage from './components/CreatorCenterPage.vue'
|
||||
import { DASHBOARD_GROUPS, useCreatorDashboard } from './composables/useCreatorDashboard'
|
||||
import { useCreatorDashboard } from './composables/useCreatorDashboard'
|
||||
import { formatCount } from '@/utils/format'
|
||||
import echarts from '@/plugins/echarts'
|
||||
|
||||
const { loading, board, lastUpdated, load, display, groupValue } = useCreatorDashboard()
|
||||
const { loading, board, lastUpdated, load } = useCreatorDashboard()
|
||||
|
||||
const playbackRef = ref(null)
|
||||
const statusRef = ref(null)
|
||||
const mediaRef = ref(null)
|
||||
const interactionRef = ref(null)
|
||||
const revenueRef = ref(null)
|
||||
const chartInstances = []
|
||||
|
||||
const COLORS = ['#5B8FF9', '#5AD8A6', '#F6BD16', '#E86452', '#945FB9', '#C9C9C9']
|
||||
|
||||
const statusLabel = [
|
||||
{ key: 'draft', name: '草稿' },
|
||||
{ key: 'reviewing', name: '审核中' },
|
||||
{ key: 'approved', name: '待发布' },
|
||||
{ key: 'published', name: '已上线' },
|
||||
{ key: 'offline', name: '已下线' }
|
||||
]
|
||||
|
||||
function num(group, key) {
|
||||
const value = board.value?.[group]?.[key]
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function metricValue(group, key) {
|
||||
const value = board.value?.[group]?.[key]
|
||||
if (value === null || value === undefined) return null
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
function metricText(group, key) {
|
||||
const value = metricValue(group, key)
|
||||
return value === null ? '--' : formatCount(value)
|
||||
}
|
||||
|
||||
const finishRate = computed(() => {
|
||||
const value = metricValue('playback', 'finishRate')
|
||||
if (value === null) return null
|
||||
return Math.max(0, Math.min(100, value <= 1 ? value * 100 : value))
|
||||
})
|
||||
|
||||
const finishRateText = computed(() =>
|
||||
finishRate.value === null ? '--' : `${finishRate.value.toFixed(1)}%`
|
||||
)
|
||||
|
||||
const kpis = computed(() => [
|
||||
{ label: '作品总数', value: metricText('drama', 'total'), accent: '#5B8FF9' },
|
||||
{ label: '累计观看', value: metricText('playback', 'viewTotal'), accent: '#5AD8A6' },
|
||||
{ label: '累计播放', value: metricText('playback', 'playTotal'), accent: '#5B8FF9' },
|
||||
{ label: '完播率', value: finishRateText.value, accent: '#F6BD16' },
|
||||
{ label: '解锁笔数', value: metricText('revenue', 'unlockCount'), accent: '#E86452' },
|
||||
{ label: '金币收入', value: metricText('revenue', 'coinIncome'), accent: '#945FB9' }
|
||||
])
|
||||
|
||||
const statusItems = computed(() =>
|
||||
statusLabel.map((item) => ({ name: item.name, value: num('drama', item.key) }))
|
||||
)
|
||||
|
||||
const mediaItems = computed(() => {
|
||||
const total = num('drama', 'episodeTotal')
|
||||
const withMedia = num('drama', 'episodeWithMedia')
|
||||
return [
|
||||
{ name: '有片源', value: withMedia },
|
||||
{ name: '无片源', value: Math.max(0, total - withMedia) }
|
||||
]
|
||||
})
|
||||
|
||||
const playbackItems = computed(() => [
|
||||
{ name: '累计观看', value: num('playback', 'viewTotal') },
|
||||
{ name: '累计播放', value: num('playback', 'playTotal') },
|
||||
{ name: '近 7 天播放流水', value: num('playback', 'playEvents7d') },
|
||||
{ name: '播放流水总量', value: num('playback', 'playEventsTotal') },
|
||||
{ name: '完播事件', value: num('playback', 'finishEvents') }
|
||||
])
|
||||
|
||||
const interactionItems = computed(() => [
|
||||
{ name: '收藏', value: num('interaction', 'favoriteTotal') },
|
||||
{ name: '评论', value: num('interaction', 'commentTotal') },
|
||||
{ name: '待审评论', value: num('interaction', 'commentPending') }
|
||||
])
|
||||
|
||||
const revenueItems = computed(() => [
|
||||
{ name: '解锁笔数', value: num('revenue', 'unlockCount') },
|
||||
{ name: '金币收入', value: num('revenue', 'coinIncome') },
|
||||
{ name: '近 7 天金币', value: num('revenue', 'coinIncome7d') },
|
||||
{ name: '法币金额', value: num('revenue', 'paidOrderAmount') }
|
||||
])
|
||||
|
||||
const metrics = computed(() => [
|
||||
{ label: '收藏总数', value: metricText('interaction', 'favoriteTotal') },
|
||||
{ label: '评论总数', value: metricText('interaction', 'commentTotal') },
|
||||
{ label: '待审评论', value: metricText('interaction', 'commentPending') },
|
||||
{ label: '金币收入', value: metricText('revenue', 'coinIncome') },
|
||||
{ label: '近 7 天金币', value: metricText('revenue', 'coinIncome7d') },
|
||||
{ label: '法币金额', value: metricText('revenue', 'paidOrderAmount') }
|
||||
])
|
||||
|
||||
const pieBase = {
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false, position: 'center' },
|
||||
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } }
|
||||
}
|
||||
|
||||
function renderPlayback() {
|
||||
if (!playbackRef.value) return
|
||||
const chart = echarts.init(playbackRef.value)
|
||||
chartInstances.push(chart)
|
||||
const items = playbackItems.value
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 90, right: 40, top: 12, bottom: 12 },
|
||||
xAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
inverse: true,
|
||||
data: items.map((item) => item.name),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
barWidth: 12,
|
||||
data: items.map((item) => item.value),
|
||||
itemStyle: { borderRadius: [0, 4, 4, 0], color: '#5B8FF9' },
|
||||
label: { show: true, position: 'right', formatter: ({ value }) => formatCount(value) }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function renderPie(el, name, data) {
|
||||
if (!el) return
|
||||
const chart = echarts.init(el)
|
||||
chartInstances.push(chart)
|
||||
const valid = data.filter((item) => item.value > 0)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { bottom: 0, itemWidth: 12, itemHeight: 12, textStyle: { fontSize: 12 } },
|
||||
color: COLORS,
|
||||
series: [
|
||||
{
|
||||
...pieBase,
|
||||
name,
|
||||
data: valid.length
|
||||
? valid
|
||||
: [{ name: '暂无数据', value: 1, itemStyle: { color: '#E6E8EB' } }]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function renderBar(el, items) {
|
||||
if (!el) return
|
||||
const chart = echarts.init(el)
|
||||
chartInstances.push(chart)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 12, right: 16, top: 24, bottom: 12, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: items.map((item) => item.name),
|
||||
axisTick: { show: false },
|
||||
axisLabel: { interval: 0, fontSize: 11 }
|
||||
},
|
||||
yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
barWidth: 22,
|
||||
data: items.map((item, index) => ({
|
||||
value: item.value,
|
||||
itemStyle: { color: COLORS[index % COLORS.length], borderRadius: [4, 4, 0, 0] }
|
||||
})),
|
||||
label: { show: true, position: 'top', fontSize: 11 }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function disposeAll() {
|
||||
chartInstances.forEach((chart) => chart.dispose())
|
||||
chartInstances.length = 0
|
||||
}
|
||||
|
||||
function renderCharts() {
|
||||
disposeAll()
|
||||
renderPlayback()
|
||||
renderPie(statusRef.value, '作品状态', statusItems.value)
|
||||
renderPie(mediaRef.value, '片源覆盖', mediaItems.value)
|
||||
renderBar(interactionRef.value, interactionItems.value)
|
||||
renderBar(revenueRef.value, revenueItems.value)
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
chartInstances.forEach((chart) => chart.resize())
|
||||
}
|
||||
|
||||
watch(
|
||||
() => board.value,
|
||||
async (value) => {
|
||||
if (!value) return
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => window.addEventListener('resize', onResize))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
disposeAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CreatorCenterPage>
|
||||
<PageHeading
|
||||
eyebrow="创作者中心"
|
||||
title="数据看板"
|
||||
description="我的作品在播放、互动与解锁收入上的表现。"
|
||||
eyebrow="数据看板"
|
||||
title="创作者数据"
|
||||
description="本人作品的播放、互动与解锁收入总览。"
|
||||
>
|
||||
<el-button :loading="loading" @click="load">刷新</el-button>
|
||||
</PageHeading>
|
||||
|
||||
<section v-loading="loading" class="panel">
|
||||
<p class="console-hint">数据时间:{{ lastUpdated }}(统计范围仅限本人作品)</p>
|
||||
<div v-for="group in DASHBOARD_GROUPS" :key="group.key" class="board-group">
|
||||
<h3 class="board-title">{{ group.title }}</h3>
|
||||
<div class="board-grid">
|
||||
<div v-for="metric in group.metrics" :key="metric.key" class="board-card">
|
||||
<span class="board-label">{{ metric.label }}</span>
|
||||
<span
|
||||
class="board-value"
|
||||
:class="{ 'board-value-empty': groupValue(group.key, metric.key) === null }"
|
||||
>
|
||||
{{ display(groupValue(group.key, metric.key)) }}
|
||||
</span>
|
||||
<div v-loading="loading" class="board-wrap">
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>创作概览</h2>
|
||||
<p>作品规模、播放与收入核心指标</p>
|
||||
</div>
|
||||
<span class="muted">数据时间:{{ lastUpdated || '--' }}</span>
|
||||
</div>
|
||||
<div class="kpi-row">
|
||||
<article v-for="item in kpis" :key="item.label" class="kpi">
|
||||
<i :style="{ background: item.accent }"></i>
|
||||
<div>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>播放与片源</h2>
|
||||
<p>播放漏斗、作品状态与分集片源覆盖</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="viz-row">
|
||||
<div class="viz-cell">
|
||||
<h3>播放表现</h3>
|
||||
<div ref="playbackRef" class="chart chart-bar"></div>
|
||||
</div>
|
||||
<div class="viz-cell">
|
||||
<h3>作品状态</h3>
|
||||
<div ref="statusRef" class="chart chart-pie"></div>
|
||||
</div>
|
||||
<div class="viz-cell">
|
||||
<h3>片源覆盖</h3>
|
||||
<div ref="mediaRef" class="chart chart-pie"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>互动与收入</h2>
|
||||
<p>收藏、评论与解锁收入</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="viz-row">
|
||||
<div class="viz-cell">
|
||||
<h3>互动</h3>
|
||||
<div ref="interactionRef" class="chart chart-bar"></div>
|
||||
</div>
|
||||
<div class="viz-cell">
|
||||
<h3>解锁收入</h3>
|
||||
<div ref="revenueRef" class="chart chart-bar"></div>
|
||||
</div>
|
||||
<div class="viz-cell">
|
||||
<h3>关键指标</h3>
|
||||
<ul class="metric-list">
|
||||
<li v-for="item in metrics" :key="item.label">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
v-if="board?.notes?.length"
|
||||
class="board-notes"
|
||||
@@ -46,6 +322,163 @@ const { loading, board, lastUpdated, load, display, groupValue } = useCreatorDas
|
||||
<li v-for="note in board.notes" :key="note">{{ note }}</li>
|
||||
</ul>
|
||||
</el-alert>
|
||||
</section>
|
||||
</div>
|
||||
</CreatorCenterPage>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.board-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px 24px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.kpi {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 72px;
|
||||
padding: 12px 14px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
|
||||
i {
|
||||
flex: none;
|
||||
width: 3px;
|
||||
height: 32px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-size: 22px;
|
||||
line-height: 1.1;
|
||||
color: var(--el-text-color-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.viz-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.8fr) minmax(0, 0.8fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.viz-cell {
|
||||
min-width: 0;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.chart-pie {
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.metric-list {
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px dashed var(--el-border-color-lighter);
|
||||
font-size: 13px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.board-notes {
|
||||
:deep(ul) {
|
||||
padding-left: 18px;
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1100px) {
|
||||
.kpi-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.viz-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
import { AppImage } from '@/components/AppImage'
|
||||
import PageHeading from '@/views/components/PageHeading.vue'
|
||||
@@ -45,7 +45,6 @@ const {
|
||||
moveEpisode,
|
||||
uploadVideo,
|
||||
uploadCover,
|
||||
hasResumeSession,
|
||||
submit,
|
||||
withdraw
|
||||
} = useDramaEdit()
|
||||
@@ -62,6 +61,16 @@ function back() {
|
||||
window.history.back()
|
||||
}
|
||||
|
||||
/* ---------- 分集视频预览 ---------- */
|
||||
const videoPreviewVisible = ref(false)
|
||||
const videoPreviewUrl = ref('')
|
||||
|
||||
function playEpisode(row) {
|
||||
if (!row.videoUrl) return
|
||||
videoPreviewUrl.value = row.videoUrl
|
||||
videoPreviewVisible.value = true
|
||||
}
|
||||
|
||||
/** el-upload 的 http-request 回调:拿到原始 File 后交给统一的(可续传)上传逻辑 */
|
||||
function requestVideo(row, options) {
|
||||
return uploadVideo(row, options.file)
|
||||
@@ -123,15 +132,9 @@ function requestDramaCover(options) {
|
||||
<el-form-item label="作品名称" required>
|
||||
<el-input v-model="infoForm.title" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="副标题">
|
||||
<el-input v-model="infoForm.subtitle" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="题材">
|
||||
<el-input v-model="infoForm.genre" maxlength="32" placeholder="例如:都市情感" />
|
||||
</el-form-item>
|
||||
<el-form-item label="语言">
|
||||
<el-input v-model="infoForm.language" maxlength="16" placeholder="zh-CN / id-ID" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="简介">
|
||||
<el-input
|
||||
@@ -163,13 +166,6 @@ function requestDramaCover(options) {
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="预告片地址">
|
||||
<el-input
|
||||
v-model="infoForm.trailerUrl"
|
||||
maxlength="512"
|
||||
placeholder="整剧一支预告片,可留空"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="canEdit"
|
||||
@@ -202,7 +198,15 @@ function requestDramaCover(options) {
|
||||
show-icon
|
||||
:title="resumeHint"
|
||||
/>
|
||||
<el-table :data="episodes" row-key="id" border class="drama-el-table" empty-text="还没有分集">
|
||||
<div class="creator-table-scroll creator-table-scroll--episodes">
|
||||
<el-table
|
||||
:data="episodes"
|
||||
row-key="id"
|
||||
border
|
||||
class="drama-el-table"
|
||||
style="min-width: 1150px"
|
||||
empty-text="还没有分集"
|
||||
>
|
||||
<el-table-column prop="number" label="集号" width="70" />
|
||||
<el-table-column label="封面" width="88">
|
||||
<template #default="{ row }">
|
||||
@@ -224,7 +228,10 @@ function requestDramaCover(options) {
|
||||
<el-table-column prop="duration" label="时长" width="90" />
|
||||
<el-table-column label="片源" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="console-muted">{{ row.videoUrl ? '已上传' : '未上传' }}</span>
|
||||
<el-button v-if="row.videoUrl" link type="primary" @click="playEpisode(row)"
|
||||
>播放</el-button
|
||||
>
|
||||
<span v-else class="console-muted">未上传</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
@@ -238,7 +245,7 @@ function requestDramaCover(options) {
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<el-table-column label="操作" width="340">
|
||||
<template #default="{ row, $index }">
|
||||
<div class="table-actions">
|
||||
<el-button
|
||||
@@ -263,7 +270,7 @@ function requestDramaCover(options) {
|
||||
:http-request="(options) => requestVideo(row, options)"
|
||||
:disabled="!editable || uploadingId !== null"
|
||||
>
|
||||
<el-button size="small">{{ hasResumeSession ? '继续上传' : '上传视频' }}</el-button>
|
||||
<el-button size="small">上传视频</el-button>
|
||||
</el-upload>
|
||||
<el-button
|
||||
type="danger"
|
||||
@@ -276,6 +283,7 @@ function requestDramaCover(options) {
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<p v-if="uploadingId !== null" class="creator-upload-hint"
|
||||
>上传中 {{ uploadPercent }}%(分片上传,可断点续传)</p
|
||||
>
|
||||
@@ -283,11 +291,13 @@ function requestDramaCover(options) {
|
||||
|
||||
<section class="panel">
|
||||
<h3 class="creator-section-title">提交记录</h3>
|
||||
<div class="creator-table-scroll creator-table-scroll--submissions">
|
||||
<el-table
|
||||
:data="submissions"
|
||||
row-key="id"
|
||||
border
|
||||
class="drama-el-table"
|
||||
style="min-width: 1000px"
|
||||
empty-text="还没有提交记录"
|
||||
>
|
||||
<el-table-column prop="version" label="版本" width="70" />
|
||||
@@ -315,8 +325,13 @@ function requestDramaCover(options) {
|
||||
:formatter="dateCell"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="reviewNote" label="审核意见" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="110" fixed="right">
|
||||
<el-table-column
|
||||
prop="reviewNote"
|
||||
label="审核意见"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="操作" width="110">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button
|
||||
@@ -331,8 +346,20 @@ function requestDramaCover(options) {
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="videoPreviewVisible" title="视频预览" width="720px" append-to-body>
|
||||
<video
|
||||
v-if="videoPreviewUrl"
|
||||
:src="videoPreviewUrl"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
class="creator-video-preview"
|
||||
></video>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="episodeVisible"
|
||||
:title="episodeEditingId ? '编辑分集' : '新增分集'"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -30,16 +30,12 @@ const {
|
||||
profileLoading,
|
||||
dramas,
|
||||
notices,
|
||||
createVisible,
|
||||
createSaving,
|
||||
createForm,
|
||||
queryDramas,
|
||||
loadDramas,
|
||||
loadNotices,
|
||||
submit,
|
||||
withdraw,
|
||||
openCreate,
|
||||
confirmCreate
|
||||
remove
|
||||
} = useCreatorWorkspace()
|
||||
|
||||
const activeTab = ref('dramas')
|
||||
@@ -59,6 +55,11 @@ function onTabChange(name) {
|
||||
function openEdit(row) {
|
||||
router.push(`/creator-center/dramas/${row.id}`)
|
||||
}
|
||||
|
||||
/** 抖音式发布:直接进发布页选视频,不再先建空草稿 */
|
||||
function openPublish() {
|
||||
router.push('/creator-center/publish')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -68,8 +69,8 @@ function openEdit(row) {
|
||||
title="我的作品"
|
||||
description="查看作品与审核进度,提交上线 / 下线申请。"
|
||||
>
|
||||
<el-button v-if="canEdit && !impersonating" type="primary" @click="openCreate"
|
||||
>新建作品</el-button
|
||||
<el-button v-if="canEdit && !impersonating" type="primary" @click="openPublish"
|
||||
>发布作品</el-button
|
||||
>
|
||||
</PageHeading>
|
||||
|
||||
@@ -114,7 +115,7 @@ function openEdit(row) {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel creator-workspace-wrap">
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<el-tab-pane label="我的作品" name="dramas">
|
||||
<div class="console-toolbar">
|
||||
@@ -146,13 +147,14 @@ function openEdit(row) {
|
||||
<span class="console-count">共 {{ dramas.total }} 部作品</span>
|
||||
</div>
|
||||
|
||||
<div class="creator-desktop-only">
|
||||
<el-table
|
||||
v-loading="dramas.loading"
|
||||
:data="dramas.rows"
|
||||
row-key="id"
|
||||
border
|
||||
class="drama-el-table"
|
||||
empty-text="还没有作品,点右上角「新建作品」开始"
|
||||
empty-text="还没有作品,点右上角「发布作品」开始"
|
||||
>
|
||||
<el-table-column label="作品" min-width="220">
|
||||
<template #default="{ row }">
|
||||
@@ -211,7 +213,7 @@ function openEdit(row) {
|
||||
:formatter="dateCell"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="操作" width="210" fixed="right">
|
||||
<el-table-column label="操作" width="280">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button
|
||||
@@ -251,10 +253,91 @@ function openEdit(row) {
|
||||
@click="withdraw(row)"
|
||||
>撤回</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="canEdit && !impersonating"
|
||||
text
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="remove(row)"
|
||||
>删除</el-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div v-loading="dramas.loading" class="creator-mobile-only creator-drama-list">
|
||||
<el-empty v-if="!dramas.rows.length" description="还没有作品,点下方「发布作品」开始" />
|
||||
<article v-for="row in dramas.rows" :key="row.id" class="creator-drama-card">
|
||||
<div class="creator-drama-card-cover" @click="openEdit(row)">
|
||||
<AppImage
|
||||
v-if="row.cover"
|
||||
:src="row.cover"
|
||||
fit="cover"
|
||||
class="creator-drama-image"
|
||||
:alt="row.title"
|
||||
/>
|
||||
<span v-else>{{ (row.title || '?').slice(0, 1) }}</span>
|
||||
</div>
|
||||
<div class="creator-drama-card-body">
|
||||
<div class="creator-drama-card-head">
|
||||
<span class="creator-drama-title">{{ row.title }}</span>
|
||||
<el-tag size="small" :type="dramaStatusMeta(row.status).tag" effect="light">
|
||||
{{ dramaStatusMeta(row.status).label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span class="creator-drama-code">{{ row.code }}</span>
|
||||
<div class="creator-drama-card-meta">
|
||||
<span>{{ row.episodes }} 集</span>
|
||||
<span>{{ row.views }} 播放</span>
|
||||
</div>
|
||||
<div v-if="row.submissionStatus" class="creator-progress">
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="submissionStatusMeta(row.submissionStatus).tag"
|
||||
effect="plain"
|
||||
>
|
||||
{{ submissionTypeText(row.submissionType) }}·{{
|
||||
submissionStatusMeta(row.submissionStatus).label
|
||||
}}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="console-muted">尚未提交</span>
|
||||
<div v-if="canEdit && !impersonating" class="creator-drama-card-actions">
|
||||
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-dropdown
|
||||
v-if="submitOptions(row).length"
|
||||
trigger="click"
|
||||
@command="(type) => submit(row, type)"
|
||||
>
|
||||
<el-button size="small">提交审核</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="type in submitOptions(row)"
|
||||
:key="type"
|
||||
:command="type"
|
||||
>
|
||||
{{ submissionTypeText(type) }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button
|
||||
v-if="
|
||||
row.submissionId && ['SUBMITTED', 'REVIEWING'].includes(row.submissionStatus)
|
||||
"
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="withdraw(row)"
|
||||
>撤回</el-button
|
||||
>
|
||||
<el-button text type="danger" size="small" @click="remove(row)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
@@ -290,41 +373,5 @@ function openEdit(row) {
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="createVisible" title="新建作品" width="480px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="作品名称" required>
|
||||
<el-input
|
||||
v-model="createForm.title"
|
||||
maxlength="64"
|
||||
placeholder="例如:闪婚后,傅先生每天都在撒糖"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="题材">
|
||||
<el-input v-model="createForm.genre" maxlength="32" placeholder="例如:都市情感" />
|
||||
</el-form-item>
|
||||
<el-form-item label="简介">
|
||||
<el-input
|
||||
v-model="createForm.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="先建草稿,再去作品编辑页完善信息、分集与视频,然后提交审核"
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="createSaving" @click="confirmCreate"
|
||||
>创建草稿</el-button
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</CreatorCenterPage>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
createDramaApi,
|
||||
deleteDramaApi,
|
||||
getCreatorProfileApi,
|
||||
listCreatorNoticesApi,
|
||||
pageMyDramasApi,
|
||||
@@ -168,6 +169,32 @@ export function useCreatorWorkspace() {
|
||||
await loadDramas()
|
||||
}
|
||||
|
||||
/** 删作品。已上线的后端会拦,前端按状态给提示。 */
|
||||
async function remove(row) {
|
||||
const label = row.title || '该作品'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`《${label}》将被永久删除(分集、封面、审核记录一并清理),且不可恢复。确认删除?`,
|
||||
'删除作品',
|
||||
{
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteDramaApi(row.id)
|
||||
ElMessage.success('作品已删除')
|
||||
await loadDramas()
|
||||
} catch (e) {
|
||||
// 后端可能抛"已上线,请先下线"之类的业务错误;ElMessage 全局拦截器会弹 message
|
||||
}
|
||||
}
|
||||
|
||||
const createVisible = ref(false)
|
||||
const createSaving = ref(false)
|
||||
const createForm = reactive({ title: '', genre: '', description: '' })
|
||||
@@ -216,6 +243,7 @@ export function useCreatorWorkspace() {
|
||||
loadNotices,
|
||||
submit,
|
||||
withdraw,
|
||||
remove,
|
||||
openCreate,
|
||||
confirmCreate
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ export function useDramaEdit() {
|
||||
ElMessage.success('视频已上传并挂到该分集')
|
||||
await loadEpisodes()
|
||||
} catch {
|
||||
resumeHint.value = `「${file.name}」上传中断,进度已记住,可点「继续上传」接着传`
|
||||
resumeHint.value = `「${file.name}」上传中断,进度已记住;重新选择同一个文件即可续传`
|
||||
} finally {
|
||||
uploadingId.value = null
|
||||
uploadPercent.value = 0
|
||||
|
||||
@@ -43,6 +43,7 @@ const {
|
||||
openAudit,
|
||||
confirmAudit,
|
||||
freeze,
|
||||
unfreeze,
|
||||
createVisible,
|
||||
createSaving,
|
||||
createForm,
|
||||
@@ -61,8 +62,8 @@ const {
|
||||
<template>
|
||||
<PageHeading
|
||||
eyebrow="创作者管理"
|
||||
title="创作者入驻"
|
||||
description="审核创作者主体与资质,通过后创作者才能提交内容。"
|
||||
title="创作者账号"
|
||||
description="录入创作者并直接开通后台账号,账号即开即用,无需入驻审核。"
|
||||
>
|
||||
<el-button v-if="canAudit" type="primary" @click="openCreate">新增创作者</el-button>
|
||||
</PageHeading>
|
||||
@@ -170,13 +171,13 @@ const {
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" size="small" @click="openDocuments(row)">资质</el-button>
|
||||
<!-- 状态机:通过可从 pending/rejected/frozen 发起,驳回只允许 pending,冻结只允许 approved。
|
||||
已通过的行走「冻结 / 重置密码」,不再给「审核」入口(点了必然被后端拒) -->
|
||||
<!-- 账号由后台直接发放:已通过 / 冻结行都不再给「审核」入口;
|
||||
C 端提交的入驻申请仍是 pending,可在这里一次性通过 -->
|
||||
<el-button
|
||||
v-if="canAudit && row.status !== 'approved'"
|
||||
v-if="canAudit && row.status === 'pending'"
|
||||
size="small"
|
||||
@click="openAudit(row, 'APPROVE')"
|
||||
>审核</el-button
|
||||
>通过</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="canAudit && row.status === 'approved'"
|
||||
@@ -185,12 +186,18 @@ const {
|
||||
@click="freeze(row)"
|
||||
>冻结</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="canAudit && row.status === 'frozen'"
|
||||
size="small"
|
||||
@click="unfreeze(row)"
|
||||
>解冻</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="canOpenAccount && !row.adminUserId"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="accountSaving"
|
||||
:disabled="row.status !== 'approved'"
|
||||
:disabled="row.status === 'frozen'"
|
||||
@click="openAccount(row)"
|
||||
>开通账号</el-button
|
||||
>
|
||||
@@ -299,16 +306,24 @@ const {
|
||||
<el-form-item label="国家 / 地区">
|
||||
<el-input v-model="createForm.countryCode" maxlength="8" placeholder="ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="同时开通后台账号">
|
||||
<el-switch v-model="createForm.openAccount" />
|
||||
<span class="console-muted creator-switch-hint"
|
||||
>开启后保存即生成登录账号与初始密码,一次完成</span
|
||||
>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="编号自动生成(CR+日期+序号),也是开通账号时默认的登录名;录入后需要先通过入驻审核才能开通账号"
|
||||
title="编号自动生成(CR+日期+序号),也是开通账号时默认的登录名;无需入驻审核"
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="createSaving" @click="confirmCreate">录入</el-button>
|
||||
<el-button type="primary" :loading="createSaving" @click="confirmCreate">{{
|
||||
createForm.openAccount ? '创建并开通账号' : '仅创建'
|
||||
}}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import PageHeading from '@/views/components/PageHeading.vue'
|
||||
import { useRbac } from '@/stores/rbac'
|
||||
import { dateCell } from '@/utils/format'
|
||||
import { detailSubmissionApi } from '@/api/admin/creator'
|
||||
import {
|
||||
SUBMISSION_STATUS,
|
||||
SUBMISSION_TYPE,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
submissionTypeText,
|
||||
useSubmissionAudit
|
||||
} from './composables/useSubmissionAudit'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { can } = useRbac()
|
||||
const canAudit = can('submission:audit')
|
||||
@@ -32,9 +34,25 @@ const {
|
||||
query,
|
||||
reset,
|
||||
handleSizeChange,
|
||||
openAudit,
|
||||
openAudit: rawOpenAudit,
|
||||
confirmAudit
|
||||
} = useSubmissionAudit()
|
||||
|
||||
const auditDetailLoading = ref(false)
|
||||
|
||||
async function openAudit(row, defaultAction = 'APPROVE') {
|
||||
rawOpenAudit(row, defaultAction)
|
||||
auditDetailLoading.value = true
|
||||
try {
|
||||
const detail = await detailSubmissionApi(row.id)
|
||||
// 用 detail 里的完整字段覆盖 auditTarget(保留原始 openAudit 设置的 action)
|
||||
Object.assign(auditTarget.value, detail)
|
||||
} catch (e) {
|
||||
console.warn('加载提交详情失败', e)
|
||||
} finally {
|
||||
auditDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -94,9 +112,19 @@ const {
|
||||
class="drama-el-table"
|
||||
empty-text="没有符合条件的提交记录"
|
||||
>
|
||||
<el-table-column prop="id" label="提交ID" width="90" />
|
||||
<el-table-column prop="dramaId" label="短剧ID" width="90" />
|
||||
<el-table-column prop="creatorId" label="创作者ID" width="100" />
|
||||
<el-table-column prop="id" label="提交ID" width="140" />
|
||||
<el-table-column prop="dramaId" label="短剧ID" width="140" />
|
||||
<el-table-column label="创作者" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div style="display: flex; flex-direction: column; line-height: 1.4">
|
||||
<span style="font-weight: 500">{{ row.creatorName || '—' }}</span>
|
||||
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
|
||||
{{ row.creatorCode || '' }}{{ row.creatorCode && row.creatorMobile ? ' · ' : ''
|
||||
}}{{ row.creatorMobile || '' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提交类型" width="110">
|
||||
<template #default="{ row }">{{ submissionTypeText(row.type) }}</template>
|
||||
</el-table-column>
|
||||
@@ -153,18 +181,93 @@ const {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="auditVisible" title="内容提交审核" width="480px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="提交信息">
|
||||
<el-input
|
||||
:model-value="
|
||||
auditTarget
|
||||
? `提交 #${auditTarget.id} · 短剧 ${auditTarget.dramaId} · ${submissionTypeText(auditTarget.type)}`
|
||||
: ''
|
||||
"
|
||||
disabled
|
||||
<el-dialog v-model="auditVisible" title="内容提交审核" width="640px">
|
||||
<div v-loading="auditDetailLoading" class="audit-detail">
|
||||
<!-- 顶部:创作者 + 提交信息 -->
|
||||
<div class="audit-meta">
|
||||
<div class="audit-meta-item">
|
||||
<span class="audit-meta-label">创作者</span>
|
||||
<span class="audit-meta-value">
|
||||
{{ auditTarget?.creatorName || '—' }}
|
||||
<span v-if="auditTarget?.creatorCode" class="audit-meta-sub">
|
||||
· {{ auditTarget.creatorCode }}
|
||||
</span>
|
||||
<span v-if="auditTarget?.creatorMobile" class="audit-meta-sub">
|
||||
· {{ auditTarget.creatorMobile }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="audit-meta-item">
|
||||
<span class="audit-meta-label">提交</span>
|
||||
<span class="audit-meta-value">
|
||||
#{{ auditTarget?.id }} ·
|
||||
<el-tag size="small" effect="plain">
|
||||
{{ submissionTypeText(auditTarget?.type) }}
|
||||
</el-tag>
|
||||
·
|
||||
<el-tag size="small" :type="submissionStatusMeta(auditTarget?.status).tag">
|
||||
{{ submissionStatusMeta(auditTarget?.status).label }}
|
||||
</el-tag>
|
||||
<span v-if="auditTarget?.version" class="audit-meta-sub">
|
||||
· v{{ auditTarget.version }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 短剧预览卡片 -->
|
||||
<div v-if="auditTarget?.dramaTitle" class="audit-drama">
|
||||
<div class="audit-drama-cover">
|
||||
<img
|
||||
v-if="auditTarget.dramaCover"
|
||||
:src="auditTarget.dramaCover"
|
||||
:alt="auditTarget.dramaTitle"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div v-else class="audit-drama-cover-placeholder">无封面</div>
|
||||
</div>
|
||||
<div class="audit-drama-info">
|
||||
<h4 class="audit-drama-title">{{ auditTarget.dramaTitle }}</h4>
|
||||
<div class="audit-drama-tags">
|
||||
<el-tag v-if="auditTarget.dramaGenre" size="small">{{ auditTarget.dramaGenre }}</el-tag>
|
||||
<el-tag v-if="auditTarget.dramaLanguage" size="small" type="info">
|
||||
{{ auditTarget.dramaLanguage }}
|
||||
</el-tag>
|
||||
<el-tag v-if="auditTarget.dramaTotalEpisodes != null" size="small" type="success">
|
||||
共 {{ auditTarget.dramaTotalEpisodes }} 集
|
||||
</el-tag>
|
||||
</div>
|
||||
<p v-if="auditTarget.dramaDescription" class="audit-drama-desc">
|
||||
{{ auditTarget.dramaDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="audit-drama audit-drama-empty">短剧预览加载中…</div>
|
||||
|
||||
<!-- 分集预览 -->
|
||||
<div v-if="auditTarget?.episodePreviews?.length" class="audit-episodes">
|
||||
<div class="audit-section-title">分集列表({{ auditTarget.episodePreviews.length }})</div>
|
||||
<div class="audit-episode-list">
|
||||
<div v-for="ep in auditTarget.episodePreviews" :key="ep.id" class="audit-episode-item">
|
||||
<div class="audit-episode-no">第{{ ep.epNo }}集</div>
|
||||
<div class="audit-episode-title">{{ ep.title || '(未命名)' }}</div>
|
||||
<div class="audit-episode-meta">
|
||||
<span v-if="ep.durationSec">{{ ep.durationSec }}s</span>
|
||||
<el-tag
|
||||
v-if="ep.status"
|
||||
size="small"
|
||||
:type="
|
||||
ep.status === 'success' ? 'success' : ep.status === 'failed' ? 'danger' : 'info'
|
||||
"
|
||||
>
|
||||
{{ ep.status }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top" style="margin-top: 16px">
|
||||
<el-form-item label="审核结果">
|
||||
<el-radio-group v-model="auditForm.action">
|
||||
<el-radio label="APPROVE">通过</el-radio>
|
||||
@@ -201,3 +304,148 @@ const {
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.audit-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.audit-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 28px;
|
||||
padding: 10px 14px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.audit-meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.audit-meta-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.audit-meta-value {
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.audit-meta-sub {
|
||||
font-weight: 400;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.audit-drama {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.audit-drama-cover {
|
||||
width: 84px;
|
||||
height: 112px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--el-fill-color-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.audit-drama-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.audit-drama-cover-placeholder {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
}
|
||||
.audit-drama-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.audit-drama-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.audit-drama-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.audit-drama-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.audit-drama-empty {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
}
|
||||
.audit-episodes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.audit-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.audit-episode-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.audit-episode-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.audit-episode-no {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-weight: 500;
|
||||
min-width: 56px;
|
||||
}
|
||||
.audit-episode-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.audit-episode-meta {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -146,6 +146,22 @@ export function useCreatorAudit() {
|
||||
await load()
|
||||
}
|
||||
|
||||
/** 解冻:冻结是可控状态,解冻后即可恢复使用与提交 */
|
||||
async function unfreeze(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`解冻后「${row.name}」可继续使用,确认解冻?`, '解冻创作者', {
|
||||
confirmButtonText: '确认解冻',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await auditCreatorApi(row.id, { action: 'APPROVE', reason: '运营解冻' })
|
||||
ElMessage.success('已解冻')
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
// ---------- 手工录入创作者主体 ----------
|
||||
@@ -156,7 +172,9 @@ export function useCreatorAudit() {
|
||||
type: 'COMPANY',
|
||||
contactName: '',
|
||||
contactEmail: '',
|
||||
countryCode: 'ID'
|
||||
countryCode: 'ID',
|
||||
// 录入即开号:默认勾选,保存后直接弹出账号密码
|
||||
openAccount: true
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
@@ -165,7 +183,8 @@ export function useCreatorAudit() {
|
||||
type: 'COMPANY',
|
||||
contactName: '',
|
||||
contactEmail: '',
|
||||
countryCode: 'ID'
|
||||
countryCode: 'ID',
|
||||
openAccount: true
|
||||
})
|
||||
createVisible.value = true
|
||||
}
|
||||
@@ -174,9 +193,17 @@ export function useCreatorAudit() {
|
||||
if (!createForm.name.trim()) return ElMessage.warning('请填写创作者名称')
|
||||
createSaving.value = true
|
||||
try {
|
||||
await createCreatorApi({ ...createForm, name: createForm.name.trim() })
|
||||
ElMessage.success('已录入,待入驻审核通过后才能开通账号')
|
||||
const created = await createCreatorApi({ ...createForm, name: createForm.name.trim() })
|
||||
const creatorId = created.data
|
||||
createVisible.value = false
|
||||
if (createForm.openAccount && creatorId) {
|
||||
// 录入后立刻开号,凭据只在这里出现一次
|
||||
const res = await openCreatorAccountApi(creatorId)
|
||||
accountResult.value = res.data || null
|
||||
accountVisible.value = true
|
||||
} else {
|
||||
ElMessage.success('创作者已创建')
|
||||
}
|
||||
await load()
|
||||
} finally {
|
||||
createSaving.value = false
|
||||
@@ -264,6 +291,7 @@ export function useCreatorAudit() {
|
||||
openAudit,
|
||||
confirmAudit,
|
||||
freeze,
|
||||
unfreeze,
|
||||
createVisible,
|
||||
createSaving,
|
||||
createForm,
|
||||
|
||||
Reference in new issue
Block a user