This commit is contained in:
lz_db
2026-10-17 19:04:05 +08:00
parent 28b11b4365
commit 1253c159f5
43 changed files with 2652 additions and 114 deletions
+5
View File
@@ -15,3 +15,8 @@ export function getUserDetail(userId) {
export function setUserStatus(userId, data) {
return request.put(`/admin/users/${userId}/status`, data)
}
/** 后台重置用户密码(敏感) { new_password, reason } */
export function resetUserPassword(userId, data) {
return request.put(`/admin/users/${userId}/password`, data)
}
+1
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: '/collect-sources', icon: 'Download', roles: [] },
{ title: '分类管理', path: '/categories', icon: 'Menu', roles: [] },
{ title: '标签管理', path: '/tags', icon: 'PriceTag', roles: [] },
{ title: '评论管理', path: '/comments', icon: 'ChatDotRound', roles: [] },
+6
View File
@@ -58,6 +58,12 @@ const routes = [
component: () => import('@/views/SeriesManage.vue'),
meta: { title: '系列管理', icon: 'CollectionTag', roles: [] },
},
{
path: 'collect-sources',
name: 'collect-sources',
component: () => import('@/views/CollectSources.vue'),
meta: { title: '采集源管理', icon: 'Download', roles: [] },
},
{
path: 'categories',
name: 'category-manage',
+212
View File
@@ -0,0 +1,212 @@
<template>
<div class="collect-sources">
<el-card shadow="never">
<template #header>
<div class="header">
<span>采集源管理</span>
<div>
<el-button :loading="syncAllLoading" @click="syncAll">全部同步</el-button>
<el-button @click="batchVisible = true">批量添加</el-button>
<el-button type="primary" @click="showCreate">+ 添加采集源</el-button>
</div>
</div>
</template>
<el-alert type="info" :closable="false" style="margin-bottom: 12px">
添加"分类名 + 列表URL"后点同步即可自动采集;URL 中用 <code v-pre>{{page}}</code> 表示页码占位。
分类名不存在时会自动创建。同步为增量:已入库的视频自动跳过。
</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 prop="list_url" label="列表 URL" min-width="320" show-overflow-tooltip />
<el-table-column label="状态" width="80">
<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 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">
<template #default="{ row }">
<el-button link type="warning" :loading="syncingId === row.id" @click="syncOne(row)">同步</el-button>
<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="batchVisible" title="批量添加采集源" width="640px">
<el-alert type="info" :closable="false" style="margin-bottom: 10px">
每行一条,格式:<code v-pre>分类名 URL</code>(URL 可带反引号)。提交后自动创建分类并立即开始采集。
</el-alert>
<el-input v-model="batchText" type="textarea" :rows="10"
:placeholder="'每日更新 https://mdcmai4.xyz/api/v1/videos?categoryId=27&page={{page}}&size=60\n清纯少女 https://mdcmai4.xyz/api/v1/videos?categoryId=13&page={{page}}&size=60'" />
<div v-if="batchResult" style="margin-top: 10px; font-size: 13px">
<div v-if="batchResult.created.length" style="color: #67c23a">
已创建 {{ batchResult.created.length }} 个并启动采集:{{ batchResult.created.map(c => c.name).join('、') }}
</div>
<div v-if="batchResult.skipped.length" style="color: #e6a23c">
跳过 {{ batchResult.skipped.length }} 条(URL 已存在):{{ batchResult.skipped.map(s => s.name).join('、') }}
</div>
<div v-if="batchResult.failed.length" style="color: #f56c6c">
失败 {{ batchResult.failed.length }} 条:{{ batchResult.failed.map(f => f.line).join(';') }}
</div>
</div>
<template #footer>
<el-button @click="batchVisible = false">关闭</el-button>
<el-button type="primary" :loading="batchSubmitting" @click="submitBatch">提交</el-button>
</template>
</el-dialog>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑采集源' : '添加采集源'" width="560px">
<el-form :model="form" label-width="90px">
<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>
</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 { reactive, ref, onMounted } 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 syncingId = ref(0)
const syncAllLoading = ref(false)
const form = reactive({ name: '', list_url: '' })
const batchVisible = ref(false)
const batchText = ref('')
const batchSubmitting = ref(false)
const batchResult = ref(null)
async function submitBatch() {
if (!batchText.value.trim()) { ElMessage.warning('请粘贴内容'); return }
batchSubmitting.value = true
batchResult.value = null
try {
const r = await request({ url: '/admin/collection/sources/batch', method: 'post', data: { text: batchText.value } })
batchResult.value = r
if (r.created.length) { batchText.value = ''; setTimeout(load, 3000) }
else load()
} catch (e) {} finally {
batchSubmitting.value = false
}
}
onMounted(load)
async function load() {
loading.value = true
try {
const r = await request({ url: '/admin/collection/sources', method: 'get' })
list.value = r.list
} catch (e) {} finally {
loading.value = false
}
}
function showCreate() {
editing.value = null
Object.assign(form, { name: '', list_url: '' })
dialogVisible.value = true
}
function showEdit(row) {
editing.value = row
Object.assign(form, { name: row.name, list_url: row.list_url })
dialogVisible.value = true
}
async function save() {
if (!form.name || !form.list_url) { ElMessage.warning('请填写完整'); return }
if (!form.list_url.includes('{{page}}')) { ElMessage.warning('URL 需包含 {{page}} 页码占位符'); return }
submitting.value = true
try {
if (editing.value) {
await request({ url: `/admin/collection/sources/${editing.value.id}`, method: 'put', data: form })
ElMessage.success('已更新')
} else {
await request({ url: '/admin/collection/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/collection/sources/${row.id}`, method: 'put',
data: { status: row.status === 1 ? 0 : 1 },
})
ElMessage.success('已更新')
load()
} catch (e) {}
}
async function del(row) {
await ElMessageBox.confirm(
`确认删除采集源「${row.name}」?已采集的视频不会被删除。`, '警告', { type: 'warning' }
)
try {
await request({ url: `/admin/collection/sources/${row.id}`, method: 'delete' })
ElMessage.success('已删除')
load()
} catch (e) {}
}
async function syncOne(row) {
syncingId.value = row.id
try {
await request({ url: `/admin/collection/sources/${row.id}/sync`, method: 'post', data: { max_pages: 0 } })
ElMessage.success(`「${row.name}」已触发后台同步,稍后刷新查看`)
setTimeout(load, 20000)
} catch (e) {} finally {
syncingId.value = 0
}
}
async function syncAll() {
syncAllLoading.value = true
try {
const r = await request({ url: '/admin/collection/sources/sync-all', method: 'post' })
ElMessage.success(`已触发 ${r.started.length} 个源的后台同步`)
setTimeout(load, 20000)
} catch (e) {} finally {
syncAllLoading.value = false
}
}
</script>
<style scoped>
.header { display: flex; justify-content: space-between; align-items: center; }
</style>
+34 -1
View File
@@ -7,6 +7,7 @@
<div>
<el-input v-model="keyword" placeholder="搜索系列标题" clearable
style="width: 200px; margin-right: 8px" @keyup.enter="load" />
<el-button :loading="syncAllLoading" @click="syncAll">同步新剧</el-button>
<el-button type="primary" @click="showCreate">+ 新建系列</el-button>
</div>
</div>
@@ -23,6 +24,12 @@
</template>
</el-table-column>
<el-table-column prop="total_episodes" label="集数" width="90" />
<el-table-column label="来源" width="80">
<template #default="{ row }">
<el-tag v-if="row.source_id" size="small" type="warning">采集</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
@@ -31,9 +38,11 @@
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="180" fixed="right">
<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"
:loading="syncingId === row.id" @click="syncOne(row)">更新</el-button>
<el-button link type="primary" @click="toggleStatus(row)">
{{ row.status === 1 ? '隐藏' : '恢复' }}
</el-button>
@@ -116,6 +125,8 @@ const dialogVisible = ref(false)
const editing = ref(null)
const submitting = ref(false)
const coverUploading = ref(false)
const syncAllLoading = ref(false)
const syncingId = ref(0)
const form = reactive({
title: '', cover_url: '', description: '', category_id: 0,
@@ -224,6 +235,28 @@ async function del(row) {
load()
} catch (e) {}
}
async function syncOne(row) {
syncingId.value = row.id
try {
const r = await request({ url: `/admin/collection/sync/${row.id}`, method: 'post' })
ElMessage.success(`同步完成:新增 ${r.episodes_created} 集,已存在 ${r.episodes_skipped} 集`)
load()
} catch (e) {} finally {
syncingId.value = 0
}
}
async function syncAll() {
syncAllLoading.value = true
try {
await request({ url: '/admin/collection/sync-all', method: 'post', data: { pages: 2 } })
ElMessage.success('已触发后台同步,稍后刷新查看')
setTimeout(load, 15000)
} catch (e) {} finally {
syncAllLoading.value = false
}
}
</script>
<style scoped>
+86 -3
View File
@@ -48,9 +48,18 @@
</template>
</el-table-column>
<el-table-column prop="created_at" label="注册时间" width="170" />
<el-table-column label="操作" width="170" fixed="right">
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :icon="View" @click="toDetail(row)">详情</el-button>
<el-button
v-if="row.status !== 3"
link
type="warning"
:icon="Key"
@click="openResetPwd(row)"
>
改密
</el-button>
<el-button
v-if="row.status === 1"
link
@@ -84,16 +93,52 @@
@current-change="load"
/>
</div>
<!-- 重置密码弹窗 -->
<el-dialog v-model="pwdDialog.visible" title="重置用户密码" width="420px">
<el-form label-width="80px">
<el-form-item label="用户">
<span>{{ pwdDialog.nickname }} #{{ pwdDialog.userId }}</span>
</el-form-item>
<el-form-item label="新密码">
<el-input
v-model="pwdDialog.newPassword"
type="password"
show-password
placeholder="6-100 位"
maxlength="100"
/>
</el-form-item>
<el-form-item label="操作原因">
<el-input
v-model="pwdDialog.reason"
type="textarea"
:rows="2"
placeholder="必填,将记录操作日志"
maxlength="200"
/>
</el-form-item>
</el-form>
<div style="color: var(--el-color-warning); font-size: 12px; margin-top: -8px">
重置后该用户所有端登录态立即失效,需用新密码重新登录
</div>
<template #footer>
<el-button @click="pwdDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="pwdDialog.submitting" @click="submitResetPwd">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { Lock, Refresh, Search, Unlock, View } from '@element-plus/icons-vue'
import { Key, Lock, Refresh, Search, Unlock, View } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { listUsers, setUserStatus } from '@/api/users'
import { listUsers, resetUserPassword, setUserStatus } from '@/api/users'
import { askReason } from '@/utils/reason'
const STATUS_TAG = { 1: 'success', 2: 'danger', 3: 'info' }
@@ -154,5 +199,43 @@ async function toggleStatus(row, target) {
load()
}
const pwdDialog = reactive({
visible: false,
userId: 0,
nickname: '',
newPassword: '',
reason: '',
submitting: false,
})
function openResetPwd(row) {
pwdDialog.userId = row.user_id
pwdDialog.nickname = row.nickname || ''
pwdDialog.newPassword = ''
pwdDialog.reason = ''
pwdDialog.visible = true
}
async function submitResetPwd() {
const pwd = pwdDialog.newPassword.trim()
const reason = pwdDialog.reason.trim()
if (pwd.length < 6 || pwd.length > 100) {
ElMessage.warning('新密码长度需为 6-100 位')
return
}
if (!reason) {
ElMessage.warning('请填写操作原因')
return
}
pwdDialog.submitting = true
try {
await resetUserPassword(pwdDialog.userId, { new_password: pwd, reason })
ElMessage.success('密码已重置,该用户全端登录态已失效')
pwdDialog.visible = false
} finally {
pwdDialog.submitting = false
}
}
onMounted(load)
</script>