1
This commit is contained in:
@@ -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
@@ -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}`)
|
||||
|
||||
@@ -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: [] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user