chore(web): 收口前几轮后台改动(品牌 / 媒资上传 / 轮播图 / 类型管理 / 操作日志 / App 用户)

为什么:这些改动在前几轮就已完成并在 :3005 验证过,但一直留在工作区没有入库。
下一步 3-4 的四个页面依赖其中的公共样式入口(src/styles/business/index.css)与
api/admin 的公共类型(PageResult 等),如果不先收口,新页面的提交就会夹带旧改动,
既说不清 diff 边界,也让 index.css 这类多轮共用的文件长期处于半提交状态。

本提交只做归档,不改行为:
- 品牌与接口基址改成 FruitShorts / 短剧服务(.env.*、README、vite 代理)
- 去掉前端 mock 数据目录(src/data、src/permissions、stores/auth),改为走真实接口
- 新增 api/admin 下的接口封装与 src/utils/uploader.ts(媒资分片直传)
- 新增/改造:轮播图管理、类型管理、操作日志、App 用户管理、后台布局与公共样式
This commit is contained in:
Vicky committed 2026-10-15 13:28:01 +08:00
1 parent 40546bfd01
commit 44c2c46a68
59 files changed
+4082 -982

No files matched your search

+76 -39
View File
@@ -1,21 +1,33 @@
<script setup>
import { Plus, Search } from '@element-plus/icons-vue'
import { roleData } from '@/data/roles'
import { useAdminUsers } from '@/views/userManagement/composables/useAdminUsers'
import { useRbac } from '@/stores/rbac'
import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac()
const {
roles,
keyword,
roleFilter,
currentPage,
pageSize,
loading,
total,
dialogVisible,
saving,
editingId,
form,
filtered,
paginated,
users,
load,
queryUsers,
handleSizeChange,
openCreate,
openEdit,
saveUser,
toggleStatus,
addUser
removeUser,
resetPassword
} = useAdminUsers()
</script>
@@ -32,27 +44,23 @@ const {
v-model="keyword"
:prefix-icon="Search"
clearable
placeholder="搜索用户ID或用户名"
placeholder="搜索登录账号"
@keyup.enter="queryUsers"
@clear="queryUsers"
/>
<el-select v-model="roleFilter" clearable placeholder="搜索权限角色" @change="queryUsers">
<el-option
v-for="role in roleData"
:key="role.name"
:label="role.name"
:value="role.name"
/>
<el-select v-model="roleFilter" clearable placeholder="全部角色" @change="queryUsers">
<el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.id" />
</el-select>
<el-button type="primary" @click="queryUsers">查询</el-button>
<el-button type="primary" @click="dialogVisible = true">
<el-button v-if="can('system:admin-user')" type="primary" @click="openCreate">
<el-icon><Plus /></el-icon>
添加用户
</el-button>
</div>
<el-table
:data="paginated"
v-loading="loading"
:data="users"
row-key="id"
border
class="drama-el-table admin-users-table"
@@ -65,25 +73,47 @@ const {
:index="(currentPage - 1) * pageSize + 1"
/>
<el-table-column prop="id" label="ID" width="90" />
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column prop="name" label="姓名" min-width="130" />
<el-table-column prop="roleId" label="权限ID" width="110" />
<el-table-column prop="role" label="权限角色" min-width="150" />
<el-table-column prop="ipWhitelist" label="IP白名单" min-width="140" />
<el-table-column label="状态" width="110">
<el-table-column prop="username" label="登录账号" min-width="150" />
<el-table-column prop="name" label="姓名" min-width="120" />
<el-table-column prop="roleId" label="权限ID" width="100" />
<el-table-column prop="role" label="权限角色" min-width="130" />
<el-table-column prop="ipWhitelist" label="IP白名单" min-width="130" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="toggleStatus(row)">
{{ row.status }}
</el-button>
<el-button
v-if="can('system:admin-user')"
link
:type="row.status === 1 ? 'primary' : 'info'"
@click="toggleStatus(row)"
>{{ row.statusText }}</el-button
>
<el-tag v-else :type="row.status === 1 ? 'success' : 'info'" effect="light">{{
row.statusText
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="2FA状态" width="120">
<el-table-column label="2FA状态" width="110">
<template #default="{ row }">
<el-tag type="info" effect="plain">{{ row.twoFactor }}</el-tag>
<el-tag type="info" effect="plain">{{ row.twoFactorText }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="170" />
<el-table-column prop="lastLogin" label="最后登录" min-width="170" />
<el-table-column label="操作" width="245" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" @click="resetPassword(row)">重置密码</el-button>
<el-button
type="danger"
size="small"
:disabled="row.username === 'admin'"
@click="removeUser(row)"
>删除</el-button
>
</div>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="185" />
<el-table-column prop="lastLogin" label="最后登录" min-width="185" />
</el-table>
<div class="pagination">
@@ -91,40 +121,47 @@ const {
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="filtered.length"
:total="total"
layout="total, prev, pager, next, sizes, jumper"
background
@current-change="load"
@size-change="handleSizeChange"
/>
</div>
</section>
<el-dialog v-model="dialogVisible" title="添加管理员" width="520px">
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑管理员' : '添加管理员'" width="520px">
<el-form label-position="top">
<div class="form-row">
<el-form-item label="用户名" required>
<el-input v-model="form.username" />
<el-form-item label="登录账号" required>
<el-input
v-model="form.username"
:disabled="!!editingId"
placeholder="例如 content.editor03"
/>
</el-form-item>
<el-form-item label="姓名" required>
<el-input v-model="form.name" />
</el-form-item>
</div>
<el-form-item v-if="!editingId" label="初始密码" required>
<el-input v-model="form.password" type="password" show-password placeholder="至少 6 位" />
</el-form-item>
<el-form-item label="权限角色" required>
<el-select v-model="form.role" placeholder="请选择角色">
<el-option
v-for="role in roleData"
:key="role.name"
:label="role.name"
:value="role.name"
/>
<el-select v-model="form.roleId" placeholder="请选择角色">
<el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.id" />
</el-select>
</el-form-item>
<el-form-item label="IP白名单">
<el-input v-model="form.ipWhitelist" placeholder="选填,多个 IP 用逗号分隔" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="addUser">确认添加</el-button>
<el-button type="primary" :loading="saving" @click="saveUser">保存</el-button>
</template>
</el-dialog>
</template>
+355
View File
@@ -0,0 +1,355 @@
<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 value="banned">封禁(无法登录)</el-radio>
<el-radio value="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>
@@ -1,71 +1,202 @@
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { roleData } from '../../../data/roles'
import { demoAdminUsers } from '../data/adminUsers'
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createAdminUserApi,
deleteAdminUserApi,
pageAdminUsersApi,
resetAdminUserPasswordApi,
updateAdminUserApi,
updateAdminUserStatusApi
} from '@/api/admin/user'
import { listRolesApi } from '@/api/admin/role'
export function useAdminUsers() {
const roles = ref([])
const users = ref([])
const total = ref(0)
const loading = ref(false)
const keyword = ref('')
const roleFilter = ref('')
const roleFilter = ref(undefined)
const currentPage = ref(1)
const pageSize = ref(10)
const dialogVisible = ref(false)
const form = reactive({ username: '', name: '', role: '', ipWhitelist: '' })
const users = ref(demoAdminUsers.map((user) => ({ ...user })))
const filtered = computed(() =>
users.value.filter((user) => {
const query = keyword.value.trim().toLowerCase()
const matchesKeyword =
!query || `${user.id} ${user.username} ${user.name}`.toLowerCase().includes(query)
return matchesKeyword && (!roleFilter.value || user.role === roleFilter.value)
})
)
const paginated = computed(() =>
filtered.value.slice(
(currentPage.value - 1) * pageSize.value,
currentPage.value * pageSize.value
)
)
const saving = ref(false)
const editingId = ref(null)
const form = reactive({
username: '',
password: '',
name: '',
roleId: undefined,
ipWhitelist: '',
status: 1
})
async function loadRoles() {
const res = await listRolesApi()
roles.value = res.data || []
}
async function load() {
loading.value = true
try {
const res = await pageAdminUsersApi({
pageIndex: currentPage.value,
pageSize: pageSize.value,
username: keyword.value.trim() || undefined,
roleId: roleFilter.value
})
users.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
function queryUsers() {
currentPage.value = 1
}
function toggleStatus(user) {
user.status = user.status === '启用' ? '停用' : '启用'
}
function addUser() {
if (!form.username.trim() || !form.name.trim() || !form.role)
return ElMessage.warning('请填写用户名、姓名并选择权限角色')
const role = roleData.find((item) => item.name === form.role)
users.value.unshift({
id: Math.max(0, ...users.value.map((user) => user.id)) + 1,
username: form.username.trim(),
name: form.name.trim(),
roleId: role?.id || 0,
role: form.role,
ipWhitelist: form.ipWhitelist.trim() || '不限',
status: '启用',
twoFactor: '未开启',
createdAt: new Date().toLocaleString('sv-SE'),
lastLogin: '—'
})
Object.assign(form, { username: '', name: '', role: '', ipWhitelist: '' })
dialogVisible.value = false
currentPage.value = 1
ElMessage.success('管理员已添加')
load()
}
function handleSizeChange() {
currentPage.value = 1
load()
}
function openCreate() {
editingId.value = null
Object.assign(form, {
username: '',
password: '',
name: '',
roleId: undefined,
ipWhitelist: '',
status: 1
})
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
Object.assign(form, {
username: row.username,
password: '',
name: row.name,
roleId: row.roleId || undefined,
ipWhitelist: row.ipWhitelist === '不限' ? '' : row.ipWhitelist,
status: row.status
})
dialogVisible.value = true
}
async function saveUser() {
if (!form.name.trim()) return ElMessage.warning('请填写姓名')
if (!form.roleId) return ElMessage.warning('请选择权限角色')
if (!editingId.value) {
if (!form.username.trim()) return ElMessage.warning('请填写登录账号')
if (!form.password || form.password.length < 6) {
return ElMessage.warning('初始密码不能少于 6 位')
}
}
const payload = {
name: form.name.trim(),
roleId: form.roleId,
ipWhitelist: form.ipWhitelist.trim(),
status: form.status
}
saving.value = true
try {
if (editingId.value) {
await updateAdminUserApi(editingId.value, payload)
ElMessage.success('管理员已更新')
} else {
await createAdminUserApi({
...payload,
username: form.username.trim(),
password: form.password
})
ElMessage.success('管理员已添加')
currentPage.value = 1
}
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
async function toggleStatus(row) {
const next = row.status === 1 ? 0 : 1
await updateAdminUserStatusApi(row.id, next)
ElMessage.success(next === 1 ? '账号已启用' : '账号已停用')
await load()
}
async function removeUser(row) {
try {
await ElMessageBox.confirm(`确定删除管理员「${row.name}」吗?`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
})
} catch {
return
}
await deleteAdminUserApi(row.id)
ElMessage.success('管理员已删除')
if (users.value.length === 1 && currentPage.value > 1) currentPage.value -= 1
await load()
}
async function resetPassword(row) {
let password = ''
try {
const { value } = await ElMessageBox.prompt(
`为「${row.username}」设置新密码(至少 6 位)`,
'重置密码',
{
confirmButtonText: '确认重置',
cancelButtonText: '取消',
inputPlaceholder: '请输入新密码',
inputValidator: (val) => (val && val.length >= 6 ? true : '密码不能少于 6 位')
}
)
password = value
} catch {
return
}
await resetAdminUserPasswordApi(row.id, password)
ElMessage.success('密码已重置')
}
onMounted(async () => {
await loadRoles()
await load()
})
return {
roles,
keyword,
roleFilter,
currentPage,
pageSize,
loading,
total,
dialogVisible,
saving,
editingId,
form,
users,
filtered,
paginated,
load,
queryUsers,
handleSizeChange,
openCreate,
openEdit,
saveUser,
toggleStatus,
addUser
removeUser,
resetPassword
}
}
@@ -0,0 +1,198 @@
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getAppUserApi,
listAppUserBanRecordsApi,
listAppUserWatchHistoryApi,
pageAppUsersApi,
updateAppUserStatusApi
} from '@/api/admin/appUser'
/** 账号状态与展示文案,与后端 AppUserStatus 保持一致 */
export const APP_USER_STATUS = [
{ value: 'normal', label: '正常', tag: 'success' },
{ value: 'limited', label: '受限', tag: 'warning' },
{ value: 'banned', label: '封禁', tag: 'danger' },
{ value: 'deactivated', label: '已注销', tag: 'info' }
]
export const COUNTRY_OPTIONS = [
{ value: 'ID', label: '印度尼西亚' },
{ value: 'SG', label: '新加坡' },
{ value: 'MY', label: '马来西亚' },
{ value: 'PH', label: '菲律宾' },
{ value: 'TH', label: '泰国' },
{ value: 'VN', label: '越南' },
{ value: 'CN', label: '中国' }
]
export function statusMeta(status) {
return (
APP_USER_STATUS.find((item) => item.value === status) || { label: status || '-', tag: 'info' }
)
}
/** 毫秒转 mm:ss,观看流水里的进度用 */
export function formatDuration(ms) {
const total = Math.max(0, Math.floor((ms || 0) / 1000))
const minute = String(Math.floor(total / 60)).padStart(2, '0')
const second = String(total % 60).padStart(2, '0')
return `${minute}:${second}`
}
export function useAppUsers() {
const users = ref([])
const total = ref(0)
const loading = ref(false)
const keyword = ref('')
const statusFilter = ref(undefined)
const countryFilter = ref(undefined)
const currentPage = ref(1)
const pageSize = ref(10)
const detailVisible = ref(false)
const detailLoading = ref(false)
const detail = ref(null)
const banRecords = ref([])
const watchHistory = ref([])
const statusDialogVisible = ref(false)
const statusSaving = ref(false)
const statusTarget = ref(null)
const statusForm = reactive({ status: 'banned', reason: '' })
async function load() {
loading.value = true
try {
const res = await pageAppUsersApi({
pageIndex: currentPage.value,
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
status: statusFilter.value,
countryCode: countryFilter.value
})
users.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
function queryUsers() {
currentPage.value = 1
load()
}
function resetFilters() {
keyword.value = ''
statusFilter.value = undefined
countryFilter.value = undefined
queryUsers()
}
function handleSizeChange() {
currentPage.value = 1
load()
}
const detailTabs = reactive({ active: 'watch' })
async function openDetail(row) {
detailVisible.value = true
detailLoading.value = true
detail.value = row
banRecords.value = []
watchHistory.value = []
detailTabs.active = 'watch'
try {
// 三个请求互不依赖,并发拉取,抽屉打开更快
const [profile, bans, history] = await Promise.all([
getAppUserApi(row.id),
listAppUserBanRecordsApi(row.id),
listAppUserWatchHistoryApi(row.id, 50)
])
detail.value = profile.data || row
banRecords.value = bans.data || []
watchHistory.value = history.data || []
} finally {
detailLoading.value = false
}
}
function openStatusDialog(row, preset = 'banned') {
statusTarget.value = row
statusForm.status = preset
statusForm.reason = ''
statusDialogVisible.value = true
}
async function confirmStatus() {
const row = statusTarget.value
if (!row) return
const label = statusMeta(statusForm.status).label
statusSaving.value = true
try {
await updateAppUserStatusApi(row.id, statusForm.status, statusForm.reason.trim() || undefined)
ElMessage.success(`已${label},该用户全部登录会话已失效`)
statusDialogVisible.value = false
await load()
if (detailVisible.value && detail.value?.id === row.id) {
detail.value = { ...detail.value, status: statusForm.status }
banRecords.value = (await listAppUserBanRecordsApi(row.id)).data || []
}
} finally {
statusSaving.value = false
}
}
/** 恢复正常:不需要填原因,但仍要二次确认,避免误点 */
async function restoreUser(row) {
try {
await ElMessageBox.confirm(
`确认把「${row.nickname || row.uid}」恢复为正常账号?`,
'恢复正常',
{ confirmButtonText: '确认恢复', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
await updateAppUserStatusApi(row.id, 'normal')
ElMessage.success('账号已恢复正常')
await load()
if (detailVisible.value && detail.value?.id === row.id) {
detail.value = { ...detail.value, status: 'normal' }
}
}
onMounted(load)
return {
users,
total,
loading,
keyword,
statusFilter,
countryFilter,
currentPage,
pageSize,
detailVisible,
detailLoading,
detail,
banRecords,
watchHistory,
detailTabs,
statusDialogVisible,
statusSaving,
statusTarget,
statusForm,
load,
queryUsers,
resetFilters,
handleSizeChange,
openDetail,
openStatusDialog,
confirmStatus,
restoreUser
}
}
@@ -1,62 +0,0 @@
export const demoAdminUsers = [
{
id: 1001,
username: 'lin.xiaoman',
name: '林小满',
roleId: 1,
role: '超级管理员',
ipWhitelist: '不限',
status: '启用',
twoFactor: '已开启',
createdAt: '2026-09-18 09:30:00',
lastLogin: '2026-10-12 09:41:22'
},
{
id: 1002,
username: 'content.editor01',
name: '陈一诺',
roleId: 2,
role: '内容运营',
ipWhitelist: '不限',
status: '启用',
twoFactor: '未开启',
createdAt: '2026-09-21 10:15:00',
lastLogin: '2026-10-11 16:20:15'
},
{
id: 1003,
username: 'content.editor02',
name: '周子瑜',
roleId: 2,
role: '内容运营',
ipWhitelist: '不限',
status: '启用',
twoFactor: '未开启',
createdAt: '2026-09-25 14:20:00',
lastLogin: '2026-10-11 13:34:10'
},
{
id: 1004,
username: 'content.reviewer01',
name: '林晓彤',
roleId: 3,
role: '审核专员',
ipWhitelist: '不限',
status: '启用',
twoFactor: '已开启',
createdAt: '2026-10-02 11:05:00',
lastLogin: '2026-10-10 11:20:08'
},
{
id: 1005,
username: 'data.analyst01',
name: '程予安',
roleId: 4,
role: '数据分析师',
ipWhitelist: '不限',
status: '启用',
twoFactor: '未开启',
createdAt: '2026-10-05 13:02:00',
lastLogin: '2026-10-09 14:10:02'
}
]