Files
ReelShortWeb/src/views/userManagement/AppUsersView.vue
T
Vicky bedae02946 feat: phase1 UI prep — 后台 App 用户列表支持系统筛选 + 国家中文名 + 标签语义优化
新增: 系统下拉 (ANDROID/IOS)、国家中文名映射、用户列昵称/UID 冒号标签
详情抽屉: 同步系统/国家/标签展示
2026-10-27 15:45:23 +08:00

417 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- Author: fruit.com.cn -->
<script setup>
import { computed } from 'vue'
import { AppIcons } from '@/constants/icons'
import { AppImage } from '@/components/AppImage'
import PageHeading from '@/views/components/PageHeading.vue'
import { useRbac } from '@/stores/rbac'
import {
APP_USER_STATUS,
COUNTRY_OPTIONS,
countryText,
formatDateTime,
formatDuration,
platformText,
statusMeta,
useAppUsers
} from './composables/useAppUsers'
const { can } = useRbac()
const canUpdate = can('app-user:update')
const {
users,
total,
loading,
keyword,
statusFilter,
countryFilter,
platformFilter,
currentPage,
pageSize,
detailVisible,
detailLoading,
detail,
banRecords,
watchHistory,
statusDialogVisible,
statusSaving,
statusTarget,
statusForm,
load,
queryUsers,
resetFilters,
handleSizeChange,
openDetail,
openStatusDialog,
confirmStatus,
restoreUser
} = useAppUsers()
const SOURCE_TEXT = {
HOME: '首页',
DETAIL: '详情页',
SEARCH: '搜索',
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: '待处理',
approved: '已通过',
rejected: '已驳回'
}
const statusTargetLabel = computed(() => {
const row = statusTarget.value
if (!row) return ''
return `${row.nickname || '未设置昵称'}(${row.uid})`
})
/** 没有头像时用昵称首字母占位,避免空白的灰色方框 */
function avatarText(row) {
const name = (row.nickname || row.uid || '?').trim()
return name.slice(0, 1).toUpperCase()
}
const avatarPreviewList = computed(() => users.value.map((row) => row.avatarUrl).filter(Boolean))
function avatarPreviewIndex(row) {
return Math.max(0, avatarPreviewList.value.indexOf(row.avatarUrl))
}
function canBeBanned(row) {
return row.status !== 'banned' && row.status !== 'deactivated'
}
</script>
<template>
<PageHeading
eyebrow="用户管理"
title="App 用户"
description="查询 C 端用户、处置违规账号,并核对观看记录。"
/>
<section class="panel app-user-panel">
<div class="app-user-toolbar">
<el-input
v-model="keyword"
class="app-user-keyword"
clearable
placeholder="搜索 uid / 昵称 / 手机号 / 邮箱"
@keyup.enter="queryUsers"
@clear="queryUsers"
>
<template #prefix><AppIcon :icon="AppIcons.search" /></template>
</el-input>
<el-select
v-model="statusFilter"
class="app-user-status"
clearable
placeholder="全部状态"
@change="queryUsers"
>
<el-option
v-for="item in APP_USER_STATUS"
:key="item.value"
:label="item.label"
: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"
clearable
placeholder="全部国家"
@change="queryUsers"
>
<el-option
v-for="item in COUNTRY_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button type="primary" @click="queryUsers">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<span class="app-user-count">共 {{ total }} 位用户</span>
</div>
<el-table
v-loading="loading"
:data="users"
row-key="id"
border
class="drama-el-table"
empty-text="没有符合条件的用户"
>
<el-table-column
type="index"
label="序号"
width="70"
:index="(currentPage - 1) * pageSize + 1"
/>
<el-table-column label="用户" min-width="200">
<template #default="{ row }">
<div class="app-user-cell">
<span class="app-user-avatar">
<AppImage
v-if="row.avatarUrl"
:src="row.avatarUrl"
:preview-list="avatarPreviewList"
:initial-index="avatarPreviewIndex(row)"
fit="cover"
class="app-user-avatar-image"
:alt="row.nickname || row.uid"
/>
<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">UID: {{ row.uid }}</span>
</div>
</div>
</template>
</el-table-column>
<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="110">
<template #default="{ row }">{{ countryText(row.countryCode) }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusMeta(row.status).tag" effect="light">
{{ statusMeta(row.status).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="注册时间" min-width="165" show-overflow-tooltip />
<el-table-column label="最后登录" min-width="165" show-overflow-tooltip>
<template #default="{ row }">{{ row.lastLoginAt || '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button type="primary" size="small" @click="openDetail(row)">详情</el-button>
<el-button
v-if="canUpdate && canBeBanned(row)"
type="danger"
size="small"
@click="openStatusDialog(row, 'banned')"
>封禁</el-button
>
<el-button
v-else-if="canUpdate && row.status === 'banned'"
size="small"
@click="restoreUser(row)"
>解封</el-button
>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, prev, pager, next, sizes, jumper"
background
@current-change="load"
@size-change="handleSizeChange"
/>
</div>
</section>
<el-drawer v-model="detailVisible" class="app-user-drawer" title="用户详情" size="520px">
<div v-loading="detailLoading">
<template v-if="detail">
<div class="app-user-summary">
<span class="app-user-avatar">
<AppImage
v-if="detail.avatarUrl"
:src="detail.avatarUrl"
:preview-list="avatarPreviewList"
:initial-index="avatarPreviewIndex(detail)"
fit="cover"
class="app-user-avatar-image"
:alt="detail.nickname || detail.uid"
/>
<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">UID: {{ detail.uid }}</span>
</div>
<el-tag :type="statusMeta(detail.status).tag" effect="light" style="margin-left: auto">
{{ statusMeta(detail.status).label }}
</el-tag>
</div>
<div class="app-user-section">
<el-descriptions :column="2" size="small" border>
<el-descriptions-item label="手机 / 邮箱">
{{ detail.maskedTarget || '—' }}
</el-descriptions-item>
<el-descriptions-item label="国家 / 地区">
{{ 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>
<el-descriptions-item label="注册时间">{{
detail.createdAt || '—'
}}</el-descriptions-item>
<el-descriptions-item label="最后登录">{{
detail.lastLoginAt || '—'
}}</el-descriptions-item>
<el-descriptions-item label="系统">{{ platformText(detail) }}</el-descriptions-item>
<el-descriptions-item label="会员">
{{ detail.vipLevel ? `等级 ${detail.vipLevel}` : '未开通(第二阶段)' }}
</el-descriptions-item>
<el-descriptions-item label="币种">{{ detail.currency || '—' }}</el-descriptions-item>
</el-descriptions>
</div>
<div class="app-user-section">
<h3 class="app-user-section-title">观看流水(最近 {{ watchHistory.length }} 条)</h3>
<el-table :data="watchHistory" size="small" border empty-text="还没有观看记录">
<el-table-column label="短剧 / 集" min-width="180">
<template #default="{ row }">
<span :title="row.dramaTitle">
{{ row.dramaTitle || `短剧 #${row.dramaId}` }}
<template v-if="row.episodeNo"> · 第 {{ row.episodeNo }} 集</template>
</span>
<el-tag v-if="row.dramaPublished === false" type="info" size="small" effect="plain"
>已下线</el-tag
>
</template>
</el-table-column>
<el-table-column label="进度" width="96">
<template #default="{ row }">
<span class="app-user-mono">
{{ formatDuration(row.positionMs) }} / {{ formatDuration(row.durationMs) }}
</span>
</template>
</el-table-column>
<el-table-column label="完成" width="70">
<template #default="{ row }">{{ row.finished ? '是' : '否' }}</template>
</el-table-column>
<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 label="时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.watchedAt) }}</template>
</el-table-column>
</el-table>
</div>
<div class="app-user-section">
<h3 class="app-user-section-title">处置记录</h3>
<el-table :data="banRecords" size="small" border empty-text="没有处置记录">
<el-table-column label="类型" width="76">
<template #default="{ row }">{{ BAN_TYPE_TEXT[row.type] || row.type }}</template>
</el-table-column>
<el-table-column label="原因" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.reason || '—' }}</template>
</el-table-column>
<el-table-column prop="startAt" label="时间" width="160" show-overflow-tooltip />
<el-table-column label="申诉" width="84">
<template #default="{ row }">{{
APPEAL_TEXT[row.appealStatus] || row.appealStatus
}}</template>
</el-table-column>
</el-table>
</div>
</template>
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
<el-button
v-if="canUpdate && detail && canBeBanned(detail)"
type="danger"
@click="openStatusDialog(detail, 'banned')"
>封禁该用户</el-button
>
<el-button
v-else-if="canUpdate && detail && detail.status === 'banned'"
type="primary"
@click="restoreUser(detail)"
>解封</el-button
>
</template>
</el-drawer>
<el-dialog v-model="statusDialogVisible" title="处置账号" width="460px">
<el-form label-position="top">
<el-form-item label="目标账号">
<el-input :model-value="statusTargetLabel" disabled placeholder="—" />
</el-form-item>
<el-form-item label="处置方式">
<el-radio-group v-model="statusForm.status">
<el-radio label="banned">封禁(无法登录)</el-radio>
<el-radio label="limited">限制(可登录)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="原因(可选,会记入处置记录)">
<el-input
v-model="statusForm.reason"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="例如:发布违规评论"
/>
</el-form-item>
<el-alert
type="warning"
:closable="false"
show-icon
title="处置后该用户所有设备的登录会话会立即失效"
/>
</el-form>
<template #footer>
<el-button @click="statusDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="statusSaving" @click="confirmStatus">确认处置</el-button>
</template>
</el-dialog>
</template>