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:
1 parent
40546bfd01
commit
44c2c46a68
59 files changed
+4082
-982
No files matched your search
@@ -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>
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
Reference in new issue
Block a user