This commit is contained in:
lz_db
2026-10-25 11:46:44 +08:00
parent 893dfed0ed
commit 494f667719
63 changed files with 5213 additions and 239 deletions
+57
View File
@@ -0,0 +1,57 @@
import request from './request'
/** 列出所有备份文件 */
export function listBackups() {
return request.get('/admin/system/backups')
}
/** 立即创建一次备份(同步等待返回,可能耗时几秒~几十秒) */
export function createBackup() {
// 备份耗时较长,单独放宽超时到 5 分钟
return request.post('/admin/system/backup', null, { timeout: 5 * 60 * 1000 })
}
/** 删除指定备份 */
export function deleteBackup(filename) {
return request.delete(`/admin/system/backups/${encodeURIComponent(filename)}`)
}
/** 从已有备份恢复(高危) */
export function restoreFromExisting(filename) {
return request.post(
`/admin/system/backups/${encodeURIComponent(filename)}/restore`,
null,
{ timeout: 10 * 60 * 1000 },
)
}
/** 上传 .sql.gz 文件并恢复(高危) */
export function restoreFromUpload(file) {
const fd = new FormData()
fd.append('file', file)
return request.post('/admin/system/restore', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 10 * 60 * 1000,
})
}
/** 下载备份文件(手动携带 Authorization 头,绕过 axios 直接打开) */
export async function downloadBackup(filename) {
const { getToken } = await import('@/utils/auth')
const token = getToken()
const resp = await fetch(`/api/v1/admin/system/backups/${encodeURIComponent(filename)}/download`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!resp.ok) {
throw new Error(`下载失败:${resp.status}`)
}
const blob = await resp.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
+11 -1
View File
@@ -21,11 +21,21 @@ export function offlineVideo(videoId, data) {
return request.post(`/admin/videos/${videoId}/offline`, data)
}
/** 视频基础编辑 { title, description, cover_url, category_id, series_id, episode_no, status } */
/** 视频基础编辑 { title, description, cover_url, category_id, source_id, series_id, episode_no, status } */
export function updateVideo(videoId, data) {
return request.put(`/admin/videos/${videoId}`, data)
}
/** 批量编辑视频 { video_ids, source_id?, status?, category_id? } */
export function batchUpdateVideos(data) {
return request.put('/admin/videos/batch', data)
}
/** 批量下架视频 { video_ids, reason }(复用单条下架:通知作者 + 写日志 + 清缓存) */
export function batchOfflineVideos(data) {
return request.post('/admin/videos/batch-offline', data)
}
/** 删除视频(级联删除所有关联数据:标签/点赞/收藏/评论/观看记录/收益/举报) */
export function deleteVideo(videoId) {
return request.delete(`/admin/videos/${videoId}`)
+2
View File
@@ -71,6 +71,7 @@ const menuConfig = [
{ title: '发布视频', path: '/videos/create', icon: 'Upload', roles: [] },
{ title: '视频管理', path: '/videos', icon: 'Film', roles: [] },
{ title: '系列管理', path: '/series', icon: 'CollectionTag', roles: [] },
{ title: '来源管理', path: '/sources', icon: 'Flag', roles: [] },
{ title: '采集源管理', path: '/collect-sources', icon: 'Download', roles: [] },
{ title: '分类管理', path: '/categories', icon: 'Menu', roles: [] },
{ title: '标签管理', path: '/tags', icon: 'PriceTag', roles: [] },
@@ -103,6 +104,7 @@ const menuConfig = [
{ title: '推荐风控', path: '/referrals', icon: 'Share', roles: [] },
{ title: '敏感词库', path: '/sensitive-words', icon: 'Collection', roles: [] },
{ title: '平台配置', path: '/configs', icon: 'Setting', roles: [] },
{ title: '数据库备份', path: '/system/backup', icon: 'Coin', roles: [] },
{ title: '操作日志', path: '/logs', icon: 'Document', roles: [] },
],
},
+18
View File
@@ -58,6 +58,12 @@ const routes = [
component: () => import('@/views/SeriesManage.vue'),
meta: { title: '系列管理', icon: 'CollectionTag', roles: [] },
},
{
path: 'sources',
name: 'source-manage',
component: () => import('@/views/SourceManage.vue'),
meta: { title: '来源管理', icon: 'Flag', roles: [] },
},
{
path: 'collect-sources',
name: 'collect-sources',
@@ -148,6 +154,18 @@ const routes = [
component: () => import('@/views/ConfigList.vue'),
meta: { title: '平台配置', icon: 'Setting', roles: [] },
},
{
path: 'system/backup',
name: 'system-backup',
component: () => import('@/views/SystemBackup.vue'),
meta: { title: '数据库备份', icon: 'Coin', roles: [] },
},
{
path: 'user-action-logs',
name: 'user-action-logs',
component: () => import('@/views/UserActionLogs.vue'),
meta: { title: '用户操作日志', icon: 'Document', roles: [] },
},
{
path: 'logs',
name: 'log-list',
+133 -13
View File
@@ -14,12 +14,28 @@
<el-alert type="info" :closable="false" style="margin-bottom: 12px">
添加"分类名 + 列表URL"后点同步即可自动采集;URL 中用 <code v-pre>{{page}}</code> 表示页码占位。
分类名不存在时会自动创建。同步为增量:已入库的视频自动跳过。
分类名不存在时会自动创建。同步为增量:已入库的视频自动跳过。<br>
91 短剧类型:填写源站账号密码,自动登录并按源站分类拉取短剧列表与全部集数,入库为「系列 + 剧集」,仅保存封面地址与播放代理地址。
</el-alert>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="分类名" width="130" />
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag :type="row.kind === 'aidrama91' ? 'success' : (row.kind === 'aidrama91_vlog' ? 'warning' : 'info')" size="small">
{{ row.kind === 'aidrama91' ? '91短剧' : (row.kind === 'aidrama91_vlog' ? '91短视频' : '通用URL') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="来源" width="110">
<template #default="{ row }">
<el-tag v-if="sourceName(row.source_id)" type="warning" size="small">
{{ sourceName(row.source_id) }}
</el-tag>
<el-tag v-else type="info" size="small">未绑定</el-tag>
</template>
</el-table-column>
<el-table-column prop="list_url" label="列表 URL" min-width="320" show-overflow-tooltip />
<el-table-column label="状态" width="80">
<template #default="{ row }">
@@ -31,9 +47,10 @@
<el-table-column prop="last_sync_at" label="上次同步" width="160">
<template #default="{ row }">{{ row.last_sync_at || '从未' }}</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<el-table-column label="操作" width="290" fixed="right">
<template #default="{ row }">
<el-button link type="warning" :loading="syncingId === row.id" @click="syncOne(row)">同步</el-button>
<el-button link type="danger" :loading="cancellingId === row.id" @click="cancelSync(row)">取消同步</el-button>
<el-button link type="primary" @click="showEdit(row)">编辑</el-button>
<el-button link type="primary" @click="toggleStatus(row)">
{{ row.status === 1 ? '停用' : '启用' }}
@@ -68,13 +85,48 @@
</el-dialog>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑采集源' : '添加采集源'" width="560px">
<el-form :model="form" label-width="90px">
<el-form :model="form" label-width="100px">
<el-form-item label="采集类型">
<el-radio-group v-model="form.kind" :disabled="!!editing">
<el-radio value="url">通用 URL 模板</el-radio>
<el-radio value="aidrama91">91 短剧(AI短剧)</el-radio>
<el-radio value="aidrama91_vlog">91 短视频</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="分类名">
<el-input v-model="form.name" maxlength="50" placeholder="如:每日更新(不存在会自动创建分类)" />
</el-form-item>
<el-form-item label="列表 URL">
<el-input v-model="form.list_url" type="textarea" :rows="3"
placeholder="https://example.com/api/v1/videos?categoryId=27&page={{page}}&size=60" />
<el-form-item label="接口地址">
<el-input v-model="form.list_url" type="textarea" :rows="2"
:placeholder="form.kind === 'aidrama91'
? 'https://api4.zqpnd5a7.cc/pwa.php'
: 'https://example.com/api/v1/videos?categoryId=27&page={{page}}&size=60'" />
</el-form-item>
<template v-if="form.kind === 'aidrama91' || form.kind === 'aidrama91_vlog'">
<el-form-item label="源站账号">
<el-input v-model="form.account" placeholder="91 站点登录手机号/账号" />
</el-form-item>
<el-form-item label="源站密码">
<el-input v-model="form.password" type="password" show-password
:placeholder="editing && editing.has_token ? '留空表示不修改' : '91 站点登录密码'" />
</el-form-item>
<el-form-item v-if="form.kind === 'aidrama91'" label="源站分类">
<el-input v-model="form.category_ids_text" placeholder="逗号分隔,如 4(AI短剧)" />
</el-form-item>
<el-form-item label="每页条数">
<el-input-number v-model="form.page_size" :min="1" :max="50" />
<span style="margin-left: 8px; color: #909399; font-size: 12px">
{{ form.kind === 'aidrama91' ? '短剧单页上限 20' : '短视频单页上限 50' }}
</span>
</el-form-item>
</template>
<el-form-item label="内容来源">
<el-select v-model="form.source_id" placeholder="采集入库时打上该来源标签" style="width: 100%">
<el-option v-for="s in sources" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<div v-if="form.kind === 'aidrama91'" style="color: #909399; font-size: 12px">
91 短剧会自动绑定「91」来源,无需手动选择
</div>
</el-form-item>
</el-form>
<template #footer>
@@ -96,9 +148,18 @@ const dialogVisible = ref(false)
const editing = ref(null)
const submitting = ref(false)
const syncingId = ref(0)
const cancellingId = ref(0)
const syncAllLoading = ref(false)
const form = reactive({ name: '', list_url: '' })
const form = reactive({
kind: 'url', name: '', list_url: '', source_id: 0,
account: '', password: '', category_ids_text: '4', page_size: 20,
})
const sources = ref([])
function sourceName(id) {
return sources.value.find(s => s.id === id)?.name || ''
}
const batchVisible = ref(false)
const batchText = ref('')
@@ -119,7 +180,17 @@ async function submitBatch() {
}
}
onMounted(load)
onMounted(() => {
load()
loadSources()
})
async function loadSources() {
try {
const r = await request({ url: '/admin/sources', method: 'get' })
sources.value = r.list || []
} catch (e) {}
}
async function load() {
loading.value = true
@@ -133,26 +204,58 @@ async function load() {
function showCreate() {
editing.value = null
Object.assign(form, { name: '', list_url: '' })
Object.assign(form, {
kind: 'url', name: '', list_url: '', source_id: sources.value[0]?.id ?? 0,
account: '', password: '', category_ids_text: '4', page_size: 20,
})
dialogVisible.value = true
}
function showEdit(row) {
editing.value = row
Object.assign(form, { name: row.name, list_url: row.list_url })
Object.assign(form, {
kind: row.kind || 'url',
name: row.name, list_url: row.list_url, source_id: row.source_id || 0,
account: row.account || '', password: '',
category_ids_text: (row.category_ids || [4]).join(','),
page_size: row.page_size || 20,
})
dialogVisible.value = true
}
function parseCategoryIds(text) {
return (text || '').split(/[,,\s]+/).map(s => parseInt(s, 10)).filter(n => n > 0)
}
async function save() {
if (!form.name || !form.list_url) { ElMessage.warning('请填写完整'); return }
if (!form.list_url.includes('{{page}}')) { ElMessage.warning('URL 需包含 {{page}} 页码占位符'); return }
const isAidrama = form.kind === 'aidrama91' || form.kind === 'aidrama91_vlog'
if (isAidrama) {
if (!form.account) { ElMessage.warning('请填写源站账号'); return }
if (!editing.value && !form.password) { ElMessage.warning('请填写源站密码'); return }
} else if (!form.list_url.includes('{{page}}')) {
ElMessage.warning('URL 需包含 {{page}} 页码占位符'); return
}
const payload = {
kind: form.kind, name: form.name, list_url: form.list_url,
source_id: form.source_id || 0,
}
if (isAidrama) {
const ids = parseCategoryIds(form.category_ids_text)
payload.account = form.account
payload.category_ids = ids.length ? ids : [4]
payload.page_size = form.page_size || 20
if (form.password) payload.password = form.password
}
submitting.value = true
try {
if (editing.value) {
await request({ url: `/admin/collection/sources/${editing.value.id}`, method: 'put', data: form })
await request({ url: `/admin/collection/sources/${editing.value.id}`, method: 'put', data: payload })
ElMessage.success('已更新')
} else {
await request({ url: '/admin/collection/sources', method: 'post', data: form })
await request({ url: '/admin/collection/sources', method: 'post', data: payload })
ElMessage.success('已添加,点击"同步"开始采集')
}
dialogVisible.value = false
@@ -195,6 +298,23 @@ async function syncOne(row) {
}
}
async function cancelSync(row) {
try {
await ElMessageBox.confirm(
`确定要取消「${row.name}」的同步任务吗?任务会在当前页/当前条目处理完后自然停止。`,
'取消同步',
{ type: 'warning', confirmButtonText: '取消同步', cancelButtonText: '返回' },
)
} catch (_) { return }
cancellingId.value = row.id
try {
await request({ url: `/admin/collection/sources/${row.id}/sync/cancel`, method: 'post' })
ElMessage.success(`「${row.name}」已请求取消同步,稍后自动停止`)
} catch (e) {} finally {
cancellingId.value = 0
}
}
async function syncAll() {
syncAllLoading.value = true
try {
+58 -8
View File
@@ -8,6 +8,11 @@
<el-input v-model="keyword" placeholder="搜索系列标题" clearable
style="width: 200px; margin-right: 8px" @keyup.enter="load" />
<el-select v-model="filterSourceId" placeholder="全部来源" clearable
style="width: 130px; margin-right: 8px" @change="onFilterChange">
<el-option v-for="s in sources" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-dropdown trigger="click" @command="handleBatchCmd">
<el-button :loading="batchLoading">批量更新 <el-icon class="el-icon--right"><arrow-down /></el-icon></el-button>
<template #dropdown>
@@ -47,9 +52,15 @@
</template>
</el-table-column>
<el-table-column prop="total_episodes" label="集数" width="90" />
<el-table-column label="来源" width="80">
<el-table-column label="来源" width="110">
<template #default="{ row }">
<el-tag v-if="row.source_id" size="small" type="warning">采集</el-tag>
<el-tag v-if="row.source_name" size="small" type="warning">{{ row.source_name }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="采集" width="80">
<template #default="{ row }">
<el-tag v-if="row.collect_source_id" size="small" type="success">已采集</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
@@ -64,7 +75,7 @@
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="showEdit(row)">编辑</el-button>
<el-button v-if="row.source_id" link type="warning"
<el-button v-if="row.collect_source_id" link type="warning"
:loading="syncingId === row.id" @click="syncOne(row)">更新</el-button>
<el-button link type="primary" @click="toggleStatus(row)">
{{ row.status === 1 ? '隐藏' : '恢复' }}
@@ -120,6 +131,11 @@
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="来源">
<el-select v-model="form.source_id" placeholder="请选择来源" style="width: 100%">
<el-option v-for="s in sources" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
</el-form-item>
<el-form-item label="简介">
<el-input v-model="form.description" type="textarea" :rows="3" maxlength="500" show-word-limit />
</el-form-item>
@@ -151,6 +167,13 @@
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="来源">
<el-switch v-model="batchForm.touchSource" active-text="修改" inactive-text="不改" />
<el-select v-if="batchForm.touchSource" v-model="batchForm.source_id"
placeholder="请选择来源" style="width: 180px; margin-left: 12px">
<el-option v-for="s in sources" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchDialogVisible = false">取消</el-button>
@@ -173,6 +196,8 @@ const pageSize = 20
const loading = ref(false)
const keyword = ref('')
const categories = ref([])
const sources = ref([])
const filterSourceId = ref(null)
const dialogVisible = ref(false)
const editing = ref(null)
const submitting = ref(false)
@@ -184,12 +209,13 @@ const batchDialogVisible = ref(false)
const batchSubmitting = ref(false)
const form = reactive({
title: '', cover_url: '', description: '', category_id: 0,
title: '', cover_url: '', description: '', category_id: 0, source_id: 0,
})
const batchForm = reactive({
touchStatus: false, status: 1,
touchCategory: false, category_id: 0,
touchSource: false, source_id: 0,
})
function onSelectionChange(rows) {
@@ -224,18 +250,34 @@ async function uploadCover(file) {
onMounted(async () => {
load()
loadSources()
try {
const r = await request({ url: '/admin/categories', method: 'get' })
categories.value = r || []
} catch (e) {}
})
async function loadSources() {
try {
const r = await request({ url: '/admin/sources', method: 'get' })
sources.value = r.list || []
} catch (e) {}
}
function onFilterChange() {
page.value = 1
load()
}
async function load() {
loading.value = true
try {
const r = await request({
url: '/admin/series', method: 'get',
params: { keyword: keyword.value, page: page.value, page_size: pageSize },
params: {
keyword: keyword.value, page: page.value, page_size: pageSize,
source_id: filterSourceId.value ?? undefined,
},
})
list.value = r.list
total.value = r.total
@@ -246,7 +288,10 @@ async function load() {
function showCreate() {
editing.value = null
Object.assign(form, { title: '', cover_url: '', description: '', category_id: 0 })
Object.assign(form, {
title: '', cover_url: '', description: '', category_id: 0,
source_id: sources.value[0]?.id ?? 0,
})
dialogVisible.value = true
}
@@ -255,6 +300,7 @@ function showEdit(row) {
Object.assign(form, {
title: row.title, cover_url: row.cover_url,
description: row.description, category_id: row.category_id ?? 0,
source_id: row.source_id ?? 0,
})
dialogVisible.value = true
}
@@ -344,12 +390,15 @@ async function handleBatchCmd(cmd) {
}
function showBatchEdit() {
Object.assign(batchForm, { touchStatus: false, status: 1, touchCategory: false, category_id: 0 })
Object.assign(batchForm, {
touchStatus: false, status: 1, touchCategory: false, category_id: 0,
touchSource: false, source_id: sources.value[0]?.id ?? 0,
})
batchDialogVisible.value = true
}
async function batchSave() {
if (!batchForm.touchStatus && !batchForm.touchCategory) {
if (!batchForm.touchStatus && !batchForm.touchCategory && !batchForm.touchSource) {
ElMessage.warning('至少勾选一个要修改的字段')
return
}
@@ -358,6 +407,7 @@ async function batchSave() {
const data = { series_ids: selectedIds.value }
if (batchForm.touchStatus) data.status = batchForm.status
if (batchForm.touchCategory) data.category_id = batchForm.category_id
if (batchForm.touchSource) data.source_id = batchForm.source_id
const r = await request({ url: '/admin/series/batch', method: 'put', data })
ElMessage.success(`已更新 ${r.affected} 个系列`)
batchDialogVisible.value = false
+155
View File
@@ -0,0 +1,155 @@
<template>
<div class="page-container">
<el-card shadow="never">
<template #header>
<div class="header">
<span>来源管理</span>
<el-button type="primary" @click="showCreate">+ 新建来源</el-button>
</div>
</template>
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px"
title="视频和系列按「来源」区分片商(如 麻豆、91)。采集时自动打标,也可在视频/系列管理里批量修改。来源下有内容时不能删除,可改为停用。" />
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="来源名称" width="160">
<template #default="{ row }">
<el-tag type="warning" size="small">{{ row.name }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="code" label="标识" width="140">
<template #default="{ row }">{{ row.code || '-' }}</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column prop="video_count" label="视频数" width="100" />
<el-table-column prop="series_count" label="系列数" width="100" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="showEdit(row)">编辑</el-button>
<el-button link type="primary" @click="toggleStatus(row)">
{{ row.status === 1 ? '停用' : '启用' }}
</el-button>
<el-button link type="danger" @click="del(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑来源' : '新建来源'" width="420px">
<el-form :model="form" label-width="90px">
<el-form-item label="名称">
<el-input v-model="form.name" maxlength="50" placeholder="如 麻豆、91" />
</el-form-item>
<el-form-item label="标识">
<el-input v-model="form.code" maxlength="50" placeholder="如 madou(选填,用于采集自动打标)" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0"
active-text="启用" inactive-text="停用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/api/request'
const list = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const editing = ref(null)
const submitting = ref(false)
const form = reactive({ name: '', code: '', sort: 0, status: 1 })
async function load() {
loading.value = true
try {
const r = await request({ url: '/admin/sources', method: 'get' })
list.value = r.list || []
} catch (e) {} finally {
loading.value = false
}
}
function showCreate() {
editing.value = null
Object.assign(form, { name: '', code: '', sort: 0, status: 1 })
dialogVisible.value = true
}
function showEdit(row) {
editing.value = row
Object.assign(form, {
name: row.name, code: row.code || '', sort: row.sort, status: row.status,
})
dialogVisible.value = true
}
async function save() {
if (!form.name.trim()) { ElMessage.warning('请填来源名称'); return }
submitting.value = true
try {
if (editing.value) {
await request({ url: `/admin/sources/${editing.value.id}`, method: 'put', data: form })
ElMessage.success('已更新')
} else {
await request({ url: '/admin/sources', method: 'post', data: form })
ElMessage.success('已创建')
}
dialogVisible.value = false
load()
} catch (e) {} finally {
submitting.value = false
}
}
async function toggleStatus(row) {
try {
await request({
url: `/admin/sources/${row.id}`, method: 'put',
data: { status: row.status === 1 ? 0 : 1 },
})
ElMessage.success('已更新')
load()
} catch (e) {}
}
async function del(row) {
try {
await ElMessageBox.confirm(
`确认删除来源「${row.name}」?来源下有视频或系列时不允许删除。`,
'警告', { type: 'warning' }
)
} catch { return }
try {
await request({ url: `/admin/sources/${row.id}`, method: 'delete' })
ElMessage.success('已删除')
load()
} catch (e) {}
}
onMounted(load)
</script>
<style scoped>
.header { display: flex; justify-content: space-between; align-items: center; }
</style>
+218
View File
@@ -0,0 +1,218 @@
<template>
<div class="page-container">
<el-alert type="warning" :closable="false" style="margin-bottom: 16px">
<template #title>
<div>
<b>数据库备份与恢复(仅 super)</b>
<div style="font-size: 12px; margin-top: 4px; line-height: 1.6">
· 备份:mysqldump 全库导出 + gzip,文件保存在服务器 <code>runtime/backups/</code>。<br>
· 恢复:会<b style="color: #f56c6c">覆盖整个数据库</b>,操作前请确保已创建新备份,并输入「确认恢复」二次确认。
</div>
</div>
</template>
</el-alert>
<div class="filter-bar">
<el-button type="primary" :icon="Plus" :loading="creating" @click="onCreate">
立即备份
</el-button>
<el-button type="warning" :icon="Upload" :loading="restoring" @click="onUploadRestore">
上传 .sql.gz 恢复
</el-button>
<el-button :icon="Refresh" @click="load">刷新列表</el-button>
<input
ref="fileInputRef"
type="file"
accept=".sql.gz,application/gzip"
style="display:none"
@change="onFileChosen"
/>
</div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="filename" label="文件名" min-width="240" />
<el-table-column label="大小" width="120">
<template #default="{ row }">{{ formatSize(row.size) }}</template>
</el-table-column>
<el-table-column prop="created_at" label="备份时间" width="200" />
<el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }">
<el-button size="small" :icon="Download" @click="onDownload(row)">下载</el-button>
<el-button size="small" type="warning" :icon="RefreshLeft" @click="onRestore(row)">
恢复
</el-button>
<el-button size="small" type="danger" :icon="Delete" @click="onDelete(row)">
删除
</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无备份" /></template>
</el-table>
</div>
</template>
<script setup>
import {
Delete,
Download,
Plus,
Refresh,
RefreshLeft,
Upload,
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, ref } from 'vue'
import {
createBackup,
deleteBackup,
downloadBackup,
listBackups,
restoreFromExisting,
restoreFromUpload,
} from '@/api/system'
const loading = ref(false)
const creating = ref(false)
const restoring = ref(false)
const rows = ref([])
const fileInputRef = ref(null)
function formatSize(bytes) {
if (!bytes) return '0 B'
if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(2)} GB`
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(2)} MB`
if (bytes >= 1 << 10) return `${(bytes / 1024).toFixed(1)} KB`
return `${bytes} B`
}
async function load() {
loading.value = true
try {
const data = await listBackups()
rows.value = data.items || []
} catch (_) {
// request.js 已统一报错
} finally {
loading.value = false
}
}
async function onCreate() {
creating.value = true
try {
const data = await createBackup()
ElMessage.success(`备份成功:${data.filename}(${formatSize(data.size)})`)
await load()
} catch (_) {
// 已提示
} finally {
creating.value = false
}
}
async function onDownload(row) {
try {
await downloadBackup(row.filename)
ElMessage.success('开始下载')
} catch (e) {
ElMessage.error(e.message || '下载失败')
}
}
async function onDelete(row) {
try {
await ElMessageBox.confirm(
`确定删除备份文件「${row.filename}」吗?删除后无法恢复。`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch (_) {
return
}
try {
await deleteBackup(row.filename)
ElMessage.success('已删除')
await load()
} catch (_) {
// 已提示
}
}
/** 二次确认:必须输入「确认恢复」才能继续 */
async function confirmRestore(tip) {
try {
const { value } = await ElMessageBox.prompt(
`${tip}\n\n此操作会覆盖当前数据库全部数据,不可恢复!\n请输入「确认恢复」继续:`,
'高危操作',
{
type: 'error',
confirmButtonText: '执行恢复',
cancelButtonText: '取消',
inputPlaceholder: '确认恢复',
inputValidator: (v) => (v === '确认恢复' ? true : '请输入完整文字「确认恢复」'),
},
)
return value === '确认恢复'
} catch (_) {
return false
}
}
async function onRestore(row) {
const okToRun = await confirmRestore(`将从备份「${row.filename}」恢复数据库。`)
if (!okToRun) return
restoring.value = true
try {
await restoreFromExisting(row.filename)
ElMessage.success('恢复完成')
} catch (_) {
// 已提示
} finally {
restoring.value = false
}
}
function onUploadRestore() {
fileInputRef.value?.click()
}
async function onFileChosen(event) {
const file = event.target.files?.[0]
event.target.value = '' // 重置,确保下次还能选同一文件
if (!file) return
if (!file.name.endsWith('.sql.gz')) {
ElMessage.error('仅支持 .sql.gz 格式')
return
}
const okToRun = await confirmRestore(
`将上传「${file.name}」(${formatSize(file.size)})并恢复数据库。`,
)
if (!okToRun) return
restoring.value = true
try {
await restoreFromUpload(file)
ElMessage.success('上传并恢复完成')
await load()
} catch (_) {
// 已提示
} finally {
restoring.value = false
}
}
onMounted(load)
</script>
<style scoped>
.page-container {
padding: 16px;
}
.filter-bar {
display: flex;
gap: 8px;
margin-bottom: 16px;
align-items: center;
}
</style>
+156
View File
@@ -0,0 +1,156 @@
<template>
<div class="page">
<el-card shadow="never">
<template #header>
<div class="header">
<span class="title">用户操作日志</span>
<span class="tip">仅 256kb@gmail.com 等白名单账号会产生上报数据</span>
</div>
</template>
<el-form inline @submit.prevent>
<el-form-item label="用户ID">
<el-input v-model="filters.userId" placeholder="user_id" clearable style="width:140px" />
</el-form-item>
<el-form-item label="行为">
<el-select v-model="filters.action" placeholder="全部" clearable style="width:200px">
<el-option v-for="a in actionOptions" :key="a" :label="a" :value="a" />
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="filters.keyword" placeholder="detail 包含" clearable style="width:200px" />
</el-form-item>
<el-form-item label="时间">
<el-date-picker
v-model="filters.range"
type="daterange"
range-separator="至"
start-placeholder="开始"
end-placeholder="结束"
value-format="YYYY-MM-DD"
style="width:260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="load(1)">查询</el-button>
<el-button @click="reset">重置</el-button>
</el-form-item>
</el-form>
<el-table :data="rows" v-loading="loading" stripe size="small">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="created_at" label="时间" width="160" />
<el-table-column prop="user_id" label="用户" width="80" />
<el-table-column prop="action" label="行为" width="160">
<template #default="{ row }">
<el-tag size="small" :type="tagType(row.action)">{{ row.action }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="page" label="页面" width="160" show-overflow-tooltip />
<el-table-column prop="detail" label="上下文" min-width="320" show-overflow-tooltip />
<el-table-column prop="app_version" label="版本" width="100" />
<el-table-column prop="device_info" label="设备" min-width="180" show-overflow-tooltip />
</el-table>
<div class="pager">
<el-pagination
background
layout="total, prev, pager, next, sizes"
:total="total"
:page-size="pageSize"
:page-sizes="[20, 50, 100, 200]"
:current-page="page"
@current-change="load"
@size-change="onSizeChange"
/>
</div>
</el-card>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import request from '../api/request'
const rows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(50)
const loading = ref(false)
const filters = reactive({
userId: '',
action: '',
keyword: '',
range: null,
})
const actionOptions = [
'log_session_start',
'log_session_end',
'logout',
'route_enter',
'route_exit',
'feed_did_pop_next',
'player_stopall',
'player_suspend',
'player_resume',
'api_error',
]
function tagType(action) {
if (action.startsWith('player_')) return 'warning'
if (action.startsWith('route_')) return 'success'
if (action === 'api_error') return 'danger'
return 'info'
}
async function load(p) {
if (typeof p === 'number') page.value = p
loading.value = true
try {
const params = {
page: page.value,
page_size: pageSize.value,
}
if (filters.userId) params.user_id = Number(filters.userId)
if (filters.action) params.action = filters.action
if (filters.keyword) params.keyword = filters.keyword
if (filters.range && filters.range[0]) {
params.start = filters.range[0]
params.end = filters.range[1]
}
const { data } = await request.get('/admin/user-action-logs', { params })
rows.value = data.items || []
total.value = data.total || 0
} catch (e) {
ElMessage.error(e?.message || '加载失败')
} finally {
loading.value = false
}
}
function reset() {
filters.userId = ''
filters.action = ''
filters.keyword = ''
filters.range = null
load(1)
}
function onSizeChange(s) {
pageSize.value = s
load(1)
}
onMounted(() => load(1))
</script>
<style scoped>
.page { padding: 16px; }
.header { display: flex; align-items: baseline; gap: 12px; }
.title { font-weight: 600; }
.tip { color: #909399; font-size: 12px; }
.pager { margin-top: 12px; display: flex; justify-content: flex-end; }
</style>
+113 -5
View File
@@ -8,11 +8,23 @@
</el-select>
<el-input v-model="filters.author_id" placeholder="作者 ID" clearable style="width: 140px"
@keyup.enter="search" />
<el-select v-model="filters.source_id" placeholder="来源" clearable style="width: 120px"
@change="search">
<el-option v-for="s in sources" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
<el-button v-if="selectedIds.length" type="warning" plain @click="openBatchSource">
批量改来源(已选 {{ selectedIds.length }})
</el-button>
<el-button v-if="selectedIds.length" type="danger" plain :loading="batchOfflineSubmitting"
@click="batchOffline">
批量下架(已选 {{ selectedIds.length }})
</el-button>
</div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table v-loading="loading" :data="rows" border stripe @selection-change="onSelectionChange">
<el-table-column type="selection" width="42" />
<el-table-column prop="video_id" label="ID" width="80" />
<el-table-column label="封面" width="100">
<template #default="{ row }">
@@ -28,6 +40,12 @@
<el-table-column label="分类" width="90">
<template #default="{ row }">{{ categoryName(row.category_id) }}</template>
</el-table-column>
<el-table-column label="来源" width="100">
<template #default="{ row }">
<el-tag v-if="row.source_name" size="small" type="warning">{{ row.source_name }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ row.status_text }}</el-tag>
@@ -81,6 +99,11 @@
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="来源">
<el-select v-model="editForm.source_id" placeholder="请选择来源" style="width: 100%">
<el-option v-for="s in sources" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
</el-form-item>
<el-form-item label="归属系列">
<el-select v-model="editForm.series_id" placeholder="不归属系列" style="width: 100%" clearable
@change="onEditSeriesChange">
@@ -104,6 +127,20 @@
<el-button type="primary" :loading="editSubmitting" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
<!-- 批量改来源 -->
<el-dialog v-model="batchVisible" title="批量修改来源" width="400px">
<div style="margin-bottom: 12px; color: #606266; font-size: 13px">
共选中 <b>{{ selectedIds.length }}</b> 个视频,统一改为:
</div>
<el-select v-model="batchSourceId" placeholder="请选择来源" style="width: 100%">
<el-option v-for="s in sources" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<template #footer>
<el-button @click="batchVisible = false">取消</el-button>
<el-button type="primary" :loading="batchSubmitting" @click="saveBatchSource">保存</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -113,7 +150,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import request from '@/api/request'
import { listVideos, offlineVideo, updateVideo, deleteVideo } from '@/api/videos'
import { listVideos, offlineVideo, updateVideo, deleteVideo, batchUpdateVideos, batchOfflineVideos } from '@/api/videos'
import VideoPreview from '@/components/VideoPreview.vue'
import { askReason } from '@/utils/reason'
@@ -133,10 +170,17 @@ const rows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filters = reactive({ keyword: '', status: '', author_id: '' })
const filters = reactive({ keyword: '', status: '', author_id: '', source_id: '' })
const categories = ref([])
const seriesList = ref([])
const sources = ref([])
const selectedIds = ref([])
const batchVisible = ref(false)
const batchSourceId = ref(0)
const batchSubmitting = ref(false)
const batchOfflineSubmitting = ref(false)
const previewVisible = ref(false)
const previewUrl = ref('')
@@ -148,7 +192,7 @@ const coverUploading = ref(false)
const editingVideoId = ref(null)
const editForm = reactive({
title: '', description: '', cover_url: '',
category_id: 0, series_id: null, episode_no: null,
category_id: 0, source_id: 0, series_id: null, episode_no: null,
status: 2,
})
@@ -168,6 +212,7 @@ async function load() {
keyword: filters.keyword,
status: filters.status,
author_id: filters.author_id,
source_id: filters.source_id,
page: page.value,
page_size: pageSize.value,
})
@@ -180,10 +225,35 @@ async function load() {
function search() { page.value = 1; load() }
function reset() {
filters.keyword = ''; filters.status = ''; filters.author_id = ''
filters.keyword = ''; filters.status = ''; filters.author_id = ''; filters.source_id = ''
search()
}
function onSelectionChange(sel) {
selectedIds.value = sel.map(r => r.video_id)
}
function openBatchSource() {
batchSourceId.value = sources.value[0]?.id ?? 0
batchVisible.value = true
}
async function saveBatchSource() {
if (!batchSourceId.value) { ElMessage.warning('请选择来源'); return }
batchSubmitting.value = true
try {
const r = await batchUpdateVideos({
video_ids: selectedIds.value,
source_id: batchSourceId.value,
})
ElMessage.success(`已更新 ${r.affected} 个视频`)
batchVisible.value = false
load()
} catch (e) {} finally {
batchSubmitting.value = false
}
}
function openPreview(row) {
previewUrl.value = row.video_url
previewTitle.value = `预览:${row.title}`
@@ -200,6 +270,38 @@ async function offline(row) {
load()
}
async function batchOffline() {
if (!selectedIds.value.length) return
// 仅已发布的可下架,过滤一次减少后端空跑
const targets = rows.value.filter(r => selectedIds.value.includes(r.video_id) && r.status === 2)
if (!targets.length) {
ElMessage.warning('选中的视频里没有「已发布」状态的,无需下架')
return
}
let reason
try {
reason = await askReason({
title: '批量下架视频',
message: `将下架 ${targets.length} 个已发布视频(其余 ${selectedIds.value.length - targets.length} 个非已发布状态会自动跳过),并通知作者`,
})
} catch { return }
batchOfflineSubmitting.value = true
try {
const r = await batchOfflineVideos({
video_ids: targets.map(r => r.video_id),
reason,
})
if (r.skipped?.length) {
ElMessage.warning(`已下架 ${r.success} 个,跳过 ${r.skipped.length} 个(ID: ${r.skipped.slice(0, 5).join(',')}${r.skipped.length > 5 ? '…' : ''})`)
} else {
ElMessage.success(`已批量下架 ${r.success} 个视频`)
}
load()
} catch (e) {} finally {
batchOfflineSubmitting.value = false
}
}
async function deleteVideoRow(row) {
try {
await ElMessageBox.confirm(
@@ -221,6 +323,7 @@ function openEdit(row) {
description: row.description || '',
cover_url: row.cover_url || '',
category_id: row.category_id || 0,
source_id: row.source_id || 0,
series_id: row.series_id || null,
episode_no: row.episode_no || null,
status: row.status,
@@ -260,6 +363,7 @@ async function saveEdit() {
description: editForm.description,
cover_url: editForm.cover_url,
category_id: editForm.category_id || 0,
source_id: editForm.source_id || 0,
status: editForm.status,
}
// 只有用户动过才提交
@@ -285,6 +389,10 @@ onMounted(async () => {
const r = await request({ url: '/admin/series', method: 'get', params: { page_size: 200 } })
seriesList.value = r?.list || []
} catch (e) {}
try {
const r = await request({ url: '/admin/sources', method: 'get' })
sources.value = r.list || []
} catch (e) {}
})
</script>