1
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
首页-默认
|
||||
|
||||
```JSON
|
||||
{
|
||||
"oauth_type": "pwa",
|
||||
"oauth_id": "cXNQe5FQXIkaW8Ny1792802204704",
|
||||
"trace_id": "",
|
||||
"aff_x_code": "",
|
||||
"version": "7.5.0",
|
||||
"mod": "index",
|
||||
"code": "home",
|
||||
"page": 3,
|
||||
"token": "3fba65f06e1e308c819e343474075461",
|
||||
"timestamp": 1792811390633
|
||||
}
|
||||
```
|
||||
|
||||
首页-精选-最新
|
||||
|
||||
```JSON
|
||||
{
|
||||
"oauth_type": "pwa",
|
||||
"oauth_id": "cXNQe5FQXIkaW8Ny1792802204704",
|
||||
"trace_id": "",
|
||||
"aff_x_code": "",
|
||||
"version": "7.5.0",
|
||||
"mod": "choice",
|
||||
"code": "vlog",
|
||||
"type": "new",
|
||||
"page": 1,
|
||||
"limit": 30,
|
||||
"token": "3fba65f06e1e308c819e343474075461",
|
||||
"timestamp": 1792810190406
|
||||
}
|
||||
```
|
||||
|
||||
短剧-原创-全部
|
||||
|
||||
```JSON
|
||||
{
|
||||
"oauth_type": "pwa",
|
||||
"oauth_id": "cXNQe5FQXIkaW8Ny1792802204704",
|
||||
"trace_id": "",
|
||||
"aff_x_code": "",
|
||||
"version": "7.5.0",
|
||||
"mod": "aidrama",
|
||||
"code": "home",
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"pay_type": "all",
|
||||
"sort": "all",
|
||||
"category_ids": [
|
||||
4
|
||||
],
|
||||
"token": "3fba65f06e1e308c819e343474075461",
|
||||
"timestamp": 1792811903280
|
||||
}
|
||||
```
|
||||
|
||||
短剧-详情页(请求)
|
||||
|
||||
```json
|
||||
{
|
||||
"oauth_type": "pwa",
|
||||
"oauth_id": "cXNQe5FQXIkaW8Ny1792802204704",
|
||||
"trace_id": "",
|
||||
"aff_x_code": "",
|
||||
"version": "7.5.0",
|
||||
"mod": "aidrama",
|
||||
"code": "detail",
|
||||
"drama_id": 1008,
|
||||
"token": "3fba65f06e1e308c819e343474075461",
|
||||
"timestamp": 1792812296047
|
||||
}
|
||||
```
|
||||
|
||||
短剧-请求所有集数
|
||||
|
||||
```json
|
||||
{
|
||||
"oauth_type": "pwa",
|
||||
"oauth_id": "cXNQe5FQXIkaW8Ny1792802204704",
|
||||
"trace_id": "",
|
||||
"aff_x_code": "",
|
||||
"version": "7.5.0",
|
||||
"mod": "aidrama",
|
||||
"code": "episodes",
|
||||
"drama_id": 1008,
|
||||
"page": 1,
|
||||
"limit": 100,
|
||||
"token": "3fba65f06e1e308c819e343474075461",
|
||||
"timestamp": 1792812296047
|
||||
}
|
||||
```
|
||||
|
||||
<br />
|
||||
|
||||
短剧-获得所有集数
|
||||
|
||||
```json
|
||||
aaa
|
||||
aaa
|
||||
```
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""91Video PWA 登录脚本
|
||||
|
||||
通过逆向 pwa4.jl8v0p5t.cc 的加密协议,调用登录接口获取 token。
|
||||
|
||||
依赖: pip install requests pycryptodome
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
|
||||
import requests
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
# ===== 协议常量 (从前端 JS 逆向得到) =====
|
||||
API_URL = "https://api4.zqpnd5a7.cc/pwa.php"
|
||||
|
||||
# AES-256 密钥与 IV (请求加密 / 响应解密共用)
|
||||
AES_KEY = b"cc88ddc9357ff461e08f047aedee692b" # 32 bytes
|
||||
AES_IV = b"e89225cfbbimgkcu" # 16 bytes
|
||||
|
||||
# 签名用的密钥 (即 AES 密钥)
|
||||
SIGN_KEY = "cc88ddc9357ff461e08f047aedee692b"
|
||||
|
||||
# oauth_id 随机字符集
|
||||
OAUTH_CHARSET = (
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
)
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Linux; Android 10; SM-G973F) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||
),
|
||||
"Origin": "https://pwa4.jl8v0p5t.cc",
|
||||
"Referer": "https://pwa4.jl8v0p5t.cc/",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
|
||||
def _aes_encrypt(plaintext: str) -> str:
|
||||
"""AES-256-CFB (segment_size=128) 加密, 返回大写十六进制字符串。"""
|
||||
cipher = AES.new(AES_KEY, AES.MODE_CFB, AES_IV, segment_size=128)
|
||||
return cipher.encrypt(plaintext.encode("utf-8")).hex().upper()
|
||||
|
||||
|
||||
def _aes_decrypt(hex_data: str) -> str:
|
||||
"""AES-256-CFB (segment_size=128) 解密。"""
|
||||
cipher = AES.new(AES_KEY, AES.MODE_CFB, AES_IV, segment_size=128)
|
||||
return cipher.decrypt(bytes.fromhex(hex_data)).decode("utf-8", "replace")
|
||||
|
||||
|
||||
def _make_sign(data_hex: str, timestamp_sec: int) -> str:
|
||||
"""生成 sign: MD5(SHA256('client=pwa&data=<data>×tamp=<ts><sign_key>'))"""
|
||||
base = f"client=pwa&data={data_hex}×tamp={timestamp_sec}{SIGN_KEY}"
|
||||
sha = hashlib.sha256(base.encode("utf-8")).hexdigest()
|
||||
return hashlib.md5(sha.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _gen_oauth_id() -> str:
|
||||
"""生成 oauth_id: 16 位随机字符 + 毫秒时间戳。"""
|
||||
rand_part = "".join(random.choices(OAUTH_CHARSET, k=16))
|
||||
return rand_part + str(int(time.time() * 1000))
|
||||
|
||||
|
||||
def _post(session: requests.Session, payload: dict) -> dict:
|
||||
"""加密 payload 并发送请求,返回解密后的 JSON 响应。"""
|
||||
ts_ms = int(time.time() * 1000)
|
||||
ts_sec = ts_ms // 1000
|
||||
payload["timestamp"] = ts_ms
|
||||
|
||||
plaintext = json.dumps(payload, separators=(",", ":"))
|
||||
data_hex = _aes_encrypt(plaintext)
|
||||
sign = _make_sign(data_hex, ts_sec)
|
||||
|
||||
body = f"client=pwa×tamp={ts_sec}&data={data_hex}&sign={sign}"
|
||||
|
||||
resp = session.post(API_URL, data=body, headers=HEADERS, timeout=30)
|
||||
if resp.status_code == 503:
|
||||
raise RuntimeError(
|
||||
"服务端返回 503。若 oauth_id 未经 system/index 注册会触发此错误;"
|
||||
"也可能是 CloudFront 限流,请稍后重试。"
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
result = resp.json()
|
||||
if result.get("errcode") != 0:
|
||||
raise RuntimeError(f"接口返回错误: {result}")
|
||||
|
||||
encrypted_data = result.get("data", "")
|
||||
if not encrypted_data:
|
||||
return result
|
||||
|
||||
decrypted = _aes_decrypt(encrypted_data)
|
||||
return json.loads(decrypted)
|
||||
|
||||
|
||||
def login(account: str, password: str):
|
||||
"""登录并返回 (session, oauth_id, 解析后的登录响应)。
|
||||
|
||||
登录前必须先调用 system/index 注册 oauth_id,
|
||||
否则服务端会对未注册的 oauth_id 返回 503。
|
||||
"""
|
||||
session = requests.Session()
|
||||
oauth_id = _gen_oauth_id()
|
||||
|
||||
base = {
|
||||
"oauth_type": "pwa",
|
||||
"oauth_id": oauth_id,
|
||||
"trace_id": "",
|
||||
"aff_x_code": "",
|
||||
"version": "7.5.0",
|
||||
}
|
||||
|
||||
# 1) 先调用 system/index 注册 oauth_id
|
||||
_post(session, dict(base, mod="system", code="index", data={}, token=""))
|
||||
time.sleep(0.3)
|
||||
|
||||
# 2) 再调用登录接口
|
||||
payload = dict(
|
||||
base,
|
||||
mod="user",
|
||||
code="regOrLogin",
|
||||
account=account,
|
||||
password=password,
|
||||
type="login",
|
||||
)
|
||||
result = _post(session, payload)
|
||||
return session, oauth_id, result
|
||||
|
||||
|
||||
def fetch_home(session: requests.Session, oauth_id: str, token: str, page: int) -> dict:
|
||||
"""请求首页列表 (mod=index, code=home) 并返回解密后的数据。"""
|
||||
payload = {
|
||||
"oauth_type": "pwa",
|
||||
"oauth_id": oauth_id,
|
||||
"trace_id": "",
|
||||
"aff_x_code": "",
|
||||
"version": "7.5.0",
|
||||
"mod": "index",
|
||||
"code": "home",
|
||||
"page": page,
|
||||
"token": token,
|
||||
}
|
||||
return _post(session, payload)
|
||||
|
||||
|
||||
def main():
|
||||
account = "16774351679"
|
||||
password = "admin123456"
|
||||
|
||||
print(f"正在登录账号: {account}")
|
||||
session, oauth_id, result = login(account, password)
|
||||
|
||||
inner = result.get("data", result)
|
||||
if not inner.get("success"):
|
||||
print("登录失败:", inner.get("msg", result))
|
||||
return
|
||||
|
||||
token = inner.get("token")
|
||||
print(f"登录成功! token: {token}")
|
||||
|
||||
# 请求首页第 1~10 页并解密保存
|
||||
for page in range(1, 2):
|
||||
try:
|
||||
data = fetch_home(session, oauth_id, token, page)
|
||||
except Exception as e:
|
||||
print(f"第 {page} 页请求失败: {e}")
|
||||
continue
|
||||
|
||||
out_file = f"home_page_{page}.json"
|
||||
with open(out_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 打印摘要:data 直接就是视频列表
|
||||
items = data.get("data") if isinstance(data.get("data"), list) else []
|
||||
print(f"第 {page} 页: 已解密并保存到 {out_file} (共 {len(items)} 条)")
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# data_str = '46CF9369809931B4D43024E4A689EADDF078D83A100348DB8CB1B386522B6F5811D57BAD96801571597E550A83F20B865F34F7CEA7BCA0ED2CE780257CE0D818F2E36A04941BC58B0767C3263377995656FC9C251C58BB8C3C5BF36DBFD0B65348FF1031D9E381377593BB8B32F39C8F0AF2DFF7579BA2B260E6073B9B6A6EB1FC309116F38380C7EA555684C9BF4464D172B1DA3513461AB5E1A1865FF4E7AE146D980129EFFE464773B03212543462D1CBB31A79EDE0B9402A17677CCF1526213B4E787BAF7F6252A0C445412C519DCF663CA477D6CC9F1E7C8586F2D486EADE8A12A16FB215BCBCB9D4128DB568FEC5D572863A61AE0D55B36D00'
|
||||
# decrypted = json.loads(_aes_decrypt(data_str))
|
||||
|
||||
# print("-----")
|
||||
# print(json.dumps(decrypted, ensure_ascii=False, indent=2))
|
||||
main()
|
||||
@@ -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