chore(merge): 合并 phase1 UI prep 后台用户系统筛选与列表展示

合并后新增收藏/点赞列表页、用户单元格组件与系统筛选,
并统一观看时间格式与标签文案。
This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-27 20:20:31 +08:00
commit b68361b037
16 files changed
+685 -39

No files matched your search

+4
View File
@@ -11,6 +11,8 @@ export interface AppUserPageParams {
status?: string
countryCode?: string
registerSource?: string
/** 客户端平台:ANDROID / IOS / WEB */
platform?: string
}
export interface WatchHistoryPageParams {
@@ -21,6 +23,8 @@ export interface WatchHistoryPageParams {
dramaTitle?: string
dramaId?: number
episodeId?: number
/** 客户端平台:ANDROID / IOS / WEB */
platform?: string
source?: string
finished?: boolean
startTime?: string
+54
View File
@@ -0,0 +1,54 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
/** 收藏与点赞只读分页筛选条件。 */
export interface EngagementPageParams {
pageIndex?: number
pageSize?: number
keyword?: string
userId?: string | number
deviceId?: string
/** 客户端平台:ANDROID / IOS / WEB */
platform?: string
dramaId?: string | number
dramaTitle?: string
targetType?: string
targetId?: string | number
episodeId?: string | number
startTime?: string
endTime?: string
}
/** 后台互动列表行。 */
export interface EngagementItem {
id: string
userId: string | null
uid: string | null
nickname: string | null
deviceId: string | null
/** 客户端平台 ANDROID / IOS / WEB;老客户端未上报时为空。 */
platform: string | null
anonymous: boolean
dramaId: string | null
dramaTitle: string | null
dramaCover: string | null
dramaStatus: string | null
dramaPublished: boolean | null
targetType: string
targetId: string
episodeId: string | null
episodeNo: number | null
episodeTitle: string | null
commentSummary: string | null
createdAt: string
}
/** 查询收藏分页。 */
export const pageFavoritesApi = (params: EngagementPageParams) =>
request.get<PageResult<EngagementItem>>({ url: '/admin/favorites/page', params })
/** 查询点赞分页。 */
export const pageLikesApi = (params: EngagementPageParams) =>
request.get<PageResult<EngagementItem>>({ url: '/admin/likes/page', params })
+3 -1
View File
@@ -18,7 +18,8 @@
}
.app-user-toolbar .app-user-status,
.app-user-toolbar .app-user-country {
.app-user-toolbar .app-user-country,
.app-user-toolbar .app-user-platform {
width: 150px;
flex: 0 0 150px;
}
@@ -63,6 +64,7 @@
.app-user-meta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
}
+43
View File
@@ -0,0 +1,43 @@
<!-- Author: fruit.com.cn -->
<script setup>
import { computed } from 'vue'
/**
* 后台各列表共用的「用户」单元格。
*
* 口径:已登录显示昵称 + `UID xxx`;未登录显示「游客」标签 + `设备 xxx`。
* 两行都带标签,避免一串随机字母看不出是什么。平台由独立的「系统」列展示。
*/
const props = defineProps({
row: { type: Object, required: true }
})
const isGuest = computed(() => props.row?.userId == null)
</script>
<template>
<div class="app-user-meta">
<template v-if="isGuest">
<el-tag type="info" size="small">游客</el-tag>
<span class="app-user-uid app-user-device" :title="row.deviceId || ''">
设备: {{ row.deviceId || '—' }}
</span>
</template>
<template v-else>
<span class="app-user-name" :title="row.nickname || ''">
昵称: {{ row.nickname || '未设置昵称' }}
</span>
<span class="app-user-uid">UID: {{ row.uid || `#${row.userId}` }}</span>
</template>
</div>
</template>
<style scoped>
.app-user-device {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+12 -4
View File
@@ -13,15 +13,21 @@ const props = defineProps({
showLanguages: { type: Boolean, default: true },
required: { type: Boolean, default: true }
})
const emit = defineEmits(['update:languages'])
const emit = defineEmits(['update:languages', 'update:locales'])
function changeLanguages(codes) {
const next = codes.includes('zh') ? codes : ['zh', ...codes]
const locales = { ...props.locales }
next.forEach((code) => {
if (props.locales[code] == null) props.locales[code] = ''
if (locales[code] == null) locales[code] = ''
})
emit('update:locales', locales)
emit('update:languages', next)
}
function changeLocale(code, value) {
emit('update:locales', { ...props.locales, [code]: value })
}
</script>
<template>
@@ -50,13 +56,15 @@ function changeLanguages(codes) {
>
<el-input
v-if="!multiline"
v-model="locales[code]"
:model-value="locales[code]"
@update:model-value="changeLocale(code, $event)"
:maxlength="maxlength"
:placeholder="'请填写' + languageLabel(code) + label"
/>
<el-input
v-else
v-model="locales[code]"
:model-value="locales[code]"
@update:model-value="changeLocale(code, $event)"
type="textarea"
:rows="2"
:maxlength="maxlength"
@@ -372,6 +372,7 @@ onMounted(loadClassifications)
v-model:candidate-keyword="candidateKeyword"
:editing-id="editingId"
:form="form"
@update:form="Object.assign(form, $event)"
:saving="saving"
:candidate-loading="candidateLoading"
:candidates="candidates"
@@ -180,6 +180,7 @@ onMounted(load)
<LocaleTextFields
v-model:languages="languages"
:locales="form.locales"
@update:locales="form.locales = $event"
label="名称"
:maxlength="64"
/>
@@ -37,6 +37,7 @@ defineProps({
const emit = defineEmits([
'update:editorVisible',
'update:languages',
'update:form',
'update:candidateVisible',
'update:candidateKeyword',
'save',
@@ -61,13 +62,25 @@ const emit = defineEmits([
label="名称"
:maxlength="64"
@update:languages="emit('update:languages', $event)"
@update:locales="emit('update:form', { ...form, locales: $event })"
/>
<div class="classification-form-row">
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :max="SORT_MAX" controls-position="right" />
<el-input-number
:model-value="form.sort"
:min="0"
:max="SORT_MAX"
controls-position="right"
@update:model-value="emit('update:form', { ...form, sort: $event })"
/>
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
<el-switch
:model-value="form.status"
:active-value="1"
:inactive-value="0"
@update:model-value="emit('update:form', { ...form, status: $event })"
/>
</el-form-item>
</div>
</el-form>
@@ -641,6 +641,7 @@ onMounted(async () => {
<LocaleTextFields
:languages="textLanguages"
:locales="form.titleLocales"
@update:locales="form.titleLocales = $event"
label="名称"
:maxlength="80"
@update:languages="changeTextLanguages"
@@ -672,6 +673,7 @@ onMounted(async () => {
<LocaleTextFields
:languages="textLanguages"
:locales="form.synopsisLocales"
@update:locales="form.synopsisLocales = $event"
label="简介"
:maxlength="1000"
multiline
@@ -432,6 +432,7 @@ onMounted(loadCategories)
<LocaleTextFields
:languages="languages"
:locales="form.locales"
@update:locales="form.locales = $event"
label="名称"
:maxlength="64"
@update:languages="changeLanguages"
@@ -439,6 +440,7 @@ onMounted(loadCategories)
<LocaleTextFields
:languages="languages"
:locales="form.descriptionLocales"
@update:locales="form.descriptionLocales = $event"
label="描述"
:maxlength="255"
multiline
@@ -202,6 +202,7 @@ onMounted(load)
<LocaleTextFields
v-model:languages="languages"
:locales="form.locales"
@update:locales="form.locales = $event"
label="名称"
:maxlength="64"
/>
+43 -16
View File
@@ -9,9 +9,11 @@ import { useRbac } from '@/stores/rbac'
import {
APP_USER_STATUS,
COUNTRY_OPTIONS,
countryText,
formatDateTime,
formatDuration,
platformText,
statusMeta,
systemText,
useAppUsers
} from './composables/useAppUsers'
@@ -25,6 +27,7 @@ const {
keyword,
statusFilter,
countryFilter,
platformFilter,
currentPage,
pageSize,
detailVisible,
@@ -53,6 +56,11 @@ const SOURCE_TEXT = {
FAVORITE: '收藏'
}
const BAN_TYPE_TEXT = { BAN: '封禁', LIMIT: '限制', MUTE: '禁言' }
const PLATFORM_OPTIONS = [
{ value: '', label: '全部系统' },
{ value: 'ANDROID', label: 'Android' },
{ value: 'IOS', label: 'iOS' }
]
const APPEAL_TEXT = {
none: '未申诉',
pending: '待处理',
@@ -116,6 +124,20 @@ function canBeBanned(row) {
:value="item.value"
/>
</el-select>
<el-select
v-model="platformFilter"
class="app-user-platform"
clearable
placeholder="全部系统"
@change="queryUsers"
>
<el-option
v-for="item in PLATFORM_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="countryFilter"
class="app-user-country"
@@ -165,24 +187,24 @@ function canBeBanned(row) {
<template v-else>{{ avatarText(row) }}</template>
</span>
<div class="app-user-meta">
<span class="app-user-name" :title="row.nickname">{{
row.nickname || '未设置昵称'
}}</span>
<span class="app-user-uid" :title="row.uid">{{ row.uid }}</span>
<span class="app-user-name" :title="row.nickname || ''">
昵称: {{ row.nickname || '未设置昵称' }}
</span>
<span class="app-user-uid">UID: {{ row.uid }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="系统" width="130" show-overflow-tooltip>
<template #default="{ row }">{{ systemText(row) }}</template>
<el-table-column label="系统" width="110" show-overflow-tooltip>
<template #default="{ row }">{{ platformText(row) }}</template>
</el-table-column>
<el-table-column label="手机 / 邮箱" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span class="app-user-mono">{{ row.maskedTarget || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="国家" width="90">
<template #default="{ row }">{{ row.countryCode || '—' }}</template>
<el-table-column label="国家" width="110">
<template #default="{ row }">{{ countryText(row.countryCode) }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
@@ -248,8 +270,8 @@ function canBeBanned(row) {
<template v-else>{{ avatarText(detail) }}</template>
</span>
<div class="app-user-meta">
<span class="app-user-name">{{ detail.nickname || '未设置昵称' }}</span>
<span class="app-user-uid">{{ detail.uid }}</span>
<span class="app-user-name"> 昵称: {{ detail.nickname || '未设置昵称' }} </span>
<span class="app-user-uid">UID: {{ detail.uid }}</span>
</div>
<el-tag :type="statusMeta(detail.status).tag" effect="light" style="margin-left: auto">
{{ statusMeta(detail.status).label }}
@@ -262,7 +284,10 @@ function canBeBanned(row) {
{{ detail.maskedTarget || '—' }}
</el-descriptions-item>
<el-descriptions-item label="国家 / 地区">
{{ detail.countryCode || '—' }} / {{ detail.regionCode || '—' }}
{{ countryText(detail.countryCode) }}
<template v-if="detail.regionCode && detail.regionCode !== detail.countryCode">
/ {{ countryText(detail.regionCode) }}
</template>
</el-descriptions-item>
<el-descriptions-item label="语言">{{ detail.language || '—' }}</el-descriptions-item>
<el-descriptions-item label="时区">{{ detail.timezone || '—' }}</el-descriptions-item>
@@ -272,7 +297,7 @@ function canBeBanned(row) {
<el-descriptions-item label="最后登录">{{
detail.lastLoginAt || '—'
}}</el-descriptions-item>
<el-descriptions-item label="系统">{{ systemText(detail) }}</el-descriptions-item>
<el-descriptions-item label="系统">{{ platformText(detail) }}</el-descriptions-item>
<el-descriptions-item label="会员">
{{ detail.vipLevel ? `等级 ${detail.vipLevel}` : '未开通(第二阶段)' }}
</el-descriptions-item>
@@ -304,15 +329,17 @@ function canBeBanned(row) {
<el-table-column label="完成" width="70">
<template #default="{ row }">{{ row.finished ? '是' : '否' }}</template>
</el-table-column>
<el-table-column label="系统" width="120" show-overflow-tooltip>
<template #default="{ row }">{{ systemText(row) }}</template>
<el-table-column label="系统" width="100" show-overflow-tooltip>
<template #default="{ row }">{{ platformText(row) }}</template>
</el-table-column>
<el-table-column label="来源" width="80">
<template #default="{ row }">{{
SOURCE_TEXT[row.source] || row.source || '—'
}}</template>
</el-table-column>
<el-table-column prop="watchedAt" label="时间" width="160" show-overflow-tooltip />
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.watchedAt) }}</template>
</el-table-column>
</el-table>
</div>
+194
View File
@@ -0,0 +1,194 @@
<!-- Author: fruit.com.cn -->
<script setup>
import { onMounted, reactive, ref } from 'vue'
import dayjs from 'dayjs'
import PageHeading from '@/views/components/PageHeading.vue'
import AppUserCell from '@/views/components/AppUserCell.vue'
import { pageFavoritesApi } from '@/api/admin/engagement'
import { platformText } from './composables/useAppUsers'
const PAGE_SIZES = [10, 20, 50]
const PLATFORM_OPTIONS = [
{ value: '', label: '全部系统' },
{ value: 'ANDROID', label: 'Android' },
{ value: 'IOS', label: 'iOS' }
]
const filters = reactive({
keyword: '',
deviceId: '',
dramaTitle: '',
platform: '',
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 pageFavoritesApi({
pageIndex: pageIndex.value,
pageSize: pageSize.value,
keyword: filters.keyword.trim() || undefined,
deviceId: filters.deviceId.trim() || undefined,
dramaTitle: filters.dramaTitle.trim() || undefined,
platform: filters.platform || undefined,
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() {
Object.assign(filters, {
keyword: '',
deviceId: '',
dramaTitle: '',
platform: '',
range: null
})
search()
}
function handleSizeChange() {
pageIndex.value = 1
load()
}
function formatTime(value) {
return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
}
onMounted(load)
</script>
<template>
<PageHeading eyebrow="用户管理" title="收藏列表" />
<section class="panel">
<div class="console-toolbar">
<el-input
v-model="filters.keyword"
class="console-keyword"
clearable
placeholder="uid / 昵称"
@keyup.enter="search"
/>
<el-input
v-model="filters.deviceId"
class="console-keyword"
clearable
placeholder="设备 ID"
@keyup.enter="search"
/>
<el-input
v-model="filters.dramaTitle"
class="console-keyword"
clearable
placeholder="短剧名称"
@keyup.enter="search"
/>
<el-select v-model="filters.platform" class="console-select" placeholder="全部系统">
<el-option
v-for="item in PLATFORM_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-date-picker
v-model="filters.range"
class="engagement-range"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
<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 label="用户" min-width="200">
<template #default="{ row }">
<AppUserCell :row="row" />
</template>
</el-table-column>
<el-table-column label="系统" width="100">
<template #default="{ row }">{{ platformText(row) }}</template>
</el-table-column>
<el-table-column label="短剧" min-width="220">
<template #default="{ row }">
<div class="engagement-drama">
<el-image
v-if="row.dramaCover"
:src="row.dramaCover"
fit="cover"
class="engagement-cover"
/>
<span>{{ row.dramaTitle || `短剧 #${row.dramaId || '—'}` }}</span>
<el-tag v-if="row.dramaPublished === false" type="info" size="small" effect="plain"
>未发布</el-tag
>
</div>
</template>
</el-table-column>
<el-table-column label="收藏时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pageIndex"
v-model:page-size="pageSize"
:page-sizes="PAGE_SIZES"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="load"
@size-change="handleSizeChange"
/>
</div>
</section>
</template>
<style scoped>
.engagement-range {
width: 360px;
max-width: 100%;
}
.engagement-drama {
display: flex;
align-items: center;
gap: 8px;
}
.engagement-cover {
width: 40px;
height: 56px;
flex: none;
}
</style>
+201
View File
@@ -0,0 +1,201 @@
<!-- Author: fruit.com.cn -->
<script setup>
import { onMounted, reactive, ref } from 'vue'
import dayjs from 'dayjs'
import PageHeading from '@/views/components/PageHeading.vue'
import AppUserCell from '@/views/components/AppUserCell.vue'
import { pageLikesApi } from '@/api/admin/engagement'
import { platformText } from './composables/useAppUsers'
const PAGE_SIZES = [10, 20, 50]
const TARGET_OPTIONS = [
{ value: '', label: '全部' },
{ value: 'DRAMA', label: '短剧' },
{ value: 'EPISODE', label: '剧集' },
{ value: 'COMMENT', label: '评论' }
]
const PLATFORM_OPTIONS = [
{ value: '', label: '全部系统' },
{ value: 'ANDROID', label: 'Android' },
{ value: 'IOS', label: 'iOS' }
]
const filters = reactive({
keyword: '',
deviceId: '',
dramaTitle: '',
range: null,
targetType: '',
platform: ''
})
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 pageLikesApi({
pageIndex: pageIndex.value,
pageSize: pageSize.value,
keyword: filters.keyword.trim() || undefined,
deviceId: filters.deviceId.trim() || undefined,
dramaTitle: filters.dramaTitle.trim() || undefined,
targetType: filters.targetType || undefined,
platform: filters.platform || undefined,
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() {
Object.assign(filters, {
keyword: '',
deviceId: '',
dramaTitle: '',
range: null,
targetType: '',
platform: ''
})
search()
}
function handleSizeChange() {
pageIndex.value = 1
load()
}
function formatTime(value) {
return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
}
function targetText(row) {
if (row.targetType === 'COMMENT') return row.commentSummary || '—'
if (row.targetType === 'EPISODE') return `${row.dramaTitle || '—'} 第 ${row.episodeNo ?? '—'} 集`
return row.dramaTitle || '—'
}
function typeText(type) {
return TARGET_OPTIONS.find((item) => item.value === type)?.label || type || '—'
}
onMounted(load)
</script>
<template>
<PageHeading eyebrow="用户管理" title="点赞列表" />
<section class="panel">
<div class="console-toolbar">
<el-input
v-model="filters.keyword"
class="console-keyword"
clearable
placeholder="uid / 昵称"
@keyup.enter="search"
/>
<el-input
v-model="filters.deviceId"
class="console-keyword"
clearable
placeholder="设备 ID"
@keyup.enter="search"
/>
<el-input
v-model="filters.dramaTitle"
class="console-keyword"
clearable
placeholder="短剧名称"
@keyup.enter="search"
/>
<el-select v-model="filters.targetType" class="console-select" placeholder="全部目标类型">
<el-option
v-for="item in TARGET_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select v-model="filters.platform" class="console-select" placeholder="全部系统">
<el-option
v-for="item in PLATFORM_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-date-picker
v-model="filters.range"
class="engagement-range"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
<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 label="用户" min-width="200">
<template #default="{ row }">
<AppUserCell :row="row" />
</template>
</el-table-column>
<el-table-column label="系统" width="100">
<template #default="{ row }">{{ platformText(row) }}</template>
</el-table-column>
<el-table-column label="目标类型" width="110">
<template #default="{ row }">{{ typeText(row.targetType) }}</template>
</el-table-column>
<el-table-column label="目标" min-width="200" show-overflow-tooltip>
<template #default="{ row }"
><span :title="targetText(row)">{{ targetText(row) }}</span></template
>
</el-table-column>
<el-table-column prop="dramaTitle" label="所属短剧" min-width="180" show-overflow-tooltip />
<el-table-column label="点赞时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pageIndex"
v-model:page-size="pageSize"
:page-sizes="PAGE_SIZES"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="load"
@size-change="handleSizeChange"
/>
</div>
</section>
</template>
<style scoped>
.engagement-range {
width: 360px;
max-width: 100%;
}
</style>
+31 -9
View File
@@ -3,8 +3,9 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import PageHeading from '@/views/components/PageHeading.vue'
import AppUserCell from '@/views/components/AppUserCell.vue'
import { pageWatchHistoryApi } from '@/api/admin/appUser'
import { formatDuration, systemText } from './composables/useAppUsers'
import { formatDateTime, formatDuration, platformText } from './composables/useAppUsers'
const SOURCE_OPTIONS = [
{ value: '', label: '全部' },
@@ -20,11 +21,18 @@ const FINISHED_OPTIONS = [
{ value: false, label: '未看完' }
]
const PLATFORM_OPTIONS = [
{ value: '', label: '全部系统' },
{ value: 'ANDROID', label: 'Android' },
{ value: 'IOS', label: 'iOS' }
]
const filters = reactive({
keyword: '',
dramaTitle: '',
source: '',
finished: '',
platform: '',
range: null
})
const rows = ref([])
@@ -43,6 +51,7 @@ async function load() {
dramaTitle: filters.dramaTitle.trim() || undefined,
source: filters.source || undefined,
finished: filters.finished === '' ? undefined : filters.finished,
platform: filters.platform || undefined,
startTime: filters.range?.[0] || undefined,
endTime: filters.range?.[1] || undefined
})
@@ -63,6 +72,7 @@ function resetFilters() {
filters.dramaTitle = ''
filters.source = ''
filters.finished = ''
filters.platform = ''
filters.range = null
search()
}
@@ -144,6 +154,19 @@ onMounted(load)
:value="item.value"
/>
</el-select>
<el-select
v-model="filters.platform"
class="console-select"
placeholder="全部系统"
@change="search"
>
<el-option
v-for="item in PLATFORM_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-date-picker
v-model="filters.range"
class="watch-history-range"
@@ -167,17 +190,16 @@ onMounted(load)
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">
<el-table-column label="观看时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.watchedAt) }}</template>
</el-table-column>
<el-table-column label="用户" min-width="200">
<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>
<AppUserCell :row="row" />
</template>
</el-table-column>
<el-table-column label="系统" width="130" show-overflow-tooltip>
<template #default="{ row }">{{ systemText(row) }}</template>
<el-table-column label="系统" width="100">
<template #default="{ row }">{{ platformText(row) }}</template>
</el-table-column>
<el-table-column label="短剧" min-width="190">
<template #default="{ row }">
@@ -1,6 +1,7 @@
// Author: fruit.com.cn
import { onMounted, reactive, ref } from 'vue'
import dayjs from 'dayjs'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getAppUserApi,
@@ -28,6 +29,69 @@ export const COUNTRY_OPTIONS = [
{ value: 'CN', label: '中国' }
]
/** 国家码 → 中文名,与后端 CountryProfile 保持一致;未收录的仍显示原码 */
const COUNTRY_TEXT = {
ID: '印度尼西亚',
MY: '马来西亚',
SG: '新加坡',
TH: '泰国',
VN: '越南',
PH: '菲律宾',
KH: '柬埔寨',
MM: '缅甸',
LA: '老挝',
BN: '文莱',
CN: '中国',
TW: '中国台湾',
HK: '中国香港',
MO: '中国澳门',
JP: '日本',
KR: '韩国',
IN: '印度',
PK: '巴基斯坦',
BD: '孟加拉国',
LK: '斯里兰卡',
NP: '尼泊尔',
US: '美国',
CA: '加拿大',
MX: '墨西哥',
BR: '巴西',
AR: '阿根廷',
CL: '智利',
CO: '哥伦比亚',
PE: '秘鲁',
VE: '委内瑞拉',
EC: '厄瓜多尔',
GT: '危地马拉',
DO: '多米尼加',
GB: '英国',
DE: '德国',
FR: '法国',
ES: '西班牙',
IT: '意大利',
NL: '荷兰',
PL: '波兰',
RU: '俄罗斯',
UA: '乌克兰',
TR: '土耳其',
SA: '沙特阿拉伯',
AE: '阿联酋',
IL: '以色列',
EG: '埃及',
NG: '尼日利亚',
ZA: '南非',
KE: '肯尼亚',
MA: '摩洛哥',
AU: '澳大利亚',
NZ: '新西兰'
}
/** 国家列统一展示中文名,无值时显示横线 */
export function countryText(code) {
const value = (code || '').toUpperCase()
return COUNTRY_TEXT[value] || value || '—'
}
export function statusMeta(status) {
return (
APP_USER_STATUS.find((item) => item.value === status) || { label: status || '-', tag: 'info' }
@@ -36,12 +100,15 @@ export function statusMeta(status) {
const PLATFORM_TEXT = { ANDROID: 'Android', IOS: 'iOS', WEB: 'Web' }
/** 登录会话上的系统,例如 Android 16。没有记录时显示横线。 */
export function systemText(row) {
const name = PLATFORM_TEXT[row?.platform] || row?.platform || ''
const version = row?.osVersion || ''
const text = `${name} ${version}`.trim()
return text || '—'
/** 客户端平台,只有 iOS / Android 两种取值(缺记录显示横线)。 */
export function platformText(row) {
const platform = row?.platform || ''
return PLATFORM_TEXT[platform] || platform || '—'
}
/** 后端返回 ISO 时间串,统一格式化成后台可读的本地时间 */
export function formatDateTime(value) {
return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
}
/** 毫秒转 mm:ss,观看流水里的进度用 */
@@ -60,6 +127,7 @@ export function useAppUsers() {
const keyword = ref('')
const statusFilter = ref(undefined)
const countryFilter = ref(undefined)
const platformFilter = ref(undefined)
const currentPage = ref(1)
const pageSize = ref(10)
@@ -82,7 +150,8 @@ export function useAppUsers() {
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
status: statusFilter.value,
countryCode: countryFilter.value
countryCode: countryFilter.value,
platform: platformFilter.value || undefined
})
users.value = res.data?.list || []
total.value = res.data?.total || 0
@@ -100,6 +169,7 @@ export function useAppUsers() {
keyword.value = ''
statusFilter.value = undefined
countryFilter.value = undefined
platformFilter.value = undefined
queryUsers()
}
@@ -186,6 +256,7 @@ export function useAppUsers() {
keyword,
statusFilter,
countryFilter,
platformFilter,
currentPage,
pageSize,
detailVisible,