6 Commits
Author SHA1 Message Date
Vicky 2fa1416c3e fix(web): el-radio 改用 2.5.5 的 label API —— 全项目单选组失效(点一个全亮)
为什么:项目锁 element-plus 2.5.5,而 2.5.5 的 el-radio 只有 `label` 属性(`value` 是 2.6.0+ 才引入的替代 API)。
源码可证:node_modules/.pnpm/element-plus@2.5.5_vue@3.4.15/.../radio/src/radio.mjs 的 radioPropsBase 只有
label,没有 value。所以 `<el-radio value="X">` 的 value 是无效 attribute,radio 的 label 全是默认空串:
点任意一个 → 模型被设成 "" → 所有 radio 的 label 都是 "" → 全部显示选中(用户看到"点个人、公司也选上了")。
初始值非空时没有匹配项,所以一开始一个都不选中,一点就全亮。

- 全项目 14 处 el-radio(6 个文件)统一把 value= 改成 :label=(值与显示文本不同时用 slot 承载文本)
  覆盖:创作者类型 / 创作者审核 / 内容审核 / 权益类型 / App 用户处置 / 举报处理 / 菜单类型
- 不升级 element-plus(会影响整个后台组件行为,需单独评估)

实测:7 个单选组逐个点选,均"只有被点的那个选中";创作者类型额外验落库值
(选个人 → INDIVIDUAL,选机构 → COMPANY)。
2026-10-15 16:44:22 +08:00
Vicky 4509323ba8 fix(web): 切换账号残留上一账号的标签/菜单;审核按钮不再对已通过的行显示
问题 1(创作者登录后还能看到「工作台」)根因:tagsView.delAllVisitedViews() 会「保留 affix 标签」,
它判断的是 userInfo 是否还在,而 userStore.reset() 是先清标签、后清 userInfo —— 顺序反了,
写死路由表里 affix 的「工作台」标签就跨账号活了下来。整页刷新时不复现,所以只有"退出后不刷新直接登录"
才看得到(PM 复现不到就是这个原因)。修法与加固:

- userStore.reset():先清账号信息再清标签,并调 permissionStore.clearRoutes() 一并清菜单状态
- permissionStore 增加 routersOwner(当前菜单属于哪个账号)+ clearRoutes();守卫发现账号变了会先 resetRouter()
  摘掉上一个账号动态加的路由,再重建(避免两套菜单并存、也避免创作者还能直接进 /dashboard)
- 守卫里"菜单拉取失败沿用上次缓存"这条:换账号后不再沿用(宁可先给空菜单)

问题 2(创作者入驻/审核):真实 UI 全流程(新增→审核→开通→凭据→登录落地)本身能跑通,
但复现出「审核」按钮对**所有状态**都显示 —— 后端状态机是 通过可从 pending/rejected/frozen 发起、
驳回只允许 pending、冻结只允许 approved,对「已通过」的行点审核提交必然 200003 红错。修法:
- 已通过的行不再显示「审核」(保留 冻结/重置密码/开通账号)
- 审核弹窗里的「驳回」只对 pending 显示,其余状态只能通过

顺带修:views/common/DashboardView 取列表时 res 可能为 undefined(拦截器把业务错误吞成 undefined),
账号切换瞬间会抛 Cannot read properties of undefined,已加可选链。
2026-10-15 16:30:29 +08:00
Vicky 8ce43ccd4c feat(web): 创作者审核页支持开号/重置密码;登录落地按角色分流 + 首登强制改密
为什么:后台能开号了,但界面还没有入口;而且创作者登录后会落到 /dashboard/index(他没有运营后台权限)
直接掉 404。这批把开号入口、一次性凭据展示、落地分流与首登改密一次补齐。

- 创作者入驻页:「新增创作者」按钮(名称/类型/联系人/邮箱/国家)、「账号」列(已开通/未开通)、
  行内「开通账号 / 重置密码」;密码一次性弹窗展示(等宽可选中 + 一键复制 + 提示只显示这一次)
- 落地分流:新增 landingPath()(取账号能进的第一个菜单),登录与访问根路径都走它 ——
  创作者落 /creator-center/workspace,超管第一条仍是工作台(行为不变)
- 首登强制改密:Layout 挂 MustChangePasswordDialog,登录或刷新后若 profile 带 mustChangePassword 就弹不可关闭的
  改密框,改完清标记;只能改密或退出登录
2026-10-15 16:01:13 +08:00
Vicky bac070b60b fix(web): 代看目标失效时清掉本地选择
为什么:实测发现目标创作者被删掉后,顶部的「当前创作者」还显示着那个已经进不去的 id(下拉里也没有对应选项,
标签却还挂着"只读代看"),和页面提示的"请先选择代看创作者"互相打架。现在探测到身份不通过且有本地目标时,
一并清掉选择与缓存,让下拉回到占位符。
2026-10-15 15:30:35 +08:00
Vicky 685c912313 feat(web): 创作者中心「当前创作者」代看下拉 + 未绑定引导页
为什么:超管点进创作者中心原本是一片"未绑定创作者主体"的报错,既没说明原因也没有出路。
这批把两件事做出来:超管能在顶部选一个创作者只读代看;没有创作者身份的账号看到明确的引导页而不是白屏。

- 新增 useCreatorAccess(身份三态:granted / need-target / unbound)+ ImpersonationBar + CreatorAccessGuide,
  四个页面用 CreatorCenterPage 统一套壳;身份不满足时不打业务接口,避免一串无意义的报错提示
- 代看目标存 localStorage,由 axios.request 统一给 /admin/creator-center 的请求加 X-Creator-Id 头
  (不做查询参数;后端只对具备 impersonate 权限的账号认这个头)
- axios 支持 silent 请求:业务码非 0 时不弹全局提示、把 Result 交给调用方判断(引导页据此分流)
- 代看只读体验:代看态下「新建作品 / 编辑 / 提交审核 / 撤回」不渲染,编辑页整页禁用并提示原因
- 顶部下拉只在有 impersonate 权限时显示;无权限账号看不到、也不会请求 targets 接口
2026-10-15 15:25:53 +08:00
Vicky 0a2e3e0b81 feat(web): 07 创作者中心四个页面(我的作品 / 作品编辑 / 收费配置 / 数据看板)
为什么:07 的接口五批都做完了,但创作者在界面上一个都点不到 —— 只能靠 curl。
这批把创作者中心做进后台管理(沿用 AppUsersView 那套 api 封装 + composable + 视图 + 样式与 console.css),
创作者用自己的后台账号登录就能完成"建草稿 → 编辑分集传片 → 提交审核 → 配置收费 → 看数据"的闭环。

- WorkspaceView:主体状态 / 我的作品(含最近一次提交的审核进度)/ 通知列表;新建草稿、按状态给出可提交的动作、撤回
- DramaEditView:作品信息(封面、预告片)、分集增删与上移下移、分片上传(复用 /admin/media)+ 断点续传
  (会话存 localStorage,中断后「继续上传」接着传)、产物挂载、提交记录与撤回;非草稿/已下线状态整页转只读
- PriceConfigView:整剧免费集数与解锁金币 + 逐集免费/收费/试看秒数;已上线只读(需先申请下线)
- DashboardView:作品 / 播放 / 互动 / 解锁收入四组指标,取不到的显示「暂无数据源」+ notes 口径说明
- 时间列统一 yyyy-MM-dd HH:mm:ss;表格列与后端字段一一对应,不在前端编字段
- media.ts 补上传进度接口;uploader 补 resumeUpload(先查服务端进度再续传)
2026-10-15 14:47:28 +08:00
39 changed files with 2905 additions and 32 deletions

No files matched your search

