diff --git a/src/api/admin/commerce.ts b/src/api/admin/commerce.ts new file mode 100644 index 0000000..da6cae9 --- /dev/null +++ b/src/api/admin/commerce.ts @@ -0,0 +1,122 @@ +import request from '@/axios' +import type { PageResult } from './types' + +export interface OrderItem { + id: number + orderNo: string + userId: number + productCode: string + productName: string + type: string + platform: string + amount: number + currency: string + coinAmount: number + /** CREATED / PAID / DELIVERED / REFUNDED / CLOSED */ + status: string + paidAt?: string + deliveredAt?: string + refundReason: string + failReason: string + createdAt?: string +} + +export interface OrderEventItem { + id: number + orderNo: string + event: string + fromStatus: string + toStatus: string + source: string + payload: string + operatorId: number + createdAt?: string +} + +export interface EntitlementItem { + id: number + userId: number + dramaId: number + episodeId: number + /** EPISODE / DRAMA */ + type: string + /** ORDER / ADMIN / ACTIVITY */ + source: string + /** ACTIVE / REVOKED / EXPIRED */ + status: string + orderNo: string + startAt?: string + revokeReason: string +} + +export interface LedgerItem { + id: number + userId: number + changeAmount: number + balanceAfter: number + bizType: string + bizId: string + remark: string + operatorId: number + createdAt?: string +} + +export interface ReconcileItem { + id: number + bizDate: string + platform: string + totalCount: number + matchedCount: number + diffCount: number + amountSum: number + status: string + detail: string + finishedAt?: string +} + +export const pageOrdersApi = (params: Record) => { + return request.get>({ url: '/admin/orders/page', params }) +} + +export const listOrderEventsApi = (orderNo: string) => { + return request.get({ url: `/admin/orders/${orderNo}/events` }) +} + +export const compensateOrderApi = (orderNo: string) => { + return request.post({ url: `/admin/orders/${orderNo}/compensate` }) +} + +export const refundOrderApi = (orderNo: string, reason: string) => { + return request.post({ url: `/admin/orders/${orderNo}/refund`, data: { reason } }) +} + +export const pageEntitlementsApi = (params: Record) => { + return request.get>({ url: '/admin/entitlements/page', params }) +} + +export const grantEntitlementApi = (data: Record) => { + return request.post({ url: '/admin/entitlements', data }) +} + +export const revokeEntitlementApi = (id: number, reason: string) => { + return request.put({ url: `/admin/entitlements/${id}/revoke`, params: { reason } }) +} + +export const listWalletLedgerApi = (userId: number, limit = 20) => { + return request.get({ + url: `/admin/wallets/${userId}/ledger`, + params: { limit } + }) +} + +export const adjustCoinsApi = (data: Record) => { + return request.post({ url: '/admin/wallets/coins', data }) +} + +export const pageReconcileApi = (params: Record) => { + return request.get>({ url: '/admin/reconcile/page', params }) +} + +export const runReconcileApi = (params: Record) => { + return request.post({ url: '/admin/reconcile/run', params }) +} diff --git a/src/api/admin/creator.ts b/src/api/admin/creator.ts new file mode 100644 index 0000000..762f314 --- /dev/null +++ b/src/api/admin/creator.ts @@ -0,0 +1,72 @@ +import request from '@/axios' +import type { PageResult } from './types' + +export interface CreatorItem { + id: number + userId: number + code: string + name: string + /** INDIVIDUAL / COMPANY */ + type: string + contactName: string + contactMobile: string + contactEmail: string + countryCode: string + /** pending / approved / rejected / frozen */ + status: string + auditNote: string + auditBy: number + auditAt?: string + settlementCurrency: string + createdAt?: string +} + +export interface CreatorDocumentItem { + id: number + creatorId: number + /** ID_CARD / BUSINESS_LICENSE / CONTRACT */ + type: string + fileUrl: string + status: string + auditNote: string + createdAt?: string +} + +export interface SubmissionItem { + id: number + creatorId: number + dramaId: number + /** CREATE / UPDATE / PUBLISH / OFFLINE */ + type: string + /** DRAFT / SUBMITTED / REVIEWING / APPROVED / REJECTED / WITHDRAWN */ + status: string + version: number + submitAt?: string + reviewerId: number + reviewAt?: string + reviewNote: string + rejectCode: string + createdAt?: string +} + +export const pageCreatorsApi = (params: Record) => { + return request.get>({ url: '/admin/creators/page', params }) +} + +export const listCreatorDocumentsApi = (id: number) => { + return request.get({ url: `/admin/creators/${id}/documents` }) +} + +/** action: APPROVE / REJECT / FREEZE */ +export const auditCreatorApi = (id: number, data: Record) => { + return request.put({ url: `/admin/creators/${id}/audit`, data }) +} + +export const pageSubmissionsApi = (params: Record) => { + return request.get>({ url: '/admin/submissions/page', params }) +} + +/** action: APPROVE / REJECT / WITHDRAW */ +export const auditSubmissionApi = (id: number, data: Record) => { + return request.put({ url: `/admin/submissions/${id}/audit`, data }) +} diff --git a/src/api/admin/dashboard.ts b/src/api/admin/dashboard.ts new file mode 100644 index 0000000..3509778 --- /dev/null +++ b/src/api/admin/dashboard.ts @@ -0,0 +1,34 @@ +import request from '@/axios' + +/** 指标口径见后端 DashboardService 注释与 docs/api-phase1.md「2.8 数据看板」 */ +export interface DashboardOverview { + generatedAt: string + user: Record + content: Record + playback: Record + revenue: Record + cost: Record + notes: string[] +} + +export interface TranscodeBoard { + generatedAt: string + queue: Record + failedTasks: { + id: number + episodeId: number + status: string + retryCount: number + errorMsg: string + createdAt?: string + }[] + notes: string[] +} + +export const getDashboardOverviewApi = () => { + return request.get({ url: '/admin/dashboard/overview' }) +} + +export const getTranscodeBoardApi = () => { + return request.get({ url: '/admin/dashboard/transcode' }) +} diff --git a/src/api/admin/governance.ts b/src/api/admin/governance.ts new file mode 100644 index 0000000..635c4a2 --- /dev/null +++ b/src/api/admin/governance.ts @@ -0,0 +1,49 @@ +import request from '@/axios' +import type { PageResult, CommentItem, ReportItem, SensitiveWordItem } from './types' + +export const pageCommentsApi = (params: Record) => { + return request.get>({ url: '/admin/comments/page', params }) +} + +export const listCommentAuditRecordsApi = (id: number) => { + return request.get[]>({ + url: `/admin/comments/${id}/audit-records` + }) +} + +/** result: PASS / REJECT */ +export const auditCommentApi = (id: number, data: Record) => { + return request.put({ url: `/admin/comments/${id}/audit`, data }) +} + +export const deleteCommentApi = (id: number) => { + return request.delete({ url: `/admin/comments/${id}` }) +} + +export const pageReportsApi = (params: Record) => { + return request.get>({ url: '/admin/reports/page', params }) +} + +/** status: handled / rejected;banTarget=true 时联动封禁被举报用户 */ +export const handleReportApi = (id: number, data: Record) => { + return request.put({ url: `/admin/reports/${id}/handle`, data }) +} + +export const pageSensitiveWordsApi = (params: Record) => { + return request.get>({ + url: '/admin/sensitive-words/page', + params + }) +} + +export const createSensitiveWordApi = (data: Record) => { + return request.post({ url: '/admin/sensitive-words', data }) +} + +export const updateSensitiveWordApi = (id: number, data: Record) => { + return request.put({ url: `/admin/sensitive-words/${id}`, data }) +} + +export const deleteSensitiveWordApi = (id: number) => { + return request.delete({ url: `/admin/sensitive-words/${id}` }) +} diff --git a/src/styles/business/console.css b/src/styles/business/console.css new file mode 100644 index 0000000..1352abb --- /dev/null +++ b/src/styles/business/console.css @@ -0,0 +1,171 @@ +/** + * 运营台通用版式:内容治理 / 订单与权益 / 创作者审核 / 数据看板四个页面共用。 + * + * 两条底线和 App 用户页一致: + * 1. 筛选控件窄窗口下换行,而不是被挤出可视区; + * 2. 卡片固定尺寸,指标位数变化时栅格不跳动。 + */ + +.console-toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 14px; +} + +.console-toolbar .console-keyword { + flex: 1 1 240px; + min-width: 200px; + max-width: 320px; +} + +.console-toolbar .console-select { + width: 160px; + flex: 0 0 160px; +} + +.console-toolbar .console-user, +.console-toolbar .console-drama { + width: 150px; + flex: 0 0 150px; +} + +.console-count { + margin-left: auto; + color: #858b95; + font-size: 13px; + white-space: nowrap; +} + +.console-hint { + margin: 0 0 12px; + color: #858b95; + font-size: 13px; + line-height: 1.6; +} + +/* el-alert 复用同一类名时,保留弹窗内的间距而不是当成段落文本 */ +section .console-hint.el-alert { + margin-bottom: 14px; +} + +.console-muted { + color: #858b95; + font-size: 13px; +} + +.console-full { + width: 100%; +} + +.console-number { + width: 180px; +} + +/* 金币变动:正负一眼可辨,等宽数字让列对齐 */ +.console-credit, +.console-debit { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.console-credit { + color: #4d866a; +} + +.console-debit { + color: #b54848; +} + +.console-drawer .el-drawer__header { + margin-bottom: 8px; +} + +.console-drawer .el-table { + font-size: 12px; +} + +.console-drawer .el-table .el-table__cell { + padding: 6px 0; +} + +/* ---------- 数据看板 ---------- */ + +.board-group + .board-group { + margin-top: 20px; +} + +.board-title { + margin: 0 0 10px; + color: #302d44; + font-size: 14px; + font-weight: 650; +} + +.board-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px; +} + +.board-card { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 74px; + padding: 12px 14px; + border: 1px solid #e6e8eb; + border-radius: 6px; + background: #fff; +} + +.board-label { + color: #727983; + font-size: 12px; +} + +.board-value { + color: #303640; + font-size: 20px; + font-variant-numeric: tabular-nums; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.board-value-empty { + color: #9a97a8; + font-size: 13px; + font-weight: 400; +} + +.board-notes { + margin-top: 18px; +} + +.board-notes ul { + margin: 0; + padding-left: 18px; + line-height: 1.7; +} + +.board-transcode { + margin-top: 16px; +} + +html.dark .console-count, +html.dark .console-hint, +html.dark .console-muted, +html.dark .board-label { + color: var(--el-text-color-secondary); +} + +html.dark .board-title, +html.dark .board-value { + color: var(--el-text-color-primary); +} + +html.dark .board-card { + border-color: var(--el-border-color-light); + background: var(--el-bg-color-overlay); +} diff --git a/src/styles/business/index.css b/src/styles/business/index.css index 5994ba9..0fececf 100644 --- a/src/styles/business/index.css +++ b/src/styles/business/index.css @@ -2,4 +2,9 @@ @import './theme.css'; @import './typography.css'; @import './drama-management.css'; +@import './banner-management.css'; +@import './operation-log.css'; +@import './app-user-management.css'; +@import './console.css'; +@import './layout.css'; @import './dark.css'; diff --git a/src/views/commerce/OrderManagementView.vue b/src/views/commerce/OrderManagementView.vue new file mode 100644 index 0000000..47260f0 --- /dev/null +++ b/src/views/commerce/OrderManagementView.vue @@ -0,0 +1,679 @@ + + + diff --git a/src/views/creator/CreatorAuditView.vue b/src/views/creator/CreatorAuditView.vue new file mode 100644 index 0000000..eca3772 --- /dev/null +++ b/src/views/creator/CreatorAuditView.vue @@ -0,0 +1,234 @@ + + + diff --git a/src/views/creator/SubmissionAuditView.vue b/src/views/creator/SubmissionAuditView.vue new file mode 100644 index 0000000..5478c75 --- /dev/null +++ b/src/views/creator/SubmissionAuditView.vue @@ -0,0 +1,201 @@ + + + diff --git a/src/views/creator/composables/useCreatorAudit.js b/src/views/creator/composables/useCreatorAudit.js new file mode 100644 index 0000000..41522c1 --- /dev/null +++ b/src/views/creator/composables/useCreatorAudit.js @@ -0,0 +1,167 @@ +import { onMounted, reactive, ref } from 'vue' +import { ElMessage, ElMessageBox } from 'element-plus' +import { auditCreatorApi, listCreatorDocumentsApi, pageCreatorsApi } from '@/api/admin/creator' + +/** 与后端 CreatorService 的状态取值保持一致 */ +export const CREATOR_STATUS = [ + { value: 'pending', label: '待审核', tag: 'warning' }, + { value: 'approved', label: '已通过', tag: 'success' }, + { value: 'rejected', label: '已驳回', tag: 'danger' }, + { value: 'frozen', label: '已冻结', tag: 'info' } +] + +export const CREATOR_TYPE = [ + { value: 'INDIVIDUAL', label: '个人' }, + { value: 'COMPANY', label: '机构' } +] + +export function creatorStatusMeta(status) { + return ( + CREATOR_STATUS.find((item) => item.value === status) || { label: status || '-', tag: 'info' } + ) +} + +export function creatorTypeText(type) { + return CREATOR_TYPE.find((item) => item.value === type)?.label || type || '-' +} + +export function useCreatorAudit() { + const rows = ref([]) + const total = ref(0) + const loading = ref(false) + const keyword = ref('') + const statusFilter = ref(undefined) + const typeFilter = ref(undefined) + const currentPage = ref(1) + const pageSize = ref(10) + + const auditVisible = ref(false) + const auditSaving = ref(false) + const auditTarget = ref(null) + const auditForm = reactive({ action: 'APPROVE', reason: '', rejectCode: '' }) + + const documentsVisible = ref(false) + const documentsLoading = ref(false) + const documents = ref([]) + const documentsTarget = ref(null) + + async function load() { + loading.value = true + try { + const res = await pageCreatorsApi({ + pageIndex: currentPage.value, + pageSize: pageSize.value, + keyword: keyword.value.trim() || undefined, + status: statusFilter.value, + type: typeFilter.value + }) + rows.value = res.data?.list || [] + total.value = res.data?.total || 0 + } finally { + loading.value = false + } + } + + function query() { + currentPage.value = 1 + load() + } + + function reset() { + keyword.value = '' + statusFilter.value = undefined + typeFilter.value = undefined + query() + } + + function handleSizeChange() { + currentPage.value = 1 + load() + } + + async function openDocuments(row) { + documentsTarget.value = row + documentsVisible.value = true + documentsLoading.value = true + documents.value = [] + try { + documents.value = (await listCreatorDocumentsApi(row.id)).data || [] + } finally { + documentsLoading.value = false + } + } + + function openAudit(row, action = 'APPROVE') { + auditTarget.value = row + auditForm.action = action + auditForm.reason = '' + auditForm.rejectCode = '' + auditVisible.value = true + } + + async function confirmAudit() { + const row = auditTarget.value + if (!row) return + if (auditForm.action === 'REJECT' && !auditForm.reason.trim()) { + return ElMessage.warning('驳回必须填写原因') + } + auditSaving.value = true + try { + await auditCreatorApi(row.id, { + action: auditForm.action, + reason: auditForm.reason.trim(), + rejectCode: auditForm.rejectCode.trim() + }) + ElMessage.success('审核已提交') + auditVisible.value = false + await load() + } finally { + auditSaving.value = false + } + } + + /** 冻结是不可逆的运营动作,单独二次确认 */ + async function freeze(row) { + try { + await ElMessageBox.confirm(`冻结后「${row.name}」将无法提交内容,确认冻结?`, '冻结创作者', { + confirmButtonText: '确认冻结', + cancelButtonText: '取消', + type: 'warning' + }) + } catch { + return + } + await auditCreatorApi(row.id, { action: 'FREEZE', reason: '运营冻结' }) + ElMessage.success('已冻结') + await load() + } + + onMounted(load) + + return { + rows, + total, + loading, + keyword, + statusFilter, + typeFilter, + currentPage, + pageSize, + auditVisible, + auditSaving, + auditTarget, + auditForm, + documentsVisible, + documentsLoading, + documents, + documentsTarget, + load, + query, + reset, + handleSizeChange, + openDocuments, + openAudit, + confirmAudit, + freeze + } +} diff --git a/src/views/creator/composables/useSubmissionAudit.js b/src/views/creator/composables/useSubmissionAudit.js new file mode 100644 index 0000000..d5a9ba9 --- /dev/null +++ b/src/views/creator/composables/useSubmissionAudit.js @@ -0,0 +1,136 @@ +import { onMounted, reactive, ref } from 'vue' +import { ElMessage } from 'element-plus' +import { auditSubmissionApi, pageSubmissionsApi } from '@/api/admin/creator' + +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 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 || '-' +} + +export function useSubmissionAudit() { + const rows = ref([]) + const total = ref(0) + const loading = ref(false) + const statusFilter = ref('SUBMITTED') + const typeFilter = ref(undefined) + const dramaId = ref(undefined) + const currentPage = ref(1) + const pageSize = ref(10) + + const auditVisible = ref(false) + const auditSaving = ref(false) + const auditTarget = ref(null) + const auditForm = reactive({ action: 'APPROVE', reason: '', rejectCode: '' }) + + async function load() { + loading.value = true + try { + const res = await pageSubmissionsApi({ + pageIndex: currentPage.value, + pageSize: pageSize.value, + status: statusFilter.value, + type: typeFilter.value, + dramaId: dramaId.value || undefined + }) + rows.value = res.data?.list || [] + total.value = res.data?.total || 0 + } finally { + loading.value = false + } + } + + function query() { + currentPage.value = 1 + load() + } + + function reset() { + statusFilter.value = 'SUBMITTED' + typeFilter.value = undefined + dramaId.value = undefined + query() + } + + function handleSizeChange() { + currentPage.value = 1 + load() + } + + function openAudit(row, action = 'APPROVE') { + auditTarget.value = row + auditForm.action = action + auditForm.reason = '' + auditForm.rejectCode = '' + auditVisible.value = true + } + + async function confirmAudit() { + const row = auditTarget.value + if (!row) return + if (auditForm.action === 'REJECT' && !auditForm.reason.trim()) { + return ElMessage.warning('驳回必须填写原因') + } + auditSaving.value = true + try { + await auditSubmissionApi(row.id, { + action: auditForm.action, + reason: auditForm.reason.trim(), + rejectCode: auditForm.rejectCode.trim() + }) + ElMessage.success( + auditForm.action === 'APPROVE' ? '审核通过,已按类型落到短剧状态' : '审核结果已保存' + ) + auditVisible.value = false + await load() + } finally { + auditSaving.value = false + } + } + + onMounted(load) + + return { + rows, + total, + loading, + statusFilter, + typeFilter, + dramaId, + currentPage, + pageSize, + auditVisible, + auditSaving, + auditTarget, + auditForm, + load, + query, + reset, + handleSizeChange, + openAudit, + confirmAudit + } +} diff --git a/src/views/dashboard/BusinessDashboardView.vue b/src/views/dashboard/BusinessDashboardView.vue new file mode 100644 index 0000000..0946423 --- /dev/null +++ b/src/views/dashboard/BusinessDashboardView.vue @@ -0,0 +1,180 @@ + + + diff --git a/src/views/governance/ContentGovernanceView.vue b/src/views/governance/ContentGovernanceView.vue new file mode 100644 index 0000000..23f3db4 --- /dev/null +++ b/src/views/governance/ContentGovernanceView.vue @@ -0,0 +1,630 @@ + + +