This commit is contained in:
lzcode
2026-10-16 16:05:01 +08:00
parent 24d63e6702
commit 9962136fc8
19 changed files with 1425 additions and 208 deletions
+168
View File
@@ -0,0 +1,168 @@
<template>
<div class="cat-manage">
<el-card shadow="never">
<template #header>
<div class="header">
<span>分类管理</span>
<div>
<el-input v-model="keyword" placeholder="搜索分类名" clearable
style="width: 200px; margin-right: 8px" @keyup.enter="load" />
<el-button type="primary" @click="showCreate">+ 新建分类</el-button>
</div>
</div>
</template>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="分类名" min-width="120" />
<el-table-column prop="sort" label="排序" width="80" />
<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 prop="video_count" label="视频数" width="90" />
<el-table-column label="操作" width="180" 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-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
style="margin-top: 12px; text-align: right"
@current-change="load"
/>
</el-card>
<!-- 新建/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="editing ? '编辑分类' : '新建分类'" width="400px">
<el-form :model="form" label-width="80px">
<el-form-item label="分类名" required>
<el-input v-model="form.name" maxlength="50" show-word-limit />
</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-radio-group v-model="form.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">停用</el-radio>
</el-radio-group>
</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 total = ref(0)
const page = ref(1)
const pageSize = 20
const loading = ref(false)
const keyword = ref('')
const dialogVisible = ref(false)
const editing = ref(null)
const submitting = ref(false)
const form = reactive({ name: '', sort: 0, status: 1 })
onMounted(load)
async function load() {
loading.value = true
try {
const r = await request({
url: '/admin/categories/admin', method: 'get',
params: { keyword: keyword.value || undefined, page: page.value, page_size: pageSize },
})
list.value = r.list
total.value = r.total
} catch (e) {} finally {
loading.value = false
}
}
function showCreate() {
editing.value = null
Object.assign(form, { name: '', sort: 0, status: 1 })
dialogVisible.value = true
}
function showEdit(row) {
editing.value = row
Object.assign(form, { name: row.name, 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/categories/${editing.value.id}`, method: 'put',
data: { name: form.name, sort: form.sort, status: form.status },
})
ElMessage.success('已更新')
} else {
await request({
url: '/admin/categories', method: 'post',
data: { name: form.name, sort: form.sort, status: form.status },
})
ElMessage.success('已创建')
}
dialogVisible.value = false
load()
} catch (e) {} finally {
submitting.value = false
}
}
async function toggleStatus(row) {
const next = row.status === 1 ? 0 : 1
try {
await request({
url: `/admin/categories/${row.id}`, method: 'put',
data: { status: next },
})
ElMessage.success('已更新')
load()
} catch (e) {}
}
async function del(row) {
await ElMessageBox.confirm(`确认删除「${row.name}」?有视频绑定时拒绝删除。`, '警告', { type: 'warning' })
try {
await request({ url: `/admin/categories/${row.id}`, method: 'delete' })
ElMessage.success('已删除')
load()
} catch (e) {}
}
</script>
<style scoped>
.header { display: flex; justify-content: space-between; align-items: center; }
</style>
+6 -6
View File
@@ -140,12 +140,12 @@ const rules = {
onMounted(async () => {
try {
const res = await request({ url: '/categories', method: 'get' })
categories.value = res.data || []
const res = await request({ url: '/admin/categories', method: 'get' })
categories.value = res || []
} catch (e) {}
try {
const s = await request({ url: '/admin/series', method: 'get', params: { page_size: 200 } })
seriesList.value = s.data?.list || []
seriesList.value = s?.list || []
} catch (e) {}
})
@@ -158,8 +158,8 @@ async function uploadFile({ file }) {
try {
// 1) 先拿上传凭证
const t = await request({ url: '/admin/videos/upload', method: 'post' })
const token = t.data.token
const uploadUrl = t.data.upload_url
const token = t.token
const uploadUrl = t.upload_url
// 2) 上传文件(multipart/form-data,带 token query)
const fd = new FormData()
fd.append('file', file)
@@ -209,7 +209,7 @@ async function submit() {
if (form.duration) payload.duration = form.duration
}
const res = await request({ url: '/admin/videos', method: 'post', data: payload })
const newVideoId = res.data?.video_id
const newVideoId = res?.video_id
resultMsg.value = newVideoId
// 如果选了系列 + 集数,绑定进去
if (newVideoId && form.series_id && form.episode_no) {
+5 -5
View File
@@ -138,7 +138,7 @@ async function uploadCover(file) {
url: '/admin/upload/image', method: 'post', data: fd,
headers: { 'Content-Type': 'multipart/form-data' },
})
form.cover_url = r.data.url
form.cover_url = r.url
ElMessage.success('封面上传成功')
} catch (e) {
ElMessage.error(e?.message || '上传失败')
@@ -150,8 +150,8 @@ async function uploadCover(file) {
onMounted(async () => {
load()
try {
const r = await request({ url: '/categories', method: 'get' })
categories.value = r.data || []
const r = await request({ url: '/admin/categories', method: 'get' })
categories.value = r || []
} catch (e) {}
})
@@ -162,8 +162,8 @@ async function load() {
url: '/admin/series', method: 'get',
params: { keyword: keyword.value, page: page.value, page_size: pageSize },
})
list.value = r.data.list
total.value = r.data.total
list.value = r.list
total.value = r.total
} catch (e) {} finally {
loading.value = false
}
+178
View File
@@ -0,0 +1,178 @@
<template>
<div class="tag-manage">
<el-card shadow="never">
<template #header>
<div class="header">
<span>标签管理</span>
<div>
<el-input v-model="keyword" placeholder="搜索标签名" clearable
style="width: 200px; margin-right: 8px" @keyup.enter="load" />
<el-button type="primary" @click="showCreate">+ 新建标签</el-button>
</div>
</div>
</template>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="标签" min-width="140">
<template #default="{ row }">
<el-tag effect="plain">{{ row.name }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="video_count" label="视频数" width="100" sortable />
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="showEdit(row)">重命名</el-button>
<el-button link type="danger" @click="del(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
style="margin-top: 12px; text-align: right"
@current-change="load"
/>
</el-card>
<!-- 新建/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="editing ? '重命名标签' : '新建标签(支持批量)'" width="400px">
<el-form :model="form" label-width="70px">
<el-form-item :label="editing ? '标签名' : '标签'">
<el-input
v-if="editing"
v-model="form.name"
maxlength="20"
show-word-limit
/>
<el-input
v-else
v-model="form.name"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="用 空格 / 逗号(, ,) / 句号(. 。) / 竖线(| |) / 换行 分隔多个标签,一次可创建多个"
/>
</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 total = ref(0)
const page = ref(1)
const pageSize = 50
const loading = ref(false)
const keyword = ref('')
const dialogVisible = ref(false)
const editing = ref(null)
const submitting = ref(false)
const form = reactive({ name: '' })
onMounted(load)
async function load() {
loading.value = true
try {
const r = await request({
url: '/admin/tags', method: 'get',
params: { keyword: keyword.value || undefined, page: page.value, page_size: pageSize },
})
list.value = r.list
total.value = r.total
} catch (e) {} finally {
loading.value = false
}
}
function showCreate() {
editing.value = null
form.name = ''
dialogVisible.value = true
}
function showEdit(row) {
editing.value = row
form.name = row.name
dialogVisible.value = true
}
async function save() {
submitting.value = true
try {
if (editing.value) {
const name = form.name.trim()
if (!name) return ElMessage.warning('标签名不能为空')
if (name.length > 20) return ElMessage.warning('标签名不能超过 20 字')
await request({
url: `/admin/tags/${editing.value.id}`, method: 'put',
data: { name },
})
ElMessage.success('已更新')
} else {
// 批量分隔:空格/制表/换行/全半角逗号/全半角句号/全半角竖线
const tokens = form.name
.split(/[\s,,。.||]+/)
.map(s => s.trim())
.filter(Boolean)
// 去重 + 限长
const unique = [...new Set(tokens)].filter(t => t.length <= 20)
if (!unique.length) {
return ElMessage.warning('请至少输入一个标签')
}
const skipped = tokens.length - unique.length
let created = 0, dup = 0
for (const t of unique) {
try {
await request({ url: '/admin/tags', method: 'post', data: { name: t } })
created++
} catch (e) {
// 已存在的跳过(后端返回 DUPLICATE 错误)
dup++
}
}
const parts = [`成功创建 ${created} 个`]
if (dup) parts.push(`${dup} 个已存在跳过`)
if (skipped > 0) parts.push(`${skipped} 个重复合并`)
if (tokens.some(t => t.length > 20)) parts.push('超长标签已忽略')
ElMessage.success(parts.join(','))
}
dialogVisible.value = false
load()
} catch (e) {} finally {
submitting.value = false
}
}
async function del(row) {
await ElMessageBox.confirm(
`确认删除标签「${row.name}」?同时会从 ${row.video_count} 条视频上解绑该标签。`,
'警告', { type: 'warning' }
)
try {
await request({ url: `/admin/tags/${row.id}`, method: 'delete' })
ElMessage.success('已删除')
load()
} catch (e) {}
}
</script>
<style scoped>
.header { display: flex; justify-content: space-between; align-items: center; }
</style>
+156 -25
View File
@@ -25,6 +25,9 @@
<el-table-column label="作者" width="140">
<template #default="{ row }">{{ row.author?.nickname || '-' }}</template>
</el-table-column>
<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 :type="statusTagType(row.status)" size="small">{{ row.status_text }}</el-tag>
@@ -33,9 +36,10 @@
<el-table-column prop="view_count" label="播放" width="80" />
<el-table-column prop="like_count" label="点赞" width="80" />
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="180" fixed="right">
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :icon="View" @click="openPreview(row)">预览</el-button>
<el-button link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button>
<el-button v-if="row.status === 2" link type="danger" :icon="Delete" @click="offline(row)">
下架
</el-button>
@@ -50,15 +54,65 @@
</div>
<VideoPreview v-model="previewVisible" :video-url="previewUrl" :title="previewTitle" />
<!-- 编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑视频" width="560px">
<el-form :model="editForm" label-width="90px">
<el-form-item label="标题">
<el-input v-model="editForm.title" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="封面图">
<div class="edit-cover-row">
<el-image v-if="editForm.cover_url" :src="editForm.cover_url" fit="cover" class="edit-cover" />
<el-upload
:show-file-list="false"
:auto-upload="false"
accept="image/jpeg,image/png,image/webp"
:on-change="(f) => uploadEditCover(f)"
>
<el-button plain :loading="coverUploading">选择图片</el-button>
</el-upload>
<el-input v-model="editForm.cover_url" placeholder="或直接粘贴封面 URL" style="margin-left: 8px" />
</div>
</el-form-item>
<el-form-item label="分类">
<el-select v-model="editForm.category_id" placeholder="请选择" style="width: 100%" clearable>
<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.series_id" placeholder="不归属系列" style="width: 100%" clearable
@change="onEditSeriesChange">
<el-option v-for="s in seriesList" :key="s.id" :label="s.title" :value="s.id" />
</el-select>
</el-form-item>
<el-form-item v-if="editForm.series_id" label="第几集">
<el-input-number v-model="editForm.episode_no" :min="1" :max="9999" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="editForm.status" style="width: 100%">
<el-option v-for="(text, code) in STATUS_TEXT" :key="code" :label="text" :value="Number(code)" />
</el-select>
</el-form-item>
<el-form-item label="简介">
<el-input v-model="editForm.description" type="textarea" :rows="3" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { Delete, Refresh, Search, View } from '@element-plus/icons-vue'
import { Delete, Edit, Refresh, Search, View } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import { listVideos, offlineVideo } from '@/api/videos'
import request from '@/api/request'
import { listVideos, offlineVideo, updateVideo } from '@/api/videos'
import VideoPreview from '@/components/VideoPreview.vue'
import { askReason } from '@/utils/reason'
@@ -80,14 +134,32 @@ const page = ref(1)
const pageSize = ref(20)
const filters = reactive({ keyword: '', status: '', author_id: '' })
const categories = ref([])
const seriesList = ref([])
const previewVisible = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
const editVisible = ref(false)
const editSubmitting = ref(false)
const coverUploading = ref(false)
const editingVideoId = ref(null)
const editForm = reactive({
title: '', description: '', cover_url: '',
category_id: 0, series_id: null, episode_no: null,
status: 2,
})
function statusTagType(status) {
return STATUS_TAG[status] || 'info'
}
function categoryName(cid) {
const c = categories.value.find(x => x.id === cid)
return c?.name || (cid ? `#${cid}` : '-')
}
async function load() {
loading.value = true
try {
@@ -105,15 +177,9 @@ async function load() {
}
}
function search() {
page.value = 1
load()
}
function search() { page.value = 1; load() }
function reset() {
filters.keyword = ''
filters.status = ''
filters.author_id = ''
filters.keyword = ''; filters.status = ''; filters.author_id = ''
search()
}
@@ -127,30 +193,95 @@ async function offline(row) {
let reason
try {
reason = await askReason({ title: '下架视频', message: `下架《${row.title}》并通知作者` })
} catch {
return
}
} catch { return }
await offlineVideo(row.video_id, { reason })
ElMessage.success('已下架')
load()
}
onMounted(load)
// ---------- 编辑弹窗 ----------
function openEdit(row) {
editingVideoId.value = row.video_id
Object.assign(editForm, {
title: row.title || '',
description: row.description || '',
cover_url: row.cover_url || '',
category_id: row.category_id || 0,
series_id: row.series_id || null,
episode_no: row.episode_no || null,
status: row.status,
})
editVisible.value = true
}
function onEditSeriesChange() {
if (!editForm.series_id) editForm.episode_no = null
}
async function uploadEditCover(file) {
if (!file || !file.raw) return
coverUploading.value = true
try {
const fd = new FormData()
fd.append('file', file.raw)
const r = await request({
url: '/admin/upload/image', method: 'post', data: fd,
headers: { 'Content-Type': 'multipart/form-data' },
})
editForm.cover_url = r.url
ElMessage.success('封面上传成功')
} catch (e) {
ElMessage.error(e?.message || '上传失败')
} finally {
coverUploading.value = false
}
}
async function saveEdit() {
if (!editForm.title.trim()) { ElMessage.warning('标题不能为空'); return }
editSubmitting.value = true
try {
const payload = {
title: editForm.title.trim(),
description: editForm.description,
cover_url: editForm.cover_url,
category_id: editForm.category_id || 0,
status: editForm.status,
}
// 只有用户动过才提交
if (editForm.series_id) {
payload.series_id = editForm.series_id
if (editForm.episode_no) payload.episode_no = editForm.episode_no
}
await updateVideo(editingVideoId.value, payload)
ElMessage.success('已更新')
editVisible.value = false
load()
} catch (e) {} finally {
editSubmitting.value = false
}
}
onMounted(async () => {
load()
try {
categories.value = await request({ url: '/admin/categories', method: 'get' }) || []
} catch (e) {}
try {
const r = await request({ url: '/admin/series', method: 'get', params: { page_size: 200 } })
seriesList.value = r?.list || []
} catch (e) {}
})
</script>
<style scoped>
.cover {
width: 72px;
height: 40px;
border-radius: 4px;
width: 72px; height: 40px; border-radius: 4px;
}
.cover.placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #c0c4cc;
background-color: #f5f7fa;
display: flex; align-items: center; justify-content: center;
font-size: 12px; color: #c0c4cc; background-color: #f5f7fa;
}
.edit-cover-row { display: flex; align-items: center; width: 100%; }
.edit-cover { width: 72px; height: 40px; border-radius: 4px; margin-right: 8px; object-fit: cover; }
</style>