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 接口
This commit is contained in:
1 parent
0a2e3e0b81
commit
685c912313
19 files changed
+350
-13
No files matched your search
@@ -19,6 +19,15 @@ export interface CreatorProfile {
|
|||||||
dramaTotal: number
|
dramaTotal: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 代看目标下拉项(后端只给 id / 名称 / 状态) */
|
||||||
|
export interface CreatorCenterTarget {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
statusText: string
|
||||||
|
}
|
||||||
|
|
||||||
/** 我的作品 + 最近一次提交的审核进度 */
|
/** 我的作品 + 最近一次提交的审核进度 */
|
||||||
export interface CreatorDrama {
|
export interface CreatorDrama {
|
||||||
id: number
|
id: number
|
||||||
@@ -132,6 +141,19 @@ export const getCreatorProfileApi = () => {
|
|||||||
return request.get<CreatorProfile>({ url: '/admin/creator-center/profile' })
|
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>) => {
|
export const pageMyDramasApi = (params: Record<string, unknown>) => {
|
||||||
return request.get<PageResult<CreatorDrama>>({ url: '/admin/creator-center/dramas/page', params })
|
return request.get<PageResult<CreatorDrama>>({ url: '/admin/creator-center/dramas/page', params })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ const defaultResponseInterceptors = (response: AxiosResponse) => {
|
|||||||
} else if (response.data.code === SUCCESS_CODE) {
|
} else if (response.data.code === SUCCESS_CODE) {
|
||||||
return response.data
|
return response.data
|
||||||
} else {
|
} else {
|
||||||
|
// 静默请求:调用方要自己看业务码(例如创作者中心据此渲染引导页),不弹全局提示
|
||||||
|
if ((response.config as { silent?: boolean })?.silent) {
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
ElMessage.error(response?.data?.message)
|
ElMessage.error(response?.data?.message)
|
||||||
if (response?.data?.code === 401) {
|
if (response?.data?.code === 401) {
|
||||||
const userStore = useUserStoreWithOut()
|
const userStore = useUserStoreWithOut()
|
||||||
|
|||||||
+8
-1
@@ -1,23 +1,30 @@
|
|||||||
import service from './service'
|
import service from './service'
|
||||||
import { CONTENT_TYPE } from '@/constants'
|
import { CONTENT_TYPE } from '@/constants'
|
||||||
import { useUserStoreWithOut } from '@/store/modules/user'
|
import { useUserStoreWithOut } from '@/store/modules/user'
|
||||||
|
import { creatorCenterHeaders } from '@/utils/creatorImpersonation'
|
||||||
|
|
||||||
const request = (option: AxiosConfig) => {
|
const request = (option: AxiosConfig) => {
|
||||||
const { url, method, params, data, headers, responseType } = option
|
const { url, method, params, data, headers, responseType, silent } = option
|
||||||
const userStore = useUserStoreWithOut()
|
const userStore = useUserStoreWithOut()
|
||||||
// FormData 不能带 JSON 的 Content-Type,否则 axios 会把表单转成 JSON,浏览器也无法补上 boundary
|
// FormData 不能带 JSON 的 Content-Type,否则 axios 会把表单转成 JSON,浏览器也无法补上 boundary
|
||||||
const isFormData = typeof FormData !== 'undefined' && data instanceof FormData
|
const isFormData = typeof FormData !== 'undefined' && data instanceof FormData
|
||||||
|
// 创作者中心的请求统一带上代看目标头(超管选了代看创作者时才有值;
|
||||||
|
// 后端只对具备 impersonate 权限的账号认这个头,创作者带了也不生效)
|
||||||
|
const impersonationHeaders =
|
||||||
|
typeof url === 'string' && url.startsWith('/admin/creator-center') ? creatorCenterHeaders() : {}
|
||||||
return service.request({
|
return service.request({
|
||||||
url: url,
|
url: url,
|
||||||
method,
|
method,
|
||||||
params,
|
params,
|
||||||
data,
|
data,
|
||||||
responseType: responseType,
|
responseType: responseType,
|
||||||
|
silent,
|
||||||
headers: {
|
headers: {
|
||||||
...(isFormData ? {} : { 'Content-Type': CONTENT_TYPE }),
|
...(isFormData ? {} : { 'Content-Type': CONTENT_TYPE }),
|
||||||
[userStore.getTokenKey ?? 'Authorization']: userStore.getToken
|
[userStore.getTokenKey ?? 'Authorization']: userStore.getToken
|
||||||
? `Bearer ${userStore.getToken}`
|
? `Bearer ${userStore.getToken}`
|
||||||
: '',
|
: '',
|
||||||
|
...impersonationHeaders,
|
||||||
...headers
|
...headers
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface RequestInterceptors<T> {
|
|||||||
|
|
||||||
interface RequestConfig<T = AxiosResponse> extends AxiosRequestConfig {
|
interface RequestConfig<T = AxiosResponse> extends AxiosRequestConfig {
|
||||||
interceptors?: RequestInterceptors<T>
|
interceptors?: RequestInterceptors<T>
|
||||||
|
/** 静默请求:业务码非 0 时由调用方处理,不弹全局提示(见 axios/config.ts) */
|
||||||
|
silent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -217,6 +217,59 @@
|
|||||||
font-size: 13px;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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) } : {}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import PageHeading from '@/views/components/PageHeading.vue'
|
import PageHeading from '@/views/components/PageHeading.vue'
|
||||||
|
import CreatorCenterPage from './components/CreatorCenterPage.vue'
|
||||||
import { DASHBOARD_GROUPS, useCreatorDashboard } from './composables/useCreatorDashboard'
|
import { DASHBOARD_GROUPS, useCreatorDashboard } from './composables/useCreatorDashboard'
|
||||||
|
|
||||||
const { loading, board, lastUpdated, load, display, groupValue } = useCreatorDashboard()
|
const { loading, board, lastUpdated, load, display, groupValue } = useCreatorDashboard()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<CreatorCenterPage>
|
||||||
<PageHeading
|
<PageHeading
|
||||||
eyebrow="创作者中心"
|
eyebrow="创作者中心"
|
||||||
title="数据看板"
|
title="数据看板"
|
||||||
@@ -43,4 +45,5 @@ const { loading, board, lastUpdated, load, display, groupValue } = useCreatorDas
|
|||||||
</ul>
|
</ul>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
</section>
|
</section>
|
||||||
|
</CreatorCenterPage>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ArrowLeft, Upload } from '@element-plus/icons-vue'
|
import { ArrowLeft, Upload } from '@element-plus/icons-vue'
|
||||||
import PageHeading from '@/views/components/PageHeading.vue'
|
import PageHeading from '@/views/components/PageHeading.vue'
|
||||||
|
import CreatorCenterPage from './components/CreatorCenterPage.vue'
|
||||||
import { useRbac } from '@/stores/rbac'
|
import { useRbac } from '@/stores/rbac'
|
||||||
import { useDramaEdit } from './composables/useDramaEdit'
|
import { useDramaEdit } from './composables/useDramaEdit'
|
||||||
|
import { useCreatorAccess } from './composables/useCreatorAccess'
|
||||||
import { dramaStatusMeta } from './composables/useCreatorWorkspace'
|
import { dramaStatusMeta } from './composables/useCreatorWorkspace'
|
||||||
|
|
||||||
const { can } = useRbac()
|
const { can } = useRbac()
|
||||||
const canEdit = can('creator-center:drama:edit')
|
const canEdit = can('creator-center:drama:edit')
|
||||||
const canUpload = can('creator-center:upload')
|
const canUpload = can('creator-center:upload')
|
||||||
|
// 代看只读:提交/撤回这些写入口直接不渲染
|
||||||
|
const { impersonating } = useCreatorAccess()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
@@ -60,6 +64,7 @@ function requestDramaCover(options) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<CreatorCenterPage>
|
||||||
<PageHeading
|
<PageHeading
|
||||||
eyebrow="创作者中心"
|
eyebrow="创作者中心"
|
||||||
title="作品编辑"
|
title="作品编辑"
|
||||||
@@ -78,7 +83,7 @@ function requestDramaCover(options) {
|
|||||||
>
|
>
|
||||||
<div class="creator-edit-actions">
|
<div class="creator-edit-actions">
|
||||||
<el-button
|
<el-button
|
||||||
v-for="type in canEdit ? submitOptions(drama || {}) : []"
|
v-for="type in canEdit && !impersonating ? submitOptions(drama || {}) : []"
|
||||||
:key="type"
|
:key="type"
|
||||||
type="primary"
|
type="primary"
|
||||||
plain
|
plain
|
||||||
@@ -224,7 +229,9 @@ function requestDramaCover(options) {
|
|||||||
@click="moveEpisode(row, 1)"
|
@click="moveEpisode(row, 1)"
|
||||||
>下移</el-button
|
>下移</el-button
|
||||||
>
|
>
|
||||||
<el-button size="small" :disabled="!editable" @click="openEpisode(row)">编辑</el-button>
|
<el-button size="small" :disabled="!editable" @click="openEpisode(row)"
|
||||||
|
>编辑</el-button
|
||||||
|
>
|
||||||
<el-upload
|
<el-upload
|
||||||
v-if="canUpload"
|
v-if="canUpload"
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
@@ -351,4 +358,5 @@ function requestDramaCover(options) {
|
|||||||
<el-button type="primary" :loading="episodeSaving" @click="saveEpisode">保存</el-button>
|
<el-button type="primary" :loading="episodeSaving" @click="saveEpisode">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
</CreatorCenterPage>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import PageHeading from '@/views/components/PageHeading.vue'
|
import PageHeading from '@/views/components/PageHeading.vue'
|
||||||
|
import CreatorCenterPage from './components/CreatorCenterPage.vue'
|
||||||
import { useRbac } from '@/stores/rbac'
|
import { useRbac } from '@/stores/rbac'
|
||||||
import { useCreatorPrice } from './composables/useCreatorPrice'
|
import { useCreatorPrice } from './composables/useCreatorPrice'
|
||||||
import { dramaStatusMeta } from './composables/useCreatorWorkspace'
|
import { dramaStatusMeta } from './composables/useCreatorWorkspace'
|
||||||
@@ -22,6 +23,7 @@ const {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<CreatorCenterPage>
|
||||||
<PageHeading
|
<PageHeading
|
||||||
eyebrow="创作者中心"
|
eyebrow="创作者中心"
|
||||||
title="收费配置"
|
title="收费配置"
|
||||||
@@ -138,4 +140,5 @@ const {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</section>
|
</section>
|
||||||
|
</CreatorCenterPage>
|
||||||
</template>
|
</template>
|
||||||
@@ -3,6 +3,8 @@ import { ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import PageHeading from '@/views/components/PageHeading.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 { useRbac } from '@/stores/rbac'
|
||||||
import { dateCell } from '@/utils/format'
|
import { dateCell } from '@/utils/format'
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +19,8 @@ import {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { can } = useRbac()
|
const { can } = useRbac()
|
||||||
const canEdit = can('creator-center:drama:edit')
|
const canEdit = can('creator-center:drama:edit')
|
||||||
|
// 代看是只读:写操作按钮直接不渲染(后端也会拒,界面不该给出做不到的入口)
|
||||||
|
const { impersonating } = useCreatorAccess()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
profile,
|
profile,
|
||||||
@@ -47,12 +51,15 @@ function openEdit(row) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<CreatorCenterPage>
|
||||||
<PageHeading
|
<PageHeading
|
||||||
eyebrow="创作者中心"
|
eyebrow="创作者中心"
|
||||||
title="我的作品"
|
title="我的作品"
|
||||||
description="查看作品与审核进度,提交上线 / 下线申请。"
|
description="查看作品与审核进度,提交上线 / 下线申请。"
|
||||||
>
|
>
|
||||||
<el-button v-if="canEdit" type="primary" @click="openCreate">新建作品</el-button>
|
<el-button v-if="canEdit && !impersonating" type="primary" @click="openCreate"
|
||||||
|
>新建作品</el-button
|
||||||
|
>
|
||||||
</PageHeading>
|
</PageHeading>
|
||||||
|
|
||||||
<section v-loading="profileLoading" class="panel creator-profile">
|
<section v-loading="profileLoading" class="panel creator-profile">
|
||||||
@@ -187,11 +194,15 @@ function openEdit(row) {
|
|||||||
<el-table-column label="操作" width="210" fixed="right">
|
<el-table-column label="操作" width="210" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button v-if="canEdit" type="primary" size="small" @click="openEdit(row)"
|
<el-button
|
||||||
|
v-if="canEdit && !impersonating"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="openEdit(row)"
|
||||||
>编辑</el-button
|
>编辑</el-button
|
||||||
>
|
>
|
||||||
<el-dropdown
|
<el-dropdown
|
||||||
v-if="canEdit && submitOptions(row).length"
|
v-if="canEdit && !impersonating && submitOptions(row).length"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
@command="(type) => submit(row, type)"
|
@command="(type) => submit(row, type)"
|
||||||
>
|
>
|
||||||
@@ -211,6 +222,7 @@ function openEdit(row) {
|
|||||||
<el-button
|
<el-button
|
||||||
v-if="
|
v-if="
|
||||||
canEdit &&
|
canEdit &&
|
||||||
|
!impersonating &&
|
||||||
row.submissionId &&
|
row.submissionId &&
|
||||||
['SUBMITTED', 'REVIEWING'].includes(row.submissionStatus)
|
['SUBMITTED', 'REVIEWING'].includes(row.submissionStatus)
|
||||||
"
|
"
|
||||||
@@ -289,7 +301,10 @@ function openEdit(row) {
|
|||||||
</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"
|
||||||
|
>创建草稿</el-button
|
||||||
|
>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
</CreatorCenterPage>
|
||||||
</template>
|
</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,81 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { getCreatorDashboardApi } from '@/api/admin/creatorCenter'
|
import { getCreatorDashboardApi } from '@/api/admin/creatorCenter'
|
||||||
|
import { useCreatorAccess } from './useCreatorAccess'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 指标展示配置:key 与后端 /admin/creator-center/dashboard 的字段一一对应,
|
* 指标展示配置:key 与后端 /admin/creator-center/dashboard 的字段一一对应,
|
||||||
@@ -56,6 +57,7 @@ export const DASHBOARD_GROUPS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function useCreatorDashboard() {
|
export function useCreatorDashboard() {
|
||||||
|
const { ensureAccess } = useCreatorAccess()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const board = ref(null)
|
const board = ref(null)
|
||||||
const lastUpdated = ref('')
|
const lastUpdated = ref('')
|
||||||
@@ -81,7 +83,10 @@ export function useCreatorDashboard() {
|
|||||||
return board.value?.[groupKey]?.[metricKey]
|
return board.value?.[groupKey]?.[metricKey]
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(async () => {
|
||||||
|
if (!(await ensureAccess())) return
|
||||||
|
await load()
|
||||||
|
})
|
||||||
|
|
||||||
return { loading, board, lastUpdated, load, display, groupValue }
|
return { loading, board, lastUpdated, load, display, groupValue }
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,10 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { getPricesApi, pageMyDramasApi, savePricesApi } from '@/api/admin/creatorCenter'
|
import { getPricesApi, pageMyDramasApi, savePricesApi } from '@/api/admin/creatorCenter'
|
||||||
import { dramaStatusMeta } from './useCreatorWorkspace'
|
import { dramaStatusMeta } from './useCreatorWorkspace'
|
||||||
|
import { useCreatorAccess } from './useCreatorAccess'
|
||||||
|
|
||||||
export function useCreatorPrice() {
|
export function useCreatorPrice() {
|
||||||
|
const { ensureAccess, impersonating } = useCreatorAccess()
|
||||||
const dramas = reactive({ rows: [], loading: false })
|
const dramas = reactive({ rows: [], loading: false })
|
||||||
const dramaId = ref(null)
|
const dramaId = ref(null)
|
||||||
const config = reactive({ freeEpisodes: 0, unlockCoin: 0, episodes: [] })
|
const config = reactive({ freeEpisodes: 0, unlockCoin: 0, episodes: [] })
|
||||||
@@ -17,6 +19,7 @@ export function useCreatorPrice() {
|
|||||||
* 已上线要改价得先申请下线,避免创作者无审核地改已上线内容的价格。
|
* 已上线要改价得先申请下线,避免创作者无审核地改已上线内容的价格。
|
||||||
*/
|
*/
|
||||||
const priceEditable = computed(() => {
|
const priceEditable = computed(() => {
|
||||||
|
if (impersonating.value) return false
|
||||||
const status = current.value?.status
|
const status = current.value?.status
|
||||||
return status === 'draft' || status === 'offline'
|
return status === 'draft' || status === 'offline'
|
||||||
})
|
})
|
||||||
@@ -80,6 +83,7 @@ export function useCreatorPrice() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (!(await ensureAccess())) return
|
||||||
await loadDramas()
|
await loadDramas()
|
||||||
await loadPrices()
|
await loadPrices()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
submitDramaApi,
|
submitDramaApi,
|
||||||
withdrawSubmissionApi
|
withdrawSubmissionApi
|
||||||
} from '@/api/admin/creatorCenter'
|
} from '@/api/admin/creatorCenter'
|
||||||
|
import { useCreatorAccess } from './useCreatorAccess'
|
||||||
|
|
||||||
/** 作品状态:与后端 ContentStatus 一一对应 */
|
/** 作品状态:与后端 ContentStatus 一一对应 */
|
||||||
export const DRAMA_STATUS = [
|
export const DRAMA_STATUS = [
|
||||||
@@ -70,6 +71,7 @@ export function submitOptions(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useCreatorWorkspace() {
|
export function useCreatorWorkspace() {
|
||||||
|
const { ensureAccess } = useCreatorAccess()
|
||||||
const profileLoading = ref(false)
|
const profileLoading = ref(false)
|
||||||
const profile = ref(null)
|
const profile = ref(null)
|
||||||
|
|
||||||
@@ -190,7 +192,9 @@ export function useCreatorWorkspace() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
// 身份不满足(未绑定 / 未选代看目标)时不打业务接口,交给外层渲染引导页
|
||||||
|
if (!(await ensureAccess())) return
|
||||||
loadProfile()
|
loadProfile()
|
||||||
loadDramas()
|
loadDramas()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { dateCell } from '@/utils/format'
|
import { dateCell } from '@/utils/format'
|
||||||
import { probeVideoDuration, resumeUpload, uploadFile } from '@/utils/uploader'
|
import { probeVideoDuration, resumeUpload, uploadFile } from '@/utils/uploader'
|
||||||
import { submissionStatusMeta, submissionTypeText, submitOptions } from './useCreatorWorkspace'
|
import { submissionStatusMeta, submissionTypeText, submitOptions } from './useCreatorWorkspace'
|
||||||
|
import { useCreatorAccess } from './useCreatorAccess'
|
||||||
|
|
||||||
/** 中断的上传会话按「作品 + 文件名 + 大小」记住,刷新后还能接着传 */
|
/** 中断的上传会话按「作品 + 文件名 + 大小」记住,刷新后还能接着传 */
|
||||||
const RESUME_KEY = 'creator-upload-sessions'
|
const RESUME_KEY = 'creator-upload-sessions'
|
||||||
@@ -37,6 +38,7 @@ function writeSession(key, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useDramaEdit() {
|
export function useDramaEdit() {
|
||||||
|
const { ensureAccess, impersonating } = useCreatorAccess()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const dramaId = Number(route.params.id)
|
const dramaId = Number(route.params.id)
|
||||||
|
|
||||||
@@ -56,10 +58,13 @@ export function useDramaEdit() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/** 只有草稿与已下线能改(与后端 requireEditable 一致),其余状态页面转只读 */
|
/** 只有草稿与已下线能改(与后端 requireEditable 一致),其余状态页面转只读 */
|
||||||
const editable = computed(
|
const editable = computed(() => {
|
||||||
() => drama.value?.status === 'draft' || drama.value?.status === 'offline'
|
// 代看只读:整页禁用,与后端 requireWritable 的边界保持一致
|
||||||
)
|
if (impersonating.value) return false
|
||||||
|
return drama.value?.status === 'draft' || drama.value?.status === 'offline'
|
||||||
|
})
|
||||||
const readonlyReason = computed(() => {
|
const readonlyReason = computed(() => {
|
||||||
|
if (impersonating.value) return '代看模式只读:可以查看作品与分集,但不能编辑、上传或提交'
|
||||||
switch (drama.value?.status) {
|
switch (drama.value?.status) {
|
||||||
case 'published':
|
case 'published':
|
||||||
return '已上线的作品只读,要改内容请先提交「申请下线」'
|
return '已上线的作品只读,要改内容请先提交「申请下线」'
|
||||||
@@ -304,7 +309,10 @@ export function useDramaEdit() {
|
|||||||
await loadAll()
|
await loadAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadAll)
|
onMounted(async () => {
|
||||||
|
if (!(await ensureAccess())) return
|
||||||
|
await loadAll()
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dramaId,
|
dramaId,
|
||||||
|
|||||||
Vendored
+2
@@ -40,6 +40,8 @@ declare global {
|
|||||||
method?: AxiosMethod
|
method?: AxiosMethod
|
||||||
headers?: RawAxiosRequestHeaders
|
headers?: RawAxiosRequestHeaders
|
||||||
responseType?: AxiosResponseType
|
responseType?: AxiosResponseType
|
||||||
|
/** 静默请求:业务码非 0 时不弹全局提示,并把完整 Result 交给调用方判断 */
|
||||||
|
silent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
declare interface IResponse<T = any> {
|
declare interface IResponse<T = any> {
|
||||||
|
|||||||
Reference in new issue
Block a user