请输入文本:文本

This commit is contained in:
Fox_block
2026-03-01 21:27:24 +08:00
parent 38240a42ab
commit 7b5fd3a067
34 changed files with 600 additions and 877 deletions
+7 -11
View File
@@ -5,8 +5,6 @@ import { Sidebar } from './components/Sidebar'
import { ContentArea } from './components/ContentArea'
import { Wizard } from './components/Wizard'
import { ThemeProvider, useTheme } from './contexts/ThemeContext'
import { ThemeEditorProvider } from './contexts/ThemeEditorContext'
import { ThemeEditor } from './components/ThemeEditor'
function MainContent(): React.JSX.Element {
const navigate = useNavigate()
@@ -110,13 +108,14 @@ function MainContent(): React.JSX.Element {
}
const isDark = currentTheme?.mode === 'dark'
const brandColor = currentTheme?.config?.tdesign?.brandColor || '#0052D9'
return (
<ConfigProvider
theme={{
algorithm: isDark ? antTheme.darkAlgorithm : antTheme.defaultAlgorithm,
token: {
colorPrimary: 'var(--ant-color-primary, #1890ff)'
colorPrimary: brandColor
}
}}
>
@@ -222,14 +221,11 @@ function getPlatform(): string {
function App(): React.JSX.Element {
return (
<ThemeProvider>
<ThemeEditorProvider>
<HashRouter>
<Routes>
<Route path="/*" element={<MainContent />} />
</Routes>
</HashRouter>
<ThemeEditor />
</ThemeEditorProvider>
<HashRouter>
<Routes>
<Route path="/*" element={<MainContent />} />
</Routes>
</HashRouter>
</ThemeProvider>
)
}
+5 -5
View File
@@ -8,6 +8,7 @@ body,
padding: 0;
overflow: hidden;
user-select: none;
background: var(--ss-bg-color, #f5f5f5);
}
input,
@@ -29,11 +30,10 @@ textarea {
.ss-sidebar .ant-menu-item-selected {
background-color: var(--ss-sidebar-active-bg, var(--ss-item-hover, transparent)) !important;
color: var(--ss-sidebar-active-text, var(--ss-sidebar-text, var(--ss-text-main)));
}
html[theme-mode='dark'] .ss-sidebar .ant-menu-item-selected {
color: #ffffff !important;
color: var(
--ss-sidebar-active-text,
var(--ant-color-primary, var(--ss-sidebar-text, var(--ss-text-main)))
);
}
@media (pointer: coarse) {
+16 -13
View File
@@ -1,8 +1,6 @@
import React, { Suspense, lazy } from 'react'
import { Layout, Space, Button, Tag, Spin } from 'antd'
import { Layout, Space, Button, Tag } from 'antd'
import { Routes, Route, Navigate } from 'react-router-dom'
import { WindowControls } from './WindowControls'
import { ThemeEditor } from './ThemeEditor'
const Home = lazy(() => import('./Home').then((m) => ({ default: m.Home })))
const StudentManager = lazy(() =>
@@ -51,17 +49,16 @@ export function ContentArea({
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
backgroundColor: 'var(--ss-bg-color)'
background: 'var(--ss-bg-color)'
}}
>
<div
style={
{
height: '32px',
WebkitAppRegion: 'drag',
height: '40px',
display: 'flex',
alignItems: 'center',
backgroundColor: 'var(--ss-header-bg)',
background: 'var(--ss-header-bg)',
borderBottom: '1px solid var(--ss-border-color)',
flexShrink: 0
} as React.CSSProperties
@@ -73,12 +70,11 @@ export function ContentArea({
{
display: 'flex',
alignItems: 'center',
paddingRight: '0px',
WebkitAppRegion: 'no-drag'
paddingRight: '12px'
} as React.CSSProperties
}
>
<Space size="small" style={{ marginRight: '12px' }}>
<Space size="small">
{permissionTag}
{hasAnyPassword && (
<>
@@ -91,7 +87,6 @@ export function ContentArea({
</>
)}
</Space>
<WindowControls />
</div>
</div>
@@ -106,7 +101,16 @@ export function ContentArea({
height: '100%'
}}
>
<Spin size="large" description="正在载入页面..." />
<div
style={{
width: '40px',
height: '40px',
border: '3px solid var(--ss-border-color)',
borderTopColor: 'var(--ant-color-primary)',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}}
/>
</div>
}
>
@@ -129,7 +133,6 @@ export function ContentArea({
</Routes>
</Suspense>
</Content>
<ThemeEditor />
</Layout>
)
}
+1 -1
View File
@@ -786,7 +786,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
<div
style={{
padding: '16px',
backgroundColor:
background:
customScore > 0
? 'var(--ant-color-success-bg, #f6ffed)'
: customScore < 0
+15 -23
View File
@@ -1,7 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Tabs, Card, Form, Select, Input, Button, Space, Divider, Tag, Modal, message } from 'antd'
import { useTheme } from '../contexts/ThemeContext'
import { useThemeEditor } from '../contexts/ThemeEditorContext'
import { ThemeQuickSettings } from './ThemeQuickSettings'
type permissionLevel = 'admin' | 'points' | 'view'
type appSettings = {
@@ -12,8 +11,6 @@ type appSettings = {
}
export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }) => {
const { themes, currentTheme, setTheme } = useTheme()
const { startEditing } = useThemeEditor()
const [activeTab, setActiveTab] = useState('appearance')
const [settings, setSettings] = useState<appSettings>({
is_wizard_completed: false,
@@ -313,22 +310,11 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
label: '外观',
children: (
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
<Form.Item label="当前主题">
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<Select
value={currentTheme?.id}
onChange={(v) => setTheme(v as string)}
style={{ width: '200px' }}
options={themes.map((t) => ({ value: t.id, label: t.name }))}
/>
<Button onClick={() => startEditing(currentTheme || undefined)}></Button>
<Button type="link" onClick={() => startEditing()}>
</Button>
</div>
</Form.Item>
<ThemeQuickSettings />
<Divider />
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
<Form.Item label="界面缩放">
<Select
value={settings.window_zoom || '1.0'}
@@ -690,7 +676,6 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
>
<div>secscore://settings</div>
<div>secscore://score</div>
<div>secscore://sidebar/toggle</div>
</div>
<Divider />
<Space>
@@ -767,7 +752,14 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
]
return (
<div style={{ padding: '24px', maxWidth: '900px', margin: '0 auto' }}>
<div
style={{
padding: '24px',
maxWidth: '900px',
margin: '0 auto',
color: 'var(--ss-text-main)'
}}
>
{contextHolder}
<div
style={{
@@ -777,7 +769,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
marginBottom: '16px'
}}
>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}></h2>
<h2 style={{ margin: 0 }}></h2>
{permissionTag}
</div>
@@ -871,7 +863,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div></div>
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
"结算历史"
</div>
</div>
</Modal>
+1 -1
View File
@@ -158,7 +158,7 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
className="ss-sidebar"
width={200}
style={{
backgroundColor: 'var(--ss-sidebar-bg)',
background: 'var(--ss-sidebar-bg)',
borderRight: '1px solid var(--ss-border-color)',
display: 'flex',
flexDirection: 'column'
+28 -8
View File
@@ -178,7 +178,7 @@ export const ThemeEditor: React.FC = () => {
</div>
<Row gutter={[12, 12]}>
{group.items.map((item) => (
<Col span={6} key={item.key}>
<Col span={groupKey === 'background' ? 12 : 6} key={item.key}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div
style={{
@@ -192,13 +192,33 @@ export const ThemeEditor: React.FC = () => {
>
{item.label}
</div>
<ColorPicker
value={editingTheme.config.custom[item.key] || '#ffffff'}
onChange={(color: Color) =>
updateConfig('custom', item.key, color.toHexString())
}
showText
/>
{groupKey === 'background' ? (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div
style={{
width: 32,
height: 32,
borderRadius: 6,
border: '1px solid var(--ss-border-color)',
background: editingTheme.config.custom[item.key] || '#ffffff',
flexShrink: 0
}}
/>
<Input
value={editingTheme.config.custom[item.key] || ''}
onChange={(e) => updateConfig('custom', item.key, e.target.value)}
placeholder="例如 #ffffff 或 linear-gradient(...)"
/>
</div>
) : (
<ColorPicker
value={editingTheme.config.custom[item.key] || '#ffffff'}
onChange={(color: Color) =>
updateConfig('custom', item.key, color.toHexString())
}
showText
/>
)}
</div>
</Col>
))}
@@ -0,0 +1,302 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Button, ColorPicker, Input, Segmented, Space, Typography, message } from 'antd'
import type { Color } from 'antd/es/color-picker'
import { useTheme } from '../contexts/ThemeContext'
import type { themeConfig } from '../../../preload/types'
type props = {
compact?: boolean
}
const presetPrimaryColors = [
'#1677FF',
'#2F54EB',
'#722ED1',
'#EB2F96',
'#F5222D',
'#FA8C16',
'#FADB14',
'#52C41A',
'#13C2C2'
]
const presetGradients: {
id: string
label: string
light: string
dark: string
}[] = [
{
id: 'blue',
label: '清爽蓝',
light: 'linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)',
dark: 'linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)'
},
{
id: 'pink',
label: '柔和粉',
light: 'linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)',
dark: 'linear-gradient(180deg, #1a0f14 0%, #1d1218 55%, #120c10 100%)'
},
{
id: 'cyan',
label: '青蓝',
light: 'linear-gradient(180deg, #f0f9ff 0%, #e0f2fe 55%, #f0f9ff 100%)',
dark: 'linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)'
},
{
id: 'purple',
label: '紫韵',
light: 'linear-gradient(180deg, #faf5ff 0%, #f3e8ff 55%, #faf5ff 100%)',
dark: 'linear-gradient(180deg, #0f0a14 0%, #151020 55%, #0d0910 100%)'
}
]
const deepClone = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T
const normalizeHex = (value: string): string | null => {
const s = String(value || '').trim()
if (!s) return null
const m = s.match(/^#?([0-9a-fA-F]{6})$/)
if (!m) return null
return `#${m[1].toUpperCase()}`
}
const buildGradient = (a: string, b: string, dir: 'v' | 'h' | 'd'): string => {
const angle = dir === 'h' ? '90deg' : dir === 'd' ? '135deg' : '180deg'
return `linear-gradient(${angle}, ${a} 0%, ${b} 100%)`
}
export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
const { currentTheme, setTheme, themes, applyTheme } = useTheme()
const [messageApi, holder] = message.useMessage()
const [workingTheme, setWorkingTheme] = useState<themeConfig | null>(null)
const [saving, setSaving] = useState(false)
const [primaryInput, setPrimaryInput] = useState('')
const primaryColor = useMemo(() => {
const fromTheme = workingTheme?.config?.tdesign?.brandColor
return normalizeHex(primaryInput) || normalizeHex(fromTheme || '') || '#1677FF'
}, [primaryInput, workingTheme])
const [gradientDir, setGradientDir] = useState<'v' | 'h' | 'd'>('v')
const [g1, setG1] = useState<string>('#f7fbff')
const [g2, setG2] = useState<string>('#f8f9fc')
const currentBg = workingTheme?.config?.custom?.['--ss-bg-color'] || ''
useEffect(() => {
if (!currentTheme) return
const base = deepClone(currentTheme)
const editable =
base.id === 'custom-default' || base.id.startsWith('custom-')
? base
: { ...base, id: 'custom-default', name: '我的主题' }
setWorkingTheme(editable)
setPrimaryInput(editable.config?.tdesign?.brandColor || '')
}, [currentTheme])
// 实时预览主题变化
useEffect(() => {
if (!workingTheme) return
applyTheme(workingTheme)
}, [workingTheme, applyTheme])
const ensureCustomThemeSelected = async (theme: themeConfig): Promise<boolean> => {
if (!(window as any).api) return false
const exists = themes.some((t) => t.id === theme.id)
if (!exists) {
const res = await (window as any).api.saveTheme(theme)
if (!res?.success) return false
}
if (currentTheme?.id !== theme.id) {
await setTheme(theme.id)
}
return true
}
const patchThemeConfig = (type: 'tdesign' | 'custom', key: string, value: string) => {
setWorkingTheme((prev) => {
if (!prev) return prev
const next = deepClone(prev)
next.config = next.config || ({} as any)
next.config[type] = { ...(next.config[type] || {}), [key]: value }
return next
})
}
const save = async () => {
if (!(window as any).api) return
if (!workingTheme) return
setSaving(true)
try {
const ok = await ensureCustomThemeSelected(workingTheme)
if (!ok) throw new Error('保存失败')
const res = await (window as any).api.saveTheme(workingTheme)
if (!res?.success) throw new Error(res?.message || '保存失败')
messageApi.success('已保存')
} catch (e: any) {
messageApi.error(e?.message || '保存失败')
} finally {
setSaving(false)
}
}
const setPrimary = (hex: string) => {
setPrimaryInput(hex)
patchThemeConfig('tdesign', 'brandColor', hex)
}
const setGradientPreset = (value: string) => {
patchThemeConfig('custom', '--ss-bg-color', value)
}
const setGradientFromPickers = () => {
const g = buildGradient(g1, g2, gradientDir)
patchThemeConfig('custom', '--ss-bg-color', g)
}
const setMode = async (mode: 'light' | 'dark') => {
// 根据模式切换到对应的内置主题
const targetThemeId = mode === 'light' ? 'light-default' : 'dark-default'
await setTheme(targetThemeId)
}
if (!workingTheme) return null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: compact ? 12 : 16 }}>
{holder}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography.Text strong></Typography.Text>
<Space>
<Button type="primary" loading={saving} onClick={save}>
</Button>
</Space>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Typography.Text></Typography.Text>
<Segmented
value={workingTheme.mode}
onChange={(v) => setMode(v as any)}
options={[
{ label: '浅色', value: 'light' },
{ label: '深色', value: 'dark' }
]}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Typography.Text></Typography.Text>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<Input
value={primaryInput}
onChange={(e) => setPrimaryInput(e.target.value)}
onBlur={() => {
const n = normalizeHex(primaryInput)
if (n) setPrimary(n)
else setPrimary(primaryColor)
}}
style={{ width: 120 }}
/>
{presetPrimaryColors.map((c) => (
<button
key={c}
type="button"
onClick={() => setPrimary(c)}
style={{
width: 18,
height: 18,
borderRadius: 999,
border:
c === primaryColor ? '2px solid rgba(0,0,0,0.35)' : '1px solid rgba(0,0,0,0.18)',
background: c,
cursor: 'pointer',
padding: 0
}}
/>
))}
<ColorPicker
value={primaryColor}
onChange={(color: Color) => setPrimary(color.toHexString().toUpperCase())}
/>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Typography.Text></Typography.Text>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: 10
}}
>
{presetGradients.map((g) => {
const gradientValue = g[workingTheme.mode]
const active = currentBg === gradientValue
return (
<button
key={g.id}
type="button"
onClick={() => setGradientPreset(gradientValue)}
style={{
borderRadius: 10,
border: active
? '2px solid var(--ant-color-primary)'
: '1px solid var(--ss-border-color)',
padding: 10,
background: 'transparent',
cursor: 'pointer',
textAlign: 'left'
}}
>
<div
style={{
height: 56,
borderRadius: 8,
border: '1px solid var(--ss-border-color)',
background: gradientValue
}}
/>
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ss-text-secondary)' }}>
{g.label}
</div>
</button>
)
})}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<Segmented
value={gradientDir}
onChange={(v) => setGradientDir(v as any)}
options={[
{ label: '纵向', value: 'v' },
{ label: '横向', value: 'h' },
{ label: '对角', value: 'd' }
]}
/>
<Space size="small">
<ColorPicker value={g1} onChange={(c: Color) => setG1(c.toHexString())} />
<ColorPicker value={g2} onChange={(c: Color) => setG2(c.toHexString())} />
<Button onClick={setGradientFromPickers}></Button>
</Space>
<div
style={{
width: 160,
height: 32,
borderRadius: 8,
border: '1px solid var(--ss-border-color)',
background: buildGradient(g1, g2, gradientDir)
}}
/>
</div>
</div>
</div>
)
}
+3 -12
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react'
import { Modal, Form, Select, message, Typography } from 'antd'
import { useTheme } from '../contexts/ThemeContext'
import { Modal, message, Typography } from 'antd'
import { ThemeQuickSettings } from './ThemeQuickSettings'
interface wizardProps {
visible: boolean
@@ -8,7 +8,6 @@ interface wizardProps {
}
export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
const { themes, currentTheme, setTheme } = useTheme()
const [loading, setLoading] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
@@ -47,15 +46,7 @@ export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
SecScore
</Typography.Paragraph>
<Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
<Form.Item label="外观主题">
<Select
value={currentTheme?.id}
onChange={(v) => setTheme(v as string)}
options={themes.map((t) => ({ value: t.id, label: t.name }))}
/>
</Form.Item>
</Form>
<ThemeQuickSettings compact />
</Modal>
)
}
+43 -9
View File
@@ -6,6 +6,7 @@ interface themeContextType {
currentTheme: themeConfig | null
setTheme: (id: string) => Promise<void>
themes: themeConfig[]
applyTheme: (theme: themeConfig) => void
}
const ThemeContext = createContext<themeContextType | undefined>(undefined)
@@ -25,21 +26,27 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
root.setAttribute('theme-mode', theme.mode)
if (tdesign.brandColor) {
const colorMap = generateColorMap(tdesign.brandColor, theme.mode)
const brandColor = tdesign.brandColor || '#1677FF'
const hex = brandColor.replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
root.style.setProperty('--ss-primary-color', brandColor)
root.style.setProperty('--ss-primary-rgb', `${r}, ${g}, ${b}`)
nextKeys.push('--ss-primary-color')
nextKeys.push('--ss-primary-rgb')
if (brandColor) {
const colorMap = generateColorMap(brandColor, theme.mode)
Object.entries(colorMap).forEach(([key, value]) => {
root.style.setProperty(key, value)
nextKeys.push(key)
})
root.style.setProperty('--ant-color-primary', tdesign.brandColor)
root.style.setProperty('--ant-color-primary', brandColor)
nextKeys.push('--ant-color-primary')
const hex = tdesign.brandColor.replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
root.style.setProperty('--ant-color-primary-hover', `rgba(${r}, ${g}, ${b}, 0.85)`)
root.style.setProperty('--ant-color-primary-active', `rgba(${r}, ${g}, ${b}, 0.7)`)
root.style.setProperty('--ant-color-primary-bg', `rgba(${r}, ${g}, ${b}, 0.1)`)
@@ -71,6 +78,26 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
nextKeys.push(key)
})
const bgLight = custom['--ss-bg-color-light']
const bgDark = custom['--ss-bg-color-dark']
if (bgLight || bgDark) {
const resolved = theme.mode === 'dark' ? bgDark || bgLight : bgLight || bgDark
if (resolved) {
root.style.setProperty('--ss-bg-color', resolved)
nextKeys.push('--ss-bg-color')
}
}
if (!custom['--ss-sidebar-active-bg']) {
const alpha = theme.mode === 'dark' ? 0.22 : 0.12
root.style.setProperty('--ss-sidebar-active-bg', `rgba(${r}, ${g}, ${b}, ${alpha})`)
nextKeys.push('--ss-sidebar-active-bg')
}
if (!custom['--ss-sidebar-active-text']) {
root.style.setProperty('--ss-sidebar-active-text', brandColor)
nextKeys.push('--ss-sidebar-active-text')
}
appliedStyleKeysRef.current = nextKeys
}, [])
@@ -120,8 +147,15 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
}
}
const applyTheme = useCallback(
(theme: themeConfig) => {
applyThemeConfig(theme)
},
[applyThemeConfig]
)
return (
<ThemeContext.Provider value={{ currentTheme, setTheme, themes }}>
<ThemeContext.Provider value={{ currentTheme, setTheme, themes, applyTheme }}>
{children}
</ThemeContext.Provider>
)