Files
go_blog/templates/admin/settings_site.html
kevin efc30156b9 feat: 站点设置接口 JSON 化——/api/admin/settings/*
- settings.go:5 组 save handler 改 JSON 绑定(siteSettingsRequest/
  uploadSettingsRequest/downloadSettingsRequest/commentSettingsRequest/
  navLinkSettingsRequest);request struct 变更子函数签名(不再触碰
  c.PostForm),upload/navlink/download 的 action 分发保留,未知
  action 返回 400;enabled 用 *bool(nil 沿用旧默认启用语义)
- favicon/logo 上传拆出:POST /api/admin/settings/site/favicon|logo
  (multipart,图片类别校验 + 旧本地文件替换),SiteSettingsSave 只
  处理文本/URL/clear(存储路径校验保留 illegal_dir 400)
- main.go:设置旧 POST 路由移除,新 /api/admin/settings 分组注册
- 模板:base.html 新增 blogSettingsForm 委托(data-api-url/data-action/
  data-confirm → POST + redirect/alert);settings_site 主表单 JSON +
  logo/favicon 选择即上传;navlinks/upload/download/comments 页全部
  小表单改委托(约 14 个)
- blogForm:剔除 file 字段(文件走 multipart)
- 测试:TestStorageDirTraversalRejected、TestAddUploadFileTypeRejectsDangerousExtensions
  更新 JSON 断言;env 路由补 /api/admin/settings
- main_test 冒烟补设置 API 断言;go build/vet/test 全绿
2026-08-27 19:52:07 +08:00

204 lines
11 KiB
HTML

{{define "settings_site"}}
{{template "header" .}}
<section class="max-w-3xl mx-auto px-4 py-12">
<h2 class="text-3xl font-bold text-gray-900 mb-2">{{index .Tr "settings_site_title"}}</h2>
<p class="text-gray-500 mb-4">{{index .Tr "settings_site_desc"}}</p>
<div class="flex gap-2 mb-8">
<a href="/admin/settings/site" class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">{{index .Tr "settings_site_title"}}</a>
<a href="/admin/settings/navlinks" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_navlinks_title"}}</a>
<a href="/admin/settings/upload" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_upload_title"}}</a>
<a href="/admin/settings/download" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_download_title"}}</a>
</div>
{{if .Success}}
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">{{.Success}}</div>
{{end}}
<div id="siteSettingsError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 hidden"></div>
<form id="siteSettingsForm" action="/api/admin/settings/site" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
<!-- Logo -->
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">{{index .Tr "settings_logo"}}</label>
{{if .Site.Logo}}
{{if .SiteLogoIsURL}}
<img src="{{.Site.Logo}}" alt="logo" class="h-12 w-auto mb-2">
{{else}}
<img src="/uploads/logos/{{.Site.Logo}}" alt="logo" class="h-12 w-auto mb-2">
{{end}}
{{end}}
<input type="text" name="logo_url" placeholder="{{index .Tr "settings_logo_url"}}"
class="w-full border border-gray-300 rounded-lg px-3 py-2 mb-2" value="{{if .SiteLogoIsURL}}{{.Site.Logo}}{{end}}">
<input type="file" id="logoFileInput" name="logo" accept="image/*"
class="block w-full text-sm text-gray-500 mb-2">
<label class="inline-flex items-center gap-2 text-sm text-gray-600">
<input type="checkbox" name="logo_clear" value="1"> {{index .Tr "settings_logo_clear"}}
</label>
</div>
<!-- Favicon -->
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">{{index .Tr "settings_favicon"}}</label>
{{if .Site.Favicon}}
<div class="flex items-center gap-2 mb-2">
<span class="text-sm text-gray-600">{{index .Tr "settings_favicon_current"}}:</span>
{{if .SiteFaviconIsURL}}
<img src="{{.Site.Favicon}}" alt="favicon" class="h-8 w-8">
{{else}}
<img src="/uploads/logos/{{.Site.Favicon}}" alt="favicon" class="h-8 w-8">
{{end}}
</div>
{{end}}
<input type="text" name="favicon_url" placeholder="{{index .Tr "settings_favicon_url"}}"
class="w-full border border-gray-300 rounded-lg px-3 py-2 mb-2" value="{{if .SiteFaviconIsURL}}{{.Site.Favicon}}{{end}}">
<input type="file" id="faviconFileInput" name="favicon" accept="image/x-icon,image/png,image/svg+xml"
class="block w-full text-sm text-gray-500 mb-2">
<p class="text-xs text-gray-500 mb-2">{{index .Tr "settings_favicon_hint"}}</p>
<label class="inline-flex items-center gap-2 text-sm text-gray-600">
<input type="checkbox" name="favicon_clear" value="1"> {{index .Tr "settings_favicon_clear"}}
</label>
</div>
<!-- Title texts -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_logo_text_zh"}}</label>
<input type="text" name="logo_text_zh" value="{{.Site.LogoTextZh}}"
class="w-full border border-gray-300 rounded-lg px-3 py-2">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_logo_text_en"}}</label>
<input type="text" name="logo_text_en" value="{{.Site.LogoTextEn}}"
class="w-full border border-gray-300 rounded-lg px-3 py-2">
</div>
</div>
<!-- Header texts -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_header_text_zh"}}</label>
<textarea name="header_text_zh" rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2">{{.Site.HeaderTextZh}}</textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_header_text_en"}}</label>
<textarea name="header_text_en" rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2">{{.Site.HeaderTextEn}}</textarea>
</div>
</div>
<!-- Home welcome & subtitle -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_home_welcome_zh"}}</label>
<input type="text" name="home_welcome_zh" value="{{.Site.HomeWelcomeZh}}"
class="w-full border border-gray-300 rounded-lg px-3 py-2">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_home_welcome_en"}}</label>
<input type="text" name="home_welcome_en" value="{{.Site.HomeWelcomeEn}}"
class="w-full border border-gray-300 rounded-lg px-3 py-2">
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_home_subtitle_zh"}}</label>
<textarea name="home_subtitle_zh" rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2">{{.Site.HomeSubtitleZh}}</textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_home_subtitle_en"}}</label>
<textarea name="home_subtitle_en" rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2">{{.Site.HomeSubtitleEn}}</textarea>
</div>
</div>
<!-- Footer texts -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_footer_text_zh"}}</label>
<textarea name="footer_text_zh" rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2">{{.Site.FooterTextZh}}</textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_footer_text_en"}}</label>
<textarea name="footer_text_en" rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2">{{.Site.FooterTextEn}}</textarea>
</div>
</div>
<p class="text-xs text-gray-400">{{index .Tr "settings_leave_blank"}}</p>
<!-- Canonical site URL (RSS) -->
<div class="pt-4 border-t border-gray-200">
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_site_url"}}</label>
<input type="url" name="site_url" value="{{.Site.SiteURL}}" placeholder="https://example.com"
class="w-full border border-gray-300 rounded-lg px-3 py-2">
<p class="text-xs text-gray-500 mt-1">{{index .Tr "settings_site_url_hint"}}</p>
</div>
<!-- Registration settings -->
<div class="pt-4 border-t border-gray-200">
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="allow_registration" value="1" {{if .Site.AllowRegistration}}checked{{end}}
class="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<div>
<span class="block text-sm font-semibold text-gray-700">{{index .Tr "settings_allow_registration"}}</span>
<span class="block text-xs text-gray-500">{{index .Tr "settings_allow_registration_hint"}}</span>
</div>
</label>
</div>
<button type="submit"
class="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
{{index .Tr "settings_save"}}
</button>
</form>
</section>
{{template "footer" .}}
<script>
// 站点设置主表单:文本字段走 JSON API。
(function () {
var form = document.getElementById('siteSettingsForm');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
var btn = e.submitter || null;
blogAPI('POST', '/api/admin/settings/site', blogForm(form, btn)).then(function (r) {
if (r.ok) { window.location.href = r.redirect || '/admin/settings/site'; }
else { blogShowError('siteSettingsError', r.error || 'Failed to save settings.'); }
});
});
})();
// logo / favicon 文件选择即上传(multipart)。
(function () {
function bindImageUpload(inputId, url, field) {
var input = document.getElementById(inputId);
if (!input) return;
input.addEventListener('change', function () {
var file = this.files[0];
if (!file) return;
var meta = document.querySelector('meta[name="csrf-token"]');
var fd = new FormData();
fd.append(field, file);
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-Token': meta ? meta.getAttribute('content') : '' },
body: fd,
credentials: 'same-origin'
}).then(function (r) { return r.json(); })
.then(function (r) {
if (r.ok) { window.location.href = r.redirect || '/admin/settings/site'; }
else { blogShowError('siteSettingsError', r.error || 'Upload failed.'); }
});
});
}
bindImageUpload('logoFileInput', '/api/admin/settings/site/logo', 'logo');
bindImageUpload('faviconFileInput', '/api/admin/settings/site/favicon', 'favicon');
})();
</script>
{{end}}