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

+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>