+30
View File
@@ -62,6 +62,36 @@ export const auditCreatorApi = (id: number, data: Record<string, unknown>) => {
return request.put<CreatorItem>({ url: `/admin/creators/${id}/audit`, data })
}
/** 手工录入创作者主体(C 端申请入驻未实现前的入口) */
export const createCreatorApi = (data: Record<string, unknown>) => {
return request.post<number>({ url: '/admin/creators', data })
}
export interface CreatorAccountResult {
creatorId: number
adminUserId: number
username: string
/** 一次性明文密码:只在开通 / 重置的这次响应里出现,之后拿不回来 */
password: string | null
created: boolean
message: string
}
/** 开通创作者账号(仅审核通过可开;已开通则幂等返回) */
export const openCreatorAccountApi = (id: number, username?: string) => {
return request.post<CreatorAccountResult>({
url: `/admin/creators/${id}/account`,
data: { username }
})
}
/** 重置创作者账号密码(一次性明文返回) */
export const resetCreatorPasswordApi = (id: number) => {
return request.post<CreatorAccountResult>({
url: `/admin/creators/${id}/account/reset-password`
})
}
export const pageSubmissionsApi = (params: Record<string, unknown>) => {
return request.get<PageResult<SubmissionItem>>({ url: '/admin/submissions/page', params })
}
+246
View File
@@ -0,0 +1,246 @@
import request from '@/axios'
import type { PageResult } from './types'
/** 创作者主体:登录账号绑定的创作者信息 */
export interface CreatorProfile {
id: number
code: string
name: string
/** INDIVIDUAL / COMPANY */
type: string
/** pending / approved / rejected / frozen */
status: string
statusText: string
auditNote: string
auditAt?: string
contactName: string
contactEmail: string
countryCode: string
dramaTotal: number
}
/** 代看目标下拉项(后端只给 id / 名称 / 状态) */
export interface CreatorCenterTarget {
id: number
code: string
name: string
status: string
statusText: string
}
/** 我的作品 + 最近一次提交的审核进度 */
export interface CreatorDrama {
id: number
code: string
title: string
subtitle?: string
genre?: string
description?: string
cover: string
trailerUrl?: string
language?: string
status: string
statusText: string
episodes: number
views: number
updatedAt?: string
submissionId?: number | null
submissionType?: string | null
submissionStatus?: string | null
submissionStatusText?: string | null
reviewNote?: string | null
rejectCode?: string | null
submitAt?: string | null
reviewAt?: string | null
}
export interface CreatorNotice {
id: number
/** CREATOR(入驻审核)/ SUBMISSION(内容提交审核) */
bizType: string
title: string
content: string
result: string
time?: string
}
export interface CreatorSubmission {
id: number
creatorId: number
dramaId: number
/** CREATE / UPDATE / PUBLISH / OFFLINE */
type: string
/** DRAFT / SUBMITTED / REVIEWING / APPROVED / REJECTED / WITHDRAWN */
status: string
version: number
submitAt?: string
reviewAt?: string
reviewNote: string
rejectCode: string
}
export interface CreatorEpisode {
id: number
dramaId: number
number: number
title: string
duration: string
durationSec: number
cover: string
videoUrl: string
isFree: boolean
unlockCoin: number
status: string
statusText: string
views: number
sort: number
}
export interface CreatorDramaEditData {
title: string
subtitle?: string
categoryId?: number
genre?: string
description?: string
cover?: string
trailerUrl?: string
language?: string
}
export interface PriceEpisodeConfig {
episodeId: number
number: number
title: string
free: boolean
unlockCoin: number
previewSeconds: number
}
export interface PriceConfig {
dramaId: number
freeEpisodes: number
unlockCoin: number
episodeCount: number
changedCount: number
episodes: PriceEpisodeConfig[]
}
export interface CreatorDashboard {
generatedAt: string
drama: Record<string, number>
playback: Record<string, number>
interaction: Record<string, number>
/** paidOrderAmount 取不到时为 null */
revenue: Record<string, number | null>
/** 按天趋势:creator_stat_daily 无产出,恒为 null */
daily: null
notes: string[]
}
export const getCreatorProfileApi = () => {
return request.get<CreatorProfile>({ url: '/admin/creator-center/profile' })
}
/**
* 静默探测当前账号的创作者身份:不弹全局提示,直接把 Result 交给调用方判断。
* 用于「已绑定 / 可代看但没选目标 / 未绑定」三态分流,渲染引导页。
*/
export const probeCreatorAccessApi = () => {
return request.get<CreatorProfile>({ url: '/admin/creator-center/profile', silent: true })
}
/** 代看目标下拉的数据源(仅 impersonate 权限可调) */
export const listImpersonationTargetsApi = () => {
return request.get<CreatorCenterTarget[]>({ url: '/admin/creator-center/impersonation/targets' })
}
export const pageMyDramasApi = (params: Record<string, unknown>) => {
return request.get<PageResult<CreatorDrama>>({ url: '/admin/creator-center/dramas/page', params })
}
export const getMyDramaApi = (id: number) => {
return request.get<CreatorDrama>({ url: `/admin/creator-center/dramas/${id}` })
}
export const listCreatorNoticesApi = () => {
return request.get<CreatorNotice[]>({ url: '/admin/creator-center/notices' })
}
export const getCreatorDashboardApi = () => {
return request.get<CreatorDashboard>({ url: '/admin/creator-center/dashboard' })
}
export const createDramaApi = (data: CreatorDramaEditData) => {
return request.post<number>({ url: '/admin/creator-center/dramas', data })
}
export const updateDramaApi = (id: number, data: CreatorDramaEditData) => {
return request.put<void>({ url: `/admin/creator-center/dramas/${id}`, data })
}
export const listEpisodesApi = (dramaId: number) => {
return request.get<CreatorEpisode[]>({ url: `/admin/creator-center/dramas/${dramaId}/episodes` })
}
export const addEpisodeApi = (dramaId: number, data: Record<string, unknown>) => {
return request.post<number>({ url: `/admin/creator-center/dramas/${dramaId}/episodes`, data })
}
export const updateEpisodeApi = (
dramaId: number,
episodeId: number,
data: Record<string, unknown>
) => {
return request.put<void>({
url: `/admin/creator-center/dramas/${dramaId}/episodes/${episodeId}`,
data
})
}
export const deleteEpisodeApi = (dramaId: number, episodeId: number) => {
return request.delete<void>({
url: `/admin/creator-center/dramas/${dramaId}/episodes/${episodeId}`
})
}
export const sortEpisodesApi = (dramaId: number, ids: number[]) => {
return request.put<void>({
url: `/admin/creator-center/dramas/${dramaId}/episodes/sort`,
data: { ids }
})
}
/** 把上传好的媒资挂到分集(产物必须是本人上传的) */
export const attachAssetApi = (dramaId: number, episodeId: number, assetId: number) => {
return request.post<void>({
url: `/admin/creator-center/dramas/${dramaId}/episodes/${episodeId}/attach`,
data: { assetId }
})
}
export const getPricesApi = (dramaId: number) => {
return request.get<PriceConfig>({ url: `/admin/creator-center/dramas/${dramaId}/prices` })
}
export const savePricesApi = (dramaId: number, data: Record<string, unknown>) => {
return request.put<PriceConfig>({ url: `/admin/creator-center/dramas/${dramaId}/prices`, data })
}
export const submitDramaApi = (dramaId: number, type: string) => {
return request.post<CreatorSubmission>({
url: `/admin/creator-center/dramas/${dramaId}/submit`,
data: { type }
})
}
export const withdrawSubmissionApi = (submissionId: number) => {
return request.post<CreatorSubmission>({
url: `/admin/creator-center/submissions/${submissionId}/withdraw`
})
}
export const listSubmissionsApi = (dramaId: number) => {
return request.get<CreatorSubmission[]>({
url: `/admin/creator-center/dramas/${dramaId}/submissions`
})
}
+20
View File
@@ -36,6 +36,26 @@ export const completeUploadApi = (uploadId: string, durationSec?: number) => {
})
}
export interface UploadProgress {
uploadId: string
fileName: string
bizType: string
fileSize: number
chunkSize: number
totalChunks: number
receivedChunks: number
percent: number
/** uploading / completed */
status: string
assetId: number
url: string
}
/** 断点续传回显:传到第几片、还剩多少(服务端说了算,客户端不自己记账) */
export const getUploadProgressApi = (uploadId: string) => {
return request.get<UploadProgress>({ url: `/admin/media/upload/${uploadId}` })
}
export const getMediaApi = (id: number) => {
return request.get<MediaAsset>({ url: `/admin/media/${id}` })
}
+5
View File
@@ -27,3 +27,8 @@ export const getAdminRoleApi = (
export const getTestRoleApi = (params: RoleParams): Promise<IResponse<string[]>> => {
return request.get({ url: '/mock/role/list2', params })
}
/** 自助改密(首登强制改密也走这个接口) */
export const changePasswordApi = (data: { oldPassword: string; newPassword: string }) => {
return request.post<void>({ url: '/admin/auth/password', data })
}
+4
View File
@@ -41,6 +41,10 @@ const defaultResponseInterceptors = (response: AxiosResponse) => {
} else if (response.data.code === SUCCESS_CODE) {
return response.data
} else {
// 静默请求:调用方要自己看业务码(例如创作者中心据此渲染引导页),不弹全局提示
if ((response.config as { silent?: boolean })?.silent) {
return response.data
}
ElMessage.error(response?.data?.message)
if (response?.data?.code === 401) {
const userStore = useUserStoreWithOut()
+8 -1
View File
@@ -1,23 +1,30 @@
import service from './service'
import { CONTENT_TYPE } from '@/constants'
import { useUserStoreWithOut } from '@/store/modules/user'
import { creatorCenterHeaders } from '@/utils/creatorImpersonation'
const request = (option: AxiosConfig) => {
const { url, method, params, data, headers, responseType } = option
const { url, method, params, data, headers, responseType, silent } = option
const userStore = useUserStoreWithOut()
// FormData 不能带 JSON 的 Content-Type,否则 axios 会把表单转成 JSON,浏览器也无法补上 boundary
const isFormData = typeof FormData !== 'undefined' && data instanceof FormData
// 创作者中心的请求统一带上代看目标头(超管选了代看创作者时才有值;
// 后端只对具备 impersonate 权限的账号认这个头,创作者带了也不生效)
const impersonationHeaders =
typeof url === 'string' && url.startsWith('/admin/creator-center') ? creatorCenterHeaders() : {}
return service.request({
url: url,
method,
params,
data,
responseType: responseType,
silent,
headers: {
...(isFormData ? {} : { 'Content-Type': CONTENT_TYPE }),
[userStore.getTokenKey ?? 'Authorization']: userStore.getToken
? `Bearer ${userStore.getToken}`
: '',
...impersonationHeaders,
...headers
}
})
+2
View File
@@ -18,6 +18,8 @@ interface RequestInterceptors<T> {
interface RequestConfig<T = AxiosResponse> extends AxiosRequestConfig {
interceptors?: RequestInterceptors<T>
/** 静默请求:业务码非 0 时由调用方处理,不弹全局提示(见 axios/config.ts) */
silent?: boolean
}
export {
+3
View File
@@ -3,6 +3,7 @@ import { computed, defineComponent, unref } from 'vue'
import { useAppStore } from '@/store/modules/app'
import { Backtop } from '@/components/Backtop'
import { Setting } from '@/components/Setting'
import MustChangePasswordDialog from '@/views/components/MustChangePasswordDialog.vue'
import { useRenderLayout } from './components/useRenderLayout'
import { useDesign } from '@/hooks/web/useDesign'
@@ -59,6 +60,8 @@ export default defineComponent({
<Backtop></Backtop>
<MustChangePasswordDialog></MustChangePasswordDialog>
<Setting></Setting>
</section>
)
+28 -4
View File
@@ -1,4 +1,4 @@
import router from './router'
import router, { resetRouter } from './router'
import { useAppStoreWithOut } from '@/store/modules/app'
import type { RouteRecordRaw } from 'vue-router'
import { useTitle } from '@/hooks/web/useTitle'
@@ -8,6 +8,7 @@ import { usePageLoading } from '@/hooks/web/usePageLoading'
import { NO_REDIRECT_WHITE_LIST } from '@/constants'
import { useUserStoreWithOut } from '@/store/modules/user'
import { getAdminRoleApi, getProfileApi } from '@/api/login'
import { landingPath } from '@/utils/landing'
const { start, done } = useNProgress()
@@ -24,10 +25,21 @@ router.beforeEach(async (to, from, next) => {
next({ path: '/' })
} else {
if (permissionStore.getIsAddRouters) {
next()
return
// 已加过路由:只有还是同一个账号才能直接放行。
// 换账号时(例如超管退出后创作者登录)必须重建,否则侧边栏/路由会残留上一个账号的
const username = userStore.getUserInfo?.username || ''
if (permissionStore.getRoutersOwner === username) {
next()
return
}
// 摘掉上一个账号动态加进去的路由,避免两套菜单并存
resetRouter()
permissionStore.setIsAddRouters(false)
}
const username = userStore.getUserInfo?.username || ''
const ownerChanged = permissionStore.getRoutersOwner !== username
// 开发者可根据实际情况进行修改
const roleRouters = userStore.getRoleRouters || []
@@ -37,7 +49,6 @@ router.beforeEach(async (to, from, next) => {
// 每次进入应用都重新拉菜单与权限:后台改了菜单或角色权限,刷新即可生效,不必重新登录
let routers = (userStore.getRoleRouters || []) as AppCustomRouteRecordRaw[]
try {
const username = userStore.getUserInfo?.username || ''
const [profileRes, menuRes] = await Promise.all([
getProfileApi(),
getAdminRoleApi({ roleName: username })
@@ -48,6 +59,9 @@ router.beforeEach(async (to, from, next) => {
if (menuRes?.data?.length) {
routers = menuRes.data
userStore.setRoleRouters(routers)
} else if (ownerChanged) {
// 换账号后菜单拉不到就别沿用上一个账号的缓存,宁可先给空菜单
routers = []
}
} catch {
// 拉取失败时退回上次缓存的菜单,不阻塞进入
@@ -63,6 +77,16 @@ router.beforeEach(async (to, from, next) => {
permissionStore.getAddRouters.forEach((route) => {
router.addRoute(route as unknown as RouteRecordRaw) // 动态添加可访问路由表
})
permissionStore.setRoutersOwner(username)
// 根路径按"这个账号能进的第一个菜单"落地:路由表里写死的 redirect 是 /dashboard/index,
// 创作者没有运营后台权限,访问 '/' 会直接掉 404
if (to.path === '/') {
permissionStore.setIsAddRouters(true)
next({ path: landingPath(), replace: true })
return
}
const redirectPath = from.query.redirect || to.path
const redirect = decodeURIComponent(redirectPath as string)
const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect }
+19 -2
View File
@@ -13,6 +13,8 @@ export interface PermissionState {
addRouters: AppRouteRecordRaw[]
isAddRouters: boolean
menuTabRouters: AppRouteRecordRaw[]
/** 当前这套菜单属于哪个账号:账号变了必须重建,不能沿用上一个账号的路由 */
routersOwner: string
}
export const usePermissionStore = defineStore('permission', {
@@ -20,7 +22,8 @@ export const usePermissionStore = defineStore('permission', {
routers: [],
addRouters: [],
isAddRouters: false,
menuTabRouters: []
menuTabRouters: [],
routersOwner: ''
}),
getters: {
getRouters(): AppRouteRecordRaw[] {
@@ -34,6 +37,9 @@ export const usePermissionStore = defineStore('permission', {
},
getMenuTabRouters(): AppRouteRecordRaw[] {
return this.menuTabRouters
},
getRoutersOwner(): string {
return this.routersOwner
}
},
actions: {
@@ -75,10 +81,21 @@ export const usePermissionStore = defineStore('permission', {
},
setMenuTabRouters(routers: AppRouteRecordRaw[]): void {
this.menuTabRouters = routers
},
setRoutersOwner(owner: string): void {
this.routersOwner = owner
},
/** 登出 / 切换账号时清空菜单与路由状态(由 userStore.reset 调用) */
clearRoutes(): void {
this.routers = []
this.addRouters = []
this.menuTabRouters = []
this.isAddRouters = false
this.routersOwner = ''
}
},
persist: {
paths: ['routers', 'addRouters', 'menuTabRouters']
paths: ['routers', 'addRouters', 'menuTabRouters', 'routersOwner']
}
})
+7 -1
View File
@@ -5,6 +5,7 @@ import { ElMessageBox } from 'element-plus'
import { useI18n } from '@/hooks/web/useI18n'
import { loginOutApi } from '@/api/login'
import { useTagsViewStore } from './tagsView'
import { usePermissionStore } from './permission'
import router from '@/router'
interface UserState {
@@ -78,10 +79,15 @@ export const useUserStore = defineStore('user', {
},
reset() {
const tagsViewStore = useTagsViewStore()
tagsViewStore.delAllViews()
const permissionStore = usePermissionStore()
// 顺序要紧:delAllVisitedViews() 会「保留 affix 标签」,而它判断的正是 userInfo 是否还在。
// 先清账号信息再清标签,否则写死路由表里 affix 的「工作台」会在切换账号后残留。
this.setToken('')
this.setUserInfo(undefined)
this.setRoleRouters([])
tagsViewStore.delAllViews()
// 菜单/路由一起清:否则换账号后 permissionStore 里还是上一个账号的路由表
permissionStore.clearRoutes()
router.replace('/login')
},
logout() {
+303
View File
@@ -0,0 +1,303 @@
/**
* 创作者中心(我的作品 / 作品编辑 / 收费配置 / 数据看板)。
* 只放这四个页面专属的类名;工具条、抽屉、看板卡片沿用 console.css。
*/
.creator-profile-main {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.creator-profile-name {
color: #302d44;
font-size: 16px;
font-weight: 650;
}
.creator-profile-meta {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
margin-top: 10px;
color: #858b95;
font-size: 13px;
}
.creator-profile-meta b {
color: #302d44;
font-weight: 600;
}
.creator-profile-alert,
.creator-edit-alert {
margin-top: 12px;
}
/* 作品单元格:封面固定尺寸,标题块 min-width:0 才能让省略号生效 */
.creator-drama-cell {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.creator-drama-cover {
display: grid;
place-content: center;
flex: 0 0 34px;
width: 34px;
height: 46px;
overflow: hidden;
border-radius: 4px;
background: #eef0f5;
color: #9a97a8;
font-size: 14px;
font-weight: 600;
}
.creator-drama-cover img,
.creator-cover-preview img,
.creator-episode-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.creator-drama-meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.creator-drama-title,
.creator-drama-code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.creator-drama-title {
color: #302d44;
font-size: 13px;
font-weight: 600;
}
.creator-drama-code {
color: #9a97a8;
font-size: 12px;
}
.creator-progress {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.creator-progress .console-muted {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 通知列表 */
.creator-notice-list {
min-height: 120px;
}
.creator-notice {
padding: 12px 0;
border-bottom: 1px solid #f0eef4;
}
.creator-notice:last-child {
border-bottom: none;
}
.creator-notice-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.creator-notice-title {
color: #302d44;
font-size: 13px;
font-weight: 600;
}
.creator-notice-body {
margin: 6px 0 0;
color: #727983;
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
/* 作品编辑页 */
.creator-edit-head {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.creator-edit-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
flex-wrap: wrap;
}
.creator-section-title {
margin: 0 0 12px;
color: #302d44;
font-size: 14px;
font-weight: 650;
}
.creator-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.creator-section-head .creator-section-title {
margin: 0;
}
.creator-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0 16px;
}
.creator-cover-row {
display: flex;
align-items: center;
gap: 10px;
}
.creator-cover-preview {
display: grid;
place-content: center;
width: 56px;
height: 76px;
overflow: hidden;
border: 1px solid #e6e8eb;
border-radius: 4px;
background: #fafbfc;
color: #9a97a8;
font-size: 12px;
}
.creator-episode-cover {
display: grid;
place-content: center;
width: 48px;
height: 30px;
overflow: hidden;
border-radius: 3px;
background: #eef0f5;
color: #9a97a8;
font-size: 12px;
}
.creator-upload-hint {
margin: 10px 0 0;
color: #727983;
font-size: 13px;
}
/* 超管代看:顶部「当前创作者」下拉 */
.creator-impersonate-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 10px 14px;
margin-bottom: 14px;
border: 1px solid #f0e6cf;
border-radius: 6px;
background: #fdf9ef;
}
.creator-impersonate-select {
width: 280px;
}
/* 未绑定 / 未选目标时的引导页 */
.creator-guide {
max-width: 720px;
}
.creator-guide-title {
margin: 0 0 10px;
color: #302d44;
font-size: 15px;
font-weight: 650;
}
.creator-guide-text {
margin: 0 0 10px;
color: #727983;
font-size: 13px;
line-height: 1.7;
}
.creator-guide-list {
margin: 0 0 10px;
padding-left: 18px;
color: #727983;
font-size: 13px;
line-height: 1.8;
}
html.dark .creator-impersonate-bar {
border-color: var(--el-border-color-light);
background: var(--el-fill-color-light);
}
html.dark .creator-guide-title {
color: var(--el-text-color-primary);
}
/* 开通账号后展示的一次性凭据:等宽、可选中复制 */
.creator-account-value {
font-family: Menlo, Consolas, monospace;
font-size: 14px;
font-weight: 600;
letter-spacing: 0;
user-select: all;
}
.creator-account-alert {
margin-bottom: 14px;
}
html.dark .creator-profile-name,
html.dark .creator-profile-meta b,
html.dark .creator-drama-title,
html.dark .creator-notice-title,
html.dark .creator-section-title {
color: var(--el-text-color-primary);
}
html.dark .creator-notice {
border-bottom-color: var(--el-border-color-light);
}
html.dark .creator-cover-preview,
html.dark .creator-drama-cover,
html.dark .creator-episode-cover {
border-color: var(--el-border-color-light);
background: var(--el-fill-color-light);
}
+1
View File
@@ -6,5 +6,6 @@
@import './operation-log.css';
@import './app-user-management.css';
@import './console.css';
@import './creator-center.css';
@import './layout.css';
@import './dark.css';
+29
View File
@@ -0,0 +1,29 @@
import { ref } from 'vue'
/**
* 超管「代看创作者」的当前目标。
*
* 目标走独立请求头 `X-Creator-Id`(不做查询参数),并且**只有带 `creator-center:impersonate`
* 权限的账号带上它才生效** —— 创作者自己带这个头没有任何作用。
* 这里只负责"记住用户选了谁",权限判断在后端。
*/
const STORAGE_KEY = 'creator-center:impersonate-id'
function readStored(): number | null {
const raw = localStorage.getItem(STORAGE_KEY)
const value = raw ? Number(raw) : NaN
return Number.isFinite(value) && value > 0 ? value : null
}
export const impersonateCreatorId = ref<number | null>(readStored())
export function setImpersonateCreatorId(id: number | null) {
impersonateCreatorId.value = id
if (id) localStorage.setItem(STORAGE_KEY, String(id))
else localStorage.removeItem(STORAGE_KEY)
}
/** 创作者中心相关请求统一带上目标头(没选目标就不带) */
export function creatorCenterHeaders(): Record<string, string> {
return impersonateCreatorId.value ? { 'X-Creator-Id': String(impersonateCreatorId.value) } : {}
}
+13
View File
@@ -0,0 +1,13 @@
import { usePermissionStoreWithOut } from '@/store/modules/permission'
/**
* 登录 / 访问根路径时的落地页:取"这个账号实际能进的第一个菜单"。
*
* 原来写死 `/dashboard/index`,创作者没有运营后台权限,登录后会直接掉进 404;
* 现在按账号自己的路由表决定 —— 创作者落创作者中心,超管第一条仍是工作台(行为不变)。
*/
export function landingPath(): string {
const permissionStore = usePermissionStoreWithOut()
const first = permissionStore.addRouters?.[0]?.path
return (first as string) || '/dashboard/index'
}
+51 -1
View File
@@ -1,4 +1,9 @@
import { completeUploadApi, initUploadApi, uploadChunkApi } from '@/api/admin/media'
import {
completeUploadApi,
getUploadProgressApi,
initUploadApi,
uploadChunkApi
} from '@/api/admin/media'
import type { MediaAsset } from '@/api/admin/types'
export interface UploadFileOptions {
@@ -9,6 +14,8 @@ export interface UploadFileOptions {
/** 视频时长(秒),由调用方探测后传入 */
durationSec?: number
onProgress?: (percent: number) => void
/** init 建好会话就回调一次:调用方可以把它记下来,失败/刷新后走 resumeUpload 续传 */
onSession?: (uploadId: string) => void
}
/**
@@ -23,6 +30,7 @@ export async function uploadFile(file: File, options: UploadFileOptions = {}): P
bizType
})
const info = initRes.data
options.onSession?.(info.uploadId)
const total = Math.max(info.totalChunks, 1)
const pending: number[] = Array.from({ length: total }, (_, index) => index)
let finished = 0
@@ -45,6 +53,48 @@ export async function uploadFile(file: File, options: UploadFileOptions = {}): P
return doneRes.data
}
/**
* 断点续传:先问服务端传到第几片,从断点接着传,再合并。
*
* 服务端只记账"已收到多少片",不回传具体是哪几片,所以这里按"已收片数之后的分片全部重传"处理 ——
* 重传同一片是幂等覆盖,不会产生脏数据;片数落后于实际时最多多传几片。
*/
export async function resumeUpload(
file: File,
uploadId: string,
options: UploadFileOptions = {}
): Promise<MediaAsset> {
const { concurrency = 3, onProgress } = options
const progress = (await getUploadProgressApi(uploadId)).data
const total = Math.max(progress.totalChunks, 1)
const received = Math.min(progress.receivedChunks || 0, total)
const pending: number[] = []
for (let index = received; index < total; index += 1) {
pending.push(index)
}
let finished = received
onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
const worker = async () => {
for (;;) {
const index = pending.shift()
if (index === undefined) return
const start = index * progress.chunkSize
const chunk = file.slice(start, Math.min(start + progress.chunkSize, file.size))
await uploadChunkApi(uploadId, index, chunk, file.name)
finished += 1
onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, Math.max(pending.length, 1)) }, worker)
)
const doneRes = await completeUploadApi(uploadId, options.durationSec)
onProgress?.(100)
return doneRes.data
}
/** 读取本地视频时长,取不到时返回 0。 */
export function probeVideoDuration(file: File): Promise<number> {
return new Promise((resolve) => {
+5 -2
View File
@@ -7,6 +7,7 @@ import { useForm } from '@/hooks/web/useForm'
import { loginApi, getTestRoleApi, getAdminRoleApi } from '@/api/login'
import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission'
import { landingPath } from '@/utils/landing'
import { useRouter } from 'vue-router'
import type { RouteLocationNormalizedLoaded, RouteRecordRaw } from 'vue-router'
import { UserType } from '@/api/login/types'
@@ -200,7 +201,7 @@ const signIn = async () => {
addRoute(route as RouteRecordRaw) // 动态添加可访问路由表
})
permissionStore.setIsAddRouters(true)
push({ path: redirect.value || permissionStore.addRouters[0].path })
push({ path: redirect.value || landingPath() })
}
}
} finally {
@@ -230,8 +231,10 @@ const getRole = async () => {
permissionStore.getAddRouters.forEach((route) => {
addRoute(route as RouteRecordRaw) // 动态添加可访问路由表
})
// 记住这套菜单属于哪个账号:换账号时守卫据此重建
permissionStore.setRoutersOwner(params.roleName)
permissionStore.setIsAddRouters(true)
push({ path: redirect.value || permissionStore.addRouters[0].path })
push({ path: redirect.value || landingPath() })
}
}
+2 -2
View File
@@ -660,8 +660,8 @@ function onTabChange(name) {
</el-form-item>
<el-form-item label="权益类型">
<el-radio-group v-model="grantForm.type">
<el-radio value="EPISODE">单集</el-radio>
<el-radio value="DRAMA">整剧</el-radio>
<el-radio label="EPISODE">单集</el-radio>
<el-radio label="DRAMA">整剧</el-radio>
</el-radio-group>
</el-form-item>
<el-alert
+3 -2
View File
@@ -33,8 +33,9 @@ onMounted(async () => {
loading.value = true
try {
const res = await pageDramasApi({ pageIndex: 1, pageSize: 200 })
dramas.value = res.data?.list || []
total.value = res.data?.total || 0
// 接口失败时 axios 拦截器会把业务错误吞成 undefined,这里不能让页面跟着崩
dramas.value = res?.data?.list || []
total.value = res?.data?.total || 0
} finally {
loading.value = false
}
@@ -0,0 +1,80 @@
<script setup>
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { changePasswordApi } from '@/api/login'
import { useUserStore } from '@/store/modules/user'
/**
* 首登强制改密:后台开号 / 重置密码时置的标记,登录(或刷新)后必须先把密码改掉。
* 对话框不可关闭,只能改密或退出登录 —— 初始密码是运营转达的,越早换掉越好。
*/
const userStore = useUserStore()
const visible = computed(() => Boolean(userStore.getUserInfo?.mustChangePassword))
const form = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
const saving = ref(false)
async function submit() {
if (!form.oldPassword) return ElMessage.warning('请输入当前密码(运营给你的初始密码)')
if (!form.newPassword || form.newPassword.length < 8) return ElMessage.warning('新密码至少 8 位')
if (form.newPassword !== form.confirmPassword) return ElMessage.warning('两次输入的新密码不一致')
saving.value = true
try {
await changePasswordApi({
oldPassword: form.oldPassword,
newPassword: form.newPassword
})
ElMessage.success('密码已更新')
userStore.setUserInfo({ ...userStore.getUserInfo, mustChangePassword: false })
form.oldPassword = ''
form.newPassword = ''
form.confirmPassword = ''
} finally {
saving.value = false
}
}
async function logout() {
await userStore.logout()
window.location.reload()
}
</script>
<template>
<el-dialog
:model-value="visible"
title="首次登录请修改密码"
width="460px"
:show-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<el-alert
class="must-change-alert"
type="warning"
:closable="false"
show-icon
title="当前使用的是运营转达的初始密码,请改成只有你知道的密码后再开始使用"
/>
<el-form label-position="top">
<el-form-item label="当前密码(初始密码)" required>
<el-input v-model="form.oldPassword" type="password" show-password autocomplete="off" />
</el-form-item>
<el-form-item label="新密码(至少 8 位)" required>
<el-input v-model="form.newPassword" type="password" show-password autocomplete="off" />
</el-form-item>
<el-form-item label="确认新密码" required>
<el-input v-model="form.confirmPassword" type="password" show-password autocomplete="off" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="logout">退出登录</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存新密码</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.must-change-alert {
margin-bottom: 14px;
}
</style>
@@ -0,0 +1,49 @@
<script setup>
import PageHeading from '@/views/components/PageHeading.vue'
import CreatorCenterPage from './components/CreatorCenterPage.vue'
import { DASHBOARD_GROUPS, useCreatorDashboard } from './composables/useCreatorDashboard'
const { loading, board, lastUpdated, load, display, groupValue } = useCreatorDashboard()
</script>
<template>
<CreatorCenterPage>
<PageHeading
eyebrow="创作者中心"
title="数据看板"
description="我的作品在播放、互动与解锁收入上的表现。"
>
<el-button :loading="loading" @click="load">刷新</el-button>
</PageHeading>
<section v-loading="loading" class="panel">
<p class="console-hint">数据时间:{{ lastUpdated }}(统计范围仅限本人作品)</p>
<div v-for="group in DASHBOARD_GROUPS" :key="group.key" class="board-group">
<h3 class="board-title">{{ group.title }}</h3>
<div class="board-grid">
<div v-for="metric in group.metrics" :key="metric.key" class="board-card">
<span class="board-label">{{ metric.label }}</span>
<span
class="board-value"
:class="{ 'board-value-empty': groupValue(group.key, metric.key) === null }"
>
{{ display(groupValue(group.key, metric.key)) }}
</span>
</div>
</div>
</div>
<el-alert
v-if="board?.notes?.length"
class="board-notes"
type="info"
:closable="false"
show-icon
title="口径与缺失数据源说明"
>
<ul>
<li v-for="note in board.notes" :key="note">{{ note }}</li>
</ul>
</el-alert>
</section>
</CreatorCenterPage>
</template>
+362
View File
@@ -0,0 +1,362 @@
<script setup>
import { ArrowLeft, Upload } from '@element-plus/icons-vue'
import PageHeading from '@/views/components/PageHeading.vue'
import CreatorCenterPage from './components/CreatorCenterPage.vue'
import { useRbac } from '@/stores/rbac'
import { useDramaEdit } from './composables/useDramaEdit'
import { useCreatorAccess } from './composables/useCreatorAccess'
import { dramaStatusMeta } from './composables/useCreatorWorkspace'
const { can } = useRbac()
const canEdit = can('creator-center:drama:edit')
const canUpload = can('creator-center:upload')
// 代看只读:提交/撤回这些写入口直接不渲染
const { impersonating } = useCreatorAccess()
const {
loading,
drama,
episodes,
submissions,
editable,
readonlyReason,
infoForm,
savingInfo,
episodeVisible,
episodeSaving,
episodeEditingId,
episodeForm,
uploadingId,
uploadPercent,
resumeHint,
submitOptions,
submissionStatusMeta,
submissionTypeText,
dateCell,
saveInfo,
openEpisode,
saveEpisode,
removeEpisode,
moveEpisode,
uploadVideo,
uploadCover,
hasResumeSession,
submit,
withdraw
} = useDramaEdit()
function back() {
window.history.back()
}
/** el-upload 的 http-request 回调:拿到原始 File 后交给统一的(可续传)上传逻辑 */
function requestVideo(row, options) {
return uploadVideo(row, options.file)
}
function requestEpisodeCover(row, options) {
return uploadCover(row, options.file)
}
function requestDramaCover(options) {
return uploadCover(null, options.file)
}
</script>
<template>
<CreatorCenterPage>
<PageHeading
eyebrow="创作者中心"
title="作品编辑"
:description="drama ? `${drama.title}(${drama.code})` : ''"
>
<el-button :icon="ArrowLeft" @click="back">返回</el-button>
</PageHeading>
<section v-loading="loading" class="panel">
<div class="creator-edit-head">
<el-tag :type="dramaStatusMeta(drama?.status).tag" effect="light">
{{ dramaStatusMeta(drama?.status).label }}
</el-tag>
<span class="console-muted"
>分集 {{ drama?.episodes ?? 0 }} 集 · 播放 {{ drama?.views ?? 0 }}</span
>
<div class="creator-edit-actions">
<el-button
v-for="type in canEdit && !impersonating ? submitOptions(drama || {}) : []"
:key="type"
type="primary"
plain
size="small"
@click="submit(type)"
>{{ submissionTypeText(type) }}</el-button
>
</div>
</div>
<el-alert
v-if="readonlyReason"
class="creator-edit-alert"
type="warning"
:closable="false"
show-icon
:title="readonlyReason"
/>
</section>
<section class="panel">
<h3 class="creator-section-title">作品信息</h3>
<el-form class="creator-form" label-position="top" :disabled="!editable">
<div class="creator-form-grid">
<el-form-item label="作品名称" required>
<el-input v-model="infoForm.title" maxlength="64" />
</el-form-item>
<el-form-item label="副标题">
<el-input v-model="infoForm.subtitle" maxlength="64" />
</el-form-item>
<el-form-item label="题材">
<el-input v-model="infoForm.genre" maxlength="32" placeholder="例如:都市情感" />
</el-form-item>
<el-form-item label="语言">
<el-input v-model="infoForm.language" maxlength="16" placeholder="zh-CN / id-ID" />
</el-form-item>
</div>
<el-form-item label="简介">
<el-input
v-model="infoForm.description"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
/>
</el-form-item>
<div class="creator-form-grid">
<el-form-item label="作品封面">
<div class="creator-cover-row">
<div class="creator-cover-preview">
<img v-if="infoForm.cover" :src="infoForm.cover" alt="" />
<span v-else>无</span>
</div>
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="image/*"
:http-request="requestDramaCover"
:disabled="uploadingId !== null"
>
<el-button size="small" :icon="Upload">上传封面</el-button>
</el-upload>
</div>
</el-form-item>
<el-form-item label="预告片地址">
<el-input
v-model="infoForm.trailerUrl"
maxlength="512"
placeholder="整剧一支预告片,可留空"
/>
</el-form-item>
</div>
<el-button
v-if="canEdit"
type="primary"
:loading="savingInfo"
:disabled="!editable"
@click="saveInfo"
>保存作品信息</el-button
>
</el-form>
</section>
<section class="panel">
<div class="creator-section-head">
<h3 class="creator-section-title">分集管理</h3>
<el-button
v-if="canEdit"
type="primary"
size="small"
:disabled="!editable"
@click="openEpisode(null)"
>新增分集</el-button
>
</div>
<el-alert
v-if="resumeHint"
class="creator-edit-alert"
type="info"
:closable="false"
show-icon
:title="resumeHint"
/>
<el-table :data="episodes" row-key="id" border class="drama-el-table" empty-text="还没有分集">
<el-table-column prop="number" label="集号" width="70" />
<el-table-column label="封面" width="88">
<template #default="{ row }">
<div class="creator-episode-cover">
<img v-if="row.cover" :src="row.cover" alt="" />
<span v-else>无</span>
</div>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
<el-table-column prop="duration" label="时长" width="90" />
<el-table-column label="片源" min-width="120">
<template #default="{ row }">
<span class="console-muted">{{ row.videoUrl ? '已上传' : '未上传' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag
size="small"
effect="light"
:type="row.status === 'published' ? 'success' : 'info'"
>
{{ row.statusText }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row, $index }">
<div class="table-actions">
<el-button
size="small"
:disabled="!editable || $index === 0"
@click="moveEpisode(row, -1)"
>上移</el-button
>
<el-button
size="small"
:disabled="!editable || $index === episodes.length - 1"
@click="moveEpisode(row, 1)"
>下移</el-button
>
<el-button size="small" :disabled="!editable" @click="openEpisode(row)"
>编辑</el-button
>
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="video/*"
:http-request="(options) => requestVideo(row, options)"
:disabled="!editable || uploadingId !== null"
>
<el-button size="small">{{ hasResumeSession ? '继续上传' : '上传视频' }}</el-button>
</el-upload>
<el-button
size="small"
class="danger-action"
:disabled="!editable"
@click="removeEpisode(row)"
>删除</el-button
>
</div>
</template>
</el-table-column>
</el-table>
<p v-if="uploadingId !== null" class="creator-upload-hint"
>上传中 {{ uploadPercent }}%(分片上传,可断点续传)</p
>
</section>
<section class="panel">
<h3 class="creator-section-title">提交记录</h3>
<el-table
:data="submissions"
row-key="id"
border
class="drama-el-table"
empty-text="还没有提交记录"
>
<el-table-column prop="version" label="版本" width="70" />
<el-table-column label="类型" width="100">
<template #default="{ row }">{{ submissionTypeText(row.type) }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag size="small" :type="submissionStatusMeta(row.status).tag" effect="light">
{{ submissionStatusMeta(row.status).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="submitAt"
label="提交时间"
min-width="165"
:formatter="dateCell"
show-overflow-tooltip
/>
<el-table-column
prop="reviewAt"
label="审核时间"
min-width="165"
:formatter="dateCell"
show-overflow-tooltip
/>
<el-table-column prop="reviewNote" label="审核意见" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button
v-if="canEdit && ['SUBMITTED', 'REVIEWING'].includes(row.status)"
size="small"
class="danger-action"
@click="withdraw(row.id)"
>撤回</el-button
>
<span v-else class="console-muted">—</span>
</div>
</template>
</el-table-column>
</el-table>
</section>
<el-dialog
v-model="episodeVisible"
:title="episodeEditingId ? '编辑分集' : '新增分集'"
width="480px"
>
<el-form label-position="top">
<el-form-item label="分集标题">
<el-input v-model="episodeForm.title" maxlength="64" />
</el-form-item>
<el-form-item label="时长(秒)">
<el-input-number
v-model="episodeForm.durationSec"
:min="0"
:max="36000"
class="console-number"
/>
<span class="console-muted">上传视频后会自动带出时长</span>
</el-form-item>
<el-form-item v-if="episodeEditingId" label="封面">
<div class="creator-cover-row">
<div class="creator-cover-preview">
<img v-if="episodeForm.cover" :src="episodeForm.cover" alt="" />
<span v-else>无</span>
</div>
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="image/*"
:http-request="(options) => requestEpisodeCover({ id: episodeEditingId }, options)"
>
<el-button size="small" :icon="Upload">上传封面</el-button>
</el-upload>
</div>
</el-form-item>
<el-form-item v-if="episodeEditingId" label="集号">
<el-input-number v-model="episodeForm.epNo" :min="1" :max="9999" class="console-number" />
</el-form-item>
<el-alert
type="info"
:closable="false"
show-icon
title="免费 / 收费与试看秒数在「收费配置」页设置;新分集默认免费"
/>
</el-form>
<template #footer>
<el-button @click="episodeVisible = false">取消</el-button>
<el-button type="primary" :loading="episodeSaving" @click="saveEpisode">保存</el-button>
</template>
</el-dialog>
</CreatorCenterPage>
</template>
@@ -0,0 +1,144 @@
<script setup>
import PageHeading from '@/views/components/PageHeading.vue'
import CreatorCenterPage from './components/CreatorCenterPage.vue'
import { useRbac } from '@/stores/rbac'
import { useCreatorPrice } from './composables/useCreatorPrice'
import { dramaStatusMeta } from './composables/useCreatorWorkspace'
const { can } = useRbac()
const canUpdate = can('creator-center:price:update')
const {
dramas,
dramaId,
config,
loading,
saving,
current,
priceEditable,
readonlyReason,
changeDrama,
save
} = useCreatorPrice()
</script>
<template>
<CreatorCenterPage>
<PageHeading
eyebrow="创作者中心"
title="收费配置"
description="设置免费集数、解锁金币与试看秒数;改完立即影响播放与下载的付费判定。"
>
<el-button
type="primary"
:loading="saving"
:disabled="!canUpdate || !priceEditable"
@click="save"
>保存配置</el-button
>
</PageHeading>
<section class="panel">
<div class="console-toolbar">
<el-select
v-model="dramaId"
class="console-drama"
placeholder="选择作品"
:loading="dramas.loading"
@change="changeDrama"
>
<el-option
v-for="item in dramas.rows"
:key="item.id"
:label="item.title"
:value="item.id"
/>
</el-select>
<el-tag v-if="current" :type="dramaStatusMeta(current.status).tag" effect="light">
{{ dramaStatusMeta(current.status).label }}
</el-tag>
<span class="console-count">{{ config.episodes.length }} 集</span>
</div>
<el-alert
v-if="readonlyReason"
class="creator-edit-alert"
type="warning"
:closable="false"
show-icon
:title="readonlyReason"
/>
<el-empty v-if="!dramas.rows.length" description="还没有作品,先去「我的作品」新建一部" />
</section>
<section v-if="dramaId" v-loading="loading" class="panel">
<h3 class="creator-section-title">整剧默认</h3>
<el-form class="creator-form" label-position="top" :disabled="!canUpdate || !priceEditable">
<div class="creator-form-grid">
<el-form-item label="免费集数">
<el-input-number
v-model="config.freeEpisodes"
:min="0"
:max="999"
class="console-number"
/>
<span class="console-muted">前 N 集免费,其余按解锁金币收费</span>
</el-form-item>
<el-form-item label="整剧解锁金币">
<el-input-number
v-model="config.unlockCoin"
:min="0"
:max="9999"
class="console-number"
/>
<span class="console-muted">逐集未单独指定时生效</span>
</el-form-item>
</div>
</el-form>
<h3 class="creator-section-title">分集明细</h3>
<el-table
:data="config.episodes"
row-key="episodeId"
border
class="drama-el-table"
empty-text="还没有分集"
>
<el-table-column prop="number" label="集号" width="70" />
<el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip />
<el-table-column label="免费" width="90">
<template #default="{ row }">
<el-switch v-model="row.free" :disabled="!canUpdate || !priceEditable" />
</template>
</el-table-column>
<el-table-column label="解锁金币" width="160">
<template #default="{ row }">
<el-input-number
v-model="row.unlockCoin"
:min="0"
:max="9999"
size="small"
:disabled="!canUpdate || !priceEditable || row.free"
/>
</template>
</el-table-column>
<el-table-column label="试看秒数" width="170">
<template #default="{ row }">
<el-input-number
v-model="row.previewSeconds"
:min="0"
:max="600"
size="small"
:disabled="!canUpdate || !priceEditable || row.free"
/>
</template>
</el-table-column>
<el-table-column label="说明" min-width="200">
<template #default="{ row }">
<span class="console-muted">{{ row.free ? '免费观看' : '0 秒 = 不限制试看' }}</span>
</template>
</el-table-column>
</el-table>
</section>
</CreatorCenterPage>
</template>
+310
View File
@@ -0,0 +1,310 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import PageHeading from '@/views/components/PageHeading.vue'
import CreatorCenterPage from './components/CreatorCenterPage.vue'
import { useCreatorAccess } from './composables/useCreatorAccess'
import { useRbac } from '@/stores/rbac'
import { dateCell } from '@/utils/format'
import {
DRAMA_STATUS,
dramaStatusMeta,
submissionStatusMeta,
submissionTypeText,
submitOptions,
useCreatorWorkspace
} from './composables/useCreatorWorkspace'
const router = useRouter()
const { can } = useRbac()
const canEdit = can('creator-center:drama:edit')
// 代看是只读:写操作按钮直接不渲染(后端也会拒,界面不该给出做不到的入口)
const { impersonating } = useCreatorAccess()
const {
profile,
profileLoading,
dramas,
notices,
createVisible,
createSaving,
createForm,
queryDramas,
loadDramas,
loadNotices,
submit,
withdraw,
openCreate,
confirmCreate
} = useCreatorWorkspace()
const activeTab = ref('dramas')
function onTabChange(name) {
if (name === 'notices' && !notices.loaded) loadNotices()
}
function openEdit(row) {
router.push(`/creator-center/dramas/${row.id}`)
}
</script>
<template>
<CreatorCenterPage>
<PageHeading
eyebrow="创作者中心"
title="我的作品"
description="查看作品与审核进度,提交上线 / 下线申请。"
>
<el-button v-if="canEdit && !impersonating" type="primary" @click="openCreate"
>新建作品</el-button
>
</PageHeading>
<section v-loading="profileLoading" class="panel creator-profile">
<div class="creator-profile-main">
<span class="creator-profile-name">{{ profile?.name || '—' }}</span>
<el-tag
size="small"
effect="light"
:type="profile?.status === 'approved' ? 'success' : 'warning'"
>
{{ profile?.statusText || '—' }}
</el-tag>
<span class="console-muted">{{ profile?.code || '' }}</span>
</div>
<div class="creator-profile-meta">
<span
>作品数 <b>{{ profile?.dramaTotal ?? 0 }}</b></span
>
<span
>联系人 <b>{{ profile?.contactName || '—' }}</b></span
>
<span
>联系邮箱 <b>{{ profile?.contactEmail || '—' }}</b></span
>
</div>
<el-alert
v-if="profile && profile.status === 'rejected'"
class="creator-profile-alert"
type="error"
:closable="false"
show-icon
:title="`入驻审核未通过:${profile.auditNote || '未填写原因'}`"
/>
<el-alert
v-else-if="profile && profile.status !== 'approved'"
class="creator-profile-alert"
type="warning"
:closable="false"
show-icon
title="入驻审核通过后才能创建作品与提交审核"
/>
</section>
<section class="panel">
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane label="我的作品" name="dramas">
<div class="console-toolbar">
<el-input
v-model="dramas.keyword"
class="console-keyword"
:prefix-icon="Search"
clearable
placeholder="搜索作品名称"
@keyup.enter="queryDramas"
@clear="queryDramas"
/>
<el-select
v-model="dramas.status"
class="console-select"
clearable
placeholder="全部状态"
@change="queryDramas"
>
<el-option
v-for="item in DRAMA_STATUS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button type="primary" @click="queryDramas">查询</el-button>
<span class="console-count">共 {{ dramas.total }} 部作品</span>
</div>
<el-table
v-loading="dramas.loading"
:data="dramas.rows"
row-key="id"
border
class="drama-el-table"
empty-text="还没有作品,点右上角「新建作品」开始"
>
<el-table-column label="作品" min-width="220">
<template #default="{ row }">
<div class="creator-drama-cell">
<div class="creator-drama-cover">
<img v-if="row.cover" :src="row.cover" alt="" />
<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>
</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">
<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>
<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="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
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)
"
size="small"
class="danger-action"
@click="withdraw(row)"
>撤回</el-button
>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="dramas.page"
v-model:page-size="dramas.size"
:page-sizes="[10, 20, 50]"
:total="dramas.total"
layout="total, prev, pager, next, sizes"
background
@current-change="loadDramas"
@size-change="queryDramas"
/>
</div>
</el-tab-pane>
<el-tab-pane label="通知" name="notices">
<div v-loading="notices.loading" class="creator-notice-list">
<el-empty v-if="!notices.rows.length" description="还没有审核结果通知" />
<div
v-for="item in notices.rows"
:key="`${item.bizType}-${item.id}`"
class="creator-notice"
>
<div class="creator-notice-head">
<span class="creator-notice-title">{{ item.title }}</span>
<span class="console-muted">{{
item.time ? String(item.time).replace('T', ' ').slice(0, 19) : ''
}}</span>
</div>
<p class="creator-notice-body">{{ item.content || '—' }}</p>
</div>
</div>
</el-tab-pane>
</el-tabs>
</section>
<el-dialog v-model="createVisible" title="新建作品" width="480px">
<el-form label-position="top">
<el-form-item label="作品名称" required>
<el-input
v-model="createForm.title"
maxlength="64"
placeholder="例如:闪婚后,傅先生每天都在撒糖"
/>
</el-form-item>
<el-form-item label="题材">
<el-input v-model="createForm.genre" maxlength="32" placeholder="例如:都市情感" />
</el-form-item>
<el-form-item label="简介">
<el-input
v-model="createForm.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-alert
type="info"
:closable="false"
show-icon
title="先建草稿,再去作品编辑页完善信息、分集与视频,然后提交审核"
/>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createSaving" @click="confirmCreate"
>创建草稿</el-button
>
</template>
</el-dialog>
</CreatorCenterPage>
</template>
@@ -0,0 +1,32 @@
<script setup>
defineProps({
status: { type: String, default: 'unbound' }
})
</script>
<template>
<section class="panel creator-guide">
<template v-if="status === 'need-target'">
<h3 class="creator-guide-title">请选择要代看的创作者</h3>
<p class="creator-guide-text">
你这个账号是后台账号,未绑定创作者主体,但拥有「代看创作者」权限。
在页面顶部的「当前创作者」下拉里选一位,即可只读查看他的作品、分集、收费与数据看板。
</p>
<p class="creator-guide-text">代看是只读的:编辑、上传、提交审核、改价等写操作都会被拒绝。</p>
</template>
<template v-else>
<h3 class="creator-guide-title">当前账号还没有创作者身份</h3>
<p class="creator-guide-text">
创作者中心只能由「已绑定创作者主体的后台账号」进入:先完成创作者入驻审核,再由运营把后台账号绑定到这个创作者主体上。
</p>
<ul class="creator-guide-list">
<li>入驻与审核:运营在后台「创作者管理 → 创作者入驻」里审核主体与资质。</li>
<li>账号绑定:审核通过后由运营(或 DBA)在 `creator.admin_user_id` 上绑定你的后台账号。</li>
<li>绑定后重新登录,就能看到自己的作品、分集、收费配置与数据看板。</li>
</ul>
<p class="creator-guide-text console-muted">
如果你需要只读查看其他创作者的数据,请联系管理员开通「代看创作者」权限。
</p>
</template>
</section>
</template>
@@ -0,0 +1,22 @@
<script setup>
import { computed, onMounted } from 'vue'
import CreatorAccessGuide from './CreatorAccessGuide.vue'
import ImpersonationBar from './ImpersonationBar.vue'
import { useCreatorAccess } from '../composables/useCreatorAccess'
/**
* 创作者中心四个页面共用的外壳:
* 顶部渲染「当前创作者」下拉(仅超管/有代看权限的账号可见),
* 身份不满足时渲染引导页,满足时才渲染页面内容。
*/
const { status, ensureAccess } = useCreatorAccess()
const ok = computed(() => status.value === 'granted')
onMounted(ensureAccess)
</script>
<template>
<ImpersonationBar />
<CreatorAccessGuide v-if="status === 'unbound' || status === 'need-target'" :status="status" />
<slot v-else-if="ok" />
</template>
@@ -0,0 +1,33 @@
<script setup>
import { onMounted } from 'vue'
import { useCreatorAccess } from '../composables/useCreatorAccess'
const { canImpersonate, impersonating, targets, loadTargets, changeTarget, currentCreatorId } =
useCreatorAccess()
onMounted(() => {
if (canImpersonate) loadTargets()
})
</script>
<template>
<div v-if="canImpersonate" class="creator-impersonate-bar">
<span class="console-muted">当前创作者</span>
<el-select
:model-value="currentCreatorId"
class="creator-impersonate-select"
clearable
placeholder="请选择要代看的创作者"
@change="changeTarget"
>
<el-option
v-for="item in targets"
:key="item.id"
:label="`${item.name}(${item.code}·${item.statusText})`"
:value="item.id"
/>
</el-select>
<el-tag v-if="impersonating" type="warning" effect="light">只读代看</el-tag>
<span v-if="impersonating" class="console-muted">代看状态下所有修改操作都会被拒绝</span>
</div>
</template>
@@ -0,0 +1,88 @@
import { computed, ref } from 'vue'
import { listImpersonationTargetsApi, probeCreatorAccessApi } from '@/api/admin/creatorCenter'
import { useRbac } from '@/stores/rbac'
import { impersonateCreatorId, setImpersonateCreatorId } from '@/utils/creatorImpersonation'
/** 后端可识别的身份类错误码,与 ErrorCode 保持一致 */
const CODE_NOT_BOUND = 200004
const CODE_TARGET_REQUIRED = 200005
/** 一次会话里只探测一次:多个页面切换不重复打接口 */
let probePromise = null
/**
* 创作者中心的"身份三态":
* - granted:已绑定创作者,或超管指定了代看目标 → 正常渲染页面
* - unbound:未绑定且没有代看权限 → 渲染引导页
* - need-target:有代看权限但还没选目标 → 渲染引导页(提示去顶部选)
*/
export function useCreatorAccess() {
const { can } = useRbac()
const canImpersonate = can('creator-center:impersonate')
const status = ref('unknown')
const impersonating = computed(() => canImpersonate && !!impersonateCreatorId.value)
const targets = ref([])
const targetsLoaded = ref(false)
async function ensureAccess() {
if (status.value === 'granted') return true
if (probePromise) return probePromise
probePromise = probeCreatorAccessApi()
.then((res) => {
if (res?.code === 0) {
status.value = 'granted'
return true
}
// 目标失效(创作者被删 / 没有代看权限)时清掉本地选择:
// 否则顶部下拉会一直显示一个已经进不去的 id,和页面提示互相打架
if (impersonateCreatorId.value) {
setImpersonateCreatorId(null)
targets.value = []
targetsLoaded.value = false
}
if (res?.code === CODE_TARGET_REQUIRED) {
status.value = 'need-target'
return false
}
if (res?.code === CODE_NOT_BOUND) {
status.value = 'unbound'
return false
}
status.value = canImpersonate ? 'need-target' : 'unbound'
return false
})
.catch(() => {
status.value = canImpersonate ? 'need-target' : 'unbound'
return false
})
.finally(() => {
probePromise = null
})
return probePromise
}
async function loadTargets() {
if (!canImpersonate || targetsLoaded.value) return
const res = await listImpersonationTargetsApi()
targets.value = res?.data || []
targetsLoaded.value = true
}
/** 切换代看目标:重新探测身份并刷新页面数据(数据加载都挂在页面挂载时,最省事也最不易漏) */
function changeTarget(id) {
setImpersonateCreatorId(id || null)
window.location.reload()
}
return {
canImpersonate,
status,
impersonating,
targets,
ensureAccess,
loadTargets,
changeTarget,
currentCreatorId: impersonateCreatorId
}
}
@@ -0,0 +1,92 @@
import { onMounted, ref } from 'vue'
import { getCreatorDashboardApi } from '@/api/admin/creatorCenter'
import { useCreatorAccess } from './useCreatorAccess'
/**
* 指标展示配置:key 与后端 /admin/creator-center/dashboard 的字段一一对应,
* 不在前端编字段;口径见后端 CreatorDashboardService 注释与 docs/api-phase1.md「2.10」。
*/
export const DASHBOARD_GROUPS = [
{
key: 'drama',
title: '我的作品',
metrics: [
{ key: 'total', label: '作品总数' },
{ key: 'draft', label: '草稿' },
{ key: 'reviewing', label: '审核中' },
{ key: 'approved', label: '待发布' },
{ key: 'published', label: '已上线' },
{ key: 'offline', label: '已下线' },
{ key: 'episodeTotal', label: '分集总数' },
{ key: 'episodeWithMedia', label: '有片源分集' }
]
},
{
key: 'playback',
title: '播放',
metrics: [
{ key: 'viewTotal', label: '累计观看' },
{ key: 'playTotal', label: '累计播放' },
{ key: 'watchedDramas', label: '看过我的剧(用户×剧)' },
{ key: 'finishedDramas', label: '其中标记完播' },
{ key: 'finishRate', label: '完播率' },
{ key: 'playEvents7d', label: '近 7 天播放流水' },
{ key: 'playEventsTotal', label: '播放流水总量' },
{ key: 'finishEvents', label: '完播事件' }
]
},
{
key: 'interaction',
title: '互动',
metrics: [
{ key: 'favoriteTotal', label: '收藏' },
{ key: 'commentTotal', label: '评论' },
{ key: 'commentPending', label: '待审评论' }
]
},
{
key: 'revenue',
title: '解锁收入',
metrics: [
{ key: 'unlockCount', label: '解锁笔数' },
{ key: 'coinIncome', label: '金币收入' },
{ key: 'coinIncome7d', label: '近 7 天金币收入' },
{ key: 'paidOrderAmount', label: '法币收入金额' }
]
}
]
export function useCreatorDashboard() {
const { ensureAccess } = useCreatorAccess()
const loading = ref(false)
const board = ref(null)
const lastUpdated = ref('')
async function load() {
loading.value = true
try {
board.value = (await getCreatorDashboardApi()).data || null
lastUpdated.value = new Date().toLocaleString()
} finally {
loading.value = false
}
}
/** 取不到的指标后端给 null,这里显示「暂无数据源」而不是 0 */
function display(value) {
if (value === null || value === undefined) return '暂无数据源'
if (typeof value === 'number' && !Number.isInteger(value)) return value.toFixed(3)
return value
}
function groupValue(groupKey, metricKey) {
return board.value?.[groupKey]?.[metricKey]
}
onMounted(async () => {
if (!(await ensureAccess())) return
await load()
})
return { loading, board, lastUpdated, load, display, groupValue }
}
@@ -0,0 +1,104 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getPricesApi, pageMyDramasApi, savePricesApi } from '@/api/admin/creatorCenter'
import { dramaStatusMeta } from './useCreatorWorkspace'
import { useCreatorAccess } from './useCreatorAccess'
export function useCreatorPrice() {
const { ensureAccess, impersonating } = useCreatorAccess()
const dramas = reactive({ rows: [], loading: false })
const dramaId = ref(null)
const config = reactive({ freeEpisodes: 0, unlockCoin: 0, episodes: [] })
const loading = ref(false)
const saving = ref(false)
const current = computed(() => dramas.rows.find((item) => item.id === dramaId.value) || null)
/**
* 价格只能改草稿 / 已下线(后端 requireEditable 同一口径):
* 已上线要改价得先申请下线,避免创作者无审核地改已上线内容的价格。
*/
const priceEditable = computed(() => {
if (impersonating.value) return false
const status = current.value?.status
return status === 'draft' || status === 'offline'
})
const readonlyReason = computed(() => {
if (!current.value) return ''
if (priceEditable.value) return ''
return `「${dramaStatusMeta(current.value.status).label}」状态不可改价:请先「申请下线」,改完再提交上线`
})
async function loadDramas() {
dramas.loading = true
try {
const res = await pageMyDramasApi({ pageIndex: 1, pageSize: 100 })
dramas.rows = res.data?.list || []
if (!dramaId.value && dramas.rows.length) {
dramaId.value = dramas.rows[0].id
}
} finally {
dramas.loading = false
}
}
async function loadPrices() {
if (!dramaId.value) return
loading.value = true
try {
const res = await getPricesApi(dramaId.value)
config.freeEpisodes = res.data?.freeEpisodes ?? 0
config.unlockCoin = res.data?.unlockCoin ?? 0
config.episodes = res.data?.episodes || []
} finally {
loading.value = false
}
}
async function changeDrama() {
await loadPrices()
}
async function save() {
if (!dramaId.value) return
saving.value = true
try {
const res = await savePricesApi(dramaId.value, {
freeEpisodes: config.freeEpisodes,
unlockCoin: config.unlockCoin,
episodes: config.episodes.map((item) => ({
episodeId: item.episodeId,
isFree: item.free ? 1 : 0,
unlockCoin: item.unlockCoin,
previewSeconds: item.previewSeconds
}))
})
config.freeEpisodes = res.data?.freeEpisodes ?? config.freeEpisodes
config.unlockCoin = res.data?.unlockCoin ?? config.unlockCoin
config.episodes = res.data?.episodes || config.episodes
ElMessage.success(`已保存,本次调整 ${res.data?.changedCount ?? 0} 集;改价立即影响付费判定`)
} finally {
saving.value = false
}
}
onMounted(async () => {
if (!(await ensureAccess())) return
await loadDramas()
await loadPrices()
})
return {
dramas,
dramaId,
config,
loading,
saving,
current,
priceEditable,
readonlyReason,
changeDrama,
loadPrices,
save
}
}
@@ -0,0 +1,219 @@
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createDramaApi,
getCreatorProfileApi,
listCreatorNoticesApi,
pageMyDramasApi,
submitDramaApi,
withdrawSubmissionApi
} from '@/api/admin/creatorCenter'
import { useCreatorAccess } from './useCreatorAccess'
/** 作品状态:与后端 ContentStatus 一一对应 */
export const DRAMA_STATUS = [
{ value: 'draft', label: '草稿', tag: 'info' },
{ value: 'reviewing', label: '审核中', tag: 'warning' },
{ value: 'approved', label: '待发布', tag: 'warning' },
{ value: 'published', label: '已上线', tag: 'success' },
{ value: 'offline', label: '已下线', tag: 'info' }
]
/** 提交状态:与后端 content_submission.status 一一对应 */
export const SUBMISSION_STATUS = [
{ value: 'DRAFT', label: '草稿', tag: 'info' },
{ value: 'SUBMITTED', label: '已提交', tag: 'warning' },
{ value: 'REVIEWING', label: '审核中', tag: 'warning' },
{ value: 'APPROVED', label: '已通过', tag: 'success' },
{ value: 'REJECTED', label: '已驳回', tag: 'danger' },
{ value: 'WITHDRAWN', label: '已撤回', tag: 'info' }
]
export const SUBMISSION_TYPE = [
{ value: 'CREATE', label: '新建' },
{ value: 'UPDATE', label: '编辑' },
{ value: 'PUBLISH', label: '申请上线' },
{ value: 'OFFLINE', label: '申请下线' }
]
export function dramaStatusMeta(status) {
return DRAMA_STATUS.find((item) => item.value === status) || { label: status || '-', tag: 'info' }
}
export function submissionStatusMeta(status) {
return (
SUBMISSION_STATUS.find((item) => item.value === status) || { label: status || '-', tag: 'info' }
)
}
export function submissionTypeText(type) {
return SUBMISSION_TYPE.find((item) => item.value === type)?.label || type || '-'
}
/**
* 可提交的类型:与后端 submit 的状态矩阵一一对应,前端只做"给不给出这个按钮",
* 真正的校验仍在服务端(前端不复制规则,只减少无效点击)。
*/
export function submitOptions(row) {
const firstSubmit = !row.submissionId
const options = []
if (row.status === 'draft') {
options.push(firstSubmit ? 'CREATE' : 'UPDATE')
options.push('PUBLISH')
} else if (row.status === 'offline') {
options.push('UPDATE', 'PUBLISH')
} else if (row.status === 'reviewing' || row.status === 'approved') {
options.push('PUBLISH')
} else if (row.status === 'published') {
options.push('OFFLINE')
}
return options
}
export function useCreatorWorkspace() {
const { ensureAccess } = useCreatorAccess()
const profileLoading = ref(false)
const profile = ref(null)
const dramas = reactive({
rows: [],
total: 0,
loading: false,
page: 1,
size: 10,
keyword: '',
status: undefined
})
const notices = reactive({ rows: [], loading: false, loaded: false })
async function loadProfile() {
profileLoading.value = true
try {
profile.value = (await getCreatorProfileApi()).data || null
} finally {
profileLoading.value = false
}
}
async function loadDramas() {
dramas.loading = true
try {
const res = await pageMyDramasApi({
pageIndex: dramas.page,
pageSize: dramas.size,
keyword: dramas.keyword.trim() || undefined,
status: dramas.status || undefined
})
dramas.rows = res.data?.list || []
dramas.total = res.data?.total || 0
} finally {
dramas.loading = false
}
}
function queryDramas() {
dramas.page = 1
loadDramas()
}
async function loadNotices() {
notices.loading = true
try {
notices.rows = (await listCreatorNoticesApi()).data || []
notices.loaded = true
} finally {
notices.loading = false
}
}
async function submit(row, type) {
const label = submissionTypeText(type)
if (type === 'OFFLINE') {
try {
await ElMessageBox.confirm(
`提交「申请下线」后由后台审核,审核通过前内容仍在线。确认提交?`,
label,
{ confirmButtonText: '确认提交', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
}
await submitDramaApi(row.id, type)
ElMessage.success(`已提交「${label}」,等待后台审核`)
await loadDramas()
if (notices.loaded) await loadNotices()
}
async function withdraw(row) {
if (!row.submissionId) return
try {
await ElMessageBox.confirm(
'撤回后作品回到草稿、可以继续编辑,确认撤回这次提交?',
'撤回提交',
{
confirmButtonText: '确认撤回',
cancelButtonText: '取消'
}
)
} catch {
return
}
await withdrawSubmissionApi(row.submissionId)
ElMessage.success('已撤回,作品回到草稿')
await loadDramas()
}
const createVisible = ref(false)
const createSaving = ref(false)
const createForm = reactive({ title: '', genre: '', description: '' })
function openCreate() {
Object.assign(createForm, { title: '', genre: '', description: '' })
createVisible.value = true
}
async function confirmCreate() {
if (!createForm.title.trim()) return ElMessage.warning('请填写作品名称')
createSaving.value = true
try {
const res = await createDramaApi({
title: createForm.title.trim(),
genre: createForm.genre.trim(),
description: createForm.description.trim()
})
ElMessage.success('草稿已创建,可以继续编辑分集')
createVisible.value = false
await loadDramas()
return res.data
} finally {
createSaving.value = false
}
}
onMounted(async () => {
// 身份不满足(未绑定 / 未选代看目标)时不打业务接口,交给外层渲染引导页
if (!(await ensureAccess())) return
loadProfile()
loadDramas()
})
return {
profile,
profileLoading,
dramas,
notices,
createVisible,
createSaving,
createForm,
loadProfile,
loadDramas,
queryDramas,
loadNotices,
submit,
withdraw,
openCreate,
confirmCreate
}
}
@@ -0,0 +1,350 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
addEpisodeApi,
attachAssetApi,
deleteEpisodeApi,
getMyDramaApi,
listEpisodesApi,
listSubmissionsApi,
sortEpisodesApi,
submitDramaApi,
updateDramaApi,
updateEpisodeApi,
withdrawSubmissionApi
} from '@/api/admin/creatorCenter'
import { dateCell } from '@/utils/format'
import { probeVideoDuration, resumeUpload, uploadFile } from '@/utils/uploader'
import { submissionStatusMeta, submissionTypeText, submitOptions } from './useCreatorWorkspace'
import { useCreatorAccess } from './useCreatorAccess'
/** 中断的上传会话按「作品 + 文件名 + 大小」记住,刷新后还能接着传 */
const RESUME_KEY = 'creator-upload-sessions'
function readSessions() {
try {
return JSON.parse(localStorage.getItem(RESUME_KEY) || '{}')
} catch {
return {}
}
}
function writeSession(key, value) {
const all = readSessions()
if (value) all[key] = value
else delete all[key]
localStorage.setItem(RESUME_KEY, JSON.stringify(all))
}
export function useDramaEdit() {
const { ensureAccess, impersonating } = useCreatorAccess()
const route = useRoute()
const dramaId = Number(route.params.id)
const loading = ref(false)
const drama = ref(null)
const episodes = ref([])
const submissions = ref([])
const savingInfo = ref(false)
const infoForm = reactive({
title: '',
subtitle: '',
genre: '',
description: '',
cover: '',
trailerUrl: '',
language: 'zh-CN'
})
/** 只有草稿与已下线能改(与后端 requireEditable 一致),其余状态页面转只读 */
const editable = computed(() => {
// 代看只读:整页禁用,与后端 requireWritable 的边界保持一致
if (impersonating.value) return false
return drama.value?.status === 'draft' || drama.value?.status === 'offline'
})
const readonlyReason = computed(() => {
if (impersonating.value) return '代看模式只读:可以查看作品与分集,但不能编辑、上传或提交'
switch (drama.value?.status) {
case 'published':
return '已上线的作品只读,要改内容请先提交「申请下线」'
case 'reviewing':
return '提交正在审核中,撤回后即可继续编辑'
case 'approved':
return '作品已通过审核待发布,暂不可编辑'
default:
return ''
}
})
async function loadAll() {
loading.value = true
try {
const [detail, episodeList, submissionList] = await Promise.all([
getMyDramaApi(dramaId),
listEpisodesApi(dramaId),
listSubmissionsApi(dramaId)
])
drama.value = detail.data || null
episodes.value = episodeList.data || []
submissions.value = submissionList.data || []
Object.assign(infoForm, {
title: drama.value?.title || '',
subtitle: drama.value?.subtitle || '',
genre: drama.value?.genre || '',
description: drama.value?.description || '',
cover: drama.value?.cover || '',
trailerUrl: drama.value?.trailerUrl || '',
language: drama.value?.language || 'zh-CN'
})
} finally {
loading.value = false
}
}
async function loadEpisodes() {
episodes.value = (await listEpisodesApi(dramaId)).data || []
const detail = await getMyDramaApi(dramaId)
drama.value = detail.data || drama.value
}
async function saveInfo() {
if (!infoForm.title.trim()) return ElMessage.warning('请填写作品名称')
savingInfo.value = true
try {
await updateDramaApi(dramaId, {
title: infoForm.title.trim(),
subtitle: infoForm.subtitle,
genre: infoForm.genre,
description: infoForm.description,
cover: infoForm.cover,
trailerUrl: infoForm.trailerUrl,
language: infoForm.language
})
ElMessage.success('作品信息已保存')
await loadAll()
} finally {
savingInfo.value = false
}
}
// ---------- 分集 ----------
const episodeVisible = ref(false)
const episodeSaving = ref(false)
const episodeEditingId = ref(null)
const episodeForm = reactive({ title: '', durationSec: 0, cover: '', epNo: undefined })
function openEpisode(row) {
episodeEditingId.value = row?.id ?? null
Object.assign(episodeForm, {
title: row?.title ?? '',
durationSec: row?.durationSec ?? 0,
cover: row?.cover ?? '',
epNo: row?.number ?? undefined
})
episodeVisible.value = true
}
async function saveEpisode() {
episodeSaving.value = true
try {
if (episodeEditingId.value) {
await updateEpisodeApi(dramaId, episodeEditingId.value, {
title: episodeForm.title.trim(),
durationSec: episodeForm.durationSec || 0,
cover: episodeForm.cover,
epNo: episodeForm.epNo
})
} else {
// 新增不传集号:后端按当前最大集号往后排
await addEpisodeApi(dramaId, {
title: episodeForm.title.trim(),
durationSec: episodeForm.durationSec || 0,
cover: episodeForm.cover
})
}
ElMessage.success('分集已保存')
episodeVisible.value = false
await loadEpisodes()
} finally {
episodeSaving.value = false
}
}
async function removeEpisode(row) {
try {
await ElMessageBox.confirm(
`删除第 ${row.number} 集「${row.title || '未命名'}」?`,
'删除分集',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
}
)
} catch {
return
}
await deleteEpisodeApi(dramaId, row.id)
ElMessage.success('已删除')
await loadEpisodes()
}
/** 上移 / 下移:排序接口收的是"排好序的完整 id 列表",本地换位后整体提交 */
async function moveEpisode(row, offset) {
const list = [...episodes.value]
const index = list.findIndex((item) => item.id === row.id)
const target = index + offset
if (index < 0 || target < 0 || target >= list.length) return
const [moved] = list.splice(index, 1)
list.splice(target, 0, moved)
episodes.value = list
await sortEpisodesApi(
dramaId,
list.map((item) => item.id)
)
ElMessage.success('顺序已调整')
await loadEpisodes()
}
// ---------- 上传(复用分片上传,支持断点续传) ----------
const uploadingId = ref(null)
const uploadPercent = ref(0)
const resumeHint = ref('')
function sessionKey(file) {
return `${dramaId}:${file.name}:${file.size}`
}
function findResumeSession(file) {
return readSessions()[sessionKey(file)]?.uploadId || ''
}
function hasResumeSession(file) {
return Boolean(findResumeSession(file))
}
/** 上传视频并挂到该分集:有中断会话就接着传,否则新起一个会话 */
async function uploadVideo(row, file) {
uploadingId.value = row.id
uploadPercent.value = 0
const key = sessionKey(file)
const uploadId = findResumeSession(file)
try {
const durationSec = await probeVideoDuration(file)
const asset = uploadId
? await resumeUpload(file, uploadId, {
durationSec,
onProgress: (percent) => (uploadPercent.value = percent)
})
: await uploadFile(file, {
bizType: 'EPISODE',
durationSec,
onProgress: (percent) => (uploadPercent.value = percent),
// 会话一建立就记下来:中途断网 / 关页面后还能接着传
onSession: (id) => writeSession(key, { uploadId: id })
})
writeSession(key, null)
await attachAssetApi(dramaId, row.id, asset.id)
resumeHint.value = ''
ElMessage.success('视频已上传并挂到该分集')
await loadEpisodes()
} catch {
resumeHint.value = `「${file.name}」上传中断,进度已记住,可点「继续上传」接着传`
} finally {
uploadingId.value = null
uploadPercent.value = 0
}
}
/** 上传封面(作品封面或分集封面):图片走同一个分片上传接口,bizType=DRAMA_COVER */
async function uploadCover(row, file) {
uploadingId.value = row ? row.id : -1
uploadPercent.value = 0
try {
const asset = await uploadFile(file, {
bizType: 'DRAMA_COVER',
onProgress: (percent) => (uploadPercent.value = percent)
})
if (row) {
await updateEpisodeApi(dramaId, row.id, { cover: asset.url })
ElMessage.success('分集封面已更新')
await loadEpisodes()
} else {
infoForm.cover = asset.url
ElMessage.success('封面已上传,保存作品信息后生效')
}
} finally {
uploadingId.value = null
uploadPercent.value = 0
}
}
// ---------- 提交审核 ----------
async function submit(type) {
if (type === 'OFFLINE') {
try {
await ElMessageBox.confirm(
'申请下线后由后台审核,通过后内容才下线。确认提交?',
'申请下线',
{
confirmButtonText: '确认提交',
cancelButtonText: '取消',
type: 'warning'
}
)
} catch {
return
}
}
await submitDramaApi(dramaId, type)
ElMessage.success('已提交,等待后台审核')
await loadAll()
}
async function withdraw(submissionId) {
await withdrawSubmissionApi(submissionId)
ElMessage.success('已撤回,作品回到草稿')
await loadAll()
}
onMounted(async () => {
if (!(await ensureAccess())) return
await loadAll()
})
return {
dramaId,
loading,
drama,
episodes,
submissions,
editable,
readonlyReason,
infoForm,
savingInfo,
episodeVisible,
episodeSaving,
episodeEditingId,
episodeForm,
uploadingId,
uploadPercent,
resumeHint,
submitOptions,
submissionStatusMeta,
submissionTypeText,
dateCell,
loadAll,
saveInfo,
openEpisode,
saveEpisode,
removeEpisode,
moveEpisode,
uploadVideo,
uploadCover,
hasResumeSession,
submit,
withdraw
}
}
+118 -6
View File
@@ -13,6 +13,8 @@ import {
const { can } = useRbac()
const canAudit = can('creator:audit')
/** 开通 / 重置账号是独立权限点(第一阶段只给超管) */
const canOpenAccount = can('creator:account')
const {
rows,
@@ -38,7 +40,19 @@ const {
openDocuments,
openAudit,
confirmAudit,
freeze
freeze,
createVisible,
createSaving,
createForm,
openCreate,
confirmCreate,
accountVisible,
accountSaving,
accountResult,
openAccount,
resetPassword,
copyCredential,
closeAccount
} = useCreatorAudit()
</script>
@@ -47,7 +61,9 @@ const {
eyebrow="创作者管理"
title="创作者入驻"
description="审核创作者主体与资质,通过后创作者才能提交内容。"
/>
>
<el-button v-if="canAudit" type="primary" @click="openCreate">新增创作者</el-button>
</PageHeading>
<section class="panel">
<div class="console-toolbar">
@@ -127,6 +143,12 @@ const {
</el-tag>
</template>
</el-table-column>
<el-table-column label="账号" width="110">
<template #default="{ row }">
<el-tag v-if="row.adminUserId" type="success" effect="light">已开通</el-tag>
<span v-else class="console-muted">未开通</span>
</template>
</el-table-column>
<el-table-column
prop="auditAt"
label="审核时间"
@@ -141,11 +163,16 @@ const {
show-overflow-tooltip
:formatter="dateCell"
/>
<el-table-column label="操作" width="230" fixed="right">
<el-table-column label="操作" width="330" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button type="primary" size="small" @click="openDocuments(row)">资质</el-button>
<el-button v-if="canAudit" size="small" @click="openAudit(row, 'APPROVE')"
<!-- 状态机:通过可从 pending/rejected/frozen 发起,驳回只允许 pending,冻结只允许 approved。
已通过的行走「冻结 / 重置密码」,不再给「审核」入口(点了必然被后端拒) -->
<el-button
v-if="canAudit && row.status !== 'approved'"
size="small"
@click="openAudit(row, 'APPROVE')"
>审核</el-button
>
<el-button
@@ -155,6 +182,21 @@ const {
@click="freeze(row)"
>冻结</el-button
>
<el-button
v-if="canOpenAccount && !row.adminUserId"
type="primary"
size="small"
:loading="accountSaving"
:disabled="row.status !== 'approved'"
@click="openAccount(row)"
>开通账号</el-button
>
<el-button
v-if="canOpenAccount && row.adminUserId"
size="small"
@click="resetPassword(row)"
>重置密码</el-button
>
</div>
</template>
</el-table-column>
@@ -205,8 +247,9 @@ const {
</el-form-item>
<el-form-item label="审核动作">
<el-radio-group v-model="auditForm.action">
<el-radio value="APPROVE">通过</el-radio>
<el-radio value="REJECT">驳回</el-radio>
<el-radio label="APPROVE">通过</el-radio>
<!-- 驳回只对「待审核」开放(与后端 requireState 一致);已驳回/已冻结的行重新审核时只能「通过」 -->
<el-radio v-if="auditTarget?.status === 'pending'" label="REJECT">驳回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="auditForm.action === 'REJECT'" label="驳回原因码">
@@ -231,4 +274,73 @@ const {
<el-button type="primary" :loading="auditSaving" @click="confirmAudit">提交</el-button>
</template>
</el-dialog>
<el-dialog v-model="createVisible" title="新增创作者" width="480px">
<el-form label-position="top">
<el-form-item label="创作者名称" required>
<el-input v-model="createForm.name" maxlength="128" placeholder="公司名或个人名" />
</el-form-item>
<el-form-item label="类型">
<el-radio-group v-model="createForm.type">
<el-radio v-for="item in CREATOR_TYPE" :key="item.value" :label="item.value">{{
item.label
}}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="createForm.contactName" maxlength="64" />
</el-form-item>
<el-form-item label="联系邮箱">
<el-input v-model="createForm.contactEmail" maxlength="128" />
</el-form-item>
<el-form-item label="国家 / 地区">
<el-input v-model="createForm.countryCode" maxlength="8" placeholder="ID" />
</el-form-item>
<el-alert
type="info"
:closable="false"
show-icon
title="编号自动生成(CR+日期+序号),也是开通账号时默认的登录名;录入后需要先通过入驻审核才能开通账号"
/>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createSaving" @click="confirmCreate">录入</el-button>
</template>
</el-dialog>
<el-dialog
:model-value="accountVisible"
:title="accountResult?.created ? '账号已开通' : '账号凭据'"
width="520px"
@close="closeAccount"
@update:model-value="closeAccount"
>
<el-alert
v-if="accountResult?.password"
class="creator-account-alert"
type="warning"
:closable="false"
show-icon
title="密码只显示这一次,关闭后无法再次查看;请立即复制并转达给创作者"
/>
<el-descriptions :column="1" border>
<el-descriptions-item label="登录账号">
<span class="creator-account-value">{{ accountResult?.username }}</span>
</el-descriptions-item>
<el-descriptions-item label="初始密码">
<span v-if="accountResult?.password" class="creator-account-value">{{
accountResult.password
}}</span>
<span v-else class="console-muted">本次未生成新密码(账号此前已开通)</span>
</el-descriptions-item>
<el-descriptions-item label="说明">
<span class="console-muted">{{ accountResult?.message }}</span>
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button v-if="accountResult?.password" @click="copyCredential">复制账号与密码</el-button>
<el-button type="primary" @click="closeAccount">我已保存</el-button>
</template>
</el-dialog>
</template>
+2 -2
View File
@@ -165,8 +165,8 @@ const {
</el-form-item>
<el-form-item label="审核结果">
<el-radio-group v-model="auditForm.action">
<el-radio value="APPROVE">通过</el-radio>
<el-radio value="REJECT">驳回</el-radio>
<el-radio label="APPROVE">通过</el-radio>
<el-radio label="REJECT">驳回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="auditForm.action === 'REJECT'" label="驳回原因码">
@@ -1,6 +1,13 @@
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { auditCreatorApi, listCreatorDocumentsApi, pageCreatorsApi } from '@/api/admin/creator'
import {
auditCreatorApi,
createCreatorApi,
listCreatorDocumentsApi,
openCreatorAccountApi,
pageCreatorsApi,
resetCreatorPasswordApi
} from '@/api/admin/creator'
/** 与后端 CreatorService 的状态取值保持一致 */
export const CREATOR_STATUS = [
@@ -138,6 +145,97 @@ export function useCreatorAudit() {
onMounted(load)
// ---------- 手工录入创作者主体 ----------
const createVisible = ref(false)
const createSaving = ref(false)
const createForm = reactive({
name: '',
type: 'COMPANY',
contactName: '',
contactEmail: '',
countryCode: 'ID'
})
function openCreate() {
Object.assign(createForm, {
name: '',
type: 'COMPANY',
contactName: '',
contactEmail: '',
countryCode: 'ID'
})
createVisible.value = true
}
async function confirmCreate() {
if (!createForm.name.trim()) return ElMessage.warning('请填写创作者名称')
createSaving.value = true
try {
await createCreatorApi({ ...createForm, name: createForm.name.trim() })
ElMessage.success('已录入,待入驻审核通过后才能开通账号')
createVisible.value = false
await load()
} finally {
createSaving.value = false
}
}
// ---------- 开通账号 / 重置密码 ----------
const accountVisible = ref(false)
const accountSaving = ref(false)
/** 一次性凭据展示:password 只在开通 / 重置那一次有值 */
const accountResult = ref(null)
async function openAccount(row) {
accountSaving.value = true
try {
const res = await openCreatorAccountApi(row.id)
accountResult.value = res.data || null
accountVisible.value = true
await load()
} finally {
accountSaving.value = false
}
}
async function resetPassword(row) {
try {
await ElMessageBox.confirm(
`重置后「${row.name}」的旧密码立即失效,需要把新密码重新转达给他。确认重置?`,
'重置账号密码',
{ confirmButtonText: '确认重置', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
accountSaving.value = true
try {
const res = await resetCreatorPasswordApi(row.id)
accountResult.value = res.data || null
accountVisible.value = true
} finally {
accountSaving.value = false
}
}
/** 一次性密码要能被复制走;复制完由运营自己销毁截图 */
async function copyCredential() {
const item = accountResult.value
if (!item?.password) return
const text = `登录地址:${window.location.origin}/#/login\n账号:${item.username}\n初始密码:${item.password}`
try {
await navigator.clipboard.writeText(text)
ElMessage.success('账号与密码已复制')
} catch {
ElMessage.warning('浏览器不允许自动复制,请手动选中复制')
}
}
function closeAccount() {
accountVisible.value = false
accountResult.value = null
}
return {
rows,
total,
@@ -162,6 +260,18 @@ export function useCreatorAudit() {
openDocuments,
openAudit,
confirmAudit,
freeze
freeze,
createVisible,
createSaving,
createForm,
openCreate,
confirmCreate,
accountVisible,
accountSaving,
accountResult,
openAccount,
resetPassword,
copyCredential,
closeAccount
}
}
@@ -556,8 +556,8 @@ const handleTargetText = computed(() => {
</el-form-item>
<el-form-item label="处理结论">
<el-radio-group v-model="handleForm.status">
<el-radio value="handled">举报成立</el-radio>
<el-radio value="rejected">举报不成立</el-radio>
<el-radio label="handled">举报成立</el-radio>
<el-radio label="rejected">举报不成立</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="处理说明">
+3 -3
View File
@@ -43,9 +43,9 @@ const { treeData, loading, form, parentOptions, resetForm, selectNode, saveMenu,
</el-form-item>
<el-form-item label="菜单类型">
<el-radio-group v-model="form.type">
<el-radio value="CATALOG">目录</el-radio>
<el-radio value="MENU">菜单</el-radio>
<el-radio value="BUTTON">按钮</el-radio>
<el-radio label="CATALOG">目录</el-radio>
<el-radio label="MENU">菜单</el-radio>
<el-radio label="BUTTON">按钮</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="上级菜单">
+2 -2
View File
@@ -326,8 +326,8 @@ function canBeBanned(row) {
</el-form-item>
<el-form-item label="处置方式">
<el-radio-group v-model="statusForm.status">
<el-radio value="banned">封禁(无法登录)</el-radio>
<el-radio value="limited">限制(可登录)</el-radio>
<el-radio label="banned">封禁(无法登录)</el-radio>
<el-radio label="limited">限制(可登录)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="原因(可选,会记入处置记录)">
+2
View File
@@ -40,6 +40,8 @@ declare global {
method?: AxiosMethod
headers?: RawAxiosRequestHeaders
responseType?: AxiosResponseType
/** 静默请求:业务码非 0 时不弹全局提示,并把完整 Result 交给调用方判断 */
silent?: boolean
}
declare interface IResponse<T = any> {