This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-21 18:10:21 +08:00
commit ceb7905697
8 files changed
+636 -28

No files matched your search

+21
View File
@@ -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>({
+2
View File
@@ -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) => {
+36
View File
@@ -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 */
+18
View File
@@ -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">
+62 -13
View File
@@ -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>