diff --git a/src/api/admin/creatorCenter.ts b/src/api/admin/creatorCenter.ts index 9090be6..5069dfc 100644 --- a/src/api/admin/creatorCenter.ts +++ b/src/api/admin/creatorCenter.ts @@ -19,6 +19,15 @@ export interface CreatorProfile { dramaTotal: number } +/** 代看目标下拉项(后端只给 id / 名称 / 状态) */ +export interface CreatorCenterTarget { + id: number + code: string + name: string + status: string + statusText: string +} + /** 我的作品 + 最近一次提交的审核进度 */ export interface CreatorDrama { id: number @@ -132,6 +141,19 @@ export const getCreatorProfileApi = () => { return request.get({ url: '/admin/creator-center/profile' }) } +/** + * 静默探测当前账号的创作者身份:不弹全局提示,直接把 Result 交给调用方判断。 + * 用于「已绑定 / 可代看但没选目标 / 未绑定」三态分流,渲染引导页。 + */ +export const probeCreatorAccessApi = () => { + return request.get({ url: '/admin/creator-center/profile', silent: true }) +} + +/** 代看目标下拉的数据源(仅 impersonate 权限可调) */ +export const listImpersonationTargetsApi = () => { + return request.get({ url: '/admin/creator-center/impersonation/targets' }) +} + export const pageMyDramasApi = (params: Record) => { return request.get>({ url: '/admin/creator-center/dramas/page', params }) } diff --git a/src/axios/config.ts b/src/axios/config.ts index 93d3f19..e55ade7 100644 --- a/src/axios/config.ts +++ b/src/axios/config.ts @@ -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() diff --git a/src/axios/index.ts b/src/axios/index.ts index 07ec0a1..65c1c70 100644 --- a/src/axios/index.ts +++ b/src/axios/index.ts @@ -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 } }) diff --git a/src/axios/types/index.ts b/src/axios/types/index.ts index b70eb26..3a5f3f2 100644 --- a/src/axios/types/index.ts +++ b/src/axios/types/index.ts @@ -18,6 +18,8 @@ interface RequestInterceptors { interface RequestConfig extends AxiosRequestConfig { interceptors?: RequestInterceptors + /** 静默请求:业务码非 0 时由调用方处理,不弹全局提示(见 axios/config.ts) */ + silent?: boolean } export { diff --git a/src/styles/business/creator-center.css b/src/styles/business/creator-center.css index bba67a6..11ceb3c 100644 --- a/src/styles/business/creator-center.css +++ b/src/styles/business/creator-center.css @@ -217,6 +217,59 @@ 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-meta b, html.dark .creator-drama-title, diff --git a/src/utils/creatorImpersonation.ts b/src/utils/creatorImpersonation.ts new file mode 100644 index 0000000..3787160 --- /dev/null +++ b/src/utils/creatorImpersonation.ts @@ -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(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 { + return impersonateCreatorId.value ? { 'X-Creator-Id': String(impersonateCreatorId.value) } : {} +} diff --git a/src/views/creator-center/DashboardView.vue b/src/views/creator-center/DashboardView.vue index e6a6520..01ffb13 100644 --- a/src/views/creator-center/DashboardView.vue +++ b/src/views/creator-center/DashboardView.vue @@ -1,46 +1,49 @@ diff --git a/src/views/creator-center/DramaEditView.vue b/src/views/creator-center/DramaEditView.vue index b233030..d2140fa 100644 --- a/src/views/creator-center/DramaEditView.vue +++ b/src/views/creator-center/DramaEditView.vue @@ -1,13 +1,17 @@ diff --git a/src/views/creator-center/PriceConfigView.vue b/src/views/creator-center/PriceConfigView.vue index b815649..b31e00a 100644 --- a/src/views/creator-center/PriceConfigView.vue +++ b/src/views/creator-center/PriceConfigView.vue @@ -1,5 +1,6 @@ diff --git a/src/views/creator-center/WorkspaceView.vue b/src/views/creator-center/WorkspaceView.vue index 5dffc0f..7ded411 100644 --- a/src/views/creator-center/WorkspaceView.vue +++ b/src/views/creator-center/WorkspaceView.vue @@ -3,6 +3,8 @@ 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 { @@ -17,6 +19,8 @@ import { const router = useRouter() const { can } = useRbac() const canEdit = can('creator-center:drama:edit') +// 代看是只读:写操作按钮直接不渲染(后端也会拒,界面不该给出做不到的入口) +const { impersonating } = useCreatorAccess() const { profile, @@ -47,249 +51,260 @@ function openEdit(row) { diff --git a/src/views/creator-center/components/CreatorAccessGuide.vue b/src/views/creator-center/components/CreatorAccessGuide.vue new file mode 100644 index 0000000..900efc4 --- /dev/null +++ b/src/views/creator-center/components/CreatorAccessGuide.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/views/creator-center/components/CreatorCenterPage.vue b/src/views/creator-center/components/CreatorCenterPage.vue new file mode 100644 index 0000000..cf6d162 --- /dev/null +++ b/src/views/creator-center/components/CreatorCenterPage.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/views/creator-center/components/ImpersonationBar.vue b/src/views/creator-center/components/ImpersonationBar.vue new file mode 100644 index 0000000..ed8e009 --- /dev/null +++ b/src/views/creator-center/components/ImpersonationBar.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/views/creator-center/composables/useCreatorAccess.js b/src/views/creator-center/composables/useCreatorAccess.js new file mode 100644 index 0000000..75ae7ea --- /dev/null +++ b/src/views/creator-center/composables/useCreatorAccess.js @@ -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 + } +} diff --git a/src/views/creator-center/composables/useCreatorDashboard.js b/src/views/creator-center/composables/useCreatorDashboard.js index c05fa79..f6facdc 100644 --- a/src/views/creator-center/composables/useCreatorDashboard.js +++ b/src/views/creator-center/composables/useCreatorDashboard.js @@ -1,5 +1,6 @@ import { onMounted, ref } from 'vue' import { getCreatorDashboardApi } from '@/api/admin/creatorCenter' +import { useCreatorAccess } from './useCreatorAccess' /** * 指标展示配置:key 与后端 /admin/creator-center/dashboard 的字段一一对应, @@ -56,6 +57,7 @@ export const DASHBOARD_GROUPS = [ ] export function useCreatorDashboard() { + const { ensureAccess } = useCreatorAccess() const loading = ref(false) const board = ref(null) const lastUpdated = ref('') @@ -81,7 +83,10 @@ export function useCreatorDashboard() { return board.value?.[groupKey]?.[metricKey] } - onMounted(load) + onMounted(async () => { + if (!(await ensureAccess())) return + await load() + }) return { loading, board, lastUpdated, load, display, groupValue } } diff --git a/src/views/creator-center/composables/useCreatorPrice.js b/src/views/creator-center/composables/useCreatorPrice.js index 5de1592..3fd3670 100644 --- a/src/views/creator-center/composables/useCreatorPrice.js +++ b/src/views/creator-center/composables/useCreatorPrice.js @@ -2,8 +2,10 @@ 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: [] }) @@ -17,6 +19,7 @@ export function useCreatorPrice() { * 已上线要改价得先申请下线,避免创作者无审核地改已上线内容的价格。 */ const priceEditable = computed(() => { + if (impersonating.value) return false const status = current.value?.status return status === 'draft' || status === 'offline' }) @@ -80,6 +83,7 @@ export function useCreatorPrice() { } onMounted(async () => { + if (!(await ensureAccess())) return await loadDramas() await loadPrices() }) diff --git a/src/views/creator-center/composables/useCreatorWorkspace.js b/src/views/creator-center/composables/useCreatorWorkspace.js index b0a6f19..4bce410 100644 --- a/src/views/creator-center/composables/useCreatorWorkspace.js +++ b/src/views/creator-center/composables/useCreatorWorkspace.js @@ -8,6 +8,7 @@ import { submitDramaApi, withdrawSubmissionApi } from '@/api/admin/creatorCenter' +import { useCreatorAccess } from './useCreatorAccess' /** 作品状态:与后端 ContentStatus 一一对应 */ export const DRAMA_STATUS = [ @@ -70,6 +71,7 @@ export function submitOptions(row) { } export function useCreatorWorkspace() { + const { ensureAccess } = useCreatorAccess() const profileLoading = ref(false) const profile = ref(null) @@ -190,7 +192,9 @@ export function useCreatorWorkspace() { } } - onMounted(() => { + onMounted(async () => { + // 身份不满足(未绑定 / 未选代看目标)时不打业务接口,交给外层渲染引导页 + if (!(await ensureAccess())) return loadProfile() loadDramas() }) diff --git a/src/views/creator-center/composables/useDramaEdit.js b/src/views/creator-center/composables/useDramaEdit.js index f3fcb39..b85e909 100644 --- a/src/views/creator-center/composables/useDramaEdit.js +++ b/src/views/creator-center/composables/useDramaEdit.js @@ -17,6 +17,7 @@ import { 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' @@ -37,6 +38,7 @@ function writeSession(key, value) { } export function useDramaEdit() { + const { ensureAccess, impersonating } = useCreatorAccess() const route = useRoute() const dramaId = Number(route.params.id) @@ -56,10 +58,13 @@ export function useDramaEdit() { }) /** 只有草稿与已下线能改(与后端 requireEditable 一致),其余状态页面转只读 */ - const editable = computed( - () => drama.value?.status === 'draft' || drama.value?.status === 'offline' - ) + 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 '已上线的作品只读,要改内容请先提交「申请下线」' @@ -304,7 +309,10 @@ export function useDramaEdit() { await loadAll() } - onMounted(loadAll) + onMounted(async () => { + if (!(await ensureAccess())) return + await loadAll() + }) return { dramaId, diff --git a/types/global.d.ts b/types/global.d.ts index ac5ac1f..7e07612 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -40,6 +40,8 @@ declare global { method?: AxiosMethod headers?: RawAxiosRequestHeaders responseType?: AxiosResponseType + /** 静默请求:业务码非 0 时不弹全局提示,并把完整 Result 交给调用方判断 */ + silent?: boolean } declare interface IResponse {