13 Commits
Author SHA1 Message Date
Zhou Weidong 88db96c374 feat(commerce): 新增打赏任务管理页
web-ci / build (push) Failing after 8s
2026-11-09 10:27:50 +08:00
Zhou Weidong 96b213354b fix(layout): 扩大顶栏图标与用户菜单点击热区 2026-11-09 10:27:49 +08:00
Zhou Weidong 0d862dfe1d fix(router): 按账号菜单落地避免创作者进入 404 2026-11-09 10:27:48 +08:00
Zhou Weidong 8535305185 style(creator-center): 数据看板改用管理端卡片布局 2026-11-09 10:27:47 +08:00
Zhou Weidong 837266ce7e feat(creator): 内容审核展示创作者与分集信息 2026-11-09 10:27:42 +08:00
Zhou Weidong 71ee469563 feat(creator-center): 分集支持视频预览与表格横向滚动 2026-11-09 10:27:41 +08:00
Zhou Weidong 3c730c7d33 feat(creator-center): 新增两步发布作品并支持删除作品 2026-11-09 10:27:40 +08:00
Vicky 4055b7a2a9 fix(layout): 修复顶栏通知角标脱离铃铛右上角
web-ci / build (push) Failing after 10s
角标改为锚定图标右上角(right 9px + 右移 50%),
位数变化时位置不漂移。
2026-11-05 10:56:09 +08:00
Vicky bbff52f324 feat(drama): 短剧审核合并为审核通过并上线一键操作
审核列草稿/已下线/审核中统一为「审核通过并上线」,
审核中保留驳回,待发布与已上线维持原按钮。
2026-11-05 10:56:09 +08:00
Zhou Weidong 9ce014e7b3 feat(classification): 分类支持榜单布局与顺序维护
web-ci / build (push) Failing after 12s
2026-11-02 14:47:59 +08:00
Vicky 23026dc371 style(setting): 按 eslint 规则规范系统设置模板格式
web-ci / build (push) Canceled after 0s
2026-10-30 17:17:39 +08:00
Vicky 2f52011491 perf(build): 图标改为离线子集并精简图表依赖
web-ci / build (push) Canceled after 0s
2026-10-30 17:14:51 +08:00
Vicky dd4be08d49 feat(setting): 系统设置支持上传 App 登录页背景 2026-10-30 17:14:50 +08:00
36 changed files with 3621 additions and 407 deletions

No files matched your search

+5
View File
@@ -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 })
+39
View File
@@ -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`,
+3 -2
View File
@@ -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 */
+23 -9
View File
@@ -4,11 +4,14 @@ import request from '@/axios'
import type { PageResult } from './types' import type { PageResult } from './types'
export interface DramaClassificationItem { export interface DramaClassificationItem {
id: number id: string
name: string name: string
code: string
nameEn: string nameEn: string
nameId: string nameId: string
locales?: Record<string, string> locales?: Record<string, string>
layoutType: 'GRID' | 'RANKING'
revision: string
sort: number sort: number
status: number status: number
dramaCount: number dramaCount: number
@@ -18,17 +21,20 @@ export interface DramaClassificationItem {
export interface DramaClassificationSaveData { export interface DramaClassificationSaveData {
name: string name: string
locales: Record<string, string> locales: Record<string, string>
code?: string
layoutType?: 'GRID' | 'RANKING'
sort?: number sort?: number
status?: number status?: number
} }
export interface ClassificationDramaItem { export interface ClassificationDramaItem {
id: number id: string
title: string title: string
cover: string cover: string
status: string status: string
totalEpisodes: number totalEpisodes: number
sort: number sort: number
position?: number
updatedAt: string updatedAt: string
} }
@@ -43,19 +49,19 @@ export const listClassificationsApi = () => {
} }
export const createClassificationApi = (data: DramaClassificationSaveData) => { export const createClassificationApi = (data: DramaClassificationSaveData) => {
return request.post<number>({ url: '/admin/drama-classifications', data }) return request.post<string>({ url: '/admin/drama-classifications', data })
} }
export const updateClassificationApi = (id: number, data: DramaClassificationSaveData) => { export const updateClassificationApi = (id: string, data: DramaClassificationSaveData) => {
return request.put<void>({ url: `/admin/drama-classifications/${id}`, data }) return request.put<void>({ url: `/admin/drama-classifications/${id}`, data })
} }
export const deleteClassificationApi = (id: number) => { export const deleteClassificationApi = (id: string) => {
return request.delete<void>({ url: `/admin/drama-classifications/${id}` }) return request.delete<void>({ url: `/admin/drama-classifications/${id}` })
} }
/** 分类下已关联的短剧 */ /** 分类下已关联的短剧 */
export const listClassificationDramasApi = (id: number, params: ClassificationDramaPageParams) => { export const listClassificationDramasApi = (id: string, params: ClassificationDramaPageParams) => {
return request.get<PageResult<ClassificationDramaItem>>({ return request.get<PageResult<ClassificationDramaItem>>({
url: `/admin/drama-classifications/${id}/dramas`, url: `/admin/drama-classifications/${id}/dramas`,
params params
@@ -64,7 +70,7 @@ export const listClassificationDramasApi = (id: number, params: ClassificationDr
/** 尚未关联到该分类的短剧(添加弹窗用) */ /** 尚未关联到该分类的短剧(添加弹窗用) */
export const listClassificationDramaCandidatesApi = ( export const listClassificationDramaCandidatesApi = (
id: number, id: string,
params: ClassificationDramaPageParams params: ClassificationDramaPageParams
) => { ) => {
return request.get<PageResult<ClassificationDramaItem>>({ return request.get<PageResult<ClassificationDramaItem>>({
@@ -74,7 +80,7 @@ export const listClassificationDramaCandidatesApi = (
} }
/** 批量关联短剧,返回实际新增数量 */ /** 批量关联短剧,返回实际新增数量 */
export const bindClassificationDramasApi = (id: number, dramaIds: number[]) => { export const bindClassificationDramasApi = (id: string, dramaIds: string[]) => {
return request.post<number>({ return request.post<number>({
url: `/admin/drama-classifications/${id}/dramas`, url: `/admin/drama-classifications/${id}/dramas`,
data: { dramaIds } data: { dramaIds }
@@ -82,8 +88,16 @@ export const bindClassificationDramasApi = (id: number, dramaIds: number[]) => {
} }
/** 从分类移除短剧 */ /** 从分类移除短剧 */
export const unbindClassificationDramaApi = (id: number, dramaId: number) => { export const unbindClassificationDramaApi = (id: string, dramaId: string) => {
return request.delete<void>({ return request.delete<void>({
url: `/admin/drama-classifications/${id}/dramas/${dramaId}` url: `/admin/drama-classifications/${id}/dramas/${dramaId}`
}) })
} }
/** 原子保存推荐榜完整内容与顺序 */
export const saveRankingItemsApi = (id: string, dramaIds: string[]) => {
return request.put<void>({
url: `/admin/drama-classifications/${id}/ranking-items`,
data: { dramaIds }
})
}
+93
View File
@@ -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 })
+2 -1
View File
@@ -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
@@ -0,0 +1 @@
{"prefix":"material-symbols","info":{"name":"Material Symbols","total":15728,"author":{"name":"Google","url":"https://github.com/google/material-design-icons"},"license":{"title":"Apache 2.0","spdx":"Apache-2.0","url":"https://github.com/google/material-design-icons/blob/master/LICENSE"},"samples":["downloading","privacy-tip","filter-drama-outline","assignment-ind","monitoring","desktop-access-disabled-outline-sharp"],"height":24,"category":"Material","tags":["Has Padding"],"palette":false},"lastModified":1791524209,"width":24,"height":24,"icons":{"menu-rounded":{"body":"<path fill=\"currentColor\" d=\"M4 18q-.425 0-.712-.288T3 17t.288-.712T4 16h16q.425 0 .713.288T21 17t-.288.713T20 18zm0-5q-.425 0-.712-.288T3 12t.288-.712T4 11h16q.425 0 .713.288T21 12t-.288.713T20 13zm0-5q-.425 0-.712-.288T3 7t.288-.712T4 6h16q.425 0 .713.288T21 7t-.288.713T20 8z\"/>"},"menu-open-rounded":{"body":"<path fill=\"currentColor\" d=\"M4 18q-.425 0-.712-.288T3 17t.288-.712T4 16h11q.425 0 .713.288T16 17t-.288.713T15 18zm14.9-1.7l-3.6-3.6q-.3-.3-.3-.7t.3-.7l3.6-3.6q.275-.275.7-.275t.7.275t.275.7t-.275.7L17.4 12l2.9 2.9q.275.275.275.7t-.275.7t-.7.275t-.7-.275M4 13q-.425 0-.712-.288T3 12t.288-.712T4 11h8q.425 0 .713.288T13 12t-.288.713T12 13zm0-5q-.425 0-.712-.288T3 7t.288-.712T4 6h11q.425 0 .713.288T16 7t-.288.713T15 8z\"/>"},"dashboard-rounded":{"body":"<path fill=\"currentColor\" d=\"M14 9q-.425 0-.712-.288T13 8V4q0-.425.288-.712T14 3h6q.425 0 .713.288T21 4v4q0 .425-.288.713T20 9zM4 13q-.425 0-.712-.288T3 12V4q0-.425.288-.712T4 3h6q.425 0 .713.288T11 4v8q0 .425-.288.713T10 13zm10 8q-.425 0-.712-.288T13 20v-8q0-.425.288-.712T14 11h6q.425 0 .713.288T21 12v8q0 .425-.288.713T20 21zM4 21q-.425 0-.712-.288T3 20v-4q0-.425.288-.712T4 15h6q.425 0 .713.288T11 16v4q0 .425-.288.713T10 21z\"/>"},"video-library-rounded":{"body":"<path fill=\"currentColor\" d=\"M17.525 10.625q.35-.225.35-.625t-.35-.625L12.65 6.25q-.375-.25-.763-.038t-.387.663v6.25q0 .45.388.663t.762-.038zM8 18q-.825 0-1.412-.587T6 16V4q0-.825.588-1.412T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm-4 4q-.825 0-1.412-.587T2 20V7q0-.425.288-.712T3 6t.713.288T4 7v13h13q.425 0 .713.288T18 21t-.288.713T17 22z\"/>"},"settings-rounded":{"body":"<path fill=\"currentColor\" d=\"M10.825 22q-.675 0-1.162-.45t-.588-1.1L8.85 18.8q-.325-.125-.612-.3t-.563-.375l-1.55.65q-.625.275-1.25.05t-.975-.8l-1.175-2.05q-.35-.575-.2-1.225t.675-1.075l1.325-1Q4.5 12.5 4.5 12.337v-.675q0-.162.025-.337l-1.325-1Q2.675 9.9 2.525 9.25t.2-1.225L3.9 5.975q.35-.575.975-.8t1.25.05l1.55.65q.275-.2.575-.375t.6-.3l.225-1.65q.1-.65.588-1.1T10.825 2h2.35q.675 0 1.163.45t.587 1.1l.225 1.65q.325.125.613.3t.562.375l1.55-.65q.625-.275 1.25-.05t.975.8l1.175 2.05q.35.575.2 1.225t-.675 1.075l-1.325 1q.025.175.025.338v.674q0 .163-.05.338l1.325 1q.525.425.675 1.075t-.2 1.225l-1.2 2.05q-.35.575-.975.8t-1.25-.05l-1.5-.65q-.275.2-.575.375t-.6.3l-.225 1.65q-.1.65-.587 1.1t-1.163.45zm1.225-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5\"/>"},"group-rounded":{"body":"<path fill=\"currentColor\" d=\"M1 17.2q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2v.8q0 .825-.587 1.413T15 20H3q-.825 0-1.412-.587T1 18zM18.45 20q.275-.45.413-.962T19 18v-1q0-1.1-.612-2.113T16.65 13.15q1.275.15 2.4.513t2.1.887q.9.5 1.375 1.112T23 17v1q0 .825-.587 1.413T21 20zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175m11.65 0Q16.65 12 15 12q-.275 0-.7-.062t-.7-.138q.675-.8 1.038-1.775T15 8t-.362-2.025T13.6 4.2q.35-.125.7-.163T15 4q1.65 0 2.825 1.175T19 8t-1.175 2.825\"/>"},"movie-edit-rounded":{"body":"<path fill=\"currentColor\" d=\"M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4l1.625 3.25q.175.35.5.55t.7.2q.75 0 1.15-.638t.05-1.312L7 4h2l1.625 3.25q.175.35.5.55t.7.2q.75 0 1.15-.638t.05-1.312L12 4h2l1.625 3.25q.175.35.5.55t.7.2q.75 0 1.15-.638t.05-1.312L17 4h3q.825 0 1.413.587T22 6v3.45q0 .3-.25.475t-.55.1q-.825-.15-1.638.087T18.1 11l-5.525 5.525q-.275.275-.425.638t-.15.762V19q0 .425-.288.713T11 20zm10 0v-1.65q0-.2.075-.387t.225-.338l5.225-5.2q.225-.225.5-.325t.55-.1q.3 0 .575.113t.5.337l.925.925q.2.225.313.5t.112.55t-.1.563t-.325.512l-5.2 5.2q-.15.15-.337.225T16.65 21H15q-.425 0-.712-.287T14 20m6.575-4.6l.925-.975l-.925-.925l-.95.95z\"/>"},"policy-rounded":{"body":"<path fill=\"currentColor\" d=\"M11.675 21.875q-.15-.025-.3-.075Q8 20.675 6 17.637T4 11.1V6.375q0-.625.363-1.125t.937-.725l6-2.25q.35-.125.7-.125t.7.125l6 2.25q.575.225.938.725T20 6.375V11.1q0 1.575-.413 3.063T18.4 17l-2.95-2.95q.275-.475.413-.987T16 12q0-1.65-1.175-2.825T12 8T9.175 9.175T8 12t1.175 2.825T12 16q.525 0 1.038-.137T14 15.45l3.225 3.2q-.95 1.125-2.087 1.913T12.625 21.8q-.15.05-.3.075T12 21.9t-.325-.025m-1.088-8.462Q10 12.825 10 12t.588-1.412T12 10t1.413.588T14 12t-.587 1.413T12 14t-1.412-.587\"/>"},"payments-rounded":{"body":"<path fill=\"currentColor\" d=\"M3 20q-.825 0-1.412-.587T1 18V8q0-.425.288-.712T2 7t.713.288T3Line truncated
+2 -10
View File
@@ -5,7 +5,7 @@ import { computed, unref } from 'vue'
import { ElIcon } from 'element-plus' import { ElIcon } from 'element-plus'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue/offline'
import { normalizeAppIcon } from '@/constants/icons' import { normalizeAppIcon } from '@/constants/icons'
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
@@ -29,11 +29,6 @@ const symbolId = computed(() => {
return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : unref(normalizedIcon) return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : unref(normalizedIcon)
}) })
// 是否使用在线图标
const isUseOnline = computed(() => {
return import.meta.env.VITE_USE_ONLINE_ICON === 'true'
})
const getIconifyStyle = computed(() => { const getIconifyStyle = computed(() => {
const { color, size } = props const { color, size } = props
return { return {
@@ -49,10 +44,7 @@ const getIconifyStyle = computed(() => {
<use :xlink:href="symbolId" /> <use :xlink:href="symbolId" />
</svg> </svg>
<template v-else> <Icon v-else :icon="normalizedIcon" :style="getIconifyStyle" />
<Icon v-if="isUseOnline" :icon="normalizedIcon" :style="getIconifyStyle" />
<div v-else :class="`${normalizedIcon} iconify`" :style="getIconifyStyle"></div>
</template>
</ElIcon> </ElIcon>
</template> </template>
+12 -7
View File
@@ -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>
+27 -1
View File
@@ -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>
+3
View File
@@ -5,6 +5,9 @@ import 'vue/jsx'
// 引入unocss // 引入unocss
import '@/plugins/unocss' import '@/plugins/unocss'
// 注册后台使用的本地图标子集
import '@/plugins/icons'
// 导入全局的svg图标 // 导入全局的svg图标
import '@/plugins/svgIcon' import '@/plugins/svgIcon'
+8 -1
View File
@@ -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 -18
View File
@@ -2,22 +2,12 @@
import * as echarts from 'echarts/core' import * as echarts from 'echarts/core'
import { import { BarChart, LineChart, PieChart } from 'echarts/charts'
BarChart,
LineChart,
PieChart,
MapChart,
PictorialBarChart,
RadarChart
} from 'echarts/charts'
import { import {
TitleComponent, TitleComponent,
TooltipComponent, TooltipComponent,
GridComponent, GridComponent,
PolarComponent,
AriaComponent,
ParallelComponent,
LegendComponent LegendComponent
} from 'echarts/components' } from 'echarts/components'
@@ -28,16 +18,10 @@ echarts.use([
TitleComponent, TitleComponent,
TooltipComponent, TooltipComponent,
GridComponent, GridComponent,
PolarComponent,
AriaComponent,
ParallelComponent,
BarChart, BarChart,
LineChart, LineChart,
PieChart, PieChart,
MapChart, CanvasRenderer
CanvasRenderer,
PictorialBarChart,
RadarChart
]) ])
export default echarts export default echarts
+6
View File
@@ -0,0 +1,6 @@
// Author: fruit.com.cn
import { addCollection } from '@iconify/vue/offline'
import materialSymbols from '@/assets/icons/material-symbols-subset.json'
addCollection(materialSymbols)
+2 -1
View File
@@ -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
+146
View File
@@ -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);
}
}
+78
View File
@@ -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>
+174 -11
View File
@@ -4,6 +4,7 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { VueDraggable } from 'vue-draggable-plus'
import { AppIcon } from '@/components/AppIcon' import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons' import { AppIcons } from '@/constants/icons'
import { import {
@@ -19,6 +20,7 @@ import {
listClassificationDramaCandidatesApi, listClassificationDramaCandidatesApi,
listClassificationDramasApi, listClassificationDramasApi,
listClassificationsApi, listClassificationsApi,
saveRankingItemsApi,
unbindClassificationDramaApi, unbindClassificationDramaApi,
updateClassificationApi updateClassificationApi
} from '@/api/admin/dramaClassification' } from '@/api/admin/dramaClassification'
@@ -43,6 +45,7 @@ const activeId = ref(null)
const activeClassification = computed( const activeClassification = computed(
() => classifications.value.find((item) => item.id === activeId.value) || null () => classifications.value.find((item) => item.id === activeId.value) || null
) )
const isRanking = computed(() => activeClassification.value?.layoutType === 'RANKING')
const loading = ref(false) const loading = ref(false)
const dramas = ref([]) const dramas = ref([])
@@ -50,12 +53,19 @@ const total = ref(0)
const pageIndex = ref(1) const pageIndex = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const keyword = ref('') const keyword = ref('')
const rankingDirty = ref(false)
const dialogVisible = ref(false) const dialogVisible = ref(false)
const saving = ref(false) const saving = ref(false)
const editingId = ref(null) const editingId = ref(null)
const languages = ref(['zh']) const languages = ref(['zh'])
const emptyForm = () => ({ locales: { zh: '' }, sort: 1, status: 1 }) const emptyForm = () => ({
locales: { zh: '' },
code: '',
layoutType: 'GRID',
sort: 1,
status: 1
})
const form = reactive(emptyForm()) const form = reactive(emptyForm())
const candidateVisible = ref(false) const candidateVisible = ref(false)
@@ -91,10 +101,11 @@ async function loadDramas() {
const res = await listClassificationDramasApi(activeId.value, { const res = await listClassificationDramasApi(activeId.value, {
keyword: keyword.value || undefined, keyword: keyword.value || undefined,
pageIndex: pageIndex.value, pageIndex: pageIndex.value,
pageSize: pageSize.value pageSize: isRanking.value ? 50 : pageSize.value
}) })
dramas.value = res.data?.list || [] dramas.value = res.data?.list || []
total.value = res.data?.total || 0 total.value = res.data?.total || 0
rankingDirty.value = false
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -105,6 +116,7 @@ function selectClassification(row) {
activeId.value = row.id activeId.value = row.id
pageIndex.value = 1 pageIndex.value = 1
keyword.value = '' keyword.value = ''
rankingDirty.value = false
loadDramas() loadDramas()
} }
@@ -132,6 +144,8 @@ function openEdit(row) {
languages.value = languageCodes(locales) languages.value = languageCodes(locales)
Object.assign(form, { Object.assign(form, {
locales, locales,
code: row.code || '',
layoutType: row.layoutType || 'GRID',
sort: row.sort ?? 0, sort: row.sort ?? 0,
status: row.status ?? 1 status: row.status ?? 1
}) })
@@ -150,6 +164,10 @@ async function saveClassification() {
const payload = { const payload = {
name: locales.zh, name: locales.zh,
locales, locales,
code:
form.code ||
(form.layoutType === 'RANKING' && !editingId.value ? 'RECOMMEND_RANKING' : undefined),
layoutType: form.layoutType,
sort: form.sort, sort: form.sort,
status: form.status status: form.status
} }
@@ -197,7 +215,12 @@ async function removeDrama(row) {
} catch { } catch {
return return
} }
await unbindClassificationDramaApi(activeId.value, row.id) if (isRanking.value) {
const remaining = dramas.value.filter((item) => item.id !== row.id).map((item) => item.id)
await saveRankingItemsApi(activeId.value, remaining)
} else {
await unbindClassificationDramaApi(activeId.value, row.id)
}
ElMessage.success('已移出') ElMessage.success('已移出')
await Promise.all([loadDramas(), loadClassifications()]) await Promise.all([loadDramas(), loadClassifications()])
} }
@@ -247,11 +270,23 @@ async function confirmBind() {
} }
binding.value = true binding.value = true
try { try {
const res = await bindClassificationDramasApi( if (isRanking.value) {
activeId.value, const existingIds = dramas.value.map((item) => item.id)
selectedDramas.value.map((item) => item.id) const selectedIds = selectedDramas.value.map((item) => item.id)
) const nextIds = [...new Set([...existingIds, ...selectedIds])]
ElMessage.success(`已关联 ${res.data ?? selectedDramas.value.length} 部短剧`) if (nextIds.length > 50) {
ElMessage.warning('推荐榜最多 50 部短剧')
return
}
await saveRankingItemsApi(activeId.value, nextIds)
ElMessage.success(`推荐榜已增加 ${selectedIds.length} 部短剧`)
} else {
const res = await bindClassificationDramasApi(
activeId.value,
selectedDramas.value.map((item) => item.id)
)
ElMessage.success(`已关联 ${res.data ?? selectedDramas.value.length} 部短剧`)
}
candidateVisible.value = false candidateVisible.value = false
await Promise.all([loadDramas(), loadClassifications()]) await Promise.all([loadDramas(), loadClassifications()])
} finally { } finally {
@@ -259,6 +294,22 @@ async function confirmBind() {
} }
} }
function onRankingChange() {
dramas.value = dramas.value.map((item, index) => ({ ...item, position: index + 1 }))
rankingDirty.value = true
}
async function saveRankingOrder() {
if (!activeId.value || !dramas.value.length) return
await saveRankingItemsApi(
activeId.value,
dramas.value.map((item) => item.id)
)
rankingDirty.value = false
ElMessage.success('推荐榜顺序已保存')
await loadClassifications()
}
function formatTime(value) { function formatTime(value) {
if (!value) return '—' if (!value) return '—'
return String(value).replace('T', ' ').slice(0, 19) return String(value).replace('T', ' ').slice(0, 19)
@@ -271,7 +322,7 @@ onMounted(loadClassifications)
<PageHeading <PageHeading
eyebrow="内容管理" eyebrow="内容管理"
title="分类管理" title="分类管理"
description="分类是运营自定义的分组,与「类型」无关;一个分类可以关联任意多部短剧。" description="普通分类使用剧目网格;推荐榜使用独立排行布局,并按后台配置顺序展示。"
> >
<el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate"> <el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate">
<AppIcon :icon="AppIcons.add" /> <AppIcon :icon="AppIcons.add" />
@@ -293,9 +344,11 @@ onMounted(loadClassifications)
<div class="classification-toolbar__title"> <div class="classification-toolbar__title">
{{ activeClassification?.name || '请选择分类' }} {{ activeClassification?.name || '请选择分类' }}
<small>共 {{ total }} 部关联短剧</small> <small>共 {{ total }} 部关联短剧</small>
<el-tag v-if="isRanking" size="small" type="warning">推荐榜</el-tag>
</div> </div>
<div class="classification-toolbar__search"> <div class="classification-toolbar__search">
<el-input <el-input
v-if="!isRanking"
v-model="keyword" v-model="keyword"
placeholder="搜索短剧名称" placeholder="搜索短剧名称"
clearable clearable
@@ -303,7 +356,15 @@ onMounted(loadClassifications)
@keyup.enter="search" @keyup.enter="search"
@clear="search" @clear="search"
/> />
<el-button type="primary" plain @click="search">查询</el-button> <el-button v-if="!isRanking" type="primary" plain @click="search">查询</el-button>
<el-button
v-if="isRanking && can('drama:update')"
type="primary"
:disabled="!rankingDirty"
@click="saveRankingOrder"
>
保存排序
</el-button>
<el-button v-if="can('drama:update')" :disabled="!activeId" @click="openCandidates"> <el-button v-if="can('drama:update')" :disabled="!activeId" @click="openCandidates">
<AppIcon :icon="AppIcons.add" /> <AppIcon :icon="AppIcons.add" />
添加短剧 添加短剧
@@ -311,7 +372,41 @@ onMounted(loadClassifications)
</div> </div>
</div> </div>
<VueDraggable
v-if="isRanking"
v-model="dramas"
item-key="id"
handle=".ranking-row__drag"
class="ranking-list"
@end="onRankingChange"
>
<div v-for="(row, index) in dramas" :key="row.id" class="ranking-row">
<button class="ranking-row__drag" type="button" title="拖动调整排名">
<AppIcon :icon="AppIcons.drag" />
</button>
<span class="ranking-row__rank">{{ index + 1 }}</span>
<img v-if="row.cover" :src="row.cover" class="ranking-row__cover" alt="封面" />
<div v-else class="ranking-row__cover ranking-row__cover--empty">无</div>
<div class="ranking-row__content">
<strong>{{ row.title }}</strong>
<span class="status-dot" :class="statusClass(row.status)">
{{ STATUS_TEXT[row.status] || row.status }} · {{ row.totalEpisodes || 0 }} 集
</span>
</div>
<el-button
v-if="can('drama:update')"
type="danger"
size="small"
@click="removeDrama(row)"
>
移出
</el-button>
</div>
<el-empty v-if="!dramas.length" description="推荐榜还没有短剧" />
</VueDraggable>
<el-table <el-table
v-else
v-loading="loading" v-loading="loading"
:data="dramas" :data="dramas"
row-key="id" row-key="id"
@@ -353,7 +448,7 @@ onMounted(loadClassifications)
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="classification-pagination"> <div v-if="!isRanking" class="classification-pagination">
<el-pagination <el-pagination
:current-page="pageIndex" :current-page="pageIndex"
:page-size="pageSize" :page-size="pageSize"
@@ -434,6 +529,74 @@ onMounted(loadClassifications)
border-radius: 4px; border-radius: 4px;
} }
.ranking-list {
display: grid;
gap: 1px;
overflow: hidden;
border: 1px solid var(--el-border-color);
}
.ranking-row {
display: grid;
grid-template-columns: 36px 48px 52px minmax(0, 1fr) auto;
min-height: 72px;
padding: 8px 12px;
background: var(--el-bg-color);
align-items: center;
gap: 12px;
}
.ranking-row + .ranking-row {
border-top: 1px solid var(--el-border-color-lighter);
}
.ranking-row__drag {
display: inline-flex;
width: 32px;
height: 32px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: grab;
background: transparent;
border: 0;
align-items: center;
justify-content: center;
}
.ranking-row__rank {
font-size: 24px;
font-weight: 700;
color: var(--el-text-color-primary);
text-align: center;
}
.ranking-row__cover {
width: 44px;
height: 58px;
object-fit: cover;
border-radius: 4px;
}
.ranking-row__cover--empty {
display: flex;
color: var(--el-text-color-placeholder);
background: var(--el-fill-color-light);
align-items: center;
justify-content: center;
}
.ranking-row__content {
display: grid;
min-width: 0;
gap: 6px;
}
.ranking-row__content strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.classification-cover--empty { .classification-cover--empty {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -64,6 +64,23 @@ const emit = defineEmits([
@update:languages="emit('update:languages', $event)" @update:languages="emit('update:languages', $event)"
@update:locales="emit('update:form', { ...form, locales: $event })" @update:locales="emit('update:form', { ...form, locales: $event })"
/> />
<el-form-item label="展示样式">
<el-radio-group
:model-value="form.layoutType"
@update:model-value="emit('update:form', { ...form, layoutType: $event })"
>
<el-radio-button label="GRID">普通分类</el-radio-button>
<el-radio-button label="RANKING">推荐榜</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="分类编码">
<el-input
:model-value="form.code"
maxlength="64"
placeholder="例如 RECOMMEND_RANKING"
@update:model-value="emit('update:form', { ...form, code: $event.toUpperCase() })"
/>
</el-form-item>
<div class="classification-form-row"> <div class="classification-form-row">
<el-form-item label="排序"> <el-form-item label="排序">
<el-input-number <el-input-number
@@ -29,6 +29,9 @@ const { can } = useRbac()
> >
<div class="classification-list__main"> <div class="classification-list__main">
<span class="classification-list__name">{{ item.name }}</span> <span class="classification-list__name">{{ item.name }}</span>
<el-tag v-if="item.layoutType === 'RANKING'" size="small" type="warning">
推荐榜
</el-tag>
<span class="classification-list__count">{{ item.dramaCount || 0 }}</span> <span class="classification-list__count">{{ item.dramaCount || 0 }}</span>
</div> </div>
<div class="classification-list__actions" @click.stop> <div class="classification-list__actions" @click.stop>
@@ -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 []
@@ -46,6 +46,13 @@ export const BANNER_IMAGE: ImagePreset = {
maxHeight: 1164 maxHeight: 1164
} }
export const LOGIN_BACKGROUND: ImagePreset = {
label: '登录页背景',
hint: '建议 1170×2532(390×844dp × 3,iPhone 整屏竖图),min 720×1560',
maxWidth: 1170,
maxHeight: 2532
}
/** 按规格压缩:尺寸未超且体积不大时原样返回,不做二次编码。 */ /** 按规格压缩:尺寸未超且体积不大时原样返回,不做二次编码。 */
export function compressForPreset(file: File, preset: ImagePreset): Promise<File> { export function compressForPreset(file: File, preset: ImagePreset): Promise<File> {
return compressImage(file, { return compressImage(file, {
+453 -20
View File
@@ -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>
+162 -135
View File
@@ -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
+159 -112
View File
@@ -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
+24 -9
View File
@@ -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>
+264 -16
View File
@@ -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,
+18 -8
View File
@@ -3,7 +3,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import echarts from '@/plugins/echarts' import type { ECharts } from 'echarts/core'
import type { DashboardOverview, DashboardTrend } from '@/api/admin/dashboard' import type { DashboardOverview, DashboardTrend } from '@/api/admin/dashboard'
import { formatCount } from '@/utils/format' import { formatCount } from '@/utils/format'
@@ -16,8 +16,10 @@ const props = defineProps<{
const trendRef = ref<HTMLElement>() const trendRef = ref<HTMLElement>()
const deviceRef = ref<HTMLElement>() const deviceRef = ref<HTMLElement>()
const clock = ref('') const clock = ref('')
let trendChart: echarts.ECharts | null = null let trendChart: ECharts | null = null
let deviceChart: echarts.ECharts | null = null let deviceChart: ECharts | null = null
let echartsPromise: Promise<typeof import('@/plugins/echarts').default> | null = null
let active = true
let timer = 0 let timer = 0
const AXIS = '#909399' const AXIS = '#909399'
@@ -78,12 +80,19 @@ function tick() {
clock.value = new Date().toLocaleString('zh-CN', { hour12: false }) clock.value = new Date().toLocaleString('zh-CN', { hour12: false })
} }
function renderCharts() { async function loadEcharts() {
renderTrend() echartsPromise ??= import('@/plugins/echarts').then((module) => module.default)
renderDevices() return echartsPromise
} }
function renderTrend() { async function renderCharts() {
const echarts = await loadEcharts()
if (!active) return
renderTrend(echarts)
renderDevices(echarts)
}
function renderTrend(echarts: typeof import('@/plugins/echarts').default) {
if (!trendRef.value) return if (!trendRef.value) return
trendChart = trendChart ?? echarts.init(trendRef.value) trendChart = trendChart ?? echarts.init(trendRef.value)
const trend = props.trend const trend = props.trend
@@ -130,7 +139,7 @@ function renderTrend() {
}) })
} }
function renderDevices() { function renderDevices(echarts: typeof import('@/plugins/echarts').default) {
if (!deviceRef.value) return if (!deviceRef.value) return
deviceChart = deviceChart ?? echarts.init(deviceRef.value) deviceChart = deviceChart ?? echarts.init(deviceRef.value)
const android = metric('user', 'androidActive7d') ?? 0 const android = metric('user', 'androidActive7d') ?? 0
@@ -186,6 +195,7 @@ onMounted(() => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
active = false
window.clearInterval(timer) window.clearInterval(timer)
window.removeEventListener('resize', onResize) window.removeEventListener('resize', onResize)
trendChart?.dispose() trendChart?.dispose()
+128 -4
View File
@@ -1,9 +1,11 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { listConfigsApi, updateConfigsApi } from '@/api/admin/config' import { listConfigsApi, updateConfigsApi } from '@/api/admin/config'
import { uploadFile } from '@/utils/uploader'
import { compressForPreset, LOGIN_BACKGROUND } from '@/views/contentManagement/upload/imagePresets'
import { useRbac } from '@/stores/rbac' import { useRbac } from '@/stores/rbac'
const { can } = useRbac() const { can } = useRbac()
@@ -14,6 +16,15 @@ const workspaceName = ref('')
const defaultStatus = ref('draft') const defaultStatus = ref('draft')
const registerMaxPerIp = ref(5) const registerMaxPerIp = ref(5)
const registerMaxPerDevice = ref(3) const registerMaxPerDevice = ref(3)
const loginBackgroundUrl = ref('')
const MAX_LOGIN_BG_SIZE = 8 * 1024 * 1024 // 8 MB
const ALLOWED_LOGIN_BG_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const uploadingBg = ref(false)
const bgError = ref('')
const bgFileName = ref('')
const canSave = computed(() => can('system:role'))
const statusOptions = [ const statusOptions = [
{ code: 'draft', text: '草稿' }, { code: 'draft', text: '草稿' },
@@ -34,6 +45,7 @@ async function load() {
defaultStatus.value = map['content.default-status'] || 'draft' defaultStatus.value = map['content.default-status'] || 'draft'
registerMaxPerIp.value = parseRegisterLimit(map['app.register.max-per-ip-per-day'], 5) registerMaxPerIp.value = parseRegisterLimit(map['app.register.max-per-ip-per-day'], 5)
registerMaxPerDevice.value = parseRegisterLimit(map['app.register.max-per-device-per-day'], 3) registerMaxPerDevice.value = parseRegisterLimit(map['app.register.max-per-device-per-day'], 3)
loginBackgroundUrl.value = map['app.login.background.url'] || ''
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -46,7 +58,8 @@ async function save() {
'workspace.name': workspaceName.value, 'workspace.name': workspaceName.value,
'content.default-status': defaultStatus.value, 'content.default-status': defaultStatus.value,
'app.register.max-per-ip-per-day': String(registerMaxPerIp.value), 'app.register.max-per-ip-per-day': String(registerMaxPerIp.value),
'app.register.max-per-device-per-day': String(registerMaxPerDevice.value) 'app.register.max-per-device-per-day': String(registerMaxPerDevice.value),
'app.login.background.url': loginBackgroundUrl.value
}) })
ElMessage.success('设置已保存') ElMessage.success('设置已保存')
} finally { } finally {
@@ -54,6 +67,52 @@ async function save() {
} }
} }
function beforeBgUpload(file) {
bgError.value = ''
if (!ALLOWED_LOGIN_BG_TYPES.includes(file.type)) {
bgError.value = '图片仅支持 JPG、PNG 或 WebP 格式'
return false
}
if (file.size > MAX_LOGIN_BG_SIZE) {
bgError.value = '图片大小不能超过 8MB'
return false
}
return true
}
async function uploadBg(options) {
uploadingBg.value = true
bgError.value = ''
try {
const file = await compressForPreset(options.file, LOGIN_BACKGROUND)
const asset = await uploadFile(file, {
bizType: 'LOGIN_BG'
})
loginBackgroundUrl.value = asset.url
bgFileName.value = file.name
ElMessage.success('登录页背景已上传')
} catch {
bgError.value = '图片上传失败,请检查网络后重试'
} finally {
uploadingBg.value = false
}
}
function removeBg() {
if (uploadingBg.value) return
loginBackgroundUrl.value = ''
bgFileName.value = ''
bgError.value = ''
}
const loginBgPreview = computed(() => {
const url = loginBackgroundUrl.value
if (!url) return ''
if (url.startsWith('http://') || url.startsWith('https://')) return url
if (url.startsWith('/')) return url
return `/${url}`
})
onMounted(load) onMounted(load)
</script> </script>
@@ -77,10 +136,75 @@ onMounted(load)
><el-form-item label="单 IP 24 小时注册上限(0 或负数表示不限制)" ><el-form-item label="单 IP 24 小时注册上限(0 或负数表示不限制)"
><el-input-number v-model="registerMaxPerIp" :step="1" :precision="0" /></el-form-item ><el-input-number v-model="registerMaxPerIp" :step="1" :precision="0" /></el-form-item
><el-form-item label="单设备 24 小时注册上限(0 或负数表示不限制)" ><el-form-item label="单设备 24 小时注册上限(0 或负数表示不限制)"
><el-input-number v-model="registerMaxPerDevice" :step="1" :precision="0" /></el-form-item ><el-input-number v-model="registerMaxPerDevice" :step="1" :precision="0"
><el-button type="primary" :loading="saving" :disabled="!can('system:role')" @click="save" /></el-form-item>
<el-divider content-position="left">App 登录页</el-divider>
<el-form-item label="登录页背景图">
<div class="login-bg-field">
<el-upload
:show-file-list="false"
:before-upload="beforeBgUpload"
:http-request="uploadBg"
accept=".jpg,.jpeg,.png,.webp"
>
<el-button :disabled="uploadingBg" :loading="uploadingBg">
{{ loginBackgroundUrl ? '重新上传' : '上传背景图' }}
</el-button>
</el-upload>
<span class="field-hint">建议 {{ LOGIN_BACKGROUND.hint }}</span>
<span v-if="bgFileName" class="field-filename">当前:{{ bgFileName }}</span>
<span v-if="bgError" class="field-error">{{ bgError }}</span>
<div v-if="loginBgPreview" class="login-bg-preview">
<img :src="loginBgPreview" alt="登录页背景预览" />
<el-button class="remove-bg-btn" text type="danger" size="small" @click="removeBg"
>移除</el-button
>
</div>
</div>
</el-form-item>
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="save"
>保存设置</el-button >保存设置</el-button
></el-form ></el-form
></section ></section
> >
</template> </template>
<style scoped>
.login-bg-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.field-filename {
font-size: 12px;
color: var(--el-text-color-primary);
}
.field-error {
font-size: 12px;
color: var(--el-color-danger);
}
.login-bg-preview {
position: relative;
margin-top: 4px;
width: 96px;
aspect-ratio: 1170 / 2532;
border-radius: 6px;
overflow: hidden;
background: var(--el-fill-color-light);
}
.login-bg-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.remove-bg-btn {
position: absolute;
top: 2px;
right: 2px;
padding: 2px 4px;
}
</style>
+2 -31
View File
@@ -1,35 +1,7 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import { defineConfig, toEscapedSelector as e, presetUno, presetIcons } from 'unocss' import { defineConfig, toEscapedSelector as e, presetUno } from 'unocss'
import transformerVariantGroup from '@unocss/transformer-variant-group' import transformerVariantGroup from '@unocss/transformer-variant-group'
import { loadEnv } from 'vite'
import { AppIcons } from './src/constants/icons'
const root = process.cwd()
const createPresetIcons = () => {
const isBuild = !!process.argv[4]
let env = {} as any
if (!isBuild) {
env = loadEnv(process.argv[3], root)
} else {
env = loadEnv(process.argv[4], root)
}
// @ts-ignore
if (env.VITE_USE_ONLINE_ICON === 'true') {
return []
} else {
return [
presetIcons({
prefix: '',
collections: {
'material-symbols': () =>
import('@iconify/json/json/material-symbols.json').then((i) => i.default)
}
})
]
}
}
export default defineConfig({ export default defineConfig({
// ...UnoCSS options // ...UnoCSS options
@@ -141,8 +113,7 @@ ${selector}:after {
} }
] ]
], ],
safelist: Object.values(AppIcons), presets: [presetUno({ dark: 'class', attributify: false })],
presets: [presetUno({ dark: 'class', attributify: false }), ...createPresetIcons()],
transformers: [transformerVariantGroup()], transformers: [transformerVariantGroup()],
content: { content: {
pipeline: { pipeline: {