358 lines
12 KiB
Vue
358 lines
12 KiB
Vue
<!-- Author: fruit.com.cn -->
|
||
|
||
<script setup>
|
||
import { computed } from 'vue'
|
||
import { Search } from '@element-plus/icons-vue'
|
||
import PageHeading from '@/views/components/PageHeading.vue'
|
||
import { useRbac } from '@/stores/rbac'
|
||
import {
|
||
APP_USER_STATUS,
|
||
COUNTRY_OPTIONS,
|
||
formatDuration,
|
||
statusMeta,
|
||
useAppUsers
|
||
} from './composables/useAppUsers'
|
||
|
||
const { can } = useRbac()
|
||
const canUpdate = can('app-user:update')
|
||
|
||
const {
|
||
users,
|
||
total,
|
||
loading,
|
||
keyword,
|
||
statusFilter,
|
||
countryFilter,
|
||
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 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()
|
||
}
|
||
|
||
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"
|
||
:prefix-icon="Search"
|
||
clearable
|
||
placeholder="搜索 uid / 昵称 / 手机号 / 邮箱"
|
||
@keyup.enter="queryUsers"
|
||
@clear="queryUsers"
|
||
/>
|
||
<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="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">
|
||
<img v-if="row.avatarUrl" :src="row.avatarUrl" alt="" />
|
||
<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>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
<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">
|
||
<img v-if="detail.avatarUrl" :src="detail.avatarUrl" alt="" />
|
||
<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>
|
||
</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="国家 / 地区">
|
||
{{ detail.countryCode || '—' }} / {{ detail.regionCode || '—' }}
|
||
</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="会员">
|
||
{{ 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="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>
|
||
</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>
|