Add built-in white and black themes

This commit is contained in:
eezd committed 2026-10-20 10:26:49 +08:00
1 parent 7f65162115
commit 9fd7e2a948
6 files changed
+103 -5

No files matched your search

+33
View File
@@ -1,6 +1,7 @@
package api
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
@@ -19,6 +20,29 @@ type themeManifest struct {
Version string `json:"version"`
}
//go:embed themes/white.css
var whiteThemeCSS string
//go:embed themes/black.css
var blackThemeCSS string
var builtinThemes = []struct {
themeManifest
css string
}{
{themeManifest{"white", "白色", "1"}, whiteThemeCSS},
{themeManifest{"black", "黑色", "1"}, blackThemeCSS},
}
func builtinTheme(id string) (themeManifest, string, bool) {
for _, theme := range builtinThemes {
if theme.ID == id {
return theme.themeManifest, theme.css, true
}
}
return themeManifest{}, "", false
}
func readThemeFile(root *os.Root, name string, limit int64) ([]byte, error) {
f, err := root.Open(name)
if err != nil {
@@ -43,6 +67,9 @@ func readThemeFile(root *os.Root, name string, limit int64) ([]byte, error) {
}
func (s *Server) readTheme(id string) (themeManifest, string, error) {
if manifest, css, ok := builtinTheme(id); ok {
return manifest, css, nil
}
var manifest themeManifest
if !themeIDPattern.MatchString(id) || id == "default" || s.ThemesDir == "" {
return manifest, "", fs.ErrNotExist
@@ -73,6 +100,9 @@ func (s *Server) readTheme(id string) (themeManifest, string, error) {
func (s *Server) listThemes(w http.ResponseWriter, r *http.Request) {
themes := []themeManifest{{ID: "default", Name: "默认", Version: "1"}}
for _, theme := range builtinThemes {
themes = append(themes, theme.themeManifest)
}
if s.ThemesDir != "" {
entries, err := os.ReadDir(s.ThemesDir)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
@@ -80,6 +110,9 @@ func (s *Server) listThemes(w http.ResponseWriter, r *http.Request) {
return
}
for _, entry := range entries {
if _, _, ok := builtinTheme(entry.Name()); ok {
continue
}
if !entry.IsDir() || !themeIDPattern.MatchString(entry.Name()) || entry.Name() == "default" {
continue
}
+30
View File
@@ -0,0 +1,30 @@
:root {
color-scheme: dark;
--rm-bg: #0b0b0c;
--rm-sidebar-bg: #111113;
--rm-text: #e6e8eb;
--rm-text-muted: #9b9fa8;
--rm-divider: #2b2d33;
--rm-accent: #8eafff;
--rm-accent-strong: #b3c9ff;
--rm-selection: rgba(142, 175, 255, 0.25);
--rm-hover: rgba(230, 232, 235, 0.065);
--rm-active: rgba(142, 175, 255, 0.14);
--rm-danger: #ff8291;
--rm-warn-bg: #2c2413;
--rm-warn-text: #e8c477;
--rm-shadow: 0 12px 36px rgba(0, 0, 0, 0.6);
--rm-inline-code-bg: #202127;
--rm-inline-code-text: #c3d5f5;
--rm-code-bg: #18191e;
--rm-code-text: #e1e4e8;
--rm-highlight-bg: rgba(232, 196, 119, 0.25);
}
/* 浅蓝按钮使用深色文字,保持选中状态与操作按钮的对比度。 */
.rm-btn-primary,
.rm-btn-primary:hover,
.rm-segmented button.on,
.rm-share-password button {
color: #0b0b0c;
}
+22
View File
@@ -0,0 +1,22 @@
:root {
color-scheme: light;
--rm-bg: #ffffff;
--rm-sidebar-bg: #f6f7f9;
--rm-text: #202124;
--rm-text-muted: #656b75;
--rm-divider: #e3e6eb;
--rm-accent: #2563eb;
--rm-accent-strong: #1d4ed8;
--rm-selection: rgba(37, 99, 235, 0.18);
--rm-hover: rgba(32, 33, 36, 0.045);
--rm-active: rgba(37, 99, 235, 0.10);
--rm-danger: #c42b3a;
--rm-warn-bg: #fff8e6;
--rm-warn-text: #855600;
--rm-shadow: 0 12px 36px rgba(24, 31, 44, 0.12);
--rm-inline-code-bg: #f0f2f5;
--rm-inline-code-text: #40536d;
--rm-code-bg: #292d3e;
--rm-code-text: #e1e4e8;
--rm-highlight-bg: #fff0a8;
}
+7 -1
View File
@@ -29,9 +29,15 @@ func TestThemeAndShareAppearanceBoundaries(t *testing.T) {
t.Fatalf("匿名用户不应读取主题,得到 %d", code)
}
themes := decode[struct{ Themes []themeManifest }](t, f.do(f.admin, "GET", "/api/v1/themes", "", nil))
if len(themes.Themes) != 2 || themes.Themes[1].ID != "paper" {
if len(themes.Themes) != 4 || themes.Themes[0].ID != "default" || themes.Themes[1].ID != "white" || themes.Themes[2].ID != "black" || themes.Themes[3].ID != "paper" {
t.Fatalf("主题列表错误:%+v", themes.Themes)
}
for _, id := range []string{"white", "black"} {
theme := decode[map[string]string](t, f.do(f.admin, "GET", "/api/v1/themes/"+id, "", nil))
if theme["id"] != id || !strings.Contains(theme["css"], "--rm-bg:") {
t.Fatalf("内置主题未提供配色:%s", id)
}
}
theme := decode[map[string]string](t, f.do(f.admin, "GET", "/api/v1/themes/paper", "", nil))
if !strings.Contains(theme["css"], "#faf6ed") {
t.Fatal("未读取到主题 CSS")
+5 -3
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { api, ApiError, type Theme } from './api'
import { appearancePaused, type CssSnippet } from '../themes/appearance'
import { appearancePaused, fixedThemeMode, type CssSnippet } from '../themes/appearance'
import { loadSettings, reloadTheme, settings, updateSettings, type Settings } from './settings'
import { toast } from './workspace'
@@ -11,6 +11,7 @@ const busy = ref(false)
const editing = ref<string | null>(null)
const draft = ref({ name: '', css: '' })
const missingTheme = computed(() => !themes.value.some((theme) => theme.id === settings.theme))
const fixedMode = computed(() => fixedThemeMode(settings.theme))
async function refreshThemes() {
error.value = ''
@@ -74,7 +75,7 @@ async function reset() {
<p v-if="appearancePaused" class="rm-appearance-note">已临时停用主题和 CSS。可恢复默认外观后重新打开页面。</p>
<div class="rm-setting">
<label for="rm-color-mode" class="rm-setting-label">明暗模式</label>
<select id="rm-color-mode" :value="settings.colorMode" :disabled="busy" @change="change({ colorMode: ($event.target as HTMLSelectElement).value as Settings['colorMode'] })">
<select id="rm-color-mode" :value="fixedMode ?? settings.colorMode" :disabled="busy || !!fixedMode" @change="change({ colorMode: ($event.target as HTMLSelectElement).value as Settings['colorMode'] })">
<option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option>
</select>
</div>
@@ -87,7 +88,8 @@ async function reset() {
<button class="rm-btn" :disabled="busy" @click="refreshThemes">刷新列表</button>
</div>
<p v-if="error" class="rm-appearance-note rm-auth-error">{{ error }}</p>
<p class="rm-appearance-note">主题从服务器数据目录的 themes 文件夹读取;主题和已启用的 CSS 也会应用到分享页。</p>
<p v-if="fixedMode" class="rm-appearance-note">白色、黑色主题固定使用对应明暗模式;切换回默认或自定义主题后使用原来的明暗设置。</p>
<p class="rm-appearance-note">可选择内置主题,或从服务器数据目录的 themes 文件夹添加主题;主题和已启用的 CSS 也会应用到分享页。</p>
<div class="rm-setting"><span class="rm-setting-label">CSS 片段</span><button class="rm-btn" :disabled="busy" @click="edit()">新建片段</button></div>
<div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row">
<label><input type="checkbox" :checked="snippet.enabled" :disabled="busy" @change="toggle(snippet)" />{{ snippet.name }}</label>
+6 -1
View File
@@ -13,6 +13,11 @@ export interface Appearance {
export const appearancePaused = new URLSearchParams(location.search).has('reset-appearance')
export function fixedThemeMode(theme: string): 'light' | 'dark' | undefined {
if (theme === 'white') return 'light'
if (theme === 'black') return 'dark'
}
export function sanitizeAppearance(raw: Record<string, unknown>): Appearance {
return {
colorMode: raw.colorMode === 'light' || raw.colorMode === 'dark' ? raw.colorMode : 'system',
@@ -40,7 +45,7 @@ function style(id: string, css: string) {
}
function render() {
const mode = appearancePaused ? 'system' : current.colorMode
const mode = appearancePaused ? 'system' : fixedThemeMode(current.theme) ?? current.colorMode
const dark = mode === 'system' ? darkMode.matches : mode === 'dark'
const signature = JSON.stringify([dark, appearancePaused ? '' : [current.theme, themeCSS, current.cssSnippets]])
if (signature === rendered) return