feat: add platform configuration tables and admin settings

Add four DB-backed configuration tables for site-wide settings:
site_settings (logo, top-left title, header/footer text with zh/en
variants), upload_configs (master switch, default size, storage dir),
upload_file_types (per-extension whitelist with per-type size limits),
and download_baseurls (multiple download sources with priority/default).

- Models, AutoMigrate, and first-run seed (18 common file types)
- Process-level config cache warmed at startup, refreshed on admin save
- SetUserContext injects cached site settings into templates
- base.html renders logo (local upload or external URL), title, header
  banner, and footer with i18n fallback
- Upload validator drives avatar uploads (form + AJAX) through the
  configured switch/type/size policy
- Admin pages for site/upload/download settings with i18n keys

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 20:32:28 +08:00
co-authored by Claude Fable 5
parent b474ee009a
commit e3367e6e12
17 changed files with 1277 additions and 20 deletions
+4
View File
@@ -50,6 +50,10 @@
class="inline-flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors">
{{index .Tr "article_manage"}}
</a>
<a href="/admin/settings/site"
class="inline-flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors">
{{index .Tr "settings_nav"}}
</a>
</div>
</div>
</section>
+103
View File
@@ -0,0 +1,103 @@
{{define "settings_download"}}
{{template "header" .}}
<section class="max-w-4xl mx-auto px-4 py-12">
<h2 class="text-3xl font-bold text-gray-900 mb-2">{{index .Tr "settings_download_title"}}</h2>
<p class="text-gray-500 mb-4">{{index .Tr "settings_download_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-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_site_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-blue-600 text-white">{{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 class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-8">
<table class="w-full text-left">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_url_name"}}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_base_url"}}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_priority"}}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_is_default"}}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_enabled"}}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 text-right">{{index .Tr "settings_actions"}}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{{range .BaseURLs}}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-800">{{.Name}}</td>
<td class="px-4 py-3 text-sm text-gray-500 break-all">{{.BaseURL}}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{.Priority}}</td>
<td class="px-4 py-3 text-sm">
{{if .IsDefault}}<span class="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded"></span>{{end}}
</td>
<td class="px-4 py-3 text-sm">
{{if .Enabled}}<span class="text-xs bg-green-100 text-green-700 px-2 py-1 rounded"></span>
{{else}}<span class="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded"></span>{{end}}
</td>
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
<form action="/admin/settings/download" method="post" class="inline">
<input type="hidden" name="action" value="default">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_set_default"}}</button>
</form>
<form action="/admin/settings/download" method="post" class="inline">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-gray-600 hover:text-gray-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_toggle"}}</button>
</form>
<form action="/admin/settings/download" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
</form>
</td>
</tr>
{{else}}
<tr>
<td colspan="6" class="px-4 py-12 text-center text-gray-500"></td>
</tr>
{{end}}
</tbody>
</table>
</div>
<!-- Add base URL -->
<form action="/admin/settings/download" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<input type="hidden" name="action" value="add">
<h3 class="font-semibold text-gray-800 mb-4">{{index .Tr "settings_add_url"}}</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">{{index .Tr "settings_url_name"}}</label>
<input type="text" name="name" placeholder="CDN"
class="w-full border border-gray-300 rounded-lg px-3 py-2">
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-semibold text-gray-600 mb-1">{{index .Tr "settings_base_url"}}</label>
<input type="text" name="base_url" placeholder="https://cdn.example.com/uploads" required
class="w-full border border-gray-300 rounded-lg px-3 py-2">
</div>
</div>
<div class="flex items-center gap-6">
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">{{index .Tr "settings_priority"}}</label>
<input type="number" name="priority" value="0"
class="w-24 border border-gray-300 rounded-lg px-3 py-2">
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 mt-5">
<input type="checkbox" name="enabled" value="1" checked> {{index .Tr "settings_enabled"}}
</label>
</div>
<button type="submit"
class="mt-4 bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
{{index .Tr "settings_add_url"}}
</button>
</form>
</section>
{{template "footer" .}}
{{end}}
+88
View File
@@ -0,0 +1,88 @@
{{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/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}}
<form action="/admin/settings/site" method="post" enctype="multipart/form-data" 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 .Site.LogoIsURL}}
<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 .Site.LogoIsURL}}{{.Site.Logo}}{{end}}">
<input type="file" 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>
<!-- 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>
<!-- 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>
<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" .}}
{{end}}
+139
View File
@@ -0,0 +1,139 @@
{{define "settings_upload"}}
{{template "header" .}}
<section class="max-w-5xl mx-auto px-4 py-12">
<h2 class="text-3xl font-bold text-gray-900 mb-2">{{index .Tr "settings_upload_title"}}</h2>
<p class="text-gray-500 mb-4">{{index .Tr "settings_upload_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-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_site_title"}}</a>
<a href="/admin/settings/upload" class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">{{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}}
<!-- Global policy -->
<form action="/admin/settings/upload" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
<input type="hidden" name="action" value="save_config">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_default_size"}}</label>
<input type="number" name="default_max_size" min="0" step="0.1" value="{{.DefaultMaxSizeMB}}"
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_storage_dir"}}</label>
<input type="text" name="storage_dir" value="{{.Upload.StorageDir}}"
class="w-full border border-gray-300 rounded-lg px-3 py-2">
</div>
<div class="flex items-end">
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="enabled" value="1" {{if .Upload.Enabled}}checked{{end}}>
{{index .Tr "settings_uploads_enabled"}}
</label>
</div>
</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>
<!-- File types table -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-8">
<div class="px-4 py-3 border-b border-gray-200">
<h3 class="font-semibold text-gray-800">{{index .Tr "settings_file_types"}}</h3>
</div>
<table class="w-full text-left">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_extension"}}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_mime"}}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_category"}}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_max_size"}}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "settings_enabled"}}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 text-right">{{index .Tr "settings_actions"}}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{{range .FileTypes}}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-800 font-mono">{{.Extension}}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{.MimeType}}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{index $.Tr (printf "cat_%s" .Category)}}</td>
<td class="px-4 py-3 text-sm text-gray-500">
<form action="/admin/settings/upload" method="post" class="flex items-center gap-1">
<input type="hidden" name="action" value="size_type">
<input type="hidden" name="id" value="{{.ID}}">
<input type="number" name="max_size" min="0" step="0.1" value="{{.MaxSizeMB}}"
class="w-20 border border-gray-300 rounded px-2 py-1 text-sm">
<button type="submit" class="text-blue-600 hover:text-blue-800 text-xs">{{index $.Tr "settings_update"}}</button>
</form>
</td>
<td class="px-4 py-3 text-sm">
{{if .Enabled}}<span class="text-xs bg-green-100 text-green-700 px-2 py-1 rounded"></span>
{{else}}<span class="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded"></span>{{end}}
</td>
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
<form action="/admin/settings/upload" method="post" class="inline">
<input type="hidden" name="action" value="toggle_type">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-gray-600 hover:text-gray-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_toggle"}}</button>
</form>
<form action="/admin/settings/upload" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
<input type="hidden" name="action" value="delete_type">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<!-- Add file type -->
<form action="/admin/settings/upload" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<input type="hidden" name="action" value="add_type">
<h3 class="font-semibold text-gray-800 mb-4">{{index .Tr "settings_add_type"}}</h3>
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">{{index .Tr "settings_extension"}}</label>
<input type="text" name="extension" placeholder=".pdf" required
class="w-full border border-gray-300 rounded-lg px-3 py-2">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">{{index .Tr "settings_mime"}}</label>
<input type="text" name="mime_type" placeholder="application/pdf"
class="w-full border border-gray-300 rounded-lg px-3 py-2">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">{{index .Tr "settings_category"}}</label>
<select name="category" class="w-full border border-gray-300 rounded-lg px-3 py-2">
<option value="image">{{index .Tr "cat_image"}}</option>
<option value="document">{{index .Tr "cat_document"}}</option>
<option value="archive">{{index .Tr "cat_archive"}}</option>
<option value="video">{{index .Tr "cat_video"}}</option>
<option value="other">{{index .Tr "cat_other"}}</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">{{index .Tr "settings_max_size"}} (0=default)</label>
<input type="number" name="max_size" min="0" step="0.1" value="0"
class="w-full border border-gray-300 rounded-lg px-3 py-2">
</div>
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 mb-4">
<input type="checkbox" name="enabled" value="1" checked> {{index .Tr "settings_enabled"}}
</label>
<button type="submit"
class="block bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
{{index .Tr "settings_add_type"}}
</button>
</form>
</section>
{{template "footer" .}}
{{end}}
+16 -3
View File
@@ -15,8 +15,15 @@
<!-- Navigation -->
<nav class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between">
<a href="/" class="text-xl font-bold text-gray-800 hover:text-blue-600 transition-colors">
{{index .Tr "site_title"}}
<a href="/" class="text-xl font-bold text-gray-800 hover:text-blue-600 transition-colors flex items-center gap-2">
{{if .SiteLogo}}
{{if .SiteLogoIsURL}}
<img src="{{.SiteLogo}}" alt="logo" class="h-8 w-auto">
{{else}}
<img src="/uploads/logos/{{.SiteLogo}}" alt="logo" class="h-8 w-auto">
{{end}}
{{end}}
{{if .SiteLogoText}}{{.SiteLogoText}}{{else}}{{index .Tr "site_title"}}{{end}}
</a>
<div class="flex items-center gap-4">
<a href="/" class="text-gray-600 hover:text-blue-600 transition-colors">{{index .Tr "home"}}</a>
@@ -62,6 +69,12 @@
<!-- Main Content -->
<main class="flex-1">
{{if .SiteHeaderText}}
<!-- Header banner -->
<div class="bg-blue-50 border-b border-blue-100 text-blue-800 text-sm text-center py-2 px-4">
{{.SiteHeaderText}}
</div>
{{end}}
{{end}}
{{define "footer"}}
@@ -70,7 +83,7 @@
<!-- Footer -->
<footer class="bg-white border-t border-gray-200 py-6 mt-auto">
<div class="max-w-5xl mx-auto px-4 text-center text-gray-500 text-sm">
{{index .Tr "footer_text"}}
{{if .SiteFooterText}}{{.SiteFooterText}}{{else}}{{index .Tr "footer_text"}}{{end}}
</div>
</footer>
<script>