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 })
|
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 */
|
/** action: APPROVE / REJECT / WITHDRAW */
|
||||||
export const auditSubmissionApi = (id: number, data: Record<string, unknown>) => {
|
export const auditSubmissionApi = (id: number, data: Record<string, unknown>) => {
|
||||||
return request.put<SubmissionItem>({ url: `/admin/submissions/${id}/audit`, data })
|
return request.put<SubmissionItem>({ url: `/admin/submissions/${id}/audit`, data })
|
||||||
|
|||||||
@@ -109,6 +109,35 @@ export interface CreatorDramaEditData {
|
|||||||
language?: string
|
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 {
|
export interface PriceEpisodeConfig {
|
||||||
episodeId: number
|
episodeId: number
|
||||||
number: number
|
number: number
|
||||||
@@ -176,6 +205,11 @@ export const createDramaApi = (data: CreatorDramaEditData) => {
|
|||||||
return request.post<number>({ url: '/admin/creator-center/dramas', data })
|
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) => {
|
export const updateDramaApi = (id: number, data: CreatorDramaEditData) => {
|
||||||
return request.put<void>({ url: `/admin/creator-center/dramas/${id}`, data })
|
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[]) => {
|
export const sortEpisodesApi = (dramaId: number, ids: number[]) => {
|
||||||
return request.put<void>({
|
return request.put<void>({
|
||||||
url: `/admin/creator-center/dramas/${dramaId}/episodes/sort`,
|
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}` })
|
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) => {
|
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 */
|
/** 下拉选项(短剧表单用),只返回 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
|
sort?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DramaReviewAction = 'submit' | 'approve' | 'reject' | 'publish' | 'offline'
|
export type DramaReviewAction =
|
||||||
|
'submit' | 'approve' | 'reject' | 'publish' | 'offline' | 'approvePublish'
|
||||||
|
|
||||||
export interface EpisodeItem {
|
export interface EpisodeItem {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
@@ -40,10 +40,19 @@ const dialogVisible = ref<boolean>(false)
|
|||||||
const lockScreen = () => {
|
const lockScreen = () => {
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 点击整行菜单项都能触发(之前 click 绑在内层文字 div 上,点到菜单项左右内边距没反应)
|
||||||
|
const handleCommand = (command: string) => {
|
||||||
|
if (command === 'lock') {
|
||||||
|
lockScreen()
|
||||||
|
} else if (command === 'logout') {
|
||||||
|
loginOut()
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ElDropdown class="custom-hover" :class="prefixCls" trigger="click">
|
<ElDropdown class="custom-hover" :class="prefixCls" trigger="click" @command="handleCommand">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<img
|
<img
|
||||||
v-if="avatarUrl"
|
v-if="avatarUrl"
|
||||||
@@ -63,12 +72,8 @@ const lockScreen = () => {
|
|||||||
</div>
|
</div>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<ElDropdownMenu>
|
<ElDropdownMenu>
|
||||||
<ElDropdownItem>
|
<ElDropdownItem command="lock">{{ t('lock.lockScreen') }}</ElDropdownItem>
|
||||||
<div @click="lockScreen">{{ t('lock.lockScreen') }}</div>
|
<ElDropdownItem command="logout">{{ t('common.loginOut') }}</ElDropdownItem>
|
||||||
</ElDropdownItem>
|
|
||||||
<ElDropdownItem>
|
|
||||||
<div @click="loginOut">{{ t('common.loginOut') }}</div>
|
|
||||||
</ElDropdownItem>
|
|
||||||
</ElDropdownMenu>
|
</ElDropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
</ElDropdown>
|
</ElDropdown>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import TranscodeNoticeButton from './TranscodeNoticeButton.vue'
|
|||||||
import { Screenfull } from '@/components/Screenfull'
|
import { Screenfull } from '@/components/Screenfull'
|
||||||
import { Breadcrumb } from '@/components/Breadcrumb'
|
import { Breadcrumb } from '@/components/Breadcrumb'
|
||||||
import { useAppStore } from '@/store/modules/app'
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { useDesign } from '@/hooks/web/useDesign'
|
import { useDesign } from '@/hooks/web/useDesign'
|
||||||
|
|
||||||
const { getPrefixCls, variables } = useDesign()
|
const { getPrefixCls, variables } = useDesign()
|
||||||
@@ -18,6 +19,16 @@ const prefixCls = getPrefixCls('tool-header')
|
|||||||
|
|
||||||
const appStore = useAppStore()
|
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)
|
const breadcrumb = computed(() => appStore.getBreadcrumb)
|
||||||
|
|
||||||
@@ -74,7 +85,9 @@ export default defineComponent({
|
|||||||
color="var(--top-header-text-color)"
|
color="var(--top-header-text-color)"
|
||||||
></LocaleDropdown>
|
></LocaleDropdown>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<TranscodeNoticeButton color="var(--top-header-text-color)"></TranscodeNoticeButton>
|
{canSeeTranscodeNotice.value ? (
|
||||||
|
<TranscodeNoticeButton color="var(--top-header-text-color)"></TranscodeNoticeButton>
|
||||||
|
) : undefined}
|
||||||
<UserInfo></UserInfo>
|
<UserInfo></UserInfo>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,6 +108,19 @@ export default defineComponent({
|
|||||||
:deep(.v-user-info) {
|
:deep(.v-user-info) {
|
||||||
flex-shrink: 0;
|
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) {
|
@media (width <= 768px) {
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notice-badge :deep(.el-badge__content.is-fixed) {
|
.notice-badge :deep(.el-badge__content.is-fixed) {
|
||||||
top: 8px;
|
/* 角标中心骑在铃铛右上角:right 9px + 右移 50%,与位数宽度无关 */
|
||||||
right: 2px;
|
top: calc(50% - 9px);
|
||||||
|
right: 9px;
|
||||||
|
transform: translate(50%, -50%);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
+8
-1
@@ -25,13 +25,20 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
const userStore = useUserStoreWithOut()
|
const userStore = useUserStoreWithOut()
|
||||||
if (userStore.getUserInfo) {
|
if (userStore.getUserInfo) {
|
||||||
if (to.path === '/login') {
|
if (to.path === '/login') {
|
||||||
next({ path: '/' })
|
// 已登录再访问登录页:按当前账号的菜单落地,不能回 '/'(静态路由会 redirect 到 /dashboard/index,
|
||||||
|
// 创作者没有该路由,直接掉 404)
|
||||||
|
next({ path: landingPath(), replace: true })
|
||||||
} else {
|
} else {
|
||||||
if (permissionStore.getIsAddRouters) {
|
if (permissionStore.getIsAddRouters) {
|
||||||
// 已加过路由:只有还是同一个账号才能直接放行。
|
// 已加过路由:只有还是同一个账号才能直接放行。
|
||||||
// 换账号时(例如超管退出后创作者登录)必须重建,否则侧边栏/路由会残留上一个账号的
|
// 换账号时(例如超管退出后创作者登录)必须重建,否则侧边栏/路由会残留上一个账号的
|
||||||
const username = userStore.getUserInfo?.username || ''
|
const username = userStore.getUserInfo?.username || ''
|
||||||
if (permissionStore.getRoutersOwner === username) {
|
if (permissionStore.getRoutersOwner === username) {
|
||||||
|
// 访问根路径时同样按账号菜单落地,避免走静态 redirect 掉进 /dashboard/index
|
||||||
|
if (to.path === '/') {
|
||||||
|
next({ path: landingPath(), replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
next()
|
next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -8,9 +8,10 @@ import { AppIcons } from '@/constants/icons'
|
|||||||
|
|
||||||
export const constantRouterMap: AppRouteRecordRaw[] = [
|
export const constantRouterMap: AppRouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
|
// 不写死 redirect:vue-router 会在守卫之前就把 '/' 解析成 redirect 目标,
|
||||||
|
// 创作者没有 /dashboard/index 路由会直接 404。落地页交给 permission.ts 按账号菜单决定。
|
||||||
path: '/',
|
path: '/',
|
||||||
component: Layout,
|
component: Layout,
|
||||||
redirect: '/dashboard/index',
|
|
||||||
name: 'Root',
|
name: 'Root',
|
||||||
meta: {
|
meta: {
|
||||||
hidden: true
|
hidden: true
|
||||||
|
|||||||
@@ -298,6 +298,11 @@ html.dark .creator-guide-title {
|
|||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.creator-switch-hint {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
html.dark .creator-profile-name,
|
html.dark .creator-profile-name,
|
||||||
html.dark .creator-profile-meta b,
|
html.dark .creator-profile-meta b,
|
||||||
html.dark .creator-drama-title,
|
html.dark .creator-drama-title,
|
||||||
@@ -316,3 +321,144 @@ html.dark .creator-episode-cover {
|
|||||||
border-color: var(--el-border-color-light);
|
border-color: var(--el-border-color-light);
|
||||||
background: var(--el-fill-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'
|
} from '@/views/contentManagement/upload/imagePresets'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { can, hasAny } = useRbac()
|
const { can } = useRbac()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const list = ref([])
|
const list = ref([])
|
||||||
@@ -110,7 +110,8 @@ const actionMeta = {
|
|||||||
approve: { label: '审核通过', done: '已通过审核' },
|
approve: { label: '审核通过', done: '已通过审核' },
|
||||||
reject: { label: '驳回', done: '已驳回' },
|
reject: { label: '驳回', done: '已驳回' },
|
||||||
publish: { label: '上线', done: '已上线' },
|
publish: { label: '上线', done: '已上线' },
|
||||||
offline: { label: '下线', done: '已下线' }
|
offline: { label: '下线', done: '已下线' },
|
||||||
|
approvePublish: { label: '审核通过并上线', done: '已通过审核并上线' }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -435,9 +436,9 @@ async function runAction(row, action) {
|
|||||||
function reviewActions(row) {
|
function reviewActions(row) {
|
||||||
const canReview = can('drama:review')
|
const canReview = can('drama:review')
|
||||||
if (row.status === 'draft' || row.status === 'offline') {
|
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 === 'approved') return canReview ? ['publish'] : []
|
||||||
if (row.status === 'published') return canReview ? ['offline'] : []
|
if (row.status === 'published') return canReview ? ['offline'] : []
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -1,39 +1,315 @@
|
|||||||
<!-- Author: fruit.com.cn -->
|
<!-- Author: fruit.com.cn -->
|
||||||
|
<!-- 创作者数据看板:布局与视觉对齐管理端首页(BusinessDashboardView),指标口径复用 /admin/creator-center/dashboard。 -->
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import PageHeading from '@/views/components/PageHeading.vue'
|
import PageHeading from '@/views/components/PageHeading.vue'
|
||||||
import CreatorCenterPage from './components/CreatorCenterPage.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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CreatorCenterPage>
|
<CreatorCenterPage>
|
||||||
<PageHeading
|
<PageHeading
|
||||||
eyebrow="创作者中心"
|
eyebrow="数据看板"
|
||||||
title="数据看板"
|
title="创作者数据"
|
||||||
description="我的作品在播放、互动与解锁收入上的表现。"
|
description="本人作品的播放、互动与解锁收入总览。"
|
||||||
>
|
>
|
||||||
<el-button :loading="loading" @click="load">刷新</el-button>
|
<el-button :loading="loading" @click="load">刷新</el-button>
|
||||||
</PageHeading>
|
</PageHeading>
|
||||||
|
|
||||||
<section v-loading="loading" class="panel">
|
<div v-loading="loading" class="board-wrap">
|
||||||
<p class="console-hint">数据时间:{{ lastUpdated }}(统计范围仅限本人作品)</p>
|
<section class="panel">
|
||||||
<div v-for="group in DASHBOARD_GROUPS" :key="group.key" class="board-group">
|
<div class="panel-heading">
|
||||||
<h3 class="board-title">{{ group.title }}</h3>
|
<div>
|
||||||
<div class="board-grid">
|
<h2>创作概览</h2>
|
||||||
<div v-for="metric in group.metrics" :key="metric.key" class="board-card">
|
<p>作品规模、播放与收入核心指标</p>
|
||||||
<span class="board-label">{{ metric.label }}</span>
|
</div>
|
||||||
<span
|
<span class="muted">数据时间:{{ lastUpdated || '--' }}</span>
|
||||||
class="board-value"
|
</div>
|
||||||
:class="{ 'board-value-empty': groupValue(group.key, metric.key) === null }"
|
<div class="kpi-row">
|
||||||
>
|
<article v-for="item in kpis" :key="item.label" class="kpi">
|
||||||
{{ display(groupValue(group.key, metric.key)) }}
|
<i :style="{ background: item.accent }"></i>
|
||||||
</span>
|
<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>
|
</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
|
<el-alert
|
||||||
v-if="board?.notes?.length"
|
v-if="board?.notes?.length"
|
||||||
class="board-notes"
|
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>
|
<li v-for="note in board.notes" :key="note">{{ note }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
</section>
|
</div>
|
||||||
</CreatorCenterPage>
|
</CreatorCenterPage>
|
||||||
</template>
|
</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 -->
|
<!-- Author: fruit.com.cn -->
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { AppIcons } from '@/constants/icons'
|
import { AppIcons } from '@/constants/icons'
|
||||||
import { AppImage } from '@/components/AppImage'
|
import { AppImage } from '@/components/AppImage'
|
||||||
import PageHeading from '@/views/components/PageHeading.vue'
|
import PageHeading from '@/views/components/PageHeading.vue'
|
||||||
@@ -45,7 +45,6 @@ const {
|
|||||||
moveEpisode,
|
moveEpisode,
|
||||||
uploadVideo,
|
uploadVideo,
|
||||||
uploadCover,
|
uploadCover,
|
||||||
hasResumeSession,
|
|
||||||
submit,
|
submit,
|
||||||
withdraw
|
withdraw
|
||||||
} = useDramaEdit()
|
} = useDramaEdit()
|
||||||
@@ -62,6 +61,16 @@ function back() {
|
|||||||
window.history.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 后交给统一的(可续传)上传逻辑 */
|
/** el-upload 的 http-request 回调:拿到原始 File 后交给统一的(可续传)上传逻辑 */
|
||||||
function requestVideo(row, options) {
|
function requestVideo(row, options) {
|
||||||
return uploadVideo(row, options.file)
|
return uploadVideo(row, options.file)
|
||||||
@@ -123,15 +132,9 @@ function requestDramaCover(options) {
|
|||||||
<el-form-item label="作品名称" required>
|
<el-form-item label="作品名称" required>
|
||||||
<el-input v-model="infoForm.title" maxlength="64" />
|
<el-input v-model="infoForm.title" maxlength="64" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="副标题">
|
|
||||||
<el-input v-model="infoForm.subtitle" maxlength="64" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="题材">
|
<el-form-item label="题材">
|
||||||
<el-input v-model="infoForm.genre" maxlength="32" placeholder="例如:都市情感" />
|
<el-input v-model="infoForm.genre" maxlength="32" placeholder="例如:都市情感" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="语言">
|
|
||||||
<el-input v-model="infoForm.language" maxlength="16" placeholder="zh-CN / id-ID" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
</div>
|
||||||
<el-form-item label="简介">
|
<el-form-item label="简介">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -163,13 +166,6 @@ function requestDramaCover(options) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="预告片地址">
|
|
||||||
<el-input
|
|
||||||
v-model="infoForm.trailerUrl"
|
|
||||||
maxlength="512"
|
|
||||||
placeholder="整剧一支预告片,可留空"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canEdit"
|
v-if="canEdit"
|
||||||
@@ -202,80 +198,92 @@ function requestDramaCover(options) {
|
|||||||
show-icon
|
show-icon
|
||||||
:title="resumeHint"
|
: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-column prop="number" label="集号" width="70" />
|
<el-table
|
||||||
<el-table-column label="封面" width="88">
|
:data="episodes"
|
||||||
<template #default="{ row }">
|
row-key="id"
|
||||||
<div class="creator-episode-cover">
|
border
|
||||||
<AppImage
|
class="drama-el-table"
|
||||||
v-if="row.cover"
|
style="min-width: 1150px"
|
||||||
:src="row.cover"
|
empty-text="还没有分集"
|
||||||
:preview-list="episodeCoverPreviewList"
|
>
|
||||||
:initial-index="episodeCoverPreviewIndex(row)"
|
<el-table-column prop="number" label="集号" width="70" />
|
||||||
fit="cover"
|
<el-table-column label="封面" width="88">
|
||||||
class="creator-episode-image"
|
<template #default="{ row }">
|
||||||
:alt="row.title"
|
<div class="creator-episode-cover">
|
||||||
/>
|
<AppImage
|
||||||
<span v-else>无</span>
|
v-if="row.cover"
|
||||||
</div>
|
:src="row.cover"
|
||||||
</template>
|
:preview-list="episodeCoverPreviewList"
|
||||||
</el-table-column>
|
:initial-index="episodeCoverPreviewIndex(row)"
|
||||||
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
|
fit="cover"
|
||||||
<el-table-column prop="duration" label="时长" width="90" />
|
class="creator-episode-image"
|
||||||
<el-table-column label="片源" min-width="120">
|
:alt="row.title"
|
||||||
<template #default="{ row }">
|
/>
|
||||||
<span class="console-muted">{{ row.videoUrl ? '已上传' : '未上传' }}</span>
|
<span v-else>无</span>
|
||||||
</template>
|
</div>
|
||||||
</el-table-column>
|
</template>
|
||||||
<el-table-column label="状态" width="90">
|
</el-table-column>
|
||||||
<template #default="{ row }">
|
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
|
||||||
<el-tag
|
<el-table-column prop="duration" label="时长" width="90" />
|
||||||
size="small"
|
<el-table-column label="片源" min-width="120">
|
||||||
effect="light"
|
<template #default="{ row }">
|
||||||
:type="row.status === 'published' ? 'success' : 'info'"
|
<el-button v-if="row.videoUrl" link type="primary" @click="playEpisode(row)"
|
||||||
>
|
>播放</el-button
|
||||||
{{ row.statusText }}
|
>
|
||||||
</el-tag>
|
<span v-else class="console-muted">未上传</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="300" fixed="right">
|
<el-table-column label="状态" width="90">
|
||||||
<template #default="{ row, $index }">
|
<template #default="{ row }">
|
||||||
<div class="table-actions">
|
<el-tag
|
||||||
<el-button
|
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="!editable || $index === 0"
|
effect="light"
|
||||||
@click="moveEpisode(row, -1)"
|
:type="row.status === 'published' ? 'success' : 'info'"
|
||||||
>上移</el-button
|
|
||||||
>
|
>
|
||||||
<el-button
|
{{ row.statusText }}
|
||||||
size="small"
|
</el-tag>
|
||||||
:disabled="!editable || $index === episodes.length - 1"
|
</template>
|
||||||
@click="moveEpisode(row, 1)"
|
</el-table-column>
|
||||||
>下移</el-button
|
<el-table-column label="操作" width="340">
|
||||||
>
|
<template #default="{ row, $index }">
|
||||||
<el-button size="small" :disabled="!editable" @click="openEpisode(row)"
|
<div class="table-actions">
|
||||||
>编辑</el-button
|
<el-button
|
||||||
>
|
size="small"
|
||||||
<el-upload
|
:disabled="!editable || $index === 0"
|
||||||
v-if="canUpload"
|
@click="moveEpisode(row, -1)"
|
||||||
:show-file-list="false"
|
>上移</el-button
|
||||||
accept="video/*"
|
>
|
||||||
:http-request="(options) => requestVideo(row, options)"
|
<el-button
|
||||||
:disabled="!editable || uploadingId !== null"
|
size="small"
|
||||||
>
|
:disabled="!editable || $index === episodes.length - 1"
|
||||||
<el-button size="small">{{ hasResumeSession ? '继续上传' : '上传视频' }}</el-button>
|
@click="moveEpisode(row, 1)"
|
||||||
</el-upload>
|
>下移</el-button
|
||||||
<el-button
|
>
|
||||||
type="danger"
|
<el-button size="small" :disabled="!editable" @click="openEpisode(row)"
|
||||||
size="small"
|
>编辑</el-button
|
||||||
:disabled="!editable"
|
>
|
||||||
@click="removeEpisode(row)"
|
<el-upload
|
||||||
>删除</el-button
|
v-if="canUpload"
|
||||||
>
|
:show-file-list="false"
|
||||||
</div>
|
accept="video/*"
|
||||||
</template>
|
:http-request="(options) => requestVideo(row, options)"
|
||||||
</el-table-column>
|
:disabled="!editable || uploadingId !== null"
|
||||||
</el-table>
|
>
|
||||||
|
<el-button size="small">上传视频</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="!editable"
|
||||||
|
@click="removeEpisode(row)"
|
||||||
|
>删除</el-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
<p v-if="uploadingId !== null" class="creator-upload-hint"
|
<p v-if="uploadingId !== null" class="creator-upload-hint"
|
||||||
>上传中 {{ uploadPercent }}%(分片上传,可断点续传)</p
|
>上传中 {{ uploadPercent }}%(分片上传,可断点续传)</p
|
||||||
>
|
>
|
||||||
@@ -283,56 +291,75 @@ function requestDramaCover(options) {
|
|||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h3 class="creator-section-title">提交记录</h3>
|
<h3 class="creator-section-title">提交记录</h3>
|
||||||
<el-table
|
<div class="creator-table-scroll creator-table-scroll--submissions">
|
||||||
:data="submissions"
|
<el-table
|
||||||
row-key="id"
|
:data="submissions"
|
||||||
border
|
row-key="id"
|
||||||
class="drama-el-table"
|
border
|
||||||
empty-text="还没有提交记录"
|
class="drama-el-table"
|
||||||
>
|
style="min-width: 1000px"
|
||||||
<el-table-column prop="version" label="版本" width="70" />
|
empty-text="还没有提交记录"
|
||||||
<el-table-column label="类型" width="100">
|
>
|
||||||
<template #default="{ row }">{{ submissionTypeText(row.type) }}</template>
|
<el-table-column prop="version" label="版本" width="70" />
|
||||||
</el-table-column>
|
<el-table-column label="类型" width="100">
|
||||||
<el-table-column label="状态" width="100">
|
<template #default="{ row }">{{ submissionTypeText(row.type) }}</template>
|
||||||
<template #default="{ row }">
|
</el-table-column>
|
||||||
<el-tag size="small" :type="submissionStatusMeta(row.status).tag" effect="light">
|
<el-table-column label="状态" width="100">
|
||||||
{{ submissionStatusMeta(row.status).label }}
|
<template #default="{ row }">
|
||||||
</el-tag>
|
<el-tag size="small" :type="submissionStatusMeta(row.status).tag" effect="light">
|
||||||
</template>
|
{{ submissionStatusMeta(row.status).label }}
|
||||||
</el-table-column>
|
</el-tag>
|
||||||
<el-table-column
|
</template>
|
||||||
prop="submitAt"
|
</el-table-column>
|
||||||
label="提交时间"
|
<el-table-column
|
||||||
min-width="165"
|
prop="submitAt"
|
||||||
:formatter="dateCell"
|
label="提交时间"
|
||||||
show-overflow-tooltip
|
min-width="165"
|
||||||
/>
|
:formatter="dateCell"
|
||||||
<el-table-column
|
show-overflow-tooltip
|
||||||
prop="reviewAt"
|
/>
|
||||||
label="审核时间"
|
<el-table-column
|
||||||
min-width="165"
|
prop="reviewAt"
|
||||||
:formatter="dateCell"
|
label="审核时间"
|
||||||
show-overflow-tooltip
|
min-width="165"
|
||||||
/>
|
:formatter="dateCell"
|
||||||
<el-table-column prop="reviewNote" label="审核意见" min-width="200" show-overflow-tooltip />
|
show-overflow-tooltip
|
||||||
<el-table-column label="操作" width="110" fixed="right">
|
/>
|
||||||
<template #default="{ row }">
|
<el-table-column
|
||||||
<div class="table-actions">
|
prop="reviewNote"
|
||||||
<el-button
|
label="审核意见"
|
||||||
v-if="canEdit && ['SUBMITTED', 'REVIEWING'].includes(row.status)"
|
min-width="200"
|
||||||
type="danger"
|
show-overflow-tooltip
|
||||||
size="small"
|
/>
|
||||||
@click="withdraw(row.id)"
|
<el-table-column label="操作" width="110">
|
||||||
>撤回</el-button
|
<template #default="{ row }">
|
||||||
>
|
<div class="table-actions">
|
||||||
<span v-else class="console-muted">—</span>
|
<el-button
|
||||||
</div>
|
v-if="canEdit && ['SUBMITTED', 'REVIEWING'].includes(row.status)"
|
||||||
</template>
|
type="danger"
|
||||||
</el-table-column>
|
size="small"
|
||||||
</el-table>
|
@click="withdraw(row.id)"
|
||||||
|
>撤回</el-button
|
||||||
|
>
|
||||||
|
<span v-else class="console-muted">—</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
</section>
|
</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
|
<el-dialog
|
||||||
v-model="episodeVisible"
|
v-model="episodeVisible"
|
||||||
:title="episodeEditingId ? '编辑分集' : '新增分集'"
|
:title="episodeEditingId ? '编辑分集' : '新增分集'"
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -30,16 +30,12 @@ const {
|
|||||||
profileLoading,
|
profileLoading,
|
||||||
dramas,
|
dramas,
|
||||||
notices,
|
notices,
|
||||||
createVisible,
|
|
||||||
createSaving,
|
|
||||||
createForm,
|
|
||||||
queryDramas,
|
queryDramas,
|
||||||
loadDramas,
|
loadDramas,
|
||||||
loadNotices,
|
loadNotices,
|
||||||
submit,
|
submit,
|
||||||
withdraw,
|
withdraw,
|
||||||
openCreate,
|
remove
|
||||||
confirmCreate
|
|
||||||
} = useCreatorWorkspace()
|
} = useCreatorWorkspace()
|
||||||
|
|
||||||
const activeTab = ref('dramas')
|
const activeTab = ref('dramas')
|
||||||
@@ -59,6 +55,11 @@ function onTabChange(name) {
|
|||||||
function openEdit(row) {
|
function openEdit(row) {
|
||||||
router.push(`/creator-center/dramas/${row.id}`)
|
router.push(`/creator-center/dramas/${row.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 抖音式发布:直接进发布页选视频,不再先建空草稿 */
|
||||||
|
function openPublish() {
|
||||||
|
router.push('/creator-center/publish')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -68,8 +69,8 @@ function openEdit(row) {
|
|||||||
title="我的作品"
|
title="我的作品"
|
||||||
description="查看作品与审核进度,提交上线 / 下线申请。"
|
description="查看作品与审核进度,提交上线 / 下线申请。"
|
||||||
>
|
>
|
||||||
<el-button v-if="canEdit && !impersonating" type="primary" @click="openCreate"
|
<el-button v-if="canEdit && !impersonating" type="primary" @click="openPublish"
|
||||||
>新建作品</el-button
|
>发布作品</el-button
|
||||||
>
|
>
|
||||||
</PageHeading>
|
</PageHeading>
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ function openEdit(row) {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel creator-workspace-wrap">
|
||||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||||
<el-tab-pane label="我的作品" name="dramas">
|
<el-tab-pane label="我的作品" name="dramas">
|
||||||
<div class="console-toolbar">
|
<div class="console-toolbar">
|
||||||
@@ -146,47 +147,151 @@ function openEdit(row) {
|
|||||||
<span class="console-count">共 {{ dramas.total }} 部作品</span>
|
<span class="console-count">共 {{ dramas.total }} 部作品</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table
|
<div class="creator-desktop-only">
|
||||||
v-loading="dramas.loading"
|
<el-table
|
||||||
:data="dramas.rows"
|
v-loading="dramas.loading"
|
||||||
row-key="id"
|
:data="dramas.rows"
|
||||||
border
|
row-key="id"
|
||||||
class="drama-el-table"
|
border
|
||||||
empty-text="还没有作品,点右上角「新建作品」开始"
|
class="drama-el-table"
|
||||||
>
|
empty-text="还没有作品,点右上角「发布作品」开始"
|
||||||
<el-table-column label="作品" min-width="220">
|
>
|
||||||
<template #default="{ row }">
|
<el-table-column label="作品" min-width="220">
|
||||||
<div class="creator-drama-cell">
|
<template #default="{ row }">
|
||||||
<div class="creator-drama-cover">
|
<div class="creator-drama-cell">
|
||||||
<AppImage
|
<div class="creator-drama-cover">
|
||||||
v-if="row.cover"
|
<AppImage
|
||||||
:src="row.cover"
|
v-if="row.cover"
|
||||||
:preview-list="workspaceCoverPreviewList"
|
:src="row.cover"
|
||||||
:initial-index="workspaceCoverPreviewIndex(row)"
|
:preview-list="workspaceCoverPreviewList"
|
||||||
fit="cover"
|
:initial-index="workspaceCoverPreviewIndex(row)"
|
||||||
class="creator-drama-image"
|
fit="cover"
|
||||||
:alt="row.title"
|
class="creator-drama-image"
|
||||||
/>
|
:alt="row.title"
|
||||||
<span v-else>{{ (row.title || '?').slice(0, 1) }}</span>
|
/>
|
||||||
|
<span v-else>{{ (row.title || '?').slice(0, 1) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="creator-drama-meta">
|
||||||
|
<span class="creator-drama-title">{{ row.title }}</span>
|
||||||
|
<span class="creator-drama-code">{{ row.code }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="creator-drama-meta">
|
</template>
|
||||||
<span class="creator-drama-title">{{ row.title }}</span>
|
</el-table-column>
|
||||||
<span class="creator-drama-code">{{ row.code }}</span>
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="dramaStatusMeta(row.status).tag" effect="light">{{
|
||||||
|
dramaStatusMeta(row.status).label
|
||||||
|
}}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="episodes" label="分集" width="80" />
|
||||||
|
<el-table-column prop="views" label="播放" width="100" />
|
||||||
|
<el-table-column label="审核进度" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<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>
|
||||||
|
<span v-if="row.reviewNote" class="console-muted" :title="row.reviewNote">{{
|
||||||
|
row.reviewNote
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span v-else class="console-muted">尚未提交</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
prop="updatedAt"
|
||||||
|
label="更新时间"
|
||||||
|
min-width="165"
|
||||||
|
:formatter="dateCell"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column label="操作" width="280">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="table-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="canEdit && !impersonating"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="openEdit(row)"
|
||||||
|
>编辑</el-button
|
||||||
|
>
|
||||||
|
<el-dropdown
|
||||||
|
v-if="canEdit && !impersonating && 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="
|
||||||
|
canEdit &&
|
||||||
|
!impersonating &&
|
||||||
|
row.submissionId &&
|
||||||
|
['SUBMITTED', 'REVIEWING'].includes(row.submissionStatus)
|
||||||
|
"
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
@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>
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="状态" width="100">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="dramaStatusMeta(row.status).tag" effect="light">{{
|
|
||||||
dramaStatusMeta(row.status).label
|
|
||||||
}}</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="episodes" label="分集" width="80" />
|
|
||||||
<el-table-column prop="views" label="播放" width="100" />
|
|
||||||
<el-table-column label="审核进度" min-width="200">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div v-if="row.submissionStatus" class="creator-progress">
|
<div v-if="row.submissionStatus" class="creator-progress">
|
||||||
<el-tag
|
<el-tag
|
||||||
size="small"
|
size="small"
|
||||||
@@ -197,32 +302,12 @@ function openEdit(row) {
|
|||||||
submissionStatusMeta(row.submissionStatus).label
|
submissionStatusMeta(row.submissionStatus).label
|
||||||
}}
|
}}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<span v-if="row.reviewNote" class="console-muted" :title="row.reviewNote">{{
|
|
||||||
row.reviewNote
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="console-muted">尚未提交</span>
|
<span v-else class="console-muted">尚未提交</span>
|
||||||
</template>
|
<div v-if="canEdit && !impersonating" class="creator-drama-card-actions">
|
||||||
</el-table-column>
|
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
|
||||||
<el-table-column
|
|
||||||
prop="updatedAt"
|
|
||||||
label="更新时间"
|
|
||||||
min-width="165"
|
|
||||||
:formatter="dateCell"
|
|
||||||
show-overflow-tooltip
|
|
||||||
/>
|
|
||||||
<el-table-column label="操作" width="210" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="table-actions">
|
|
||||||
<el-button
|
|
||||||
v-if="canEdit && !impersonating"
|
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
@click="openEdit(row)"
|
|
||||||
>编辑</el-button
|
|
||||||
>
|
|
||||||
<el-dropdown
|
<el-dropdown
|
||||||
v-if="canEdit && !impersonating && submitOptions(row).length"
|
v-if="submitOptions(row).length"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
@command="(type) => submit(row, type)"
|
@command="(type) => submit(row, type)"
|
||||||
>
|
>
|
||||||
@@ -241,20 +326,18 @@ function openEdit(row) {
|
|||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="
|
v-if="
|
||||||
canEdit &&
|
row.submissionId && ['SUBMITTED', 'REVIEWING'].includes(row.submissionStatus)
|
||||||
!impersonating &&
|
|
||||||
row.submissionId &&
|
|
||||||
['SUBMITTED', 'REVIEWING'].includes(row.submissionStatus)
|
|
||||||
"
|
"
|
||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
size="small"
|
||||||
@click="withdraw(row)"
|
@click="withdraw(row)"
|
||||||
>撤回</el-button
|
>撤回</el-button
|
||||||
>
|
>
|
||||||
|
<el-button text type="danger" size="small" @click="remove(row)">删除</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</el-table-column>
|
</article>
|
||||||
</el-table>
|
</div>
|
||||||
|
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
@@ -290,41 +373,5 @@ function openEdit(row) {
|
|||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</section>
|
</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>
|
</CreatorCenterPage>
|
||||||
</template>
|
</template>
|
||||||
@@ -4,6 +4,7 @@ import { onMounted, reactive, ref } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
createDramaApi,
|
createDramaApi,
|
||||||
|
deleteDramaApi,
|
||||||
getCreatorProfileApi,
|
getCreatorProfileApi,
|
||||||
listCreatorNoticesApi,
|
listCreatorNoticesApi,
|
||||||
pageMyDramasApi,
|
pageMyDramasApi,
|
||||||
@@ -168,6 +169,32 @@ export function useCreatorWorkspace() {
|
|||||||
await loadDramas()
|
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 createVisible = ref(false)
|
||||||
const createSaving = ref(false)
|
const createSaving = ref(false)
|
||||||
const createForm = reactive({ title: '', genre: '', description: '' })
|
const createForm = reactive({ title: '', genre: '', description: '' })
|
||||||
@@ -216,6 +243,7 @@ export function useCreatorWorkspace() {
|
|||||||
loadNotices,
|
loadNotices,
|
||||||
submit,
|
submit,
|
||||||
withdraw,
|
withdraw,
|
||||||
|
remove,
|
||||||
openCreate,
|
openCreate,
|
||||||
confirmCreate
|
confirmCreate
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ export function useDramaEdit() {
|
|||||||
ElMessage.success('视频已上传并挂到该分集')
|
ElMessage.success('视频已上传并挂到该分集')
|
||||||
await loadEpisodes()
|
await loadEpisodes()
|
||||||
} catch {
|
} catch {
|
||||||
resumeHint.value = `「${file.name}」上传中断,进度已记住,可点「继续上传」接着传`
|
resumeHint.value = `「${file.name}」上传中断,进度已记住;重新选择同一个文件即可续传`
|
||||||
} finally {
|
} finally {
|
||||||
uploadingId.value = null
|
uploadingId.value = null
|
||||||
uploadPercent.value = 0
|
uploadPercent.value = 0
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const {
|
|||||||
openAudit,
|
openAudit,
|
||||||
confirmAudit,
|
confirmAudit,
|
||||||
freeze,
|
freeze,
|
||||||
|
unfreeze,
|
||||||
createVisible,
|
createVisible,
|
||||||
createSaving,
|
createSaving,
|
||||||
createForm,
|
createForm,
|
||||||
@@ -61,8 +62,8 @@ const {
|
|||||||
<template>
|
<template>
|
||||||
<PageHeading
|
<PageHeading
|
||||||
eyebrow="创作者管理"
|
eyebrow="创作者管理"
|
||||||
title="创作者入驻"
|
title="创作者账号"
|
||||||
description="审核创作者主体与资质,通过后创作者才能提交内容。"
|
description="录入创作者并直接开通后台账号,账号即开即用,无需入驻审核。"
|
||||||
>
|
>
|
||||||
<el-button v-if="canAudit" type="primary" @click="openCreate">新增创作者</el-button>
|
<el-button v-if="canAudit" type="primary" @click="openCreate">新增创作者</el-button>
|
||||||
</PageHeading>
|
</PageHeading>
|
||||||
@@ -170,13 +171,13 @@ const {
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button type="primary" size="small" @click="openDocuments(row)">资质</el-button>
|
<el-button type="primary" size="small" @click="openDocuments(row)">资质</el-button>
|
||||||
<!-- 状态机:通过可从 pending/rejected/frozen 发起,驳回只允许 pending,冻结只允许 approved。
|
<!-- 账号由后台直接发放:已通过 / 冻结行都不再给「审核」入口;
|
||||||
已通过的行走「冻结 / 重置密码」,不再给「审核」入口(点了必然被后端拒) -->
|
C 端提交的入驻申请仍是 pending,可在这里一次性通过 -->
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canAudit && row.status !== 'approved'"
|
v-if="canAudit && row.status === 'pending'"
|
||||||
size="small"
|
size="small"
|
||||||
@click="openAudit(row, 'APPROVE')"
|
@click="openAudit(row, 'APPROVE')"
|
||||||
>审核</el-button
|
>通过</el-button
|
||||||
>
|
>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canAudit && row.status === 'approved'"
|
v-if="canAudit && row.status === 'approved'"
|
||||||
@@ -185,12 +186,18 @@ const {
|
|||||||
@click="freeze(row)"
|
@click="freeze(row)"
|
||||||
>冻结</el-button
|
>冻结</el-button
|
||||||
>
|
>
|
||||||
|
<el-button
|
||||||
|
v-if="canAudit && row.status === 'frozen'"
|
||||||
|
size="small"
|
||||||
|
@click="unfreeze(row)"
|
||||||
|
>解冻</el-button
|
||||||
|
>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canOpenAccount && !row.adminUserId"
|
v-if="canOpenAccount && !row.adminUserId"
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
:loading="accountSaving"
|
:loading="accountSaving"
|
||||||
:disabled="row.status !== 'approved'"
|
:disabled="row.status === 'frozen'"
|
||||||
@click="openAccount(row)"
|
@click="openAccount(row)"
|
||||||
>开通账号</el-button
|
>开通账号</el-button
|
||||||
>
|
>
|
||||||
@@ -299,16 +306,24 @@ const {
|
|||||||
<el-form-item label="国家 / 地区">
|
<el-form-item label="国家 / 地区">
|
||||||
<el-input v-model="createForm.countryCode" maxlength="8" placeholder="ID" />
|
<el-input v-model="createForm.countryCode" maxlength="8" placeholder="ID" />
|
||||||
</el-form-item>
|
</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
|
<el-alert
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
title="编号自动生成(CR+日期+序号),也是开通账号时默认的登录名;录入后需要先通过入驻审核才能开通账号"
|
title="编号自动生成(CR+日期+序号),也是开通账号时默认的登录名;无需入驻审核"
|
||||||
/>
|
/>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="createVisible = false">取消</el-button>
|
<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>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import PageHeading from '@/views/components/PageHeading.vue'
|
import PageHeading from '@/views/components/PageHeading.vue'
|
||||||
import { useRbac } from '@/stores/rbac'
|
import { useRbac } from '@/stores/rbac'
|
||||||
import { dateCell } from '@/utils/format'
|
import { dateCell } from '@/utils/format'
|
||||||
|
import { detailSubmissionApi } from '@/api/admin/creator'
|
||||||
import {
|
import {
|
||||||
SUBMISSION_STATUS,
|
SUBMISSION_STATUS,
|
||||||
SUBMISSION_TYPE,
|
SUBMISSION_TYPE,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
submissionTypeText,
|
submissionTypeText,
|
||||||
useSubmissionAudit
|
useSubmissionAudit
|
||||||
} from './composables/useSubmissionAudit'
|
} from './composables/useSubmissionAudit'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const { can } = useRbac()
|
const { can } = useRbac()
|
||||||
const canAudit = can('submission:audit')
|
const canAudit = can('submission:audit')
|
||||||
@@ -32,9 +34,25 @@ const {
|
|||||||
query,
|
query,
|
||||||
reset,
|
reset,
|
||||||
handleSizeChange,
|
handleSizeChange,
|
||||||
openAudit,
|
openAudit: rawOpenAudit,
|
||||||
confirmAudit
|
confirmAudit
|
||||||
} = useSubmissionAudit()
|
} = 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -94,9 +112,19 @@ const {
|
|||||||
class="drama-el-table"
|
class="drama-el-table"
|
||||||
empty-text="没有符合条件的提交记录"
|
empty-text="没有符合条件的提交记录"
|
||||||
>
|
>
|
||||||
<el-table-column prop="id" label="提交ID" width="90" />
|
<el-table-column prop="id" label="提交ID" width="140" />
|
||||||
<el-table-column prop="dramaId" label="短剧ID" width="90" />
|
<el-table-column prop="dramaId" label="短剧ID" width="140" />
|
||||||
<el-table-column prop="creatorId" label="创作者ID" width="100" />
|
<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">
|
<el-table-column label="提交类型" width="110">
|
||||||
<template #default="{ row }">{{ submissionTypeText(row.type) }}</template>
|
<template #default="{ row }">{{ submissionTypeText(row.type) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -153,18 +181,93 @@ const {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<el-dialog v-model="auditVisible" title="内容提交审核" width="480px">
|
<el-dialog v-model="auditVisible" title="内容提交审核" width="640px">
|
||||||
<el-form label-position="top">
|
<div v-loading="auditDetailLoading" class="audit-detail">
|
||||||
<el-form-item label="提交信息">
|
<!-- 顶部:创作者 + 提交信息 -->
|
||||||
<el-input
|
<div class="audit-meta">
|
||||||
:model-value="
|
<div class="audit-meta-item">
|
||||||
auditTarget
|
<span class="audit-meta-label">创作者</span>
|
||||||
? `提交 #${auditTarget.id} · 短剧 ${auditTarget.dramaId} · ${submissionTypeText(auditTarget.type)}`
|
<span class="audit-meta-value">
|
||||||
: ''
|
{{ auditTarget?.creatorName || '—' }}
|
||||||
"
|
<span v-if="auditTarget?.creatorCode" class="audit-meta-sub">
|
||||||
disabled
|
· {{ auditTarget.creatorCode }}
|
||||||
/>
|
</span>
|
||||||
</el-form-item>
|
<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"
|
||||||
|
/>
|
||||||
|
<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-form-item label="审核结果">
|
||||||
<el-radio-group v-model="auditForm.action">
|
<el-radio-group v-model="auditForm.action">
|
||||||
<el-radio label="APPROVE">通过</el-radio>
|
<el-radio label="APPROVE">通过</el-radio>
|
||||||
@@ -201,3 +304,148 @@ const {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</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()
|
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)
|
onMounted(load)
|
||||||
|
|
||||||
// ---------- 手工录入创作者主体 ----------
|
// ---------- 手工录入创作者主体 ----------
|
||||||
@@ -156,7 +172,9 @@ export function useCreatorAudit() {
|
|||||||
type: 'COMPANY',
|
type: 'COMPANY',
|
||||||
contactName: '',
|
contactName: '',
|
||||||
contactEmail: '',
|
contactEmail: '',
|
||||||
countryCode: 'ID'
|
countryCode: 'ID',
|
||||||
|
// 录入即开号:默认勾选,保存后直接弹出账号密码
|
||||||
|
openAccount: true
|
||||||
})
|
})
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
@@ -165,7 +183,8 @@ export function useCreatorAudit() {
|
|||||||
type: 'COMPANY',
|
type: 'COMPANY',
|
||||||
contactName: '',
|
contactName: '',
|
||||||
contactEmail: '',
|
contactEmail: '',
|
||||||
countryCode: 'ID'
|
countryCode: 'ID',
|
||||||
|
openAccount: true
|
||||||
})
|
})
|
||||||
createVisible.value = true
|
createVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -174,9 +193,17 @@ export function useCreatorAudit() {
|
|||||||
if (!createForm.name.trim()) return ElMessage.warning('请填写创作者名称')
|
if (!createForm.name.trim()) return ElMessage.warning('请填写创作者名称')
|
||||||
createSaving.value = true
|
createSaving.value = true
|
||||||
try {
|
try {
|
||||||
await createCreatorApi({ ...createForm, name: createForm.name.trim() })
|
const created = await createCreatorApi({ ...createForm, name: createForm.name.trim() })
|
||||||
ElMessage.success('已录入,待入驻审核通过后才能开通账号')
|
const creatorId = created.data
|
||||||
createVisible.value = false
|
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()
|
await load()
|
||||||
} finally {
|
} finally {
|
||||||
createSaving.value = false
|
createSaving.value = false
|
||||||
@@ -264,6 +291,7 @@ export function useCreatorAudit() {
|
|||||||
openAudit,
|
openAudit,
|
||||||
confirmAudit,
|
confirmAudit,
|
||||||
freeze,
|
freeze,
|
||||||
|
unfreeze,
|
||||||
createVisible,
|
createVisible,
|
||||||
createSaving,
|
createSaving,
|
||||||
createForm,
|
createForm,
|
||||||
|
|||||||
Reference in new issue
Block a user