Merge branch 'develop' of http://git.reelshort2.com/jianghaifeng/ReelShortWeb into develop
This commit is contained in:
commit
ceb7905697
8 files changed
+636
-28
No files matched your search
@@ -13,6 +13,20 @@ export interface AppUserPageParams {
|
||||
registerSource?: string
|
||||
}
|
||||
|
||||
export interface WatchHistoryPageParams {
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
userId?: number
|
||||
dramaTitle?: string
|
||||
dramaId?: number
|
||||
episodeId?: number
|
||||
source?: string
|
||||
finished?: boolean
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
|
||||
export const pageAppUsersApi = (params: AppUserPageParams) => {
|
||||
return request.get<PageResult<AppUserItem>>({ url: '/admin/app-users/page', params })
|
||||
}
|
||||
@@ -32,6 +46,13 @@ export const listAppUserWatchHistoryApi = (id: number, limit = 50) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const pageWatchHistoryApi = (params: WatchHistoryPageParams) => {
|
||||
return request.get<PageResult<WatchHistoryItem>>({
|
||||
url: '/admin/watch-history/page',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** normal / limited / banned,封禁与限制会立即吊销该用户全部会话 */
|
||||
export const updateAppUserStatusApi = (id: number, status: string, reason?: string) => {
|
||||
return request.put<void>({
|
||||
|
||||
@@ -15,7 +15,9 @@ export interface DramaPageParams {
|
||||
keyword?: string
|
||||
status?: string
|
||||
categoryId?: number
|
||||
categoryIds?: number[]
|
||||
isHot?: boolean
|
||||
dramaTypes?: Array<'ANIME' | 'LIVE'>
|
||||
}
|
||||
|
||||
export const pageDramasApi = (params: DramaPageParams) => {
|
||||
|
||||
@@ -20,11 +20,15 @@ export interface DramaItem {
|
||||
title: string
|
||||
subtitle?: string
|
||||
categoryId?: number
|
||||
categoryIds?: number[]
|
||||
categoryNames?: string[]
|
||||
category?: string
|
||||
genre?: string
|
||||
description?: string
|
||||
cover?: string
|
||||
language?: string
|
||||
dramaType?: 'ANIME' | 'LIVE'
|
||||
dramaTypes?: Array<'ANIME' | 'LIVE'>
|
||||
episodes?: number
|
||||
freeEpisodes?: number
|
||||
unlockCoin?: number
|
||||
@@ -33,6 +37,9 @@ export interface DramaItem {
|
||||
status: string
|
||||
statusText: string
|
||||
views?: number
|
||||
favoriteCount?: number
|
||||
likeCount?: number
|
||||
commentCount?: number
|
||||
sort?: number
|
||||
publishAt?: string
|
||||
createdAt?: string
|
||||
@@ -43,12 +50,18 @@ export interface DramaSaveData {
|
||||
title: string
|
||||
subtitle?: string
|
||||
categoryId?: number
|
||||
categoryIds?: number[]
|
||||
genre?: string
|
||||
description?: string
|
||||
cover?: string
|
||||
language?: string
|
||||
dramaType?: 'ANIME' | 'LIVE'
|
||||
dramaTypes?: Array<'ANIME' | 'LIVE'>
|
||||
freeEpisodes?: number
|
||||
unlockCoin?: number
|
||||
favoriteCount?: number
|
||||
likeCount?: number
|
||||
heat?: number
|
||||
isHot?: boolean
|
||||
sort?: number
|
||||
}
|
||||
@@ -69,6 +82,10 @@ export interface EpisodeItem {
|
||||
status: string
|
||||
statusText: string
|
||||
views?: number
|
||||
likeCount?: number
|
||||
commentCount?: number
|
||||
favoriteCount?: number
|
||||
heat?: number
|
||||
sort?: number
|
||||
}
|
||||
|
||||
@@ -81,6 +98,7 @@ export interface EpisodeSaveData {
|
||||
videoUrl?: string
|
||||
isFree?: boolean
|
||||
unlockCoin?: number
|
||||
likeCount?: number
|
||||
status?: string
|
||||
sort?: number
|
||||
}
|
||||
@@ -294,11 +312,15 @@ export interface AppUserBanRecord {
|
||||
|
||||
export interface WatchHistoryItem {
|
||||
id: number
|
||||
userId?: number
|
||||
uid?: string
|
||||
nickname?: string
|
||||
dramaId: number
|
||||
dramaTitle?: string
|
||||
dramaPublished?: boolean
|
||||
episodeId: number
|
||||
episodeNo?: number
|
||||
episodeTitle?: string
|
||||
positionMs: number
|
||||
durationMs: number
|
||||
finished?: boolean
|
||||
@@ -311,7 +333,10 @@ export interface WatchHistoryItem {
|
||||
export interface CommentItem {
|
||||
id: number
|
||||
dramaId: number
|
||||
dramaTitle?: string
|
||||
episodeId: number
|
||||
episodeNo?: number
|
||||
episodeTitle?: string
|
||||
parentId: number
|
||||
rootId: number
|
||||
userId: number
|
||||
@@ -330,9 +355,20 @@ export interface CommentItem {
|
||||
export interface ReportItem {
|
||||
id: number
|
||||
reporterId: number
|
||||
reporterNickname?: string
|
||||
reporterUid?: string
|
||||
/** COMMENT / USER / DRAMA */
|
||||
targetType: string
|
||||
targetId: number
|
||||
targetSummary?: string
|
||||
dramaId?: number
|
||||
dramaTitle?: string
|
||||
episodeId?: number
|
||||
episodeNo?: number
|
||||
episodeTitle?: string
|
||||
commentContent?: string
|
||||
targetNickname?: string
|
||||
targetUid?: string
|
||||
reasonCode: string
|
||||
description: string
|
||||
/** pending / handled / rejected */
|
||||
|
||||
@@ -147,6 +147,24 @@
|
||||
width: 180px !important;
|
||||
}
|
||||
|
||||
.readonly-stat {
|
||||
min-width: 180px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 32px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
}
|
||||
|
||||
.readonly-stat-hint {
|
||||
margin: 4px 0 0;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cover-editor {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
|
||||
@@ -29,7 +29,8 @@ const categories = ref([])
|
||||
|
||||
const keyword = ref('')
|
||||
const statusFilter = ref(undefined)
|
||||
const categoryId = ref(undefined)
|
||||
const categoryIds = ref([])
|
||||
const dramaTypes = ref([])
|
||||
const hotFilter = ref(undefined)
|
||||
const pageIndex = ref(1)
|
||||
const pageSize = ref(10)
|
||||
@@ -41,13 +42,18 @@ const coverPercent = ref(0)
|
||||
const coverError = ref('')
|
||||
const coverFileName = ref('')
|
||||
const editingId = ref(null)
|
||||
const editingCommentCount = ref(0)
|
||||
const editingLikeCount = ref(0)
|
||||
const formRef = ref()
|
||||
const form = reactive({
|
||||
title: '',
|
||||
categoryId: undefined,
|
||||
categoryIds: [],
|
||||
dramaTypes: ['LIVE'],
|
||||
description: '',
|
||||
freeEpisodes: 3,
|
||||
unlockCoin: 0,
|
||||
favoriteCount: 0,
|
||||
heat: 0,
|
||||
isHot: false,
|
||||
cover: ''
|
||||
})
|
||||
@@ -57,7 +63,12 @@ const COVER_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||
|
||||
const formRules = {
|
||||
title: [{ required: true, message: '请输入短剧名称', trigger: 'blur' }],
|
||||
categoryId: [{ required: true, message: '请选择短剧类型', trigger: 'change' }]
|
||||
categoryIds: [
|
||||
{ type: 'array', required: true, min: 1, message: '请至少选择一个类型', trigger: 'change' }
|
||||
],
|
||||
dramaTypes: [
|
||||
{ type: 'array', required: true, min: 1, message: '请至少选择一个内容类型', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
const coverDisplayName = computed(
|
||||
@@ -78,6 +89,11 @@ const statusOptions = [
|
||||
{ code: 'offline', text: '已下线' }
|
||||
]
|
||||
|
||||
const dramaTypeOptions = [
|
||||
{ value: 'ANIME', label: '漫剧' },
|
||||
{ value: 'LIVE', label: '真人剧' }
|
||||
]
|
||||
|
||||
const actionMeta = {
|
||||
submit: { label: '提交审核', done: '已提交审核' },
|
||||
approve: { label: '审核通过', done: '已通过审核' },
|
||||
@@ -94,7 +110,8 @@ async function load() {
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
categoryId: categoryId.value,
|
||||
categoryIds: categoryIds.value.length ? categoryIds.value : undefined,
|
||||
dramaTypes: dramaTypes.value.length ? dramaTypes.value : undefined,
|
||||
isHot: hotFilter.value
|
||||
})
|
||||
list.value = res.data?.list || []
|
||||
@@ -117,7 +134,8 @@ function search() {
|
||||
function resetFilters() {
|
||||
keyword.value = ''
|
||||
statusFilter.value = undefined
|
||||
categoryId.value = undefined
|
||||
categoryIds.value = []
|
||||
dramaTypes.value = []
|
||||
hotFilter.value = undefined
|
||||
search()
|
||||
}
|
||||
@@ -142,15 +160,20 @@ function openCreate() {
|
||||
editingId.value = null
|
||||
Object.assign(form, {
|
||||
title: '',
|
||||
categoryId: undefined,
|
||||
categoryIds: [],
|
||||
dramaTypes: ['LIVE'],
|
||||
description: '',
|
||||
freeEpisodes: 3,
|
||||
unlockCoin: 0,
|
||||
favoriteCount: 0,
|
||||
heat: 0,
|
||||
isHot: false,
|
||||
cover: ''
|
||||
})
|
||||
coverFileName.value = ''
|
||||
coverError.value = ''
|
||||
editingCommentCount.value = 0
|
||||
editingLikeCount.value = 0
|
||||
dialogVisible.value = true
|
||||
resetValidation()
|
||||
}
|
||||
@@ -159,19 +182,54 @@ function openEdit(row) {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
title: row.title,
|
||||
categoryId: row.categoryId || undefined,
|
||||
categoryIds:
|
||||
Array.isArray(row.categoryIds) && row.categoryIds.length
|
||||
? row.categoryIds
|
||||
: row.categoryId
|
||||
? [row.categoryId]
|
||||
: [],
|
||||
dramaTypes:
|
||||
Array.isArray(row.dramaTypes) && row.dramaTypes.length
|
||||
? row.dramaTypes
|
||||
: row.dramaType
|
||||
? [row.dramaType]
|
||||
: ['LIVE'],
|
||||
description: row.description || '',
|
||||
freeEpisodes: row.freeEpisodes ?? 3,
|
||||
unlockCoin: row.unlockCoin ?? 0,
|
||||
favoriteCount: row.favoriteCount ?? 0,
|
||||
heat: row.views ?? 0,
|
||||
isHot: !!row.isHot,
|
||||
cover: row.cover || ''
|
||||
})
|
||||
coverFileName.value = row.cover ? coverBasename(row.cover) : ''
|
||||
coverError.value = ''
|
||||
editingCommentCount.value = row.commentCount ?? 0
|
||||
editingLikeCount.value = row.likeCount ?? 0
|
||||
dialogVisible.value = true
|
||||
resetValidation()
|
||||
}
|
||||
|
||||
function dramaTypeLabel(value) {
|
||||
return dramaTypeOptions.find((item) => item.value === value)?.label || '真人剧'
|
||||
}
|
||||
|
||||
function rowCategoryList(row) {
|
||||
if (Array.isArray(row.categoryNames) && row.categoryNames.length) {
|
||||
return row.categoryNames.filter(Boolean)
|
||||
}
|
||||
if (!row.genre) return []
|
||||
return row.genre
|
||||
.split('/')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function rowDramaTypes(row) {
|
||||
if (Array.isArray(row.dramaTypes) && row.dramaTypes.length) return row.dramaTypes
|
||||
return row.dramaType ? [row.dramaType] : []
|
||||
}
|
||||
|
||||
async function saveDrama() {
|
||||
if (saving.value || uploadingCover.value) return
|
||||
try {
|
||||
@@ -179,11 +237,13 @@ async function saveDrama() {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const category = categories.value.find((item) => item.id === form.categoryId)
|
||||
const categoryNames = form.categoryIds
|
||||
.map((id) => categories.value.find((item) => item.id === id)?.name)
|
||||
.filter(Boolean)
|
||||
const payload = {
|
||||
...form,
|
||||
title: form.title.trim(),
|
||||
genre: category?.name || ''
|
||||
genre: categoryNames.join(' / ')
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
@@ -338,12 +398,30 @@ onMounted(async () => {
|
||||
:key="item.code"
|
||||
:label="item.text"
|
||||
:value="item.code" /></el-select
|
||||
><el-select v-model="categoryId" clearable placeholder="全部类型" @change="search"
|
||||
><el-select
|
||||
v-model="categoryIds"
|
||||
class="drama-multi-select"
|
||||
multiple
|
||||
clearable
|
||||
placeholder="全部类型"
|
||||
@change="search"
|
||||
><el-option
|
||||
v-for="item in categories"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id" /></el-select
|
||||
><el-select
|
||||
v-model="dramaTypes"
|
||||
class="drama-multi-select"
|
||||
multiple
|
||||
clearable
|
||||
placeholder="全部内容类型"
|
||||
@change="search"
|
||||
><el-option
|
||||
v-for="item in dramaTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value" /></el-select
|
||||
><el-select v-model="hotFilter" clearable placeholder="全部热度" @change="search"
|
||||
><el-option label="仅热门" :value="true" /><el-option
|
||||
label="非热门"
|
||||
@@ -382,13 +460,49 @@ onMounted(async () => {
|
||||
><span v-else class="table-empty-text">—</span></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column prop="genre" label="类型" width="110" />
|
||||
<el-table-column label="类型" min-width="200"
|
||||
><template #default="{ row }"
|
||||
><div class="tag-wrap"
|
||||
><el-tag v-for="name in rowCategoryList(row)" :key="name" type="info" effect="light">{{
|
||||
name
|
||||
}}</el-tag></div
|
||||
></template
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column label="内容类型" min-width="180"
|
||||
><template #default="{ row }"
|
||||
><div class="tag-wrap"
|
||||
><el-tag
|
||||
v-for="type in rowDramaTypes(row)"
|
||||
:key="type"
|
||||
:type="type === 'ANIME' ? 'warning' : 'info'"
|
||||
effect="light"
|
||||
>{{ dramaTypeLabel(type) }}</el-tag
|
||||
></div
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="集数" width="90"
|
||||
><template #default="{ row }">{{ row.episodes }} 集</template></el-table-column
|
||||
>
|
||||
<el-table-column label="播放量" width="110"
|
||||
<el-table-column label="热度" width="110"
|
||||
><template #default="{ row }">{{ formatCount(row.views) }}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="收藏" width="90"
|
||||
><template #default="{ row }">{{
|
||||
formatCount(row.favoriteCount ?? 0)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="点赞" width="90"
|
||||
><template #default="{ row }">{{
|
||||
formatCount(row.likeCount ?? 0)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="评论" width="90"
|
||||
><template #default="{ row }">{{
|
||||
formatCount(row.commentCount ?? 0)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="状态" width="110"
|
||||
><template #default="{ row }"
|
||||
><el-tag :class="['status-badge', statusClass(row.status)]" effect="light">{{
|
||||
@@ -477,8 +591,14 @@ onMounted(async () => {
|
||||
<el-form-item label="短剧名称" prop="title">
|
||||
<el-input v-model="form.title" maxlength="80" placeholder="请输入短剧名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="categoryId">
|
||||
<el-select v-model="form.categoryId" placeholder="请选择类型">
|
||||
<el-form-item label="类型" prop="categoryIds">
|
||||
<el-select
|
||||
v-model="form.categoryIds"
|
||||
class="drama-multi-select"
|
||||
multiple
|
||||
filterable
|
||||
placeholder="请选择类型"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in categories"
|
||||
:key="item.id"
|
||||
@@ -487,6 +607,22 @@ onMounted(async () => {
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="内容类型" prop="dramaTypes">
|
||||
<el-select
|
||||
v-model="form.dramaTypes"
|
||||
class="drama-multi-select"
|
||||
multiple
|
||||
filterable
|
||||
placeholder="请选择内容类型"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in dramaTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="简介">
|
||||
<el-input
|
||||
@@ -527,6 +663,38 @@ onMounted(async () => {
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<section class="editor-section">
|
||||
<h3 class="editor-section-title">互动数据</h3>
|
||||
<div class="editor-grid editor-grid--two">
|
||||
<el-form-item label="收藏数">
|
||||
<el-input-number
|
||||
v-model="form.favoriteCount"
|
||||
class="editor-number"
|
||||
controls-position="right"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="点赞数">
|
||||
<div class="readonly-stat">{{ formatCount(editingLikeCount) }}</div>
|
||||
<p class="readonly-stat-hint">全剧分集汇总,只读</p>
|
||||
</el-form-item>
|
||||
<el-form-item label="热度">
|
||||
<el-input-number
|
||||
v-model="form.heat"
|
||||
class="editor-number"
|
||||
controls-position="right"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="评论数">
|
||||
<div class="readonly-stat">{{ formatCount(editingCommentCount) }}</div>
|
||||
<p class="readonly-stat-hint">全剧汇总,实时统计,只读</p>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="editor-section">
|
||||
<h3 class="editor-section-title">封面</h3>
|
||||
<div class="cover-editor">
|
||||
@@ -609,3 +777,32 @@ onMounted(async () => {
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drama-multi-select {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.drama-multi-select :deep(.el-select__wrapper) {
|
||||
height: auto;
|
||||
min-height: 32px;
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.drama-multi-select :deep(.el-select__selection) {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
}
|
||||
|
||||
.drama-editor-form .drama-multi-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tag-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -35,6 +35,7 @@ const videoFileName = ref('')
|
||||
const previewVisible = ref(false)
|
||||
const previewEpisode = ref(null)
|
||||
const editingId = ref(null)
|
||||
const editingCommentCount = ref(0)
|
||||
const formRef = ref()
|
||||
const form = reactive({
|
||||
number: 1,
|
||||
@@ -43,6 +44,7 @@ const form = reactive({
|
||||
videoUrl: '',
|
||||
isFree: true,
|
||||
unlockCoin: 0,
|
||||
likeCount: 0,
|
||||
status: 'draft'
|
||||
})
|
||||
|
||||
@@ -127,10 +129,12 @@ function openCreate() {
|
||||
videoUrl: '',
|
||||
isFree: true,
|
||||
unlockCoin: selectedDrama.value?.unlockCoin ?? 0,
|
||||
likeCount: 0,
|
||||
status: 'draft'
|
||||
})
|
||||
videoFileName.value = ''
|
||||
videoError.value = ''
|
||||
editingCommentCount.value = 0
|
||||
dialogVisible.value = true
|
||||
resetValidation()
|
||||
}
|
||||
@@ -144,10 +148,12 @@ function openEdit(row) {
|
||||
videoUrl: row.videoUrl || '',
|
||||
isFree: !!row.isFree,
|
||||
unlockCoin: row.unlockCoin || 0,
|
||||
likeCount: row.likeCount ?? 0,
|
||||
status: row.status
|
||||
})
|
||||
videoFileName.value = row.videoUrl ? episodeBasename(row.videoUrl) : ''
|
||||
videoError.value = ''
|
||||
editingCommentCount.value = row.commentCount ?? 0
|
||||
dialogVisible.value = true
|
||||
resetValidation()
|
||||
}
|
||||
@@ -167,6 +173,7 @@ async function saveEpisode() {
|
||||
videoUrl: form.videoUrl.trim(),
|
||||
isFree: form.isFree,
|
||||
unlockCoin: form.isFree ? 0 : form.unlockCoin || 0,
|
||||
likeCount: form.likeCount,
|
||||
status: form.status
|
||||
}
|
||||
saving.value = true
|
||||
@@ -325,7 +332,9 @@ onMounted(async () => {
|
||||
<span class="episode-context-label">当前短剧</span>
|
||||
<b class="episode-context-title">{{ selectedDrama?.title || '暂无短剧' }}</b>
|
||||
<small class="episode-context-count"
|
||||
>共 {{ selectedDrama?.episodes ?? episodes.length }} 集</small
|
||||
>共 {{ selectedDrama?.episodes ?? episodes.length }} 集 · 收藏
|
||||
{{ formatCount(selectedDrama?.favoriteCount ?? 0) }} · 热度
|
||||
{{ formatCount(selectedDrama?.views ?? 0) }}</small
|
||||
>
|
||||
</div>
|
||||
<div class="episode-switcher">
|
||||
@@ -398,6 +407,24 @@ onMounted(async () => {
|
||||
<el-table-column label="播放量" width="120"
|
||||
><template #default="{ row }">{{ formatCount(row.views) }}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="点赞(本集)" width="120"
|
||||
><template #default="{ row }">{{
|
||||
formatCount(row.likeCount ?? 0)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="评论(本集)" width="120"
|
||||
><template #default="{ row }">{{
|
||||
formatCount(row.commentCount ?? 0)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="所属剧收藏" width="120"
|
||||
><template #default="{ row }">{{
|
||||
formatCount(row.favoriteCount ?? 0)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="所属剧热度" width="120"
|
||||
><template #default="{ row }">{{ formatCount(row.heat ?? 0) }}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="状态" width="110"
|
||||
><template #default="{ row }"
|
||||
><el-tag :class="['status-badge', statusClass(row.status)]" effect="light">{{
|
||||
@@ -568,6 +595,25 @@ onMounted(async () => {
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<section class="episode-editor-section">
|
||||
<h3 class="episode-editor-section-title">互动数据</h3>
|
||||
<div class="episode-editor-grid episode-editor-grid--two">
|
||||
<el-form-item label="本集点赞数">
|
||||
<el-input-number
|
||||
v-model="form.likeCount"
|
||||
class="episode-editor-number"
|
||||
controls-position="right"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="本集评论数">
|
||||
<div class="readonly-stat">{{ formatCount(editingCommentCount) }}</div>
|
||||
<p class="readonly-stat-hint">按集实时统计,不可编辑</p>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="episode-editor-section">
|
||||
<h3 class="episode-editor-section-title">付费与状态</h3>
|
||||
<div class="episode-editor-grid episode-editor-grid--three">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import PageHeading from '@/views/components/PageHeading.vue'
|
||||
import { useRbac } from '@/stores/rbac'
|
||||
@@ -26,16 +26,23 @@ const canWordUpdate = can('sensitive-word:update')
|
||||
const activeTab = ref('comments')
|
||||
|
||||
const COMMENT_STATUS = [
|
||||
{ value: '', label: '全部状态', tag: 'info' },
|
||||
{ value: 'pending', label: '待审核', tag: 'warning' },
|
||||
{ value: 'published', label: '已发布', tag: 'success' },
|
||||
{ value: 'rejected', label: '已驳回', tag: 'danger' },
|
||||
{ value: 'deleted', label: '已删除', tag: 'info' }
|
||||
]
|
||||
const REPORT_STATUS = [
|
||||
{ value: '', label: '全部状态', tag: 'info' },
|
||||
{ value: 'pending', label: '待处理', tag: 'warning' },
|
||||
{ value: 'handled', label: '已处理', tag: 'success' },
|
||||
{ value: 'rejected', label: '已驳回', tag: 'info' }
|
||||
]
|
||||
const REPORT_TARGET_TYPES = {
|
||||
COMMENT: { label: '评论', tag: 'warning' },
|
||||
USER: { label: '用户', tag: 'success' },
|
||||
DRAMA: { label: '短剧', tag: 'info' }
|
||||
}
|
||||
const WORD_LEVELS = [
|
||||
{ value: 'BLOCK', label: '禁止发布' },
|
||||
{ value: 'REVIEW', label: '转人工审核' },
|
||||
@@ -48,6 +55,22 @@ function meta(list, value) {
|
||||
function levelText(level) {
|
||||
return WORD_LEVELS.find((item) => item.value === level)?.label || level
|
||||
}
|
||||
function commentEpisodeText(row) {
|
||||
if (row.episodeNo) return `第 ${row.episodeNo} 集`
|
||||
return row.episodeId ? `分集 #${row.episodeId}` : '未关联分集'
|
||||
}
|
||||
function commentContentTooltip(row) {
|
||||
return `短剧 ID:${row.dramaId} / 分集 ID:${row.episodeId || '未关联'}`
|
||||
}
|
||||
function reportTargetMeta(type) {
|
||||
return REPORT_TARGET_TYPES[type] || { label: type || '-', tag: 'info' }
|
||||
}
|
||||
function reportTargetSummary(row) {
|
||||
return row.targetSummary || `${reportTargetMeta(row.targetType).label} #${row.targetId}`
|
||||
}
|
||||
function reporterText(row) {
|
||||
return row.reporterNickname || row.reporterUid || `#${row.reporterId}`
|
||||
}
|
||||
|
||||
const comments = reactive({
|
||||
rows: [],
|
||||
@@ -55,7 +78,7 @@ const comments = reactive({
|
||||
loading: false,
|
||||
page: 1,
|
||||
size: 10,
|
||||
status: 'pending',
|
||||
status: '',
|
||||
keyword: ''
|
||||
})
|
||||
async function loadComments() {
|
||||
@@ -123,7 +146,7 @@ const reports = reactive({
|
||||
loading: false,
|
||||
page: 1,
|
||||
size: 10,
|
||||
status: 'pending'
|
||||
status: ''
|
||||
})
|
||||
async function loadReports() {
|
||||
reports.loading = true
|
||||
@@ -271,11 +294,6 @@ function onTabChange(name) {
|
||||
if (name === 'reports') loadReports()
|
||||
if (name === 'words') loadWords()
|
||||
}
|
||||
|
||||
const handleTargetText = computed(() => {
|
||||
const row = handleTarget.value
|
||||
return row ? `${row.targetType} #${row.targetId}` : ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -325,7 +343,17 @@ const handleTargetText = computed(() => {
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="nickname" label="用户" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="content" label="内容" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="dramaId" label="短剧ID" width="90" />
|
||||
<el-table-column label="短剧 / 分集" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="commentContentTooltip(row)" placement="top">
|
||||
<div class="episode-table-title">
|
||||
<b>{{ row.dramaTitle || `短剧 #${row.dramaId}` }}</b>
|
||||
<small>{{ commentEpisodeText(row) }}</small>
|
||||
<small v-if="row.episodeTitle">{{ row.episodeTitle }}</small>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="meta(COMMENT_STATUS, row.status).tag" effect="light">{{
|
||||
@@ -410,8 +438,22 @@ const handleTargetText = computed(() => {
|
||||
empty-text="没有符合条件的工单"
|
||||
>
|
||||
<el-table-column prop="id" label="工单ID" width="90" />
|
||||
<el-table-column prop="targetType" label="对象" width="100" />
|
||||
<el-table-column prop="targetId" label="对象ID" width="90" />
|
||||
<el-table-column label="对象" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip placement="top">
|
||||
<template #content>
|
||||
<div>原始对象:#{{ row.targetId }}</div>
|
||||
<div v-if="row.commentContent">评论内容:{{ row.commentContent }}</div>
|
||||
</template>
|
||||
<div class="episode-table-title">
|
||||
<el-tag :type="reportTargetMeta(row.targetType).tag" size="small" effect="light">
|
||||
{{ reportTargetMeta(row.targetType).label }}
|
||||
</el-tag>
|
||||
<b>{{ reportTargetSummary(row) }}</b>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reasonCode" label="原因码" width="120" />
|
||||
<el-table-column
|
||||
prop="description"
|
||||
@@ -419,7 +461,9 @@ const handleTargetText = computed(() => {
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="reporterId" label="举报人" width="90" />
|
||||
<el-table-column label="举报人" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ reporterText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="meta(REPORT_STATUS, row.status).tag" effect="light">{{
|
||||
@@ -550,7 +594,12 @@ const handleTargetText = computed(() => {
|
||||
<el-dialog v-model="handleVisible" title="处理举报工单" width="480px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="工单对象">
|
||||
<el-input :model-value="handleTargetText" disabled />
|
||||
<div v-if="handleTarget" class="episode-table-title">
|
||||
<el-tag :type="reportTargetMeta(handleTarget.targetType).tag" size="small" effect="light">
|
||||
{{ reportTargetMeta(handleTarget.targetType).label }}
|
||||
</el-tag>
|
||||
<b>{{ reportTargetSummary(handleTarget) }}</b>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="处理结论">
|
||||
<el-radio-group v-model="handleForm.status">
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import PageHeading from '@/views/components/PageHeading.vue'
|
||||
import { pageWatchHistoryApi } from '@/api/admin/appUser'
|
||||
import { formatDuration } from './composables/useAppUsers'
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'HOME', label: '首页' },
|
||||
{ value: 'DETAIL', label: '详情页' },
|
||||
{ value: 'SEARCH', label: '搜索' },
|
||||
{ value: 'FAVORITE', label: '收藏' }
|
||||
]
|
||||
|
||||
const FINISHED_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: true, label: '已看完' },
|
||||
{ value: false, label: '未看完' }
|
||||
]
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
dramaTitle: '',
|
||||
source: '',
|
||||
finished: '',
|
||||
range: null
|
||||
})
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const pageIndex = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageWatchHistoryApi({
|
||||
pageIndex: pageIndex.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: filters.keyword.trim() || undefined,
|
||||
dramaTitle: filters.dramaTitle.trim() || undefined,
|
||||
source: filters.source || undefined,
|
||||
finished: filters.finished === '' ? undefined : filters.finished,
|
||||
startTime: filters.range?.[0] || undefined,
|
||||
endTime: filters.range?.[1] || undefined
|
||||
})
|
||||
rows.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
pageIndex.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = ''
|
||||
filters.dramaTitle = ''
|
||||
filters.source = ''
|
||||
filters.finished = ''
|
||||
filters.range = null
|
||||
search()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
pageIndex.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function sourceText(source) {
|
||||
return SOURCE_OPTIONS.find((item) => item.value === source)?.label || source || '—'
|
||||
}
|
||||
|
||||
function episodeText(row) {
|
||||
if (row.episodeNo) return `第 ${row.episodeNo} 集`
|
||||
return row.episodeId ? `分集 #${row.episodeId}` : '未关联分集'
|
||||
}
|
||||
|
||||
function progressText(row) {
|
||||
if (!row.durationMs) return '—'
|
||||
return `${formatDuration(row.positionMs)} / ${formatDuration(row.durationMs)}`
|
||||
}
|
||||
|
||||
function contentTooltip(row) {
|
||||
return `短剧 ID:${row.dramaId} / 分集 ID:${row.episodeId || '未关联'}`
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeading
|
||||
eyebrow="用户管理"
|
||||
title="观看记录"
|
||||
description="跨用户检索观看流水,核对短剧、分集、进度与来源。"
|
||||
/>
|
||||
|
||||
<section class="panel">
|
||||
<div class="console-toolbar">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
class="console-keyword"
|
||||
clearable
|
||||
placeholder="uid / 昵称"
|
||||
@keyup.enter="search"
|
||||
@clear="search"
|
||||
/>
|
||||
<el-input
|
||||
v-model="filters.dramaTitle"
|
||||
class="console-keyword"
|
||||
clearable
|
||||
placeholder="短剧名称"
|
||||
@keyup.enter="search"
|
||||
@clear="search"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filters.source"
|
||||
class="console-select"
|
||||
placeholder="全部来源"
|
||||
@change="search"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in SOURCE_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="filters.finished"
|
||||
class="console-select"
|
||||
placeholder="全部完成状态"
|
||||
@change="search"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in FINISHED_OPTIONS"
|
||||
:key="String(item.value)"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="filters.range"
|
||||
class="watch-history-range"
|
||||
type="datetimerange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
@change="search"
|
||||
/>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<span class="console-count">共 {{ total }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
row-key="id"
|
||||
border
|
||||
class="drama-el-table"
|
||||
empty-text="暂无符合条件的观看记录"
|
||||
>
|
||||
<el-table-column prop="watchedAt" label="观看时间" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column label="用户" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="app-user-meta">
|
||||
<span class="app-user-name">{{ row.nickname || '未设置昵称' }}</span>
|
||||
<span class="app-user-uid">{{ row.uid || `用户 #${row.userId || '—'}` }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="短剧" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="contentTooltip(row)" placement="top">
|
||||
<div class="episode-table-title">
|
||||
<b>{{ row.dramaTitle || `短剧 #${row.dramaId}` }}</b>
|
||||
<el-tag v-if="row.dramaPublished === false" type="info" size="small" effect="plain"
|
||||
>已下线</el-tag
|
||||
>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分集" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="episode-table-title">
|
||||
<b>{{ episodeText(row) }}</b>
|
||||
<small v-if="row.episodeTitle">{{ row.episodeTitle }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="进度" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="app-user-mono">{{ progressText(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="完成" width="92">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.finished ? 'success' : 'info'" effect="light">
|
||||
{{ row.finished ? '已看完' : '未看完' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" width="96">
|
||||
<template #default="{ row }">{{ sourceText(row.source) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deviceId" label="设备" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.deviceId || '—' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pageIndex"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="load"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.watch-history-range {
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user