fix: 修复站点管理API Key保存问题,移动RSS字段到主表单,修复Discover页面提示

This commit is contained in:
ShukeBta
2026-05-18 01:22:11 +08:00
parent 9827b46c49
commit 630f925725
5 changed files with 300 additions and 85 deletions
+63 -2
View File
@@ -49,11 +49,70 @@ func (h *SiteHandler) GetSite(c *gin.Context) {
// CreateSite 创建站点。
func (h *SiteHandler) CreateSite(c *gin.Context) {
var site model.Site
if err := c.ShouldBindJSON(&site); err != nil {
var body map[string]any
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
return
}
var site model.Site
// 手动映射字段(因为敏感字段有 json:"-" 标签,不会自动反序列化)
if v, ok := body["name"].(string); ok {
site.Name = v
}
if v, ok := body["url"].(string); ok {
site.URL = v
}
if v, ok := body["type"].(string); ok {
site.Type = v
}
if v, ok := body["auth_type"].(string); ok {
site.AuthType = v
}
if v, ok := body["api_key"].(string); ok {
site.APIKey = v
}
if v, ok := body["cookie"].(string); ok {
site.Cookie = v
}
if v, ok := body["auth_header"].(string); ok {
site.AuthHeader = v
}
if v, ok := body["enabled"].(bool); ok {
site.Enabled = v
}
if v, ok := body["is_default"].(bool); ok {
site.IsDefault = v
}
if v, ok := body["extra"].(string); ok {
site.Extra = v
}
// 高级设置字段
if v, ok := body["user_agent"].(string); ok {
site.UserAgent = v
}
if v, ok := body["rss_url"].(string); ok {
site.RSSURL = v
}
if v, ok := body["timeout"].(float64); ok {
site.Timeout = int(v)
}
if v, ok := body["priority"].(float64); ok {
site.Priority = int(v)
}
if v, ok := body["use_proxy"].(bool); ok {
site.UseProxy = v
}
if v, ok := body["rate_limit"].(bool); ok {
site.RateLimit = v
}
if v, ok := body["browser_emulation"].(bool); ok {
site.BrowserEmulation = v
}
if v, ok := body["downloader"].(string); ok {
site.Downloader = v
}
if err := h.svc.Site.Create(c.Request.Context(), &site); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
return
@@ -68,6 +127,8 @@ func (h *SiteHandler) UpdateSite(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
return
}
// 字段名映射:前端使用蛇形命名,GORM 会正确映射到列名
// 无需额外处理,Updates 直接使用 patch 中的 key-value
if err := h.svc.Site.Update(c.Request.Context(), c.Param("id"), patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
return
+5 -5
View File
@@ -29,8 +29,8 @@ export function DiscoverPage() {
<Sparkles className="h-6 w-6 text-primary-400" />
<div>
<h1 className="font-display text-3xl font-bold text-white">发现</h1>
<p className="text-sm text-slate-400">
来自 TMDb 的当日热门与流行榜单(需在 secrets.tmdb_api_key 配置 API Key)。
<p className="text-sm text-slate-400">
来自 TMDb 的当日热门与流行榜单(需在外部 API 中配置 TMDb API Key)。
</p>
</div>
</header>
@@ -41,9 +41,9 @@ export function DiscoverPage() {
{popular.length > 0 && <Row title="热门电影" items={popular} />}
{!loading && trending.length === 0 && popular.length === 0 && (
<div className="glass-panel">
<p className="text-slate-300">
还没有可用的发现数据。前往 <span className="text-primary-400">管理后台 → 设置</span>
,在 secrets.tmdb_api_key 中填写一个有效的 TMDb API Key。
<p className="text-slate-300">
还没有可用的发现数据。请前往 <span className="text-primary-400">管理后台 → 外部API</span>
,配置 TMDb API Key 后刷新页面。
</p>
</div>
)}
+46 -42
View File
@@ -35,53 +35,57 @@ export function LoginPage() {
}
return (
<div className="flex min-h-full items-center justify-center px-4 py-10">
<form onSubmit={handleSubmit} className="glass-panel w-full max-w-md">
<div className="mb-8 flex flex-col items-center gap-2">
<Film className="h-10 w-10 text-primary-400" />
<h1 className="font-display text-2xl font-bold tracking-wide text-white">
MediaStationGo
</h1>
<p className="text-sm text-slate-400">登录到你的家庭媒体中心</p>
</div>
<div className="flex min-h-screen flex-col items-center px-4">
{/* 表单居中区域:flex-grow + flex center 使表单在剩余空间内垂直居中 */}
<div className="flex flex-1 flex-col items-center justify-center">
<form onSubmit={handleSubmit} className="glass-panel w-full max-w-md">
<div className="mb-8 flex flex-col items-center gap-2">
<Film className="h-10 w-10 text-primary-400" />
<h1 className="font-display text-2xl font-bold tracking-wide text-white">
MediaStationGo
</h1>
<p className="text-sm text-slate-400">登录到你的家庭媒体中心</p>
</div>
<label className="mb-4 block">
<span className="mb-1 block text-sm text-slate-300">用户名</span>
<input
className="input-base"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
</label>
<label className="mb-4 block">
<span className="mb-1 block text-sm text-slate-300">用户名</span>
<input
className="input-base"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
</label>
<label className="mb-6 block">
<span className="mb-1 block text-sm text-slate-300">密码</span>
<input
type="password"
className="input-base"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
<label className="mb-6 block">
<span className="mb-1 block text-sm text-slate-300">密码</span>
<input
type="password"
className="input-base"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
<button
type="submit"
disabled={loading}
className="neon-button w-full justify-center"
>
{loading ? '登录中…' : '登录'}
</button>
<button
type="submit"
disabled={loading}
className="neon-button w-full justify-center"
>
{loading ? '登录中…' : '登录'}
</button>
<p className="mt-6 text-center text-xs text-slate-500">
首次部署默认账号:<code className="text-primary-400">admin / admin123</code>
</p>
</form>
<p className="mt-6 text-center text-xs text-slate-500">
首次部署默认账号:<code className="text-primary-400">admin / admin123</code>
</p>
</form>
</div>
<AppFooter className="mt-8" />
{/* 页脚始终在底部 */}
<AppFooter className="mb-4 mt-auto" />
</div>
)
}
-3
View File
@@ -2,7 +2,6 @@ import { FormEvent, useEffect, useState } from 'react'
import { FolderOpen, Loader2, Save, SettingsIcon } from 'lucide-react'
import toast from 'react-hot-toast'
import { AppFooter } from '../components/AppFooter'
import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
import type { Library, Setting } from '../types'
@@ -340,8 +339,6 @@ export function SettingsPage() {
</table>
</div>
)}
<AppFooter className="mt-6" />
</div>
)
}
+186 -33
View File
@@ -51,6 +51,15 @@ const defaultForm = () => ({
enabled: true,
is_default: false,
extra: '',
// 高级设置
user_agent: '',
rss_url: '',
timeout: 15,
priority: 50,
use_proxy: false,
rate_limit: false,
browser_emulation: false,
downloader: '',
})
export function SitesPage() {
@@ -103,6 +112,15 @@ export function SitesPage() {
enabled: s.enabled !== false,
is_default: s.is_default || false,
extra: s.extra || '',
// 高级设置
user_agent: s.user_agent || '',
rss_url: s.rss_url || '',
timeout: s.timeout ?? 15,
priority: s.priority ?? 50,
use_proxy: s.use_proxy || false,
rate_limit: s.rate_limit || false,
browser_emulation: s.browser_emulation || false,
downloader: s.downloader || '',
})
setAdvancedOpen(false)
setShowModal(true)
@@ -116,49 +134,69 @@ export function SitesPage() {
setEditingId(null)
}
// ── 保存 ──
const handleSave = async (e: FormEvent) => {
e.preventDefault()
if (!form.name.trim() || !form.url.trim()) {
toast.error('站点名称和地址不能为空')
return
// ── 保存(支持静默模式)──
const silentSave = async (): Promise<boolean> => {
if (!form.name.trim() || !form.url.trim()) return false
const payload: Record<string, unknown> = {
name: form.name.trim(),
url: form.url.trim(),
type: form.type,
auth_type: form.auth_type,
cookie: form.cookie || '',
api_key: form.api_key || '',
auth_header: form.auth_header || '',
enabled: form.enabled,
is_default: form.is_default,
extra: form.extra || '',
user_agent: form.user_agent || '',
rss_url: form.rss_url || '',
timeout: Number(form.timeout) || 15,
priority: Number(form.priority) || 50,
use_proxy: !!form.use_proxy,
rate_limit: !!form.rate_limit,
browser_emulation: !!form.browser_emulation,
downloader: form.downloader || '',
}
setSaving(true)
try {
const payload: Record<string, unknown> = {
name: form.name.trim(),
url: form.url.trim(),
type: form.type,
auth_type: form.auth_type,
cookie: form.cookie || '',
api_key: form.api_key || '',
auth_header: form.auth_header || '',
enabled: form.enabled,
is_default: form.is_default,
extra: form.extra || '',
}
if (editingId) {
await sitesAPI.update(editingId, payload)
toast.success('站点已更新')
} else {
await sitesAPI.create(payload)
toast.success('站点已添加')
const res = await sitesAPI.create(payload)
setEditingId((res.data as Site)?.id ?? null)
}
closeModal()
await loadSites()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
'保存失败'
toast.error(msg)
} finally {
setSaving(false)
return true
} catch {
return false
}
}
// ── 测试 ──
const handleSave = async (e: FormEvent) => {
e.preventDefault()
setSaving(true)
const ok = await silentSave()
if (ok) {
toast.success(editingId ? '站点已更新' : '站点已添加')
closeModal()
await loadSites()
} else {
const msg = '保存失败'
toast.error(msg)
}
setSaving(false)
}
// 测试(先自动保存)
const handleTest = async (id: string) => {
// 如果正在编辑当前站点,先保存
if (editingId === id) {
setSaving(true)
const ok = await silentSave()
setSaving(false)
if (!ok) {
toast.error('保存失败,无法测试')
return
}
}
setTestingId(id)
try {
const res = await sitesAPI.test(id)
@@ -247,6 +285,33 @@ export function SitesPage() {
{site.is_default && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary-400/15 text-primary-400">默认</span>
)}
{site.use_proxy && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400">代理</span>
)}
{site.rate_limit && (
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-500/15 text-yellow-400">限流</span>
)}
{site.browser_emulation && (
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400">浏览器</span>
)}
</div>
{/* 状态与统计(只读) */}
<div className="text-xs text-slate-500 space-y-0.5">
<div>状态:
<span className={
site.login_status === 'ok' ? 'text-green-400' :
site.login_status === 'failed' ? 'text-red-400' : 'text-slate-400'
}>
{site.login_status || 'unknown'}
</span>
</div>
{(site.upload_bytes || 0) > 0 && (
<div>↑ {Math.round((site.upload_bytes ?? 0) / 1073741824 * 100) / 100} GB / ↓ {Math.round((site.download_bytes ?? 0) / 1073741824 * 100) / 100} GB</div>
)}
{site.priority !== 50 && (
<div>优先级:{site.priority}</div>
)}
</div>
{/* 操作按钮 */}
@@ -466,6 +531,18 @@ export function SitesPage() {
/>
</div>
)}
{/* RSS 地址(主表单) */}
<div className="mt-4">
<label className="block text-xs text-slate-400 mb-1">RSS 地址</label>
<input
className="input-base w-full text-xs font-mono"
placeholder="https://.../torrents/rss?..."
value={form.rss_url}
onChange={(e) => setForm((f) => ({ ...f, rss_url: e.target.value }))}
/>
<p className="text-xs text-slate-500 mt-1">站点 RSS 订阅地址,用于获取最新资源</p>
</div>
</div>
{/* 高级选项 */}
@@ -479,6 +556,82 @@ export function SitesPage() {
</button>
{advancedOpen && (
<div className="mt-3 pl-4 space-y-3 border-l border-white/10">
{/* 新增高级字段 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-slate-400 mb-1">User-Agent</label>
<input
className="input-base w-full text-xs"
placeholder="自定义 UA,留空使用默认"
value={form.user_agent}
onChange={(e) => setForm((f) => ({ ...f, user_agent: e.target.value }))}
/>
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">请求超时 (秒)</label>
<input
type="number"
className="input-base w-full"
min={1}
max={300}
value={form.timeout}
onChange={(e) => setForm((f) => ({ ...f, timeout: Number(e.target.value) }))}
/>
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">优先级 (数字越大越优先)</label>
<input
type="number"
className="input-base w-full"
min={1}
max={100}
value={form.priority}
onChange={(e) => setForm((f) => ({ ...f, priority: Number(e.target.value) }))}
/>
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">关联下载器</label>
<input
className="input-base w-full text-xs"
placeholder="下载器 ID 或名称"
value={form.downloader}
onChange={(e) => setForm((f) => ({ ...f, downloader: e.target.value }))}
/>
</div>
</div>
{/* 开关类字段 */}
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={form.use_proxy}
onChange={(e) => setForm((f) => ({ ...f, use_proxy: e.target.checked }))}
/>
<span className="text-xs text-slate-300">使用代理</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={form.rate_limit}
onChange={(e) => setForm((f) => ({ ...f, rate_limit: e.target.checked }))}
/>
<span className="text-xs text-slate-300">启用限流</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={form.browser_emulation}
onChange={(e) => setForm((f) => ({ ...f, browser_emulation: e.target.checked }))}
/>
<span className="text-xs text-slate-300">浏览器模拟</span>
</label>
</div>
{/* Extra JSON */}
<div>
<label className="block text-xs text-slate-400 mb-1">Extra 扩展配置 (JSON)</label>
<textarea