Compare commits
8 Commits
e7092254ab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b997528bad | |||
| 426bedda65 | |||
| d4a833f149 | |||
| 3f4d827926 | |||
| 1eb3cc7c86 | |||
| 20067f7640 | |||
| ecbae7c4fa | |||
| b10828c851 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -23,6 +23,9 @@ storage/logs/
|
||||
# 设计文档(本地使用,不纳入版本控制)
|
||||
_Design/
|
||||
|
||||
# 自用模板(不纳入版本控制)
|
||||
templates/jay-pub/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
@ -11,6 +11,10 @@
|
||||
|
||||
<p align="center"><strong>MCE</strong> — 下一代开源社区引擎,为 BBS 3.0 而生</p>
|
||||
|
||||
> **⚠️ 维护状态**
|
||||
> 因个人原因,本项目暂时无法继续活跃维护。现有功能可正常使用,Issues/PR 可能无法及时响应。
|
||||
> 如有兴趣接手或继续开发,欢迎 Fork。
|
||||
|
||||
---
|
||||
|
||||
## 关于项目
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"metazone.cc/mce/internal/model"
|
||||
@ -19,22 +20,31 @@ import (
|
||||
)
|
||||
|
||||
// assetHashes 静态资源文件哈希表(URL 路径 → 短哈希,文件不变哈希不变)
|
||||
var assetHashes map[string]string
|
||||
var (
|
||||
assetHashes map[string]string
|
||||
assetMu sync.RWMutex
|
||||
)
|
||||
|
||||
// ComputeAssetHashes 遍历 static 目录,计算每个文件的 CRC32 作为版本号
|
||||
// 构建本地 map 后原子替换,避免并发读写冲突
|
||||
func ComputeAssetHashes(templateDir string) {
|
||||
assetHashes = make(map[string]string)
|
||||
mu := make(map[string]string)
|
||||
// 前端主题:templates/MetaLab-2026/static → URL /static/
|
||||
walkStaticDir(filepath.Join(templateDir, "static"), "/static")
|
||||
walkStaticDirInto(mu, filepath.Join(templateDir, "static"), "/static")
|
||||
// 共享资源:templates/shared/static → URL /shared/static/
|
||||
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "shared", "static"), "/shared/static")
|
||||
walkStaticDirInto(mu, filepath.Join(filepath.Dir(templateDir), "shared", "static"), "/shared/static")
|
||||
// 管理后台:templates/admin/static → URL /admin/static/
|
||||
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "admin", "static"), "/admin/static")
|
||||
log.Printf("[AssetHashes] computed %d file hashes", len(assetHashes))
|
||||
walkStaticDirInto(mu, filepath.Join(filepath.Dir(templateDir), "admin", "static"), "/admin/static")
|
||||
|
||||
assetMu.Lock()
|
||||
assetHashes = mu
|
||||
assetMu.Unlock()
|
||||
|
||||
log.Printf("[AssetHashes] computed %d file hashes", len(mu))
|
||||
}
|
||||
|
||||
// walkStaticDir 遍历目录,计算每个 .js/.css 的 CRC32,存入 assetHashes
|
||||
func walkStaticDir(dir, urlPrefix string) {
|
||||
// walkStaticDirInto 遍历目录,计算每个 .js/.css 的 CRC32,存入目标 map
|
||||
func walkStaticDirInto(dst map[string]string, dir, urlPrefix string) {
|
||||
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
@ -52,14 +62,17 @@ func walkStaticDir(dir, urlPrefix string) {
|
||||
// 生成 URL 路径:urlPrefix + 相对于 dir 的子路径
|
||||
rel, _ := filepath.Rel(dir, path)
|
||||
urlPath := urlPrefix + "/" + filepath.ToSlash(rel)
|
||||
assetHashes[urlPath] = shortHash
|
||||
dst[urlPath] = shortHash
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// AssetV 返回指定静态资源的哈希版本号(模板函数)
|
||||
// AssetV 返回指定静态资源的哈希版本号(模板函数,并发安全)
|
||||
func AssetV(path string) string {
|
||||
if h, ok := assetHashes[path]; ok {
|
||||
assetMu.RLock()
|
||||
h, ok := assetHashes[path]
|
||||
assetMu.RUnlock()
|
||||
if ok {
|
||||
return h
|
||||
}
|
||||
return "0"
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"metazone.cc/mce/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// SiteSettings 站点设置管理器(DB 持久化 + 内存缓存,不重启生效)
|
||||
@ -69,15 +71,22 @@ func (s *SiteSettings) GetBool(key string, defaultVal bool) bool {
|
||||
return b
|
||||
}
|
||||
|
||||
// Set 写入设置(同步写 DB + 更新内存)
|
||||
// Set 写入设置(同步写 DB + 更新内存,使用 UPSERT 兼容首次保存)
|
||||
func (s *SiteSettings) Set(key, value string) error {
|
||||
entry := model.SiteSetting{Key: key, Value: value}
|
||||
if err := s.db.Save(&entry).Error; err != nil {
|
||||
return err
|
||||
result := s.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&entry)
|
||||
if result.Error != nil {
|
||||
log.Printf("[SiteSettings] Set FAIL key=%s value=%s err=%v", key, value, result.Error)
|
||||
return result.Error
|
||||
}
|
||||
s.mu.Lock()
|
||||
oldVal, existed := s.data[key]
|
||||
s.data[key] = value
|
||||
s.mu.Unlock()
|
||||
log.Printf("[SiteSettings] Set OK key=%s old=%q new=%q existed=%v rows=%d", key, oldVal, value, existed, result.RowsAffected)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@ -87,6 +88,10 @@ func (sc *SiteSettingController) ThemePage(c *gin.Context) {
|
||||
func (sc *SiteSettingController) GetSettings(c *gin.Context) {
|
||||
all := sc.settingService.GetSettings()
|
||||
audit := sc.settingService.GetAuditSettings(sc.defaults)
|
||||
log.Printf("[GetSettings] all=%d keys audit=%+v", len(all), audit)
|
||||
if v, ok := all["registration.enabled"]; ok {
|
||||
log.Printf("[GetSettings] registration.enabled=%q", v)
|
||||
}
|
||||
common.Ok(c, gin.H{
|
||||
"all": all,
|
||||
"audit": audit,
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<script src="/shared/static/js/utils.js?v={{assetV "/shared/static/js/utils.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
<script src="/admin/static/js/common.js?v={{assetV "/admin/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
{{if .ExtraJS}}
|
||||
<script src="{{.ExtraJS}}" nonce="{{.CSPNonce}}"></script>
|
||||
<script src="{{.ExtraJS}}?v={{assetV .ExtraJS}}" nonce="{{.CSPNonce}}"></script>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -227,7 +227,7 @@
|
||||
<span class="setting-desc">强制阅读时的倒计时秒数</span>
|
||||
</div>
|
||||
<div class="setting-input-wrap">
|
||||
<input type="number" id="setting-guidelines-timer" class="setting-input" placeholder="60" min="1" style="width:100px" value="{{.GuidelinesTimer}}">
|
||||
<input type="number" id="setting-guidelines-timer" class="setting-input setting-input-narrow" placeholder="60" min="1" value="{{.GuidelinesTimer}}">
|
||||
<button class="btn btn-sm btn-primary btn-save" data-key="registration.guidelines_timer" data-input="setting-guidelines-timer">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<span class="setting-desc">强制阅读时的倒计时秒数</span>
|
||||
</div>
|
||||
<div class="setting-input-wrap">
|
||||
<input type="number" id="setting-guidelines-timer" class="setting-input" placeholder="60" min="1" style="width:100px" value="{{.GuidelinesTimer}}">
|
||||
<input type="number" id="setting-guidelines-timer" class="setting-input setting-input-narrow" placeholder="60" min="1" value="{{.GuidelinesTimer}}">
|
||||
<button class="btn btn-sm btn-primary btn-save" data-key="registration.guidelines_timer" data-input="setting-guidelines-timer">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="theme-status" class="settings-section" style="display:none">
|
||||
<div id="theme-status" class="settings-section">
|
||||
<div class="section-body">
|
||||
<div class="setting-item">
|
||||
<span id="theme-status-msg"></span>
|
||||
@ -41,4 +41,3 @@
|
||||
</div>
|
||||
</div>
|
||||
{{template "admin/layout/footer.html" .}}
|
||||
<script src="/admin/static/js/site-settings-theme.js?v={{assetV "/admin/static/js/site-settings-theme.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
|
||||
@ -141,6 +141,13 @@
|
||||
font-size: 13px; color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- 隐藏工具类 --- */
|
||||
#theme-status { display: none; }
|
||||
#theme-status.visible { display: block; }
|
||||
|
||||
/* --- 窄输入框 --- */
|
||||
.setting-input-narrow { width: 100px; }
|
||||
|
||||
/* --- 主题状态提示 --- */
|
||||
.status-success .section-body {
|
||||
background: #e8f5e9; border-left: 3px solid #4caf50;
|
||||
|
||||
@ -95,8 +95,7 @@
|
||||
}
|
||||
|
||||
function showStatus(msg, type) {
|
||||
statusSection.style.display = 'block';
|
||||
statusSection.className = 'settings-section';
|
||||
statusSection.className = 'settings-section visible';
|
||||
if (type === 'success') {
|
||||
statusSection.classList.add('status-success');
|
||||
} else if (type === 'error') {
|
||||
|
||||
@ -36,7 +36,10 @@
|
||||
async function loadSettings() {
|
||||
try {
|
||||
var res = await api.get('/api/admin/site-settings');
|
||||
if (!res.success) return;
|
||||
if (!res.success) {
|
||||
showToast(res.message || '加载设置失败', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 所有开关
|
||||
var audit = res.data.audit || {};
|
||||
@ -47,7 +50,7 @@
|
||||
|
||||
if (t.id === 'maintenance-enabled') {
|
||||
el.checked = allSettings[t.key] === 'true';
|
||||
} else if (t.id === 'registration-enabled') {
|
||||
} else if (t.key.indexOf('registration.') === 0) {
|
||||
el.checked = allSettings[t.key] !== 'false';
|
||||
} else {
|
||||
el.checked = audit[t.key.split('.')[1]] === true;
|
||||
@ -60,6 +63,12 @@
|
||||
// 文本输入保存按钮
|
||||
bindSaveButtons();
|
||||
|
||||
// 密码强度下拉(服务端渲染默认值,需从 API 覆盖以显示最新保存值)
|
||||
var strengthSelect = document.getElementById('setting-password-strength');
|
||||
if (strengthSelect && allSettings['password.strength']) {
|
||||
strengthSelect.value = allSettings['password.strength'];
|
||||
}
|
||||
|
||||
// 社区准则文本域
|
||||
var guidelinesEl = document.getElementById('setting-site-guidelines');
|
||||
if (guidelinesEl) {
|
||||
@ -138,6 +147,7 @@
|
||||
// --- 渲染其他设置(过滤已有专属UI的key) ---
|
||||
function renderOtherSettings(all) {
|
||||
var container = document.getElementById('general-settings');
|
||||
if (!container) return; // 部分页面无此容器
|
||||
var filtered = [];
|
||||
Object.keys(all).forEach(function(k) {
|
||||
if (!DEDICATED_KEYS[k]) filtered.push(k);
|
||||
|
||||
Reference in New Issue
Block a user