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