diff --git a/admin/src/views/SeriesManage.vue b/admin/src/views/SeriesManage.vue index f9c3783..2d221ca 100644 --- a/admin/src/views/SeriesManage.vue +++ b/admin/src/views/SeriesManage.vue @@ -58,8 +58,30 @@ - - + +
+ +
+ + 上传中… +
+ 选择图片 +
+ 移除 +
+
+ + @@ -93,11 +115,38 @@ const categories = ref([]) const dialogVisible = ref(false) const editing = ref(null) const submitting = ref(false) +const coverUploading = ref(false) const form = reactive({ title: '', cover_url: '', description: '', category_id: 0, }) +async function uploadCover(file) { + if (!file || !file.raw) return + const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] + if (!allowed.includes(file.raw.type)) { + return ElMessage.error('仅支持 jpg/png/webp/gif') + } + if (file.raw.size > 5 * 1024 * 1024) { + return ElMessage.error('图片不能超过 5MB') + } + 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' }, + }) + form.cover_url = r.data.url + ElMessage.success('封面上传成功') + } catch (e) { + ElMessage.error(e?.message || '上传失败') + } finally { + coverUploading.value = false + } +} + onMounted(async () => { load() try { @@ -181,4 +230,15 @@ async function del(row) { .header { display: flex; justify-content: space-between; align-items: center; } .title-col { display: flex; align-items: center; gap: 8px; } .cover { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; } +.cover-upload { display: flex; align-items: center; } +.cover-preview { + width: 120px; height: 70px; border-radius: 6px; overflow: hidden; + border: 1px dashed #dcdfe6; cursor: pointer; position: relative; + display: flex; align-items: center; justify-content: center; +} +.cover-preview img { width: 100%; height: 100%; object-fit: cover; } +.cover-preview .mask { + position: absolute; inset: 0; background: rgba(0,0,0,.6); + color: #fff; font-size: 12px; display: flex; align-items: center; justify-content: center; +} diff --git a/server/app/api/v1/__pycache__/admin.cpython-311.pyc b/server/app/api/v1/__pycache__/admin.cpython-311.pyc index e525698..793f291 100644 Binary files a/server/app/api/v1/__pycache__/admin.cpython-311.pyc and b/server/app/api/v1/__pycache__/admin.cpython-311.pyc differ diff --git a/server/app/api/v1/admin.py b/server/app/api/v1/admin.py index 2c8cc09..2509a56 100644 --- a/server/app/api/v1/admin.py +++ b/server/app/api/v1/admin.py @@ -174,6 +174,48 @@ async def upload_video_file(request: Request, token: str = "", }) +@router.post("/upload/image") +async def upload_admin_image(request: Request, + admin: AdminIdentity = Depends(require_super)): + """admin 通用图片上传:封面/头像/系列图等。存到 runtime/uploads/images/{YYYYMMDD}/, + 返回 h5 域名可直接访问的完整 URL。""" + from fastapi import UploadFile, HTTPException + from datetime import datetime + from pathlib import Path + from uuid import uuid4 + + form = await request.form() + files = [v for v in form.values() if hasattr(v, "filename") and v.filename] + if not files: + raise HTTPException(400, "缺少 file 字段") + upload: UploadFile = files[0] + + allowed = {".jpg", ".jpeg", ".png", ".webp", ".gif"} + suffix = Path(upload.filename or "").suffix.lower() + if suffix not in allowed: + raise HTTPException(400, f"仅支持 {','.join(sorted(allowed))}") + + PROJECT_ROOT = Path("/root/project/video") + day = datetime.now().strftime("%Y%m%d") + out_dir = PROJECT_ROOT / "runtime" / "uploads" / "images" / day + out_dir.mkdir(parents=True, exist_ok=True) + file_name = f"{uuid4().hex}{suffix}" + file_path = out_dir / file_name + + size = 0 + with open(file_path, "wb") as f: + while True: + chunk = await upload.read(1024 * 1024) + if not chunk: + break + size += len(chunk) + f.write(chunk) + await upload.close() + + url = f"https://h5.256kb.top/uploads/images/{day}/{file_name}" + return ok({"url": url, "file_size": size}) + + # ---------- 用户 / 积分 ---------- @router.get("/users")