Merge pull request #2 from Linkon-lcw/main

恭喜Linkon成为SecScore项目的第一个PR发起人!!🎉
This commit is contained in:
Fox-block-offcial
2026-01-18 21:50:38 +08:00
committed by GitHub
25 changed files with 1361 additions and 127 deletions
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+2 -2
View File
@@ -6,10 +6,10 @@ on:
workflow_dispatch:
inputs:
version:
description: "版本号(例如 1.2.3 或 v1.2.3"
description: '版本号(例如 1.2.3 或 v1.2.3'
required: false
build:
description: "构建命令标记(build:win|build:mac|build:linux|build:unpack|build:all"
description: '构建命令标记(build:win|build:mac|build:linux|build:unpack|build:all'
required: false
permissions:
+1
View File
@@ -15,3 +15,4 @@ db.sqlite
!.vscode/extensions.json
!.vscode/launch.json
!.vscode/settings.json
/.trae/
+7 -7
View File
@@ -7,7 +7,7 @@
- 为什么我们需要可逆的插件系统?
- Cordis 是如何实现资源安全的?
:::
:::
Koishi 的一切都从 Cordis 开始。但我想大部分 Koishi 的开发者都不知道 Cordis 是什么。如果让我来定义的话,Cordis 是一个**元框架 (Meta Framework)**,即一个用于构建框架的框架。
@@ -112,8 +112,8 @@ function serve(port: number) {
return () => server.close()
}
const dispose = serve(80) // 监听端口 80
dispose() // 回收副作用
const dispose = serve(80) // 监听端口 80
dispose() // 回收副作用
```
在这个例子中,`serve()` 函数将会创建一个服务器并且监听 `port` 端口。同时,调用该函数也会返回一个新的函数,用于取消该端口的监听。
@@ -123,7 +123,7 @@ dispose() // 回收副作用
- $\mathcal{C}\times\mathfrak{F}$ 对应着全局环境 (我们稍后会提到全局环境的坏处,但不影响这里的理解)
- `port` 对应于上面的 $\text{X}$,由于我们可以使用柯里化,所以在数学模型中并不需要考虑它
:::
:::
为什么需要引入这个 $\text{effect}$ 和 $\mathcal{C}\times\mathfrak{F}$ 呢?它的作用是将副作用从函数的返回值中分离出来,从而实现副作用的回收。只需定义 $\text{restore}$ 变换 (不难发现它确实是 $\text{effect}$ 的逆操作)
@@ -142,9 +142,9 @@ function serve(port: number) {
collectEffect(() => server.close())
}
serve(80) // 监听端口 80 并记录副作用
serve(443) // 监听端口 443 并记录副作用
restore() // 回收所有副作用
serve(80) // 监听端口 80 并记录副作用
serve(443) // 监听端口 443 并记录副作用
restore() // 回收所有副作用
```
当副作用被记录到全局环境时,$\mathcal{C}\times\mathfrak{F}$ 也就变成了一个更大的 $\mathcal{C}$。我们便可以这样定义:
-1
View File
@@ -91,4 +91,3 @@
- 本计划不包含“引入插件系统/插件化框架”的任何概念与实现。
- 本计划不包含“删除 hosting 目录”。它会被保留;仅确保不再作为主路径依赖。
- 本计划优先保证现有功能可用与类型安全,然后再做进一步抽象与模块扩展。
+3 -2
View File
@@ -1,7 +1,9 @@
import fs from 'fs'
import path from 'path'
const version = String(process.argv[2] || '').trim().replace(/^v/i, '')
const version = String(process.argv[2] || '')
.trim()
.replace(/^v/i, '')
if (!version) {
process.stderr.write('缺少版本号参数,例如:node scripts/ci/apply-version.mjs 1.2.3\n')
process.exit(1)
@@ -17,4 +19,3 @@ const pkgPath = path.join(process.cwd(), 'package.json')
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
pkg.version = version
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf-8')
-1
View File
@@ -88,4 +88,3 @@ if (process.env.GITHUB_OUTPUT) {
} else {
process.stdout.write(`${JSON.stringify(outputs, null, 2)}\n`)
}
+50
View File
@@ -62,6 +62,7 @@ export class WindowManager extends Service {
height: 670,
show: false,
autoHideMenuBar: true,
frame: false, // Custom title bar
icon: this.opts.icon,
title: input.title,
webPreferences: {
@@ -80,6 +81,14 @@ export class WindowManager extends Service {
win.show()
})
// Notify renderer about maximize state changes
win.on('maximize', () => {
win.webContents.send('window:maximized-changed', true)
})
win.on('unmaximize', () => {
win.webContents.send('window:maximized-changed', false)
})
win.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
@@ -142,5 +151,46 @@ export class WindowManager extends Service {
const ok = this.navigateWindow(win, route)
return ok ? { success: true } : { success: false, message: 'Window not found' }
})
// Window controls
this.mainCtx.handle('window:minimize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win) win.minimize()
})
this.mainCtx.handle('window:maximize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win) {
if (win.isMaximized()) {
win.unmaximize()
return false
} else {
win.maximize()
return true
}
}
return false
})
this.mainCtx.handle('window:close', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win) win.close()
})
this.mainCtx.handle('window:isMaximized', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win ? win.isMaximized() : false
})
this.mainCtx.handle('window:toggle-devtools', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win) {
if (win.webContents.isDevToolsOpened()) {
win.webContents.closeDevTools()
} else {
win.webContents.openDevTools()
}
}
})
}
}
+10
View File
@@ -71,6 +71,16 @@ const api = {
ipcRenderer.invoke('window:open', input),
navigateWindow: (input: { key?: string; route: string }) =>
ipcRenderer.invoke('window:navigate', input),
windowMinimize: () => ipcRenderer.invoke('window:minimize'),
windowMaximize: () => ipcRenderer.invoke('window:maximize'),
windowClose: () => ipcRenderer.invoke('window:close'),
windowIsMaximized: () => ipcRenderer.invoke('window:isMaximized'),
onWindowMaximizedChanged: (callback: (maximized: boolean) => void) => {
const subscription = (_event: any, maximized: boolean) => callback(maximized)
ipcRenderer.on('window:maximized-changed', subscription)
return () => ipcRenderer.removeListener('window:maximized-changed', subscription)
},
toggleDevTools: () => ipcRenderer.invoke('window:toggle-devtools'),
// Logger
queryLogs: (lines?: number) => ipcRenderer.invoke('log:query', lines),
+6
View File
@@ -116,6 +116,12 @@ export interface electronApi {
options?: any
}) => Promise<ipcResponse<void>>
navigateWindow: (input: { key?: string; route: string }) => Promise<ipcResponse<void>>
windowMinimize: () => Promise<void>
windowMaximize: () => Promise<boolean>
windowClose: () => Promise<void>
windowIsMaximized: () => Promise<boolean>
onWindowMaximizedChanged: (callback: (maximized: boolean) => void) => () => void
toggleDevTools: () => Promise<void>
// Logger
queryLogs: (lines?: number) => Promise<ipcResponse<string[]>>
+16 -104
View File
@@ -1,18 +1,11 @@
import { Layout, Menu, Space, Dialog, Input, Button, Tag, MessagePlugin } from 'tdesign-react'
import { Layout, Dialog, Input, MessagePlugin } from 'tdesign-react'
import { useEffect, useMemo, useState } from 'react'
import { UserIcon, SettingIcon, HistoryIcon, RootListIcon, ViewListIcon } from 'tdesign-icons-react'
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { StudentManager } from './components/StudentManager'
import { Settings } from './components/Settings'
import { ReasonManager } from './components/ReasonManager'
import { ScoreManager } from './components/ScoreManager'
import { Leaderboard } from './components/Leaderboard'
import { SettlementHistory } from './components/SettlementHistory'
import { HashRouter, useLocation, useNavigate } from 'react-router-dom'
import { Sidebar } from './components/Sidebar'
import { ContentArea } from './components/ContentArea'
import { Wizard } from './components/Wizard'
import { ThemeProvider } from './contexts/ThemeContext'
const { Header, Content, Aside } = Layout
function MainContent(): React.JSX.Element {
const navigate = useNavigate()
const location = useLocation()
@@ -26,13 +19,14 @@ function MainContent(): React.JSX.Element {
const activeMenu = useMemo(() => {
const p = location.pathname
if (p === '/' || p.startsWith('/home')) return 'home'
if (p.startsWith('/students')) return 'students'
if (p.startsWith('/score')) return 'score'
if (p.startsWith('/leaderboard')) return 'leaderboard'
if (p.startsWith('/settlements')) return 'settlements'
if (p.startsWith('/reasons')) return 'reasons'
if (p.startsWith('/settings')) return 'settings'
return 'score'
return 'home'
}, [location.pathname])
useEffect(() => {
@@ -87,6 +81,7 @@ function MainContent(): React.JSX.Element {
const onMenuChange = (v: string | number) => {
const key = String(v)
if (key === 'home') navigate('/')
if (key === 'students') navigate('/students')
if (key === 'score') navigate('/score')
if (key === 'leaderboard') navigate('/leaderboard')
@@ -95,99 +90,16 @@ function MainContent(): React.JSX.Element {
if (key === 'settings') navigate('/settings')
}
const permissionTag = (
<Tag
theme={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
variant="light"
>
{permission === 'admin' ? '管理权限' : permission === 'points' ? '积分权限' : '只读'}
</Tag>
)
return (
<Layout style={{ height: '100vh', backgroundColor: 'var(--ss-bg-color)' }}>
<Aside
className="ss-sidebar"
style={{
backgroundColor: 'var(--ss-sidebar-bg)',
borderRight: '1px solid var(--ss-border-color)'
}}
>
<div style={{ padding: '24px', textAlign: 'center' }}>
<h2 style={{ color: 'var(--ss-sidebar-text, var(--ss-text-main))', margin: 0 }}>
SecScore
</h2>
<div
style={{
fontSize: '12px',
color: 'var(--ss-sidebar-text, var(--ss-text-main))'
}}
>
</div>
</div>
<Menu value={activeMenu} onChange={onMenuChange} style={{ width: '100%', border: 'none' }}>
<Menu.MenuItem value="students" icon={<UserIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
<Menu.MenuItem value="score" icon={<HistoryIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="leaderboard" icon={<ViewListIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="settlements" icon={<HistoryIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="reasons" icon={<RootListIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
<Menu.MenuItem value="settings" icon={<SettingIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
</Menu>
</Aside>
<Layout>
<Header
style={{
backgroundColor: 'var(--ss-header-bg)',
borderBottom: '1px solid var(--ss-border-color)',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
padding: '0 24px'
}}
>
<Space>
{permissionTag}
{hasAnyPassword && (
<>
<Button size="small" variant="outline" onClick={() => setAuthVisible(true)}>
</Button>
<Button size="small" variant="outline" theme="danger" onClick={logout}>
</Button>
</>
)}
</Space>
</Header>
<Content style={{ overflowY: 'auto' }}>
<Routes>
<Route path="/" element={<Navigate to="/score" replace />} />
<Route path="/students" element={<StudentManager canEdit={permission === 'admin'} />} />
<Route
path="/score"
element={<ScoreManager canEdit={permission === 'admin' || permission === 'points'} />}
/>
<Route path="/leaderboard" element={<Leaderboard />} />
<Route path="/settlements" element={<SettlementHistory />} />
<Route path="/reasons" element={<ReasonManager canEdit={permission === 'admin'} />} />
<Route path="/settings" element={<Settings permission={permission} />} />
<Route path="*" element={<Navigate to="/score" replace />} />
</Routes>
</Content>
</Layout>
<Layout style={{ height: '100vh', flexDirection: 'row', overflow: 'hidden' }}>
<Sidebar activeMenu={activeMenu} permission={permission} onMenuChange={onMenuChange} />
<ContentArea
permission={permission}
hasAnyPassword={hasAnyPassword}
onAuthClick={() => setAuthVisible(true)}
onLogout={logout}
/>
<Wizard visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
<Dialog
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW (OEM 版本) -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="57.5732mm" height="57.5732mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 5757.32 5757.32"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.str0 {stroke:#FEFEFE;stroke-width:20;stroke-miterlimit:22.9256}
.str1 {stroke:#2387ED;stroke-width:20;stroke-miterlimit:22.9256}
.str2 {stroke:#2789ED;stroke-width:20;stroke-miterlimit:22.9256}
.fil0 {fill:#FEFEFE}
.fil1 {fill:#2389EC}
.fil4 {fill:#75BDF7}
.fil2 {fill:#FFDA31}
.fil3 {fill:#FFF270}
]]>
</style>
</defs>
<g id="圖層_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_1556526623248">
<rect class="fil0 str0" x="27.11" y="26.34" width="5675.33" height="5687.62" rx="1140.46" ry="1142.93"/>
<circle class="fil1 str1" cx="3484.74" cy="2200.34" r="920.68"/>
<circle class="fil0 str0" cx="3951.4" cy="3983.83" r="1288.52"/>
<g>
<circle class="fil2" cx="3951.4" cy="3983.83" r="1033.53"/>
<path class="fil3" d="M4139.76 3707.08c-14.94,-25.96 -43.54,-77.79 -66.66,-124.1 -23.11,-46.3 -40.75,-87.08 -61.13,-131.88 -20.37,-44.8 -43.48,-93.63 -73.05,-71.06 -29.57,22.56 -65.6,116.49 -95.71,187.21 -30.1,70.71 -54.3,118.2 -66.4,141.95 -12.1,23.74 -12.1,23.74 -12.1,23.74 0,0 0,0 -36.04,5.26 -36.03,5.25 -108.1,15.76 -187.6,26.12 -79.5,10.37 -166.42,20.58 -177.47,52.05 -11.04,31.46 53.79,84.19 113.27,132.93 59.48,48.74 113.6,93.5 140.66,115.88 27.06,22.38 27.06,22.38 27.06,22.38 0,0 0,0 -4.8,29.82 -4.81,29.82 -14.42,89.45 -29.87,166.02 -15.45,76.57 -36.73,170.06 -13.79,197.73 22.93,27.66 90.08,-10.5 157.94,-50.54 67.86,-40.04 136.43,-81.95 170.71,-102.91 34.28,-20.96 34.28,-20.96 34.28,-20.96 0,0 0,0 29.08,17.71 29.07,17.71 87.22,53.13 150.46,92.03 63.23,38.89 131.56,81.25 159.24,64.16 27.68,-17.1 14.73,-93.67 3.9,-169.09 -10.82,-75.42 -19.51,-149.7 -23.86,-186.84 -4.35,-37.13 -4.35,-37.13 58.7,-87.16 63.04,-50.02 189.13,-150.07 193.62,-207.02 4.48,-56.94 -112.64,-70.78 -173.29,-77.95 -60.65,-7.17 -64.83,-7.68 -79.61,-9.01 -14.78,-1.33 -40.17,-3.48 -61.94,-5.33 -21.77,-1.86 -39.91,-3.4 -49.64,-4.23 -9.72,-0.82 -11.01,-0.94 -25.96,-26.91z"/>
</g>
<ellipse class="fil0" cx="2140.85" cy="2197.59" rx="1194.99" ry="1178.43"/>
<circle class="fil4" cx="2124.15" cy="2207.61" r="923.37"/>
<path class="fil1 str2" d="M2788.24 3393.56c-11.75,24.43 -34.95,73.29 -54.62,123.82 -19.67,50.52 -35.81,102.71 -53.14,186.31 -17.34,83.6 -35.86,198.63 -29.23,317.47 6.63,118.84 38.41,241.5 66,328.24 27.59,86.75 50.98,137.57 62.68,162.99 11.7,25.41 11.7,25.41 11.7,25.41 0,0 0,0 -278.03,0 -278.03,0 -834.08,0 -1112.11,0 -278.02,0 -278.02,0 -291.55,0.23 -13.53,0.23 -40.59,0.69 -75.21,-7.93 -34.61,-8.62 -76.78,-26.31 -107.12,-64.94 -30.33,-38.64 -48.83,-98.21 -57.28,-137.12 -8.46,-38.92 -6.87,-57.19 -6.07,-82.24 0.79,-25.05 0.79,-56.9 0.79,-80.43 0,-23.54 0,-38.77 1.5,-69.12 1.5,-30.35 4.5,-75.83 9.34,-112.5 4.84,-36.68 11.52,-64.54 44.17,-133.73 32.66,-69.18 91.29,-179.68 171.72,-260.68 80.45,-81 182.69,-132.49 273.98,-164.28 91.29,-31.78 171.63,-43.86 218.71,-49.89 47.09,-6.04 60.94,-6.04 74.09,-6.04 13.15,0 25.61,0 212.92,0 187.31,0 549.47,0 730.87,0 181.4,0 182.04,0 182.71,0 0.67,0 1.37,0 3.04,0 1.68,0 4.31,0 6.31,0 2.01,0 3.37,0 3.98,0 0.62,0 0.48,0 0.72,0 0.24,0 0.86,0 1.02,0 0.16,0 -0.14,0 -11.89,24.43z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+6
View File
@@ -8,6 +8,12 @@ body,
margin: 0;
padding: 0;
overflow: hidden;
user-select: none; /* 禁用文本选择 */
}
/* 如果某些输入框需要允许选择,可以单独开启 */
input, textarea {
user-select: text;
}
.ss-sidebar {
+104
View File
@@ -0,0 +1,104 @@
import { Layout, Space, Button, Tag } from 'tdesign-react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { Home } from './Home'
import { StudentManager } from './StudentManager'
import { Settings } from './Settings'
import { ReasonManager } from './ReasonManager'
import { ScoreManager } from './ScoreManager'
import { Leaderboard } from './Leaderboard'
import { SettlementHistory } from './SettlementHistory'
import { WindowControls } from './WindowControls'
const { Content } = Layout
interface ContentAreaProps {
permission: 'admin' | 'points' | 'view'
hasAnyPassword: boolean
onAuthClick: () => void
onLogout: () => void
}
export function ContentArea({
permission,
hasAnyPassword,
onAuthClick,
onLogout
}: ContentAreaProps): React.JSX.Element {
const permissionTag = (
<Tag
theme={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
variant="light"
>
{permission === 'admin' ? '管理权限' : permission === 'points' ? '积分权限' : '只读'}
</Tag>
)
return (
<Layout
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
backgroundColor: 'var(--ss-bg-color)'
}}
>
<div
style={
{
height: '32px',
WebkitAppRegion: 'drag',
display: 'flex',
alignItems: 'center',
backgroundColor: 'var(--ss-header-bg)',
borderBottom: '1px solid var(--ss-border-color)',
flexShrink: 0
} as React.CSSProperties
}
>
<div style={{ flex: 1 }} />
<div
style={
{
display: 'flex',
alignItems: 'center',
paddingRight: '0px',
WebkitAppRegion: 'no-drag'
} as React.CSSProperties
}
>
<Space size="small" style={{ marginRight: '12px' }}>
{permissionTag}
{hasAnyPassword && (
<>
<Button size="small" variant="outline" onClick={onAuthClick}>
</Button>
<Button size="small" variant="outline" theme="danger" onClick={onLogout}>
</Button>
</>
)}
</Space>
<WindowControls />
</div>
</div>
<Content style={{ flex: 1, overflowY: 'auto' }}>
<Routes>
<Route path="/" element={<Home canEdit={permission === 'admin' || permission === 'points'} />} />
<Route path="/students" element={<StudentManager canEdit={permission === 'admin'} />} />
<Route
path="/score"
element={<ScoreManager canEdit={permission === 'admin' || permission === 'points'} />}
/>
<Route path="/leaderboard" element={<Leaderboard />} />
<Route path="/settlements" element={<SettlementHistory />} />
<Route path="/reasons" element={<ReasonManager canEdit={permission === 'admin'} />} />
<Route path="/settings" element={<Settings permission={permission} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Content>
</Layout>
)
}
+787
View File
@@ -0,0 +1,787 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import {
Card,
Space,
Button,
Tag,
Input,
Select,
Dialog,
MessagePlugin,
InputNumber,
Divider
} from 'tdesign-react'
import { SearchIcon, AddIcon, MinusIcon, DeleteIcon } from 'tdesign-icons-react'
import { match, pinyin } from 'pinyin-pro'
interface student {
id: number
name: string
score: number
}
interface reason {
id: number
content: string
delta: number
category: string
}
type SortType = 'alphabet' | 'surname' | 'score'
export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [students, setStudents] = useState<student[]>([])
const [reasons, setReasons] = useState<reason[]>([])
const [loading, setLoading] = useState(false)
const [sortType, setSortType] = useState<SortType>('alphabet')
const [searchKeyword, setSearchKeyword] = useState('')
// 滚动容器引用
const scrollContainerRef = useRef<HTMLDivElement>(null)
const groupRefs = useRef<Record<string, HTMLDivElement | null>>({})
// 操作框状态
const [selectedStudent, setSelectedStudent] = useState<student | null>(null)
const [operationVisible, setOperationVisible] = useState(false)
const [customScore, setCustomScore] = useState<number | undefined>(undefined)
const [reasonContent, setReasonContent] = useState('')
const [submitLoading, setSubmitLoading] = useState(false)
const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
}
const fetchData = useCallback(async (silent = false) => {
if (!(window as any).api) return
if (!silent) setLoading(true)
const [stuRes, reaRes] = await Promise.all([
(window as any).api.queryStudents({}),
(window as any).api.queryReasons()
])
if (stuRes.success) setStudents(stuRes.data)
if (reaRes.success) setReasons(reaRes.data)
if (!silent) setLoading(false)
}, [])
useEffect(() => {
fetchData()
const onDataUpdated = (e: any) => {
const category = e?.detail?.category
// 仅在学生名单、理由列表或全量更新时才重新拉取数据
// 积分变动(events)现在由本地状态维护,不再触发全量刷新以防止页面跳动
if (category === 'students' || category === 'reasons' || category === 'all') {
fetchData(true)
}
}
window.addEventListener('ss:data-updated', onDataUpdated as any)
return () => window.removeEventListener('ss:data-updated', onDataUpdated as any)
}, [fetchData])
// 获取姓氏
const getSurname = (name: string) => {
if (!name) return ''
return name.charAt(0)
}
// 获取拼音首字母
const getFirstLetter = (name: string) => {
if (!name) return ''
const firstChar = name.charAt(0)
// 如果是英文字母
if (/^[a-zA-Z]$/.test(firstChar)) return firstChar.toUpperCase()
// 如果是中文,转拼音
const py = pinyin(firstChar, { pattern: 'first', toneType: 'none' })
return py ? py.toUpperCase() : '#'
}
// 获取展示用的文字
const getDisplayText = (name: string) => {
if (!name) return ''
return name.length > 2 ? name.substring(name.length - 2) : name
}
// 拼音匹配
const matchStudentName = useCallback((name: string, keyword: string) => {
const q0 = keyword.trim().toLowerCase()
if (!q0) return true
const nameLower = String(name).toLowerCase()
if (nameLower.includes(q0)) return true
const q1 = q0.replace(/\s+/g, '')
if (q1 && nameLower.replace(/\s+/g, '').includes(q1)) return true
try {
const m0 = match(name, q0)
if (Array.isArray(m0)) return true
if (q1 && q1 !== q0) {
const m1 = match(name, q1)
if (Array.isArray(m1)) return true
}
} catch {
return false
}
return false
}, [])
// 过滤和排序学生
const sortedStudents = useMemo(() => {
let filtered = students.filter((s) => matchStudentName(s.name, searchKeyword))
switch (sortType) {
case 'alphabet':
return filtered.sort((a, b) => {
const pyA = pinyin(a.name, { toneType: 'none' })
const pyB = pinyin(b.name, { toneType: 'none' })
return pyA.localeCompare(pyB)
})
case 'surname':
return filtered.sort((a, b) => {
const surnameA = getSurname(a.name)
const surnameB = getSurname(b.name)
if (surnameA === surnameB) {
return a.name.localeCompare(b.name, 'zh-CN')
}
return surnameA.localeCompare(surnameB, 'zh-CN')
})
case 'score':
return filtered.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name, 'zh-CN'))
default:
return filtered
}
}, [students, searchKeyword, sortType, matchStudentName])
// 分组显示
const groupedStudents = useMemo(() => {
if (sortType === 'score' || (sortType === 'alphabet' && searchKeyword)) {
return [{ key: 'all', students: sortedStudents }]
}
const groups: Record<string, student[]> = {}
sortedStudents.forEach((s) => {
const key = sortType === 'alphabet' ? getFirstLetter(s.name) : getSurname(s.name)
if (!groups[key]) groups[key] = []
groups[key].push(s)
})
return Object.entries(groups)
.sort(([a], [b]) => a.localeCompare(b, 'zh-CN'))
.map(([key, students]) => ({ key, students }))
}, [sortedStudents, sortType, searchKeyword])
// 按分类分组的理由
const groupedReasons = useMemo(() => {
const groups: Record<string, reason[]> = {}
reasons.forEach((r) => {
const cat = r.category || '其他'
if (!groups[cat]) groups[cat] = []
groups[cat].push(r)
})
return Object.entries(groups).sort(([a], [b]) => {
if (a === '其他') return 1
if (b === '其他') return -1
return a.localeCompare(b, 'zh-CN')
})
}, [reasons])
// 生成头像颜色
const getAvatarColor = (name: string) => {
const colors = [
'#FF6B6B',
'#4ECDC4',
'#45B7D1',
'#FFA07A',
'#98D8C8',
'#F7DC6F',
'#BB8FCE',
'#85C1E2',
'#F8B739',
'#52B788'
]
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
const index = Math.abs(hash) % colors.length
return colors[index]
}
// 跳转到指定分组
const scrollToGroup = (key: string) => {
const element = groupRefs.current[key]
if (element) {
element.scrollIntoView({ behavior: 'auto', block: 'start' })
}
}
// 打开操作框
const openOperation = (student: student) => {
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
return
}
setSelectedStudent(student)
setCustomScore(undefined)
setReasonContent('')
setOperationVisible(true)
}
// 核心提交逻辑
const performSubmit = async (student: student, delta: number, content: string) => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
return
}
setSubmitLoading(true)
const res = await (window as any).api.createEvent({
student_name: student.name,
reason_content: content,
delta: delta
})
if (res.success) {
MessagePlugin.success(`已为 ${student.name} ${delta > 0 ? '加' : '扣'}${Math.abs(delta)}`)
setOperationVisible(false)
// 【核心改进】本地增量更新分数,避免全量刷新导致的闪烁和滚动重置
setStudents((prev) =>
prev.map((s) => (s.id === student.id ? { ...s, score: s.score + delta } : s))
)
// 通知其他组件数据已更新(但不在此处重复 fetchData)
emitDataUpdated('events')
} else {
MessagePlugin.error(res.message || '提交失败')
}
setSubmitLoading(false)
}
// 手动点击确定按钮提交(用于自定义分值)
const handleSubmit = async () => {
if (!selectedStudent) return
const delta = customScore
if (delta === undefined || !Number.isFinite(delta)) {
MessagePlugin.warning('请选择或输入分值')
return
}
const content = reasonContent || (delta > 0 ? '加分' : delta < 0 ? '扣分' : '积分变更')
await performSubmit(selectedStudent, delta, content)
}
// 快捷理由选择:点击即提交
const handleReasonSelect = (reason: reason) => {
if (!selectedStudent) return
performSubmit(selectedStudent, reason.delta, reason.content)
}
// 渲染学生卡片
const renderStudentCard = (student: student, index: number) => {
const avatarText = getDisplayText(student.name)
const avatarColor = getAvatarColor(student.name)
// 排行榜勋章
let rankBadge: string | null = null
if (sortType === 'score' && !searchKeyword) {
if (index === 0) rankBadge = '🥇'
else if (index === 1) rankBadge = '🥈'
else if (index === 2) rankBadge = '🥉'
}
return (
<div
key={student.id}
onClick={() => openOperation(student)}
style={{ cursor: 'pointer', position: 'relative' }}
>
<Card
style={{
backgroundColor: 'var(--ss-card-bg)',
transition: 'all 0.2s cubic-bezier(0.38, 0, 0.24, 1)',
border: '1px solid var(--ss-border-color)',
overflow: 'visible'
}}
hover
>
{rankBadge && (
<div
style={{
position: 'absolute',
top: '-10px',
left: '-10px',
fontSize: '24px',
zIndex: 1
}}
>
{rankBadge}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '44px',
height: '44px',
borderRadius: '12px',
backgroundColor: avatarColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 'bold',
fontSize: avatarText.length > 1 ? '14px' : '18px',
flexShrink: 0,
boxShadow: `0 4px 10px ${avatarColor}40`
}}
>
{avatarText}
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
<div
style={{
fontWeight: 600,
fontSize: '15px',
color: 'var(--ss-text-main)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{student.name}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px' }}>
<Tag
theme={student.score > 0 ? 'success' : student.score < 0 ? 'danger' : 'default'}
variant="light-outline"
size="small"
style={{ fontWeight: 'bold' }}
>
{student.score > 0 ? `+${student.score}` : student.score}
</Tag>
</div>
</div>
</div>
</Card>
</div>
)
}
// 渲染分组学生卡片
const renderGroupedCards = () => {
return groupedStudents.map((group) => (
<div
key={group.key}
style={{ marginBottom: '32px' }}
ref={(el) => (groupRefs.current[group.key] = el)}
>
{group.key !== 'all' && (
<div
style={{
fontSize: '18px',
fontWeight: 'bold',
color: 'var(--ss-text-main)',
marginBottom: '16px',
paddingLeft: '4px',
display: 'flex',
alignItems: 'center',
gap: '8px',
borderLeft: '4px solid var(--td-brand-color)',
paddingLeft: '12px'
}}
>
<span style={{ color: 'var(--td-brand-color)' }}>{group.key}</span>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', fontWeight: 'normal' }}>
({group.students.length} )
</span>
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
gap: '16px'
}}
>
{group.students.map((student, idx) => renderStudentCard(student, idx))}
</div>
</div>
))
}
// 快速导航滑动处理
const navContainerRef = useRef<HTMLDivElement>(null)
const isNavDragging = useRef(false)
const handleNavAction = useCallback((clientY: number) => {
if (!navContainerRef.current) return
const rect = navContainerRef.current.getBoundingClientRect()
const y = clientY - rect.top
const items = navContainerRef.current.children
const itemCount = items.length
if (itemCount === 0) return
// 计算当前指向第几个项
const itemHeight = rect.height / itemCount
const index = Math.floor(y / itemHeight)
const safeIndex = Math.max(0, Math.min(itemCount - 1, index))
const targetGroup = groupedStudents[safeIndex]
if (targetGroup) {
scrollToGroup(targetGroup.key)
}
}, [groupedStudents])
const onNavMouseDown = (e: React.MouseEvent) => {
isNavDragging.current = true
handleNavAction(e.clientY)
document.addEventListener('mousemove', onGlobalMouseMove)
document.addEventListener('mouseup', onGlobalMouseUp)
}
const onGlobalMouseMove = (e: MouseEvent) => {
if (isNavDragging.current) {
handleNavAction(e.clientY)
}
}
const onGlobalMouseUp = () => {
isNavDragging.current = false
document.removeEventListener('mousemove', onGlobalMouseMove)
document.removeEventListener('mouseup', onGlobalMouseUp)
}
// 渲染快速导航
const renderQuickNav = () => {
if (groupedStudents.length <= 1 || sortType === 'score' || (sortType === 'alphabet' && searchKeyword)) return null
return (
<div
ref={navContainerRef}
onMouseDown={onNavMouseDown}
style={{
position: 'fixed',
right: '12px',
top: '50%',
transform: 'translateY(-50%)',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--ss-card-bg)',
padding: '8px 4px',
borderRadius: '20px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
zIndex: 100,
maxHeight: '80vh',
border: '1px solid var(--ss-border-color)',
cursor: 'pointer',
userSelect: 'none'
}}
>
{groupedStudents.map((group) => (
<div
key={group.key}
style={{
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
fontWeight: 'bold',
color: 'var(--td-brand-color)',
borderRadius: '50%',
pointerEvents: 'none' // 让事件由父容器统一处理
}}
>
{group.key}
</div>
))}
</div>
)
}
return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto', position: 'relative' }}>
{/* 顶部工具栏 */}
<div
style={{
marginBottom: '32px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '16px',
flexWrap: 'wrap'
}}
>
<div>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)', fontSize: '24px' }}></h2>
<p style={{ margin: '4px 0 0', color: 'var(--ss-text-secondary)', fontSize: '13px' }}>
{students.length}
</p>
</div>
<Space size="medium">
{/* 搜索 */}
<Input
value={searchKeyword}
onChange={setSearchKeyword}
placeholder="搜索姓名/拼音..."
prefixIcon={<SearchIcon />}
clearable
style={{ width: '220px' }}
/>
{/* 排序方式 */}
<Select
value={sortType}
onChange={(v) => setSortType(v as SortType)}
style={{ width: '140px' }}
autoWidth
>
<Select.Option value="alphabet" label="姓名排序" />
<Select.Option value="surname" label="姓氏分组" />
<Select.Option value="score" label="积分排行" />
</Select>
</Space>
</div>
{/* 快速导航 */}
{renderQuickNav()}
{/* 学生卡片网格 */}
<div style={{ minHeight: '400px' }} ref={scrollContainerRef}>
{loading ? (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<div style={{ color: 'var(--ss-text-secondary)' }}>...</div>
</div>
) : sortedStudents.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '100px 0',
backgroundColor: 'var(--ss-card-bg)',
borderRadius: '12px',
border: '1px dashed var(--ss-border-color)'
}}
>
<div style={{ fontSize: '16px', color: 'var(--ss-text-secondary)' }}>
{searchKeyword ? '未找到匹配的学生' : '暂无学生数据,请前往学生管理添加'}
</div>
{searchKeyword && (
<Button variant="text" theme="primary" onClick={() => setSearchKeyword('')} style={{ marginTop: '8px' }}>
</Button>
)}
</div>
) : (
renderGroupedCards()
)}
</div>
{/* 操作框 */}
<Dialog
header={`积分操作:${selectedStudent?.name}`}
visible={operationVisible}
onClose={() => setOperationVisible(false)}
onConfirm={handleSubmit}
confirmBtn={{ content: '提交操作', loading: submitLoading }}
width="560px"
destroyOnClose
top="10%"
>
{selectedStudent && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', padding: '8px 0' }}>
{/* 当前状态 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
backgroundColor: 'var(--ss-bg-color)',
borderRadius: '8px'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
backgroundColor: getAvatarColor(selectedStudent.name),
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontWeight: 'bold'
}}
>
{getDisplayText(selectedStudent.name)}
</div>
<span style={{ fontWeight: 600 }}>{selectedStudent.name}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ color: 'var(--ss-text-secondary)', fontSize: '13px' }}></span>
<Tag
theme={selectedStudent.score > 0 ? 'success' : selectedStudent.score < 0 ? 'danger' : 'default'}
variant="light"
style={{ fontWeight: 'bold' }}
>
{selectedStudent.score > 0 ? `+${selectedStudent.score}` : selectedStudent.score}
</Tag>
</div>
</div>
{/* 快捷理由 */}
{groupedReasons.length > 0 && (
<div>
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontWeight: 600, fontSize: '14px' }}></span>
<Divider style={{ flex: 1, margin: 0 }} />
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '12px',
maxHeight: '240px',
overflowY: 'auto',
paddingRight: '4px'
}}
>
{groupedReasons.map(([category, items]) => (
<div key={category}>
<div
style={{
fontSize: '12px',
color: 'var(--ss-text-secondary)',
marginBottom: '6px',
paddingLeft: '2px'
}}
>
{category}
</div>
<Space breakLine size="small">
{items.map((r) => (
<Button
key={r.id}
variant="outline"
size="small"
onClick={() => handleReasonSelect(r)}
style={{
borderColor: r.delta > 0 ? 'var(--td-success-color-3)' : r.delta < 0 ? 'var(--td-error-color-3)' : undefined
}}
>
{r.content}{' '}
<span
style={{
marginLeft: '4px',
color: r.delta > 0 ? 'var(--td-success-color)' : r.delta < 0 ? 'var(--td-error-color)' : 'inherit',
fontWeight: 'bold'
}}
>
{r.delta > 0 ? `+${r.delta}` : r.delta}
</span>
</Button>
))}
</Space>
</div>
))}
</div>
</div>
)}
{/* 自定义分值 */}
<div>
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontWeight: 600, fontSize: '14px' }}></span>
<Divider style={{ flex: 1, margin: 0 }} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' }}>
{[-5, -3, -2, -1, 1, 2, 3, 5, 10].map((num) => (
<Button
key={num}
size="small"
variant={customScore === num ? 'base' : 'outline'}
theme={num > 0 ? 'success' : 'danger'}
onClick={() => setCustomScore(num)}
style={{ minWidth: '42px' }}
>
{num > 0 ? `+${num}` : num}
</Button>
))}
<Button
size="small"
variant="outline"
onClick={() => setCustomScore(0)}
style={{ minWidth: '42px' }}
>
0
</Button>
</div>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<InputNumber
value={customScore}
onChange={(v) => setCustomScore(v as number)}
min={-99}
max={99}
step={1}
style={{ width: '140px' }}
placeholder="自定义分值"
/>
<span style={{ fontSize: '13px', color: 'var(--ss-text-secondary)' }}>
</span>
</div>
</div>
{/* 理由内容 */}
<div>
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontWeight: 600, fontSize: '14px' }}></span>
<Divider style={{ flex: 1, margin: 0 }} />
</div>
<Input
value={reasonContent}
onChange={setReasonContent}
placeholder="输入加分/扣分的原因(可选)"
suffixIcon={reasonContent ? <DeleteIcon onClick={() => setReasonContent('')} style={{ cursor: 'pointer' }} /> : undefined}
/>
</div>
{/* 变动预览 */}
{customScore !== undefined && (
<div
style={{
padding: '16px',
backgroundColor: customScore > 0 ? 'var(--td-success-color-1)' : customScore < 0 ? 'var(--td-error-color-1)' : 'var(--ss-bg-color)',
borderRadius: '8px',
border: `1px solid ${customScore > 0 ? 'var(--td-success-color-2)' : customScore < 0 ? 'var(--td-error-color-2)' : 'var(--ss-border-color)'}`,
marginTop: '4px'
}}
>
<div style={{ fontSize: '13px', fontWeight: 600, marginBottom: '4px', color: 'var(--ss-text-main)' }}>
</div>
<div style={{ fontSize: '15px' }}>
{selectedStudent.name}{' '}
<span style={{ fontWeight: 'bold', color: customScore > 0 ? 'var(--td-success-color)' : customScore < 0 ? 'var(--td-error-color)' : 'inherit' }}>
{customScore > 0 ? `+${customScore}` : customScore}
</span>{' '}
<span style={{ color: 'var(--ss-text-secondary)', marginLeft: '8px' }}>
{reasonContent ? `理由:${reasonContent}` : '(无理由)'}
</span>
</div>
</div>
)}
</div>
)}
</Dialog>
</div>
)
}
+27
View File
@@ -501,6 +501,33 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
<div>{(window as any).electron?.process?.versions?.chrome || '-'}</div>
<div style={{ color: 'var(--ss-text-secondary)' }}>Node</div>
<div>{(window as any).electron?.process?.versions?.node || '-'}</div>
<div style={{ color: 'var(--ss-text-secondary)' }}>IPC </div>
<div>
<Tag
theme={(window as any).api ? 'success' : 'danger'}
variant="light"
size="small"
>
{(window as any).api ? '已连接' : '未连接 (Preload 失败)'}
</Tag>
</div>
<div style={{ color: 'var(--ss-text-secondary)' }}></div>
<div>
<Tag variant="outline" size="small">
{import.meta.env.DEV ? 'Development' : 'Production'}
</Tag>
</div>
</div>
<Divider />
<div style={{ marginTop: '16px' }}>
<Button
variant="outline"
onClick={() => {
;(window as any).api?.toggleDevTools()
}}
>
</Button>
</div>
</Card>
</Tabs.TabPanel>
+88
View File
@@ -0,0 +1,88 @@
import { Layout, Menu } from 'tdesign-react'
import { UserIcon, SettingIcon, HistoryIcon, RootListIcon, ViewListIcon, HomeIcon } from 'tdesign-icons-react'
import appLogo from '../assets/logo.svg'
const { Aside } = Layout
interface SidebarProps {
activeMenu: string
permission: 'admin' | 'points' | 'view'
onMenuChange: (value: string | number) => void
}
export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps): React.JSX.Element {
return (
<Aside
className="ss-sidebar"
style={{
backgroundColor: 'var(--ss-sidebar-bg)',
borderRight: '1px solid var(--ss-border-color)',
display: 'flex',
flexDirection: 'column'
}}
>
<div
style={
{
padding: '32px 24px 16px',
textAlign: 'center',
WebkitAppRegion: 'drag',
userSelect: 'none',
flexShrink: 0
} as React.CSSProperties
}
>
<img
src={appLogo}
style={{ width: '48px', height: '48px', marginBottom: '12px' }}
alt="logo"
/>
<h2
style={{
color: 'var(--ss-sidebar-text, var(--ss-text-main))',
margin: 0,
fontSize: '20px'
}}
>
SecScore
</h2>
<div
style={{
fontSize: '12px',
color: 'var(--ss-sidebar-text, var(--ss-text-main))',
opacity: 0.8,
marginTop: '4px'
}}
>
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<Menu value={activeMenu} onChange={onMenuChange} style={{ width: '100%', border: 'none' }}>
<Menu.MenuItem value="home" icon={<HomeIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="students" icon={<UserIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
<Menu.MenuItem value="score" icon={<HistoryIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="leaderboard" icon={<ViewListIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="settlements" icon={<HistoryIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="reasons" icon={<RootListIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
<Menu.MenuItem value="settings" icon={<SettingIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
</Menu>
</div>
</Aside>
)
}
+135
View File
@@ -0,0 +1,135 @@
import { Button } from 'tdesign-react'
import { RemoveIcon, RectangleIcon, CloseIcon, FullscreenExitIcon } from 'tdesign-icons-react'
import { useEffect, useState } from 'react'
import electronLogo from '../assets/electron.svg'
interface TitleBarProps {
children?: React.ReactNode
}
export function TitleBar({ children }: TitleBarProps): React.JSX.Element {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
if (!(window as any).api) return // Check initial state
;(window as any).api.windowIsMaximized().then((v: boolean) => setIsMaximized(v))
// Subscribe to changes
const cleanup = (window as any).api.onWindowMaximizedChanged((maximized: boolean) => {
setIsMaximized(maximized)
})
return cleanup
}, [])
const minimize = () => {
;(window as any).api?.windowMinimize()
}
const maximize = async () => {
if (!(window as any).api) return
const newState = await (window as any).api.windowMaximize()
setIsMaximized(newState)
}
const close = () => {
;(window as any).api?.windowClose()
}
return (
<div
style={
{
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 0 0 16px',
backgroundColor: 'var(--ss-header-bg, #ffffff)',
borderBottom: '1px solid var(--ss-border-color, #e7e7e7)',
WebkitAppRegion: 'drag',
userSelect: 'none',
zIndex: 1000,
transition: 'background-color 0.3s, border-color 0.3s'
} as React.CSSProperties
}
>
<div
style={{
fontSize: '12px',
fontWeight: 600,
color: 'var(--ss-text-main, #000000)',
display: 'flex',
alignItems: 'center',
gap: '8px',
flexShrink: 0
}}
>
<img src={electronLogo} alt="logo" style={{ width: '16px', height: '16px' }} />
<span>SecScore</span>
</div>
<div style={{ flex: 1 }} />
<div
style={
{
display: 'flex',
alignItems: 'center',
height: '100%',
WebkitAppRegion: 'no-drag',
paddingRight: '12px'
} as React.CSSProperties
}
>
{children}
</div>
<div
style={
{
display: 'flex',
alignItems: 'center',
WebkitAppRegion: 'no-drag',
height: '100%',
flexShrink: 0
} as React.CSSProperties
}
>
<Button
variant="text"
shape="square"
onClick={minimize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<RemoveIcon />
</Button>
<Button
variant="text"
shape="square"
onClick={maximize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
{isMaximized ? <FullscreenExitIcon /> : <RectangleIcon />}
</Button>
<Button
variant="text"
shape="square"
onClick={close}
className="titlebar-close-btn"
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<CloseIcon />
</Button>
</div>
<style>
{`
.titlebar-close-btn:hover {
background-color: #e34d59 !important;
color: white !important;
}
`}
</style>
</div>
)
}
@@ -0,0 +1,80 @@
import { Button } from 'tdesign-react'
import { RemoveIcon, RectangleIcon, CloseIcon, FullscreenExitIcon } from 'tdesign-icons-react'
import { useEffect, useState } from 'react'
export function WindowControls(): React.JSX.Element {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
if (!(window as any).api) return // Check initial state
;(window as any).api.windowIsMaximized().then((v: boolean) => setIsMaximized(v))
// Subscribe to changes
const cleanup = (window as any).api.onWindowMaximizedChanged((maximized: boolean) => {
setIsMaximized(maximized)
})
return cleanup
}, [])
const minimize = () => {
;(window as any).api?.windowMinimize()
}
const maximize = async () => {
if (!(window as any).api) return
const newState = await (window as any).api.windowMaximize()
setIsMaximized(newState)
}
const close = () => {
;(window as any).api?.windowClose()
}
return (
<div
style={
{
display: 'flex',
alignItems: 'center',
WebkitAppRegion: 'no-drag',
height: '100%',
flexShrink: 0
} as React.CSSProperties
}
>
<Button
variant="text"
shape="square"
onClick={minimize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<RemoveIcon />
</Button>
<Button
variant="text"
shape="square"
onClick={maximize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
{isMaximized ? <FullscreenExitIcon /> : <RectangleIcon />}
</Button>
<Button
variant="text"
shape="square"
onClick={close}
className="titlebar-close-btn"
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<CloseIcon />
</Button>
<style>
{`
.titlebar-close-btn:hover {
background-color: #e34d59 !important;
color: white !important;
}
`}
</style>
</div>
)
}
+1 -5
View File
@@ -1,11 +1,7 @@
function hexToRgb(hex: string): [number, number, number] {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16)
]
? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
: [0, 0, 0]
}
-1
View File
@@ -23,4 +23,3 @@
}
}
}
-1
View File
@@ -23,4 +23,3 @@
}
}
}
-1
View File
@@ -23,4 +23,3 @@
}
}
}
-1
View File
@@ -23,4 +23,3 @@
}
}
}
-1
View File
@@ -23,4 +23,3 @@
}
}
}