mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
@@ -1,6 +1,23 @@
|
||||
# SecScore
|
||||
|
||||
一款本地离线(未来加入远程模式)的“教育积分管理”桌面软件(Electron + React + TypeScript),用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供基础的权限保护与数据备份。
|
||||
<!-- GitHub 徽章 -->
|
||||
<p align="center">
|
||||
<a href="https://github.com/SECTL/SecScore/releases"><img alt="GitHub release (latest by date)" src="https://img.shields.io/github/v/release/SECTL/SecScore?style=flat-square"></a>
|
||||
<a href="https://github.com/SECTL/SecScore/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/SECTL/SecScore/ci.yml?style=flat-square&logo=github&label=CI"></a>
|
||||
<a href="https://github.com/SECTL/SecScore/releases"><img alt="Downloads" src="https://img.shields.io/github/downloads/SECTL/SecScore/total?style=flat-square"></a>
|
||||
<a href="https://github.com/SECTL/SecScore/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/SECTL/SecScore?style=flat-square"></a>
|
||||
<a href="https://github.com/SECTL/SecScore/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/SECTL/SecScore?style=flat-square"></a>
|
||||
<a href="https://github.com/SECTL/SecScore/pulls"><img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/SECTL/SecScore?style=flat-square"></a>
|
||||
<img alt="Electron" src="https://img.shields.io/badge/Electron-39+-47848F?style=flat-square&logo=electron">
|
||||
<img alt="React" src="https://img.shields.io/badge/React-19+-61DAFB?style=flat-square&logo=react">
|
||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5+-3178C6?style=flat-square&logo=typescript">
|
||||
<img alt="Vite" src="https://img.shields.io/badge/Vite-7+-646CFF?style=flat-square&logo=vite">
|
||||
</p>
|
||||
|
||||
SecScore 是一款教育积分管理软件,基于 Electron + React + TypeScript 开发,用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供权限保护与数据备份。
|
||||
|
||||
|
||||
SecScore 是一款教育积分管理软件,基于 Electron + React + TypeScript 开发,用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供权限保护与数据备份。
|
||||
|
||||
## 主要功能
|
||||
|
||||
|
||||
+5
-3
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "secscore",
|
||||
"version": "1.0.0",
|
||||
"description": "An Electron application with React and TypeScript",
|
||||
"version": "1.0.1",
|
||||
"description": "SecScore – Your Education Points Management Expert",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "example.com",
|
||||
"homepage": "https://electron-vite.org",
|
||||
"homepage": "https://example.org",
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --cache .",
|
||||
"lint:fix": "eslint --cache . --fix",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "pnpm -s typecheck:node && pnpm -s typecheck:web",
|
||||
@@ -25,6 +26,7 @@
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"better-sqlite3": "^12.6.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"mica-electron": "^1.5.16",
|
||||
"pinyin-pro": "^3.27.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
||||
Generated
+21
@@ -20,6 +20,9 @@ importers:
|
||||
chokidar:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
mica-electron:
|
||||
specifier: ^1.5.16
|
||||
version: 1.5.16
|
||||
pinyin-pro:
|
||||
specifier: ^3.27.0
|
||||
version: 3.27.0
|
||||
@@ -753,66 +756,79 @@ packages:
|
||||
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
|
||||
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.55.1':
|
||||
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
|
||||
@@ -2199,6 +2215,9 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mica-electron@1.5.16:
|
||||
resolution: {integrity: sha512-08/o9vv4cr4ozltO2U4/37R70Y6hqAjH2nr7GzpDLAdqCWkeAAge5ZPrzl+ndUDhO6IsoL2gFmpbU17Jj406Zg==}
|
||||
|
||||
mime-db@1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -5568,6 +5587,8 @@ snapshots:
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mica-electron@1.5.16: {}
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
|
||||
@@ -13,19 +13,27 @@ const rawMessage =
|
||||
''
|
||||
const message = String(rawMessage || '').trim()
|
||||
|
||||
// 是否以 release: 开头
|
||||
const isReleasePrefix = /^release:/i.test(message)
|
||||
|
||||
const semverRe = /\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/
|
||||
const versionMatch = message.match(semverRe)
|
||||
const version = versionMatch ? versionMatch[1] : ''
|
||||
|
||||
const buildToken =
|
||||
message.match(/\b(build:(?:win|mac|linux|unpack|all))\b/i)?.[1]?.toLowerCase() ?? ''
|
||||
const buildScript =
|
||||
let buildScript =
|
||||
buildToken === 'build:all'
|
||||
? 'build:all'
|
||||
: buildToken && buildToken.startsWith('build:')
|
||||
? buildToken
|
||||
: ''
|
||||
|
||||
// 如果是 release: 开头且没有显式指定构建脚本,默认为 build:all
|
||||
if (isReleasePrefix && !buildScript) {
|
||||
buildScript = 'build:all'
|
||||
}
|
||||
|
||||
const supportedBuildScripts = new Set(['build:win', 'build:mac', 'build:linux', 'build:unpack'])
|
||||
const hasBuildScript = buildScript
|
||||
? buildScript === 'build:all'
|
||||
@@ -38,7 +46,7 @@ const fail = (text) => {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const hasAnySignal = Boolean(version || buildScript)
|
||||
const hasAnySignal = Boolean(isReleasePrefix || version || buildScript)
|
||||
const shouldSkip = !hasAnySignal
|
||||
|
||||
if (!shouldSkip) {
|
||||
@@ -46,7 +54,7 @@ if (!shouldSkip) {
|
||||
fail(
|
||||
[
|
||||
'未在提交信息中检测到版本号。',
|
||||
'示例:release: v1.2.3 build:win',
|
||||
'示例:release: v1.2.3 build:win 或 release: 1.2.3',
|
||||
'支持格式:v1.2.3 或 1.2.3(可带 -beta.1 等后缀)'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
@@ -12,6 +12,10 @@ for (const name of targets) {
|
||||
console.log(`[clean-db] removed ${filePath}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[clean-db] failed to remove ${filePath}:`, e?.message || e)
|
||||
if (e.code === 'EPERM' || e.code === 'EACCES') {
|
||||
console.warn(`[clean-db] skipped ${filePath}: file in use or permission denied`)
|
||||
} else {
|
||||
console.error(`[clean-db] failed to remove ${filePath}:`, e?.message || e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { BrowserWindow, webContents } from 'electron'
|
||||
import { BrowserWindow, webContents, screen } from 'electron'
|
||||
import type { IpcMainInvokeEvent } from 'electron'
|
||||
import type { settingsKey, settingsSpec, settingChange } from '../../preload/types'
|
||||
import type { permissionLevel } from './PermissionService'
|
||||
@@ -58,7 +58,47 @@ export class SettingsService extends Service {
|
||||
webContents.getAllWebContents().forEach((wc: any) => {
|
||||
wc.setZoomFactor(zoom)
|
||||
})
|
||||
|
||||
const sidebarWindow = BrowserWindow.getAllWindows().find((win) => {
|
||||
try {
|
||||
return win.webContents.getURL().includes('#global-sidebar')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (sidebarWindow && !sidebarWindow.isDestroyed()) {
|
||||
const primaryDisplay = screen.getPrimaryDisplay()
|
||||
const { width, height } = primaryDisplay.workAreaSize
|
||||
const bounds = sidebarWindow.getBounds()
|
||||
|
||||
sidebarWindow.setBounds({
|
||||
x: width - bounds.width,
|
||||
y: Math.floor(height / 2 - bounds.height / 2),
|
||||
width: bounds.width,
|
||||
height: bounds.height
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
window_theme: {
|
||||
kind: 'string',
|
||||
defaultValue: 'auto',
|
||||
writePermission: 'admin',
|
||||
validate: (v) => v === 'auto' || v === 'dark' || v === 'light'
|
||||
},
|
||||
window_effect: {
|
||||
kind: 'string',
|
||||
defaultValue: 'mica',
|
||||
writePermission: 'admin',
|
||||
validate: (v) =>
|
||||
['mica', 'tabbed', 'acrylic', 'blur', 'transparent', 'none'].includes(v as string)
|
||||
},
|
||||
window_radius: {
|
||||
kind: 'string',
|
||||
defaultValue: 'rounded',
|
||||
writePermission: 'admin',
|
||||
validate: (v) => v === 'rounded' || v === 'small' || v === 'square'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ export interface themeConfig {
|
||||
name: string
|
||||
id: string
|
||||
mode: 'light' | 'dark'
|
||||
mica?: {
|
||||
effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none'
|
||||
theme: 'auto' | 'dark' | 'light'
|
||||
radius: 'small' | 'medium' | 'large'
|
||||
}
|
||||
config: {
|
||||
tdesign: Record<string, string>
|
||||
custom: Record<string, string>
|
||||
@@ -78,9 +83,99 @@ export class ThemeService extends Service {
|
||||
return { success: false, message: 'Permission denied' }
|
||||
}
|
||||
this.currentThemeId = themeId
|
||||
this.applyMicaEffect(themeId)
|
||||
this.notifyThemeUpdate()
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('theme:save', async (event, theme: themeConfig) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
try {
|
||||
const filePath = path.join(this.themeDir, `${theme.id}.json`)
|
||||
fs.writeFileSync(filePath, JSON.stringify(theme, null, 2), 'utf-8')
|
||||
this.notifyThemeUpdate()
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, message: String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('theme:delete', async (event, themeId: string) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
if (themeId.startsWith('default-')) {
|
||||
return { success: false, message: 'Cannot delete default themes' }
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = path.join(this.themeDir, `${themeId}.json`)
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
if (this.currentThemeId === themeId) {
|
||||
this.currentThemeId = 'light' // Fallback
|
||||
}
|
||||
this.notifyThemeUpdate()
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, message: String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle(
|
||||
'theme:set-custom',
|
||||
async (
|
||||
event,
|
||||
config: {
|
||||
effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none'
|
||||
theme: 'auto' | 'dark' | 'light'
|
||||
radius: 'small' | 'medium' | 'large'
|
||||
}
|
||||
) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
this.currentThemeId = 'custom'
|
||||
this.applyMicaConfig(config)
|
||||
this.notifyThemeUpdate()
|
||||
return { success: true }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private applyMicaEffect(themeId: string) {
|
||||
const theme = this.getThemeById(themeId)
|
||||
if (!theme?.mica) return
|
||||
|
||||
this.applyMicaConfig(theme.mica)
|
||||
}
|
||||
|
||||
private applyMicaConfig(config: {
|
||||
effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none'
|
||||
theme: 'auto' | 'dark' | 'light'
|
||||
radius: 'small' | 'medium' | 'large'
|
||||
}) {
|
||||
const radiusMap: Record<string, 'rounded' | 'small' | 'square'> = {
|
||||
small: 'small',
|
||||
medium: 'rounded',
|
||||
large: 'rounded'
|
||||
}
|
||||
|
||||
const windows = BrowserWindow.getAllWindows()
|
||||
for (const win of windows) {
|
||||
if (win.isDestroyed()) continue
|
||||
|
||||
this.mainCtx.windows.setMicaEffect(win, config.effect)
|
||||
this.mainCtx.windows.setMicaTheme(win, config.theme)
|
||||
this.mainCtx.windows.setMicaCorner(win, radiusMap[config.radius] || 'rounded')
|
||||
}
|
||||
|
||||
this.mainCtx.settings.setValue('window_effect', config.effect)
|
||||
this.mainCtx.settings.setValue('window_theme', config.theme)
|
||||
this.mainCtx.settings.setValue('window_radius', radiusMap[config.radius] || 'rounded')
|
||||
}
|
||||
|
||||
private getThemeList(): themeConfig[] {
|
||||
|
||||
@@ -3,6 +3,39 @@ import { MainContext } from '../context'
|
||||
import { BrowserWindow, shell, screen } from 'electron'
|
||||
import type { BrowserWindowConstructorOptions } from 'electron'
|
||||
|
||||
let micaElectron: typeof import('mica-electron') | null = null
|
||||
let MicaBrowserWindow: any = null
|
||||
let IS_WINDOWS_11 = false
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const micaModule = require('mica-electron')
|
||||
micaElectron = micaModule
|
||||
MicaBrowserWindow = micaModule.MicaBrowserWindow
|
||||
IS_WINDOWS_11 = micaModule.IS_WINDOWS_11
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('mica-electron not available:', error)
|
||||
}
|
||||
|
||||
interface MicaWindow extends BrowserWindow {
|
||||
setMicaEffect(): void
|
||||
setMicaTabbedEffect(): void
|
||||
setMicaAcrylicEffect(): void
|
||||
setAcrylic(): void
|
||||
setBlur(): void
|
||||
setTransparent(): void
|
||||
setDarkTheme(): void
|
||||
setLightTheme(): void
|
||||
setAutoTheme(): void
|
||||
setRoundedCorner(): void
|
||||
setSmallRoundedCorner(): void
|
||||
setSquareCorner(): void
|
||||
setBorderColor(color: string | null): void
|
||||
setCaptionColor(color: string | null): void
|
||||
setTitleTextColor(color: string | null): void
|
||||
}
|
||||
|
||||
export type windowOpenInput = {
|
||||
key: string
|
||||
title?: string
|
||||
@@ -52,7 +85,6 @@ export class WindowManager extends Service {
|
||||
const existing = this.get(input.key)
|
||||
if (existing) {
|
||||
if (input.route) {
|
||||
// Use soft navigation if window exists to prevent reload
|
||||
existing.webContents.send('app:navigate', input.route)
|
||||
}
|
||||
existing.show()
|
||||
@@ -60,12 +92,14 @@ export class WindowManager extends Service {
|
||||
return existing
|
||||
}
|
||||
|
||||
const win = new BrowserWindow({
|
||||
const baseOptions: BrowserWindowConstructorOptions = {
|
||||
width: 900,
|
||||
height: 670,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
frame: false, // Custom title bar
|
||||
frame: false,
|
||||
transparent: true,
|
||||
backgroundColor: '#00000000',
|
||||
icon: this.opts.icon,
|
||||
title: input.title,
|
||||
webPreferences: {
|
||||
@@ -73,7 +107,15 @@ export class WindowManager extends Service {
|
||||
sandbox: false
|
||||
},
|
||||
...input.options
|
||||
})
|
||||
}
|
||||
|
||||
let win: BrowserWindow
|
||||
if (MicaBrowserWindow) {
|
||||
win = new MicaBrowserWindow(baseOptions)
|
||||
this.applyMicaEffect(win)
|
||||
} else {
|
||||
win = new BrowserWindow(baseOptions)
|
||||
}
|
||||
|
||||
// Special positioning for global sidebar
|
||||
if (input.key === 'global-sidebar') {
|
||||
@@ -121,6 +163,18 @@ export class WindowManager extends Service {
|
||||
win.webContents.send('window:maximized-changed', false)
|
||||
})
|
||||
|
||||
win.on('blur', () => {
|
||||
if (input.key === 'global-sidebar' || input.key === 'main') {
|
||||
this.applyMicaEffect(win)
|
||||
}
|
||||
})
|
||||
|
||||
win.on('focus', () => {
|
||||
if (input.key === 'global-sidebar' || input.key === 'main') {
|
||||
this.applyMicaEffect(win)
|
||||
}
|
||||
})
|
||||
|
||||
win.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
@@ -130,6 +184,159 @@ export class WindowManager extends Service {
|
||||
return win
|
||||
}
|
||||
|
||||
private applyMicaEffect(win: BrowserWindow) {
|
||||
if (!micaElectron) return
|
||||
const micaWin = win as MicaWindow
|
||||
|
||||
const theme = this.mainCtx.settings.getValue('window_theme')
|
||||
switch (theme) {
|
||||
case 'dark':
|
||||
micaWin.setDarkTheme()
|
||||
break
|
||||
case 'light':
|
||||
micaWin.setLightTheme()
|
||||
break
|
||||
default:
|
||||
micaWin.setAutoTheme()
|
||||
}
|
||||
|
||||
const effect = this.mainCtx.settings.getValue('window_effect')
|
||||
switch (effect) {
|
||||
case 'mica':
|
||||
micaWin.setMicaEffect()
|
||||
break
|
||||
case 'tabbed':
|
||||
micaWin.setMicaTabbedEffect()
|
||||
break
|
||||
case 'acrylic':
|
||||
if (IS_WINDOWS_11) {
|
||||
micaWin.setMicaAcrylicEffect()
|
||||
} else {
|
||||
micaWin.setAcrylic()
|
||||
}
|
||||
break
|
||||
case 'blur':
|
||||
if (!IS_WINDOWS_11) {
|
||||
micaWin.setBlur()
|
||||
}
|
||||
break
|
||||
case 'transparent':
|
||||
if (!IS_WINDOWS_11) {
|
||||
micaWin.setTransparent()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const radius = this.mainCtx.settings.getValue('window_radius')
|
||||
switch (radius) {
|
||||
case 'small':
|
||||
micaWin.setSmallRoundedCorner()
|
||||
break
|
||||
case 'square':
|
||||
micaWin.setSquareCorner()
|
||||
break
|
||||
default:
|
||||
micaWin.setRoundedCorner()
|
||||
}
|
||||
}
|
||||
|
||||
public setMicaEffect(
|
||||
win: BrowserWindow,
|
||||
effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none' = 'mica'
|
||||
) {
|
||||
if (!micaElectron) return
|
||||
const micaWin = win as MicaWindow
|
||||
|
||||
switch (effect) {
|
||||
case 'mica':
|
||||
micaWin.setMicaEffect()
|
||||
break
|
||||
case 'tabbed':
|
||||
micaWin.setMicaTabbedEffect()
|
||||
break
|
||||
case 'acrylic':
|
||||
if (IS_WINDOWS_11) {
|
||||
micaWin.setMicaAcrylicEffect()
|
||||
} else {
|
||||
micaWin.setAcrylic()
|
||||
}
|
||||
break
|
||||
case 'blur':
|
||||
if (!IS_WINDOWS_11) {
|
||||
micaWin.setBlur()
|
||||
}
|
||||
break
|
||||
case 'transparent':
|
||||
if (!IS_WINDOWS_11) {
|
||||
micaWin.setTransparent()
|
||||
}
|
||||
break
|
||||
case 'none':
|
||||
if (IS_WINDOWS_11) {
|
||||
micaWin.setMicaEffect()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
public setMicaTheme(win: BrowserWindow, theme: 'auto' | 'dark' | 'light' = 'auto') {
|
||||
if (!micaElectron) return
|
||||
const micaWin = win as MicaWindow
|
||||
|
||||
switch (theme) {
|
||||
case 'dark':
|
||||
micaWin.setDarkTheme()
|
||||
break
|
||||
case 'light':
|
||||
micaWin.setLightTheme()
|
||||
break
|
||||
default:
|
||||
micaWin.setAutoTheme()
|
||||
}
|
||||
}
|
||||
|
||||
public setMicaCorner(win: BrowserWindow, corner: 'rounded' | 'small' | 'square' = 'rounded') {
|
||||
if (!micaElectron) return
|
||||
const micaWin = win as MicaWindow
|
||||
|
||||
switch (corner) {
|
||||
case 'small':
|
||||
micaWin.setSmallRoundedCorner()
|
||||
break
|
||||
case 'square':
|
||||
micaWin.setSquareCorner()
|
||||
break
|
||||
default:
|
||||
micaWin.setRoundedCorner()
|
||||
}
|
||||
}
|
||||
|
||||
public setMicaBorderColor(win: BrowserWindow, color: string | null) {
|
||||
if (!micaElectron) return
|
||||
const micaWin = win as MicaWindow
|
||||
micaWin.setBorderColor(color)
|
||||
}
|
||||
|
||||
public setMicaCaptionColor(win: BrowserWindow, color: string | null) {
|
||||
if (!micaElectron) return
|
||||
const micaWin = win as MicaWindow
|
||||
micaWin.setCaptionColor(color)
|
||||
}
|
||||
|
||||
public setMicaTitleTextColor(win: BrowserWindow, color: string | null) {
|
||||
if (!micaElectron) return
|
||||
const micaWin = win as MicaWindow
|
||||
micaWin.setTitleTextColor(color)
|
||||
}
|
||||
|
||||
public isMicaAvailable(): boolean {
|
||||
return micaElectron !== null
|
||||
}
|
||||
|
||||
public isWindows11(): boolean {
|
||||
return IS_WINDOWS_11
|
||||
}
|
||||
|
||||
public navigate(key: string, route: string) {
|
||||
const win = this.get(key)
|
||||
if (!win) return false
|
||||
@@ -236,7 +443,6 @@ export class WindowManager extends Service {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (win) {
|
||||
const bounds = win.getBounds()
|
||||
// Keep right side pinned
|
||||
const newX = bounds.x + (bounds.width - width)
|
||||
win.setBounds({
|
||||
x: newX,
|
||||
@@ -246,5 +452,33 @@ export class WindowManager extends Service {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('window:mica-effect', (_event, effect: string) => {
|
||||
const win = BrowserWindow.fromWebContents(_event.sender)
|
||||
if (win) {
|
||||
this.setMicaEffect(win, effect as any)
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('window:mica-theme', (_event, theme: string) => {
|
||||
const win = BrowserWindow.fromWebContents(_event.sender)
|
||||
if (win) {
|
||||
this.setMicaTheme(win, theme as any)
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('window:mica-corner', (_event, corner: string) => {
|
||||
const win = BrowserWindow.fromWebContents(_event.sender)
|
||||
if (win) {
|
||||
this.setMicaCorner(win, corner as any)
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('window:mica-info', () => {
|
||||
return {
|
||||
available: this.isMicaAvailable(),
|
||||
isWindows11: this.isWindows11()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@ const api = {
|
||||
getThemes: () => ipcRenderer.invoke('theme:list'),
|
||||
getCurrentTheme: () => ipcRenderer.invoke('theme:current'),
|
||||
setTheme: (themeId: string) => ipcRenderer.invoke('theme:set', themeId),
|
||||
saveTheme: (theme: themeConfig) => ipcRenderer.invoke('theme:save', theme),
|
||||
deleteTheme: (themeId: string) => ipcRenderer.invoke('theme:delete', themeId),
|
||||
setCustomTheme: (config: {
|
||||
effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none'
|
||||
theme: 'auto' | 'dark' | 'light'
|
||||
radius: 'small' | 'medium' | 'large'
|
||||
}) => ipcRenderer.invoke('theme:set-custom', config),
|
||||
onThemeChanged: (callback: (theme: themeConfig) => void) => {
|
||||
const subscription = (_event: any, theme: themeConfig) => callback(theme)
|
||||
ipcRenderer.on('theme:updated', subscription)
|
||||
@@ -86,7 +93,8 @@ const api = {
|
||||
return () => ipcRenderer.removeListener('app:navigate', subscription)
|
||||
},
|
||||
toggleDevTools: () => ipcRenderer.invoke('window:toggle-devtools'),
|
||||
windowResize: (width: number, height: number) => ipcRenderer.invoke('window:resize', width, height),
|
||||
windowResize: (width: number, height: number) =>
|
||||
ipcRenderer.invoke('window:resize', width, height),
|
||||
|
||||
// Logger
|
||||
queryLogs: (lines?: number) => ipcRenderer.invoke('log:query', lines),
|
||||
|
||||
@@ -11,6 +11,11 @@ export interface themeConfig {
|
||||
name: string
|
||||
id: string
|
||||
mode: 'light' | 'dark'
|
||||
mica?: {
|
||||
effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none'
|
||||
theme: 'auto' | 'dark' | 'light'
|
||||
radius: 'small' | 'medium' | 'large'
|
||||
}
|
||||
config: {
|
||||
tdesign: Record<string, string>
|
||||
custom: Record<string, string>
|
||||
@@ -21,6 +26,9 @@ export type settingsSpec = {
|
||||
is_wizard_completed: boolean
|
||||
log_level: logLevel
|
||||
window_zoom: number
|
||||
window_theme: 'auto' | 'dark' | 'light'
|
||||
window_effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none'
|
||||
window_radius: 'rounded' | 'small' | 'square'
|
||||
}
|
||||
|
||||
export type settingsKey = keyof settingsSpec
|
||||
@@ -35,6 +43,13 @@ export interface electronApi {
|
||||
getThemes: () => Promise<ipcResponse<themeConfig[]>>
|
||||
getCurrentTheme: () => Promise<ipcResponse<themeConfig>>
|
||||
setTheme: (themeId: string) => Promise<ipcResponse<void>>
|
||||
saveTheme: (theme: themeConfig) => Promise<ipcResponse<void>>
|
||||
deleteTheme: (themeId: string) => Promise<ipcResponse<void>>
|
||||
setCustomTheme: (config: {
|
||||
effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none'
|
||||
theme: 'auto' | 'dark' | 'light'
|
||||
radius: 'small' | 'medium' | 'large'
|
||||
}) => Promise<ipcResponse<void>>
|
||||
onThemeChanged: (callback: (theme: themeConfig) => void) => () => void
|
||||
|
||||
// DB - Student
|
||||
|
||||
@@ -5,7 +5,9 @@ import { Sidebar } from './components/Sidebar'
|
||||
import { ContentArea } from './components/ContentArea'
|
||||
import { Wizard } from './components/Wizard'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { ThemeEditorProvider } from './contexts/ThemeEditorContext'
|
||||
import { GlobalSidebar } from './components/GlobalSidebar'
|
||||
import { ThemeEditor } from './components/ThemeEditor'
|
||||
|
||||
function MainContent(): React.JSX.Element {
|
||||
const navigate = useNavigate()
|
||||
@@ -17,7 +19,7 @@ function MainContent(): React.JSX.Element {
|
||||
// 统一路径格式进行比对,防止 / 和 /home 导致重复跳转
|
||||
const currentPath = location.pathname === '/' ? '/home' : location.pathname
|
||||
const targetPath = route === '/' ? '/home' : route
|
||||
|
||||
|
||||
if (currentPath !== targetPath) {
|
||||
navigate(route)
|
||||
}
|
||||
@@ -143,12 +145,15 @@ function MainContent(): React.JSX.Element {
|
||||
function App(): React.JSX.Element {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/global-sidebar" element={<GlobalSidebar />} />
|
||||
<Route path="/*" element={<MainContent />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
<ThemeEditorProvider>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/global-sidebar" element={<GlobalSidebar />} />
|
||||
<Route path="/*" element={<MainContent />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
<ThemeEditor />
|
||||
</ThemeEditorProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M347.599 422.773C398.361 422.773 439.511 381.622 439.511 330.861C439.511 280.099 398.361 238.949 347.599 238.949C296.838 238.949 255.687 280.099 255.687 330.861C255.687 381.622 296.838 422.773 347.599 422.773Z" fill="#FFE55C"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M364.35 306.249C363.022 303.941 360.478 299.332 358.422 295.213C356.367 291.096 354.798 287.469 352.986 283.485C351.174 279.501 349.119 275.158 346.49 277.166C343.86 279.172 340.656 287.525 337.978 293.814C335.301 300.103 333.149 304.326 332.073 306.438C330.997 308.549 330.997 308.549 330.997 308.549C330.997 308.549 330.997 308.549 327.792 309.017C324.588 309.484 318.179 310.418 311.109 311.34C304.039 312.262 296.309 313.17 295.326 315.969C294.344 318.766 300.11 323.456 305.399 327.79C310.689 332.125 315.502 336.105 317.908 338.095C320.315 340.086 320.315 340.086 320.315 340.086C320.315 340.086 320.315 340.086 319.888 342.737C319.46 345.389 318.605 350.692 317.231 357.502C315.857 364.311 313.965 372.625 316.005 375.086C318.044 377.546 324.016 374.152 330.051 370.591C336.086 367.031 342.184 363.304 345.232 361.44C348.281 359.576 348.281 359.576 348.281 359.576C348.281 359.576 348.281 359.576 350.867 361.151C353.452 362.725 358.623 365.875 364.247 369.335C369.87 372.793 375.947 376.56 378.408 375.041C380.87 373.52 379.718 366.71 378.755 360.003C377.793 353.296 377.02 346.69 376.633 343.388C376.246 340.086 376.246 340.086 381.854 335.636C387.46 331.188 398.673 322.291 399.072 317.226C399.471 312.162 389.055 310.932 383.661 310.294C378.268 309.656 377.896 309.611 376.582 309.493C375.267 309.374 373.009 309.183 371.073 309.019C369.137 308.853 367.524 308.716 366.659 308.643C365.795 308.57 365.68 308.559 364.35 306.249Z" fill="#FFF8A0"/>
|
||||
<path d="M306.099 90C351.526 90 388.353 126.826 388.354 172.254C388.354 217.682 351.526 254.509 306.099 254.509C290.933 254.509 276.727 250.403 264.529 243.245C282.109 224.549 292.857 199.517 292.857 172.01C292.857 144.657 282.229 119.753 264.825 101.091C276.957 94.0394 291.056 90.0001 306.099 90Z" fill="#5BA3F5"/>
|
||||
<path d="M185.101 255.017C230.453 255.017 267.217 218.252 267.217 172.901C267.217 127.55 230.453 90.7855 185.101 90.7855C139.75 90.7855 102.986 127.55 102.986 172.901C102.986 218.252 139.75 255.017 185.101 255.017Z" fill="#8BC4FA"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M244.159 278.368C243.114 280.54 241.051 284.886 239.302 289.379C237.553 293.872 236.117 298.513 234.576 305.948C233.034 313.382 231.387 323.612 231.977 334.181C232.566 344.749 235.393 355.657 237.846 363.371C240.3 371.086 242.38 375.605 243.42 377.866C244.461 380.125 244.461 380.125 244.461 380.125C244.461 380.125 244.461 380.125 219.735 380.125C195.01 380.125 145.56 380.125 120.835 380.125C96.1108 380.125 96.1108 380.125 94.9076 380.146C93.7044 380.166 91.2979 380.207 88.2191 379.441C85.1413 378.674 81.3911 377.101 78.6929 373.666C75.9957 370.229 74.3505 364.932 73.599 361.471C72.8467 358.01 72.9881 356.385 73.0592 354.158C73.1295 351.93 73.1295 349.098 73.1295 347.005C73.1295 344.912 73.1295 343.557 73.2629 340.858C73.3962 338.159 73.663 334.115 74.0935 330.854C74.5239 327.592 75.1179 325.114 78.0215 318.961C80.926 312.809 86.14 302.982 93.2926 295.779C100.447 288.575 109.539 283.996 117.658 281.169C125.776 278.343 132.921 277.269 137.108 276.732C141.295 276.195 142.527 276.195 143.697 276.195C144.866 276.195 145.974 276.195 162.632 276.195C179.289 276.195 211.496 276.195 227.628 276.195C243.76 276.195 243.817 276.195 243.876 276.195C243.936 276.195 243.998 276.195 244.147 276.195C244.296 276.195 244.53 276.195 244.708 276.195C244.887 276.195 245.008 276.195 245.062 276.195C245.117 276.195 245.105 276.195 245.126 276.195C245.147 276.195 245.202 276.195 245.217 276.195C245.231 276.195 245.204 276.195 244.159 278.368Z" fill="#5BA3F5" stroke="#7BB5F8" stroke-width="0.755906" stroke-miterlimit="22.9256"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M347.599 422.773C398.361 422.773 439.511 381.622 439.511 330.861C439.511 280.099 398.361 238.949 347.599 238.949C296.838 238.949 255.687 280.099 255.687 330.861C255.687 381.622 296.838 422.773 347.599 422.773Z" fill="#FFDA31"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M364.35 306.249C363.022 303.941 360.478 299.332 358.422 295.213C356.367 291.096 354.798 287.469 352.986 283.485C351.174 279.501 349.119 275.158 346.49 277.166C343.86 279.172 340.656 287.525 337.978 293.814C335.301 300.103 333.149 304.326 332.073 306.438C330.997 308.549 330.997 308.549 330.997 308.549C330.997 308.549 330.997 308.549 327.792 309.017C324.588 309.484 318.179 310.418 311.109 311.34C304.039 312.262 296.309 313.17 295.326 315.969C294.344 318.766 300.11 323.456 305.399 327.79C310.689 332.125 315.502 336.105 317.908 338.095C320.315 340.086 320.315 340.086 320.315 340.086C320.315 340.086 320.315 340.086 319.888 342.737C319.46 345.389 318.605 350.692 317.231 357.502C315.857 364.311 313.965 372.625 316.005 375.086C318.044 377.546 324.016 374.152 330.051 370.591C336.086 367.031 342.184 363.304 345.232 361.44C348.281 359.576 348.281 359.576 348.281 359.576C348.281 359.576 348.281 359.576 350.867 361.151C353.452 362.725 358.623 365.875 364.247 369.335C369.87 372.793 375.947 376.56 378.408 375.041C380.87 373.52 379.718 366.71 378.755 360.003C377.793 353.296 377.02 346.69 376.633 343.388C376.246 340.086 376.246 340.086 381.854 335.636C387.46 331.188 398.673 322.291 399.072 317.226C399.471 312.162 389.055 310.932 383.661 310.294C378.268 309.656 377.896 309.611 376.582 309.493C375.267 309.374 373.009 309.183 371.073 309.019C369.137 308.853 367.524 308.716 366.659 308.643C365.795 308.57 365.68 308.559 364.35 306.249Z" fill="#FFF270"/>
|
||||
<path d="M306.099 90C351.526 90 388.353 126.826 388.354 172.254C388.354 217.682 351.526 254.509 306.099 254.509C290.933 254.509 276.727 250.403 264.529 243.245C282.109 224.549 292.857 199.517 292.857 172.01C292.857 144.657 282.229 119.753 264.825 101.091C276.957 94.0394 291.056 90.0001 306.099 90Z" fill="#2389EC"/>
|
||||
<path d="M185.101 255.017C230.453 255.017 267.217 218.252 267.217 172.901C267.217 127.55 230.453 90.7855 185.101 90.7855C139.75 90.7855 102.986 127.55 102.986 172.901C102.986 218.252 139.75 255.017 185.101 255.017Z" fill="#75BDF7"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M244.159 278.368C243.114 280.54 241.051 284.886 239.302 289.379C237.553 293.872 236.117 298.513 234.576 305.948C233.034 313.382 231.387 323.612 231.977 334.181C232.566 344.749 235.393 355.657 237.846 363.371C240.3 371.086 242.38 375.605 243.42 377.866C244.461 380.125 244.461 380.125 244.461 380.125C244.461 380.125 244.461 380.125 219.735 380.125C195.01 380.125 145.56 380.125 120.835 380.125C96.1108 380.125 96.1108 380.125 94.9076 380.146C93.7044 380.166 91.2979 380.207 88.2191 379.441C85.1413 378.674 81.3911 377.101 78.6929 373.666C75.9957 370.229 74.3505 364.932 73.599 361.471C72.8467 358.01 72.9881 356.385 73.0592 354.158C73.1295 351.93 73.1295 349.098 73.1295 347.005C73.1295 344.912 73.1295 343.557 73.2629 340.858C73.3962 338.159 73.663 334.115 74.0935 330.854C74.5239 327.592 75.1179 325.114 78.0215 318.961C80.926 312.809 86.14 302.982 93.2926 295.779C100.447 288.575 109.539 283.996 117.658 281.169C125.776 278.343 132.921 277.269 137.108 276.732C141.295 276.195 142.527 276.195 143.697 276.195C144.866 276.195 145.974 276.195 162.632 276.195C179.289 276.195 211.496 276.195 227.628 276.195C243.76 276.195 243.817 276.195 243.876 276.195C243.936 276.195 243.998 276.195 244.147 276.195C244.296 276.195 244.53 276.195 244.708 276.195C244.887 276.195 245.008 276.195 245.062 276.195C245.117 276.195 245.105 276.195 245.126 276.195C245.147 276.195 245.202 276.195 245.217 276.195C245.231 276.195 245.204 276.195 244.159 278.368Z" fill="#2389EC" stroke="#2789ED" stroke-width="0.755906" stroke-miterlimit="22.9256"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -1,6 +1,15 @@
|
||||
@import 'tdesign-react/dist/reset.css';
|
||||
@import 'tdesign-react/dist/tdesign.css';
|
||||
|
||||
/* 没用过tdesign,有ai或者人类会用顺便帮我改一下 */
|
||||
.t-layout {
|
||||
background-color: var(--ss-bg-color) !important;
|
||||
}
|
||||
.t-default-menu{
|
||||
background-color: transparent !important;
|
||||
}
|
||||
/* 没用过tdesign,有ai或者人类会用顺便帮我改一下 */
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@@ -99,10 +108,10 @@ html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active svg:not([fill='non
|
||||
|
||||
/* Global Sidebar Toggle Button */
|
||||
.global-sidebar-toggle {
|
||||
width: 24px;
|
||||
height: 60px;
|
||||
background-color: var(--ss-card-bg);
|
||||
border-radius: 8px 0 0 8px;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: var(--ss-bg-color);
|
||||
border-radius: 0px 0 0 0px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -126,7 +135,7 @@ html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active svg:not([fill='non
|
||||
.sidebar-content-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backgroundColor: var(--ss-card-bg);
|
||||
background-color: var(--ss-card-bg);
|
||||
border-radius: 12px 0 0 12px;
|
||||
box-shadow: -4px 0 16px rgba(0,0,0,0.15);
|
||||
border: 1px solid var(--ss-border-color);
|
||||
|
||||
@@ -2,14 +2,21 @@ import React, { Suspense, lazy } from 'react'
|
||||
import { Layout, Space, Button, Tag, Loading } from 'tdesign-react'
|
||||
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(() => import('./StudentManager').then(m => ({ default: m.StudentManager })))
|
||||
const Settings = lazy(() => import('./Settings').then(m => ({ default: m.Settings })))
|
||||
const ReasonManager = lazy(() => import('./ReasonManager').then(m => ({ default: m.ReasonManager })))
|
||||
const ScoreManager = lazy(() => import('./ScoreManager').then(m => ({ default: m.ScoreManager })))
|
||||
const Leaderboard = lazy(() => import('./Leaderboard').then(m => ({ default: m.Leaderboard })))
|
||||
const SettlementHistory = lazy(() => import('./SettlementHistory').then(m => ({ default: m.SettlementHistory })))
|
||||
const Home = lazy(() => import('./Home').then((m) => ({ default: m.Home })))
|
||||
const StudentManager = lazy(() =>
|
||||
import('./StudentManager').then((m) => ({ default: m.StudentManager }))
|
||||
)
|
||||
const Settings = lazy(() => import('./Settings').then((m) => ({ default: m.Settings })))
|
||||
const ReasonManager = lazy(() =>
|
||||
import('./ReasonManager').then((m) => ({ default: m.ReasonManager }))
|
||||
)
|
||||
const ScoreManager = lazy(() => import('./ScoreManager').then((m) => ({ default: m.ScoreManager })))
|
||||
const Leaderboard = lazy(() => import('./Leaderboard').then((m) => ({ default: m.Leaderboard })))
|
||||
const SettlementHistory = lazy(() =>
|
||||
import('./SettlementHistory').then((m) => ({ default: m.SettlementHistory }))
|
||||
)
|
||||
|
||||
const { Content } = Layout
|
||||
|
||||
@@ -119,6 +126,7 @@ export function ContentArea({
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Content>
|
||||
<ThemeEditor />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ export const EventHistory: React.FC = () => {
|
||||
loading={loading}
|
||||
bordered
|
||||
hover
|
||||
pagination={{ pageSize: 50, total: data.length, defaultCurrent: 1 }}
|
||||
scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
|
||||
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Button, Tooltip } from 'tdesign-react'
|
||||
import {
|
||||
HomeIcon,
|
||||
@@ -6,12 +6,50 @@ import {
|
||||
UserAddIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronLeftIcon,
|
||||
SettingIcon
|
||||
SettingIcon,
|
||||
CodeIcon
|
||||
} from 'tdesign-icons-react'
|
||||
|
||||
export const GlobalSidebar: React.FC = () => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [showToggle, setShowToggle] = useState(true)
|
||||
const [zoom, setZoom] = useState(1.0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!(window as any).api) return
|
||||
|
||||
// 加载初始缩放值
|
||||
const loadZoom = async () => {
|
||||
const res = await (window as any).api.getSetting('window_zoom')
|
||||
if (res.success && res.data) {
|
||||
setZoom(res.data)
|
||||
}
|
||||
}
|
||||
loadZoom()
|
||||
|
||||
// 监听缩放变化
|
||||
const unsubscribe = (window as any).api.onSettingChanged((change: any) => {
|
||||
if (change?.key === 'window_zoom') {
|
||||
setZoom(change.value)
|
||||
// 缩放变化时,重新应用当前展开/收缩状态的窗口大小
|
||||
if ((window as any).api) {
|
||||
if (expanded) {
|
||||
const width = Math.round(84 * change.value)
|
||||
const height = Math.round(300 * change.value)
|
||||
;(window as any).api.windowResize(width, height)
|
||||
} else {
|
||||
const width = Math.round(24 * change.value)
|
||||
const height = Math.round(300 * change.value)
|
||||
;(window as any).api.windowResize(width, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') unsubscribe()
|
||||
}
|
||||
}, [expanded])
|
||||
|
||||
const handleExpand = () => {
|
||||
// 1. 先隐藏三角
|
||||
@@ -20,7 +58,9 @@ export const GlobalSidebar: React.FC = () => {
|
||||
// 2. 稍后扩大窗口
|
||||
setTimeout(() => {
|
||||
if ((window as any).api) {
|
||||
;(window as any).api.windowResize(84, 300)
|
||||
const width = Math.round(60 * zoom)
|
||||
const height = Math.round(300 * zoom)
|
||||
;(window as any).api.windowResize(width, height)
|
||||
}
|
||||
// 3. 最后显示侧边栏内容
|
||||
setTimeout(() => {
|
||||
@@ -36,7 +76,9 @@ export const GlobalSidebar: React.FC = () => {
|
||||
// 2. 稍后缩小窗口
|
||||
setTimeout(() => {
|
||||
if ((window as any).api) {
|
||||
;(window as any).api.windowResize(24, 300)
|
||||
const width = Math.round(24 * zoom)
|
||||
const height = Math.round(58 * zoom)
|
||||
;(window as any).api.windowResize(width, height)
|
||||
}
|
||||
// 3. 最后重新显示三角(等待透明度动画完成)
|
||||
setTimeout(() => {
|
||||
@@ -54,10 +96,10 @@ export const GlobalSidebar: React.FC = () => {
|
||||
<div
|
||||
style={{
|
||||
height: '100vh',
|
||||
width: '84px',
|
||||
width: `84px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
justifyContent: 'flex-start',
|
||||
overflow: 'hidden',
|
||||
background: 'transparent'
|
||||
}}
|
||||
@@ -66,7 +108,12 @@ export const GlobalSidebar: React.FC = () => {
|
||||
<div
|
||||
onClick={handleExpand}
|
||||
className={`global-sidebar-toggle ${!showToggle ? 'hidden' : ''}`}
|
||||
style={{ willChange: 'opacity, transform' }}
|
||||
style={{
|
||||
willChange: 'opacity, transform',
|
||||
width: `${!expanded ? '100vw' : '0px'}`,
|
||||
height: `100vh`
|
||||
}}
|
||||
hidden={!expanded}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</div>
|
||||
@@ -75,9 +122,11 @@ export const GlobalSidebar: React.FC = () => {
|
||||
<div
|
||||
className={`sidebar-content-area ${expanded ? 'visible' : 'hidden'}`}
|
||||
style={{
|
||||
backgroundColor: 'var(--ss-card-bg)',
|
||||
backgroundColor: 'var(--ss-bg-color)',
|
||||
height: 'fit-content',
|
||||
willChange: 'opacity, transform'
|
||||
willChange: 'opacity, transform',
|
||||
width: `60px`,
|
||||
gap: `12px`
|
||||
}}
|
||||
>
|
||||
{/* 顶部的关闭/收起按钮 */}
|
||||
@@ -90,19 +139,19 @@ export const GlobalSidebar: React.FC = () => {
|
||||
<ChevronRightIcon size="20px" />
|
||||
</Button>
|
||||
|
||||
<Tooltip content="主界面" placement="left">
|
||||
<Tooltip content="主界面" placement="top">
|
||||
<Button shape="circle" variant="text" onClick={openMain}>
|
||||
<HomeIcon size="24px" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="积分操作" placement="left">
|
||||
<Tooltip content="积分操作" placement="top">
|
||||
<Button shape="circle" variant="text" onClick={() => openMain()}>
|
||||
<UserAddIcon size="24px" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="排行榜" placement="left">
|
||||
<Tooltip content="排行榜" placement="top">
|
||||
<Button
|
||||
shape="circle"
|
||||
variant="text"
|
||||
@@ -112,7 +161,7 @@ export const GlobalSidebar: React.FC = () => {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="设置" placement="left">
|
||||
<Tooltip content="设置" placement="top">
|
||||
<Button
|
||||
shape="circle"
|
||||
variant="text"
|
||||
@@ -121,6 +170,18 @@ export const GlobalSidebar: React.FC = () => {
|
||||
<SettingIcon size="24px" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{import.meta.env.DEV && (
|
||||
<Tooltip content="开发者工具" placement="top">
|
||||
<Button
|
||||
shape="circle"
|
||||
variant="text"
|
||||
onClick={() => (window as any).api?.toggleDevTools()}
|
||||
>
|
||||
<CodeIcon size="24px" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -79,7 +79,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
])
|
||||
|
||||
if (stuRes.success) {
|
||||
const enrichedStudents = (stuRes.data as student[]).map(s => ({
|
||||
const enrichedStudents = (stuRes.data as student[]).map((s) => ({
|
||||
...s,
|
||||
pinyinName: pinyin(s.name, { toneType: 'none' }).toLowerCase(),
|
||||
pinyinFirst: getFirstLetter(s.name)
|
||||
@@ -120,7 +120,11 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
if (pyLower.includes(q0)) return true
|
||||
|
||||
const q1 = q0.replace(/\s+/g, '')
|
||||
if (q1 && (nameLower.replace(/\s+/g, '').includes(q1) || pyLower.replace(/\s+/g, '').includes(q1))) return true
|
||||
if (
|
||||
q1 &&
|
||||
(nameLower.replace(/\s+/g, '').includes(q1) || pyLower.replace(/\s+/g, '').includes(q1))
|
||||
)
|
||||
return true
|
||||
|
||||
try {
|
||||
const m0 = match(s.name, q0)
|
||||
@@ -167,7 +171,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
|
||||
const groups: Record<string, student[]> = {}
|
||||
sortedStudents.forEach((s) => {
|
||||
const key = sortType === 'alphabet' ? (s.pinyinFirst || '#') : getSurname(s.name)
|
||||
const key = sortType === 'alphabet' ? s.pinyinFirst || '#' : getSurname(s.name)
|
||||
if (!groups[key]) groups[key] = []
|
||||
groups[key].push(s)
|
||||
})
|
||||
@@ -312,7 +316,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
border: '1px solid var(--ss-border-color)',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
hover
|
||||
>
|
||||
{rankBadge && (
|
||||
<div
|
||||
@@ -382,7 +385,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
<div
|
||||
key={group.key}
|
||||
style={{ marginBottom: '32px' }}
|
||||
ref={(el) => (groupRefs.current[group.key] = el)}
|
||||
ref={(el) => {
|
||||
groupRefs.current[group.key] = el
|
||||
}}
|
||||
>
|
||||
{group.key !== 'all' && (
|
||||
<div
|
||||
@@ -391,7 +396,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--ss-text-main)',
|
||||
marginBottom: '16px',
|
||||
paddingLeft: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
@@ -889,4 +893,4 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,12 +32,17 @@ export const Leaderboard: React.FC = () => {
|
||||
const fetchRankings = useCallback(async () => {
|
||||
if (!(window as any).api) return
|
||||
setLoading(true)
|
||||
const res = await (window as any).api.queryLeaderboard({ range: timeRange })
|
||||
if (res.success && res.data) {
|
||||
setStartTime(res.data.startTime)
|
||||
setData(res.data.rows)
|
||||
try {
|
||||
const res = await (window as any).api.queryLeaderboard({ range: timeRange })
|
||||
if (res.success && res.data) {
|
||||
setStartTime(res.data.startTime)
|
||||
setData(res.data.rows)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch rankings:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [timeRange])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,48 +82,51 @@ export const Leaderboard: React.FC = () => {
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const title = timeRange === 'today' ? '今天' : timeRange === 'week' ? '本周' : '本月'
|
||||
// 使用 requestIdleCallback 或 setTimeout 避免阻塞 UI
|
||||
setTimeout(() => {
|
||||
const title = timeRange === 'today' ? '今天' : timeRange === 'week' ? '本周' : '本月'
|
||||
|
||||
const sanitizeCell = (v: unknown) => {
|
||||
if (typeof v !== 'string') return v
|
||||
if (/^[=+\-@]/.test(v)) return `'${v}`
|
||||
return v
|
||||
}
|
||||
const sanitizeCell = (v: unknown) => {
|
||||
if (typeof v !== 'string') return v
|
||||
if (/^[=+\-@]/.test(v)) return `'${v}`
|
||||
return v
|
||||
}
|
||||
|
||||
const sheetData = [
|
||||
['排名', '姓名', '总积分', `${title}变化`],
|
||||
...data.map((item, index) => [
|
||||
index + 1,
|
||||
sanitizeCell(item.name),
|
||||
item.score,
|
||||
item.range_change
|
||||
])
|
||||
]
|
||||
const sheetData = [
|
||||
['排名', '姓名', '总积分', `${title}变化`],
|
||||
...data.map((item, index) => [
|
||||
index + 1,
|
||||
sanitizeCell(item.name),
|
||||
item.score,
|
||||
item.range_change
|
||||
])
|
||||
]
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(sheetData)
|
||||
ws['!cols'] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }]
|
||||
const ws = XLSX.utils.aoa_to_sheet(sheetData)
|
||||
ws['!cols'] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }]
|
||||
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, '排行榜')
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, '排行榜')
|
||||
|
||||
const xlsxBytes = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
|
||||
const blob = new Blob([xlsxBytes], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
const xlsxBytes = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
|
||||
const blob = new Blob([xlsxBytes], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
|
||||
const link = document.createElement('a')
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute(
|
||||
'download',
|
||||
`排行榜_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
)
|
||||
link.style.visibility = 'hidden'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
MessagePlugin.success('导出成功')
|
||||
const link = document.createElement('a')
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute(
|
||||
'download',
|
||||
`排行榜_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
)
|
||||
link.style.visibility = 'hidden'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
MessagePlugin.success('导出成功')
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const columns: PrimaryTableCol<studentRank>[] = [
|
||||
@@ -215,6 +223,8 @@ export const Leaderboard: React.FC = () => {
|
||||
loading={loading}
|
||||
bordered
|
||||
hover
|
||||
pagination={{ pageSize: 30, total: data.length, defaultCurrent: 1 }}
|
||||
scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
|
||||
className="ss-table-center"
|
||||
style={{ color: 'var(--ss-text-main)' }}
|
||||
/>
|
||||
|
||||
@@ -35,11 +35,16 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const fetchReasons = useCallback(async () => {
|
||||
if (!(window as any).api) return
|
||||
setLoading(true)
|
||||
const res = await (window as any).api.queryReasons()
|
||||
if (res.success && res.data) {
|
||||
setData(res.data)
|
||||
try {
|
||||
const res = await (window as any).api.queryReasons()
|
||||
if (res.success && res.data) {
|
||||
setData(res.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch reasons:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -149,6 +154,8 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
loading={loading}
|
||||
bordered
|
||||
hover
|
||||
pagination={{ pageSize: 50, total: data.length, defaultCurrent: 1 }}
|
||||
scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
|
||||
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -93,17 +93,25 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!(window as any).api) return
|
||||
setLoading(true)
|
||||
const [stuRes, reaRes, eveRes] = await Promise.all([
|
||||
(window as any).api.queryStudents({}),
|
||||
(window as any).api.queryReasons(),
|
||||
(window as any).api.queryEvents({ limit: 10 })
|
||||
])
|
||||
// 使用 setTimeout 避免 UI 阻塞
|
||||
setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [stuRes, reaRes, eveRes] = await Promise.all([
|
||||
(window as any).api.queryStudents({}),
|
||||
(window as any).api.queryReasons(),
|
||||
(window as any).api.queryEvents({ limit: 10 })
|
||||
])
|
||||
|
||||
if (stuRes.success) setStudents(stuRes.data)
|
||||
if (reaRes.success) setReasons(reaRes.data)
|
||||
if (eveRes.success) setEvents(eveRes.data)
|
||||
setLoading(false)
|
||||
if (stuRes.success) setStudents(stuRes.data)
|
||||
if (reaRes.success) setReasons(reaRes.data)
|
||||
if (eveRes.success) setEvents(eveRes.data)
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch data:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, 0)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -328,7 +336,8 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
rowKey="uuid"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{ pageSize: 5, total: events.length }}
|
||||
pagination={{ pageSize: 5, total: events.length, defaultCurrent: 1 }}
|
||||
scroll={{ type: 'virtual' }}
|
||||
style={{ color: 'var(--ss-text-main)' }}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
MessagePlugin
|
||||
} from 'tdesign-react'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { useThemeEditor } from '../contexts/ThemeEditorContext'
|
||||
|
||||
type permissionLevel = 'admin' | 'points' | 'view'
|
||||
type appSettings = {
|
||||
@@ -23,6 +24,7 @@ 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,
|
||||
@@ -261,15 +263,27 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
|
||||
<Form labelWidth={120}>
|
||||
<Form.FormItem label="当前主题">
|
||||
<Select
|
||||
value={currentTheme?.id}
|
||||
onChange={(v) => setTheme(v as string)}
|
||||
style={{ width: '320px' }}
|
||||
>
|
||||
{themes.map((t) => (
|
||||
<Select.Option key={t.id} value={t.id} label={t.name} />
|
||||
))}
|
||||
</Select>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<Select
|
||||
value={currentTheme?.id}
|
||||
onChange={(v) => setTheme(v as string)}
|
||||
style={{ width: '200px' }}
|
||||
>
|
||||
{themes.map((t) => (
|
||||
<Select.Option key={t.id} value={t.id} label={t.name} />
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
theme="default"
|
||||
onClick={() => startEditing(currentTheme || undefined)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="text" theme="primary" onClick={() => startEditing()}>
|
||||
新建主题
|
||||
</Button>
|
||||
</div>
|
||||
</Form.FormItem>
|
||||
|
||||
<Form.FormItem label="界面缩放">
|
||||
|
||||
@@ -36,13 +36,18 @@ export const SettlementHistory: React.FC = () => {
|
||||
const fetchSettlements = useCallback(async () => {
|
||||
if (!(window as any).api) return
|
||||
setLoading(true)
|
||||
const res = await (window as any).api.querySettlements()
|
||||
setLoading(false)
|
||||
if (!res.success) {
|
||||
MessagePlugin.error(res.message || '查询失败')
|
||||
return
|
||||
try {
|
||||
const res = await (window as any).api.querySettlements()
|
||||
if (!res.success) {
|
||||
MessagePlugin.error(res.message || '查询失败')
|
||||
return
|
||||
}
|
||||
setSettlements(res.data || [])
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch settlements:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
setSettlements(res.data || [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,14 +67,19 @@ export const SettlementHistory: React.FC = () => {
|
||||
if (!(window as any).api) return
|
||||
setSelectedId(id)
|
||||
setDetailLoading(true)
|
||||
const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id })
|
||||
setDetailLoading(false)
|
||||
if (!res.success || !res.data) {
|
||||
MessagePlugin.error(res.message || '查询失败')
|
||||
return
|
||||
try {
|
||||
const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id })
|
||||
if (!res.success || !res.data) {
|
||||
MessagePlugin.error(res.message || '查询失败')
|
||||
return
|
||||
}
|
||||
setSelectedSettlement(res.data.settlement)
|
||||
setRows(res.data.rows || [])
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch settlement leaderboard:', e)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
setSelectedSettlement(res.data.settlement)
|
||||
setRows(res.data.rows || [])
|
||||
}
|
||||
|
||||
const columns: PrimaryTableCol<settlementLeaderboardRow>[] = useMemo(
|
||||
@@ -120,6 +130,8 @@ export const SettlementHistory: React.FC = () => {
|
||||
loading={detailLoading}
|
||||
bordered
|
||||
hover
|
||||
pagination={{ pageSize: 50, total: rows.length, defaultCurrent: 1 }}
|
||||
scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
|
||||
style={{ color: 'var(--ss-text-main)' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ViewListIcon,
|
||||
HomeIcon
|
||||
} from 'tdesign-icons-react'
|
||||
import appLogo from '../assets/logo.svg'
|
||||
import appLogo from '../assets/logoHD.svg'
|
||||
|
||||
const { Aside } = Layout
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import { Table, Button, Space, MessagePlugin, Dialog, Form, Input } from 'tdesign-react'
|
||||
import type { PrimaryTableCol } from 'tdesign-react'
|
||||
import * as XLSX from 'xlsx'
|
||||
|
||||
// 创建 XLSX Worker
|
||||
const createXlsxWorker = () => {
|
||||
return new Worker(new URL('../workers/xlsxWorker.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
})
|
||||
}
|
||||
|
||||
interface student {
|
||||
id: number
|
||||
@@ -20,8 +26,17 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const [xlsxAoa, setXlsxAoa] = useState<any[][]>([])
|
||||
const [xlsxSelectedCol, setXlsxSelectedCol] = useState<number | null>(null)
|
||||
const xlsxInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const xlsxWorkerRef = useRef<Worker | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
// 初始化 Worker
|
||||
useEffect(() => {
|
||||
xlsxWorkerRef.current = createXlsxWorker()
|
||||
return () => {
|
||||
xlsxWorkerRef.current?.terminate()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const emitDataUpdated = (category: 'students' | 'all') => {
|
||||
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
|
||||
}
|
||||
@@ -29,11 +44,16 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const fetchStudents = useCallback(async () => {
|
||||
if (!(window as any).api) return
|
||||
setLoading(true)
|
||||
const res = await (window as any).api.queryStudents({})
|
||||
if (res.success && res.data) {
|
||||
setData(res.data)
|
||||
try {
|
||||
const res = await (window as any).api.queryStudents({})
|
||||
if (res.success && res.data) {
|
||||
setData(res.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch students:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -123,30 +143,40 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
|
||||
const parseXlsxFile = async (file: File) => {
|
||||
if (!xlsxWorkerRef.current) {
|
||||
MessagePlugin.error('Worker 未初始化')
|
||||
return
|
||||
}
|
||||
|
||||
setXlsxLoading(true)
|
||||
try {
|
||||
const buf = await file.arrayBuffer()
|
||||
const wb = XLSX.read(buf, { type: 'array' })
|
||||
const firstSheetName = wb.SheetNames?.[0]
|
||||
if (!firstSheetName) {
|
||||
MessagePlugin.error('xlsx 中未找到工作表')
|
||||
return
|
||||
}
|
||||
const ws = wb.Sheets[firstSheetName]
|
||||
const aoa = XLSX.utils.sheet_to_json(ws, { header: 1, raw: false, defval: '' }) as any[][]
|
||||
if (!Array.isArray(aoa) || aoa.length === 0) {
|
||||
MessagePlugin.error('xlsx 内容为空')
|
||||
return
|
||||
|
||||
// 使用 Worker 处理文件解析,避免阻塞主线程
|
||||
xlsxWorkerRef.current.postMessage({
|
||||
type: 'parseXlsx',
|
||||
data: { buffer: buf }
|
||||
})
|
||||
|
||||
// 监听 Worker 消息
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data.type === 'success') {
|
||||
setXlsxFileName(file.name)
|
||||
setXlsxAoa(event.data.data)
|
||||
setXlsxSelectedCol(null)
|
||||
setXlsxVisible(true)
|
||||
setImportVisible(false)
|
||||
setXlsxLoading(false)
|
||||
} else if (event.data.type === 'error') {
|
||||
MessagePlugin.error(event.data.error || '解析 xlsx 失败')
|
||||
setXlsxLoading(false)
|
||||
}
|
||||
xlsxWorkerRef.current?.removeEventListener('message', handleMessage)
|
||||
}
|
||||
|
||||
setXlsxFileName(file.name)
|
||||
setXlsxAoa(aoa)
|
||||
setXlsxSelectedCol(null)
|
||||
setXlsxVisible(true)
|
||||
setImportVisible(false)
|
||||
xlsxWorkerRef.current.addEventListener('message', handleMessage)
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.message || '解析 xlsx 失败')
|
||||
} finally {
|
||||
setXlsxLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -314,6 +344,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
loading={loading}
|
||||
bordered
|
||||
hover
|
||||
pagination={{ pageSize: 50, total: data.length, defaultCurrent: 1 }}
|
||||
scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
|
||||
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import {
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
ColorPicker,
|
||||
Button,
|
||||
Space,
|
||||
Divider,
|
||||
Row,
|
||||
Col
|
||||
} from 'tdesign-react'
|
||||
import { useThemeEditor } from '../contexts/ThemeEditorContext'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { generateColorMap } from '../utils/color'
|
||||
import { themeConfig } from '../../../preload/types'
|
||||
|
||||
const variableGroups = {
|
||||
background: {
|
||||
title: '背景颜色',
|
||||
items: [
|
||||
{ key: '--ss-bg-color', label: '全局背景' },
|
||||
{ key: '--ss-card-bg', label: '卡片背景' },
|
||||
{ key: '--ss-header-bg', label: '顶部栏背景' },
|
||||
{ key: '--ss-sidebar-bg', label: '侧边栏背景' }
|
||||
]
|
||||
},
|
||||
text: {
|
||||
title: '文字颜色',
|
||||
items: [
|
||||
{ key: '--ss-text-main', label: '主要文字' },
|
||||
{ key: '--ss-text-secondary', label: '次要文字' },
|
||||
{ key: '--ss-sidebar-active-text', label: '侧边栏选中文字' }
|
||||
]
|
||||
},
|
||||
border: {
|
||||
title: '边框与分割线',
|
||||
items: [{ key: '--ss-border-color', label: '通用边框' }]
|
||||
},
|
||||
interaction: {
|
||||
title: '交互状态',
|
||||
items: [
|
||||
{ key: '--ss-item-hover', label: '列表悬浮' },
|
||||
{ key: '--ss-sidebar-active-bg', label: '侧边栏选中背景' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export const ThemeEditor: React.FC = () => {
|
||||
const {
|
||||
isEditing,
|
||||
editingTheme,
|
||||
updateEditingTheme,
|
||||
updateConfig,
|
||||
saveEditingTheme,
|
||||
cancelEditing
|
||||
} = useThemeEditor()
|
||||
|
||||
const { currentTheme } = useTheme()
|
||||
|
||||
// 实时预览逻辑
|
||||
useEffect(() => {
|
||||
if (!isEditing || !editingTheme) return
|
||||
|
||||
const applyPreview = (theme: themeConfig) => {
|
||||
const { tdesign, custom } = theme.config
|
||||
const root = document.documentElement
|
||||
|
||||
// 1. 设置 TDesign 模式
|
||||
root.setAttribute('theme-mode', theme.mode)
|
||||
|
||||
// 2. 设置 TDesign 品牌色
|
||||
if (tdesign.brandColor) {
|
||||
const colorMap = generateColorMap(tdesign.brandColor, theme.mode)
|
||||
Object.entries(colorMap).forEach(([key, value]) => {
|
||||
root.style.setProperty(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 应用自定义变量
|
||||
Object.entries(custom).forEach(([key, value]) => {
|
||||
root.style.setProperty(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
applyPreview(editingTheme)
|
||||
}, [editingTheme, isEditing])
|
||||
|
||||
// 关闭时恢复原有主题
|
||||
useEffect(() => {
|
||||
if (!isEditing && currentTheme) {
|
||||
const { tdesign, custom } = currentTheme.config
|
||||
const root = document.documentElement
|
||||
|
||||
root.setAttribute('theme-mode', currentTheme.mode)
|
||||
|
||||
if (tdesign.brandColor) {
|
||||
const colorMap = generateColorMap(tdesign.brandColor, currentTheme.mode)
|
||||
Object.entries(colorMap).forEach(([key, value]) => {
|
||||
root.style.setProperty(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
Object.entries(custom).forEach(([key, value]) => {
|
||||
root.style.setProperty(key, value)
|
||||
})
|
||||
}
|
||||
}, [isEditing, currentTheme])
|
||||
|
||||
if (!editingTheme) return null
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
header="编辑主题"
|
||||
visible={isEditing}
|
||||
onClose={cancelEditing}
|
||||
size="500px"
|
||||
footer={
|
||||
<Space>
|
||||
<Button theme="primary" onClick={saveEditingTheme}>
|
||||
保存主题
|
||||
</Button>
|
||||
<Button theme="default" onClick={cancelEditing}>
|
||||
取消
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form labelAlign="top">
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
{/* 基本信息 */}
|
||||
<div>
|
||||
<Divider align="left">基本信息</Divider>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={12}>
|
||||
<Form.FormItem label="主题名称">
|
||||
<Input
|
||||
value={editingTheme.name}
|
||||
onChange={(v) => updateEditingTheme({ name: v })}
|
||||
placeholder="请输入主题名称"
|
||||
/>
|
||||
</Form.FormItem>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.FormItem label="色彩模式">
|
||||
<Select
|
||||
value={editingTheme.mode}
|
||||
onChange={(v) => updateEditingTheme({ mode: v as 'light' | 'dark' })}
|
||||
options={[
|
||||
{ label: '浅色 (Light)', value: 'light' },
|
||||
{ label: '深色 (Dark)', value: 'dark' }
|
||||
]}
|
||||
/>
|
||||
</Form.FormItem>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.FormItem label="主题 ID (唯一标识)" help="建议使用英文,如 my-theme">
|
||||
<Input
|
||||
value={editingTheme.id}
|
||||
onChange={(v) => updateEditingTheme({ id: v })}
|
||||
placeholder="请输入主题 ID"
|
||||
/>
|
||||
</Form.FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* TDesign 品牌色 */}
|
||||
<div>
|
||||
<Divider align="left">品牌色 (Brand)</Divider>
|
||||
<Form.FormItem label="主品牌色" help="将自动生成一系列色阶">
|
||||
<ColorPicker
|
||||
value={editingTheme.config.tdesign.brandColor}
|
||||
onChange={(v) => updateConfig('tdesign', 'brandColor', v)}
|
||||
enableAlpha={false}
|
||||
format="HEX"
|
||||
/>
|
||||
</Form.FormItem>
|
||||
</div>
|
||||
|
||||
{/* 业务自定义变量 */}
|
||||
<div>
|
||||
<Divider align="left">界面配色 (Custom)</Divider>
|
||||
{Object.entries(variableGroups).map(([groupKey, group]) => (
|
||||
<div key={groupKey} style={{ marginBottom: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
marginBottom: '12px',
|
||||
color: 'var(--td-text-color-primary)'
|
||||
}}
|
||||
>
|
||||
{group.title}
|
||||
</div>
|
||||
<Row gutter={[12, 12]}>
|
||||
{group.items.map((item) => (
|
||||
<Col span={6} key={item.key}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--td-text-color-secondary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
<ColorPicker
|
||||
value={editingTheme.config.custom[item.key] || '#ffffff'}
|
||||
onChange={(v) => updateConfig('custom', item.key, v)}
|
||||
enableAlpha
|
||||
format="HEX"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Space>
|
||||
</Form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -109,7 +109,11 @@ export function TitleBar({ children }: TitleBarProps): React.JSX.Element {
|
||||
onClick={maximize}
|
||||
style={{ width: '46px', height: '32px', borderRadius: 0 }}
|
||||
>
|
||||
{isMaximized ? <FullscreenExitIcon /> : <RectangleIcon />}
|
||||
{isMaximized ? (
|
||||
<FullscreenExitIcon style={{ transform: 'scale(0.5)' }} />
|
||||
) : (
|
||||
<RectangleIcon />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
|
||||
@@ -56,7 +56,11 @@ export function WindowControls(): React.JSX.Element {
|
||||
onClick={maximize}
|
||||
style={{ width: '46px', height: '32px', borderRadius: 0 }}
|
||||
>
|
||||
{isMaximized ? <FullscreenExitIcon /> : <RectangleIcon />}
|
||||
{isMaximized ? (
|
||||
<FullscreenExitIcon />
|
||||
) : (
|
||||
<RectangleIcon style={{ transform: 'scale(0.7)' }} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react'
|
||||
import { themeConfig } from '../../../preload/types'
|
||||
import { useTheme } from './ThemeContext'
|
||||
|
||||
interface ThemeEditorContextType {
|
||||
isEditing: boolean
|
||||
editingTheme: themeConfig | null
|
||||
startEditing: (theme?: themeConfig) => void
|
||||
updateEditingTheme: (updates: Partial<themeConfig>) => void
|
||||
updateConfig: (type: 'tdesign' | 'custom', key: string, value: string) => void
|
||||
saveEditingTheme: () => Promise<void>
|
||||
cancelEditing: () => void
|
||||
}
|
||||
|
||||
const ThemeEditorContext = createContext<ThemeEditorContextType | undefined>(undefined)
|
||||
|
||||
export const ThemeEditorProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { currentTheme } = useTheme()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editingTheme, setEditingTheme] = useState<themeConfig | null>(null)
|
||||
|
||||
const startEditing = useCallback(
|
||||
(theme?: themeConfig) => {
|
||||
if (theme) {
|
||||
setEditingTheme(JSON.parse(JSON.stringify(theme)))
|
||||
} else if (currentTheme) {
|
||||
const newTheme: themeConfig = JSON.parse(JSON.stringify(currentTheme))
|
||||
newTheme.id = `custom-${Date.now()}`
|
||||
newTheme.name = '新自定义主题'
|
||||
setEditingTheme(newTheme)
|
||||
}
|
||||
setIsEditing(true)
|
||||
},
|
||||
[currentTheme]
|
||||
)
|
||||
|
||||
const updateEditingTheme = useCallback((updates: Partial<themeConfig>) => {
|
||||
setEditingTheme((prev) => (prev ? { ...prev, ...updates } : null))
|
||||
}, [])
|
||||
|
||||
const updateConfig = useCallback((type: 'tdesign' | 'custom', key: string, value: string) => {
|
||||
setEditingTheme((prev) => {
|
||||
if (!prev) return null
|
||||
const next = { ...prev }
|
||||
next.config = { ...next.config }
|
||||
next.config[type] = { ...next.config[type], [key]: value }
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const saveEditingTheme = useCallback(async () => {
|
||||
if (!editingTheme) return
|
||||
const res = await (window as any).api.saveTheme(editingTheme)
|
||||
if (res.success) {
|
||||
await (window as any).api.setTheme(editingTheme.id)
|
||||
setIsEditing(false)
|
||||
setEditingTheme(null)
|
||||
} else {
|
||||
console.error('Failed to save theme:', res.message)
|
||||
}
|
||||
}, [editingTheme])
|
||||
|
||||
const cancelEditing = useCallback(() => {
|
||||
setIsEditing(false)
|
||||
setEditingTheme(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ThemeEditorContext.Provider
|
||||
value={{
|
||||
isEditing,
|
||||
editingTheme,
|
||||
startEditing,
|
||||
updateEditingTheme,
|
||||
updateConfig,
|
||||
saveEditingTheme,
|
||||
cancelEditing
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeEditorContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useThemeEditor = () => {
|
||||
const context = useContext(ThemeEditorContext)
|
||||
if (!context) throw new Error('useThemeEditor must be used within ThemeEditorProvider')
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as XLSX from 'xlsx'
|
||||
|
||||
// 监听主线程消息
|
||||
self.addEventListener('message', async (event: MessageEvent) => {
|
||||
const { type, data } = event.data
|
||||
|
||||
if (type === 'parseXlsx') {
|
||||
try {
|
||||
const { buffer } = data
|
||||
const wb = XLSX.read(buffer, { type: 'array' })
|
||||
const firstSheetName = wb.SheetNames?.[0]
|
||||
if (!firstSheetName) {
|
||||
self.postMessage({ type: 'error', error: 'xlsx 中未找到工作表' })
|
||||
return
|
||||
}
|
||||
const ws = wb.Sheets[firstSheetName]
|
||||
const aoa = XLSX.utils.sheet_to_json(ws, { header: 1, raw: false, defval: '' }) as any[][]
|
||||
if (!Array.isArray(aoa) || aoa.length === 0) {
|
||||
self.postMessage({ type: 'error', error: 'xlsx 内容为空' })
|
||||
return
|
||||
}
|
||||
|
||||
self.postMessage({ type: 'success', data: aoa })
|
||||
} catch (error: any) {
|
||||
self.postMessage({ type: 'error', error: error?.message || '解析 xlsx 失败' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "Acrylic-自动",
|
||||
"id": "acrylic-auto",
|
||||
"mode": "light",
|
||||
"mica": {
|
||||
"effect": "acrylic",
|
||||
"theme": "auto",
|
||||
"radius": "medium"
|
||||
},
|
||||
"config": {
|
||||
"tdesign": {
|
||||
"brandColor": "#0052D9",
|
||||
"warningColor": "#E37318",
|
||||
"errorColor": "#D32029",
|
||||
"successColor": "#248232"
|
||||
},
|
||||
"custom": {
|
||||
"--ss-bg-color": "transparent",
|
||||
"--ss-card-bg": "rgba(255, 255, 255, 0.3)",
|
||||
"--ss-text-main": "#1a1a1a",
|
||||
"--ss-text-secondary": "#5e5e5e",
|
||||
"--ss-border-color": "rgba(0, 0, 0, 0.08)",
|
||||
"--ss-header-bg": "transparent",
|
||||
"--ss-sidebar-bg": "rgba(255, 255, 255, 0.5)",
|
||||
"--ss-item-hover": "rgba(0, 0, 0, 0.04)",
|
||||
"--ss-sidebar-text": "#1a1a1a",
|
||||
"--ss-sidebar-active-bg": "rgba(0, 0, 0, 0.06)",
|
||||
"--ss-sidebar-active-text": "#1a1a1a"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "Acrylic-深色",
|
||||
"id": "acrylic-dark",
|
||||
"mode": "dark",
|
||||
"mica": {
|
||||
"effect": "acrylic",
|
||||
"theme": "dark",
|
||||
"radius": "medium"
|
||||
},
|
||||
"config": {
|
||||
"tdesign": {
|
||||
"brandColor": "#4C8BF5",
|
||||
"warningColor": "#E37318",
|
||||
"errorColor": "#D54941",
|
||||
"successColor": "#2BA471"
|
||||
},
|
||||
"custom": {
|
||||
"--ss-bg-color": "transparent",
|
||||
"--ss-card-bg": "rgba(30, 30, 30, 0.3)",
|
||||
"--ss-text-main": "#e0e0e0",
|
||||
"--ss-text-secondary": "#a0a0a0",
|
||||
"--ss-border-color": "rgba(255, 255, 255, 0.08)",
|
||||
"--ss-header-bg": "transparent",
|
||||
"--ss-sidebar-bg": "rgba(30, 30, 30, 0.5)",
|
||||
"--ss-item-hover": "rgba(255, 255, 255, 0.04)",
|
||||
"--ss-sidebar-text": "#e0e0e0",
|
||||
"--ss-sidebar-active-bg": "rgba(255, 255, 255, 0.06)",
|
||||
"--ss-sidebar-active-text": "#e0e0e0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "Acrylic-浅色",
|
||||
"id": "acrylic-light",
|
||||
"mode": "light",
|
||||
"mica": {
|
||||
"effect": "acrylic",
|
||||
"theme": "light",
|
||||
"radius": "medium"
|
||||
},
|
||||
"config": {
|
||||
"tdesign": {
|
||||
"brandColor": "#0052D9",
|
||||
"warningColor": "#E37318",
|
||||
"errorColor": "#D32029",
|
||||
"successColor": "#248232"
|
||||
},
|
||||
"custom": {
|
||||
"--ss-bg-color": "transparent",
|
||||
"--ss-card-bg": "rgba(255, 255, 255, 0.3)",
|
||||
"--ss-text-main": "#1a1a1a",
|
||||
"--ss-text-secondary": "#5e5e5e",
|
||||
"--ss-border-color": "rgba(0, 0, 0, 0.08)",
|
||||
"--ss-header-bg": "transparent",
|
||||
"--ss-sidebar-bg": "rgba(255, 255, 255, 0.5)",
|
||||
"--ss-item-hover": "rgba(0, 0, 0, 0.04)",
|
||||
"--ss-sidebar-text": "#1a1a1a",
|
||||
"--ss-sidebar-active-bg": "rgba(0, 0, 0, 0.06)",
|
||||
"--ss-sidebar-active-text": "#1a1a1a"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "自定义",
|
||||
"id": "custom",
|
||||
"mode": "light",
|
||||
"mica": {
|
||||
"effect": "mica",
|
||||
"theme": "auto",
|
||||
"radius": "large"
|
||||
},
|
||||
"config": {
|
||||
"tdesign": {
|
||||
"brandColor": "#0052D9",
|
||||
"warningColor": "#E37318",
|
||||
"errorColor": "#D32029",
|
||||
"successColor": "#248232"
|
||||
},
|
||||
"custom": {
|
||||
"--ss-bg-color": "transparent",
|
||||
"--ss-card-bg": "rgba(255, 255, 255, 0.7)",
|
||||
"--ss-text-main": "#1a1a1a",
|
||||
"--ss-text-secondary": "#5e5e5e",
|
||||
"--ss-border-color": "rgba(0, 0, 0, 0.1)",
|
||||
"--ss-header-bg": "transparent",
|
||||
"--ss-sidebar-bg": "rgba(255, 255, 255, 0.85)",
|
||||
"--ss-item-hover": "rgba(0, 0, 0, 0.05)",
|
||||
"--ss-sidebar-text": "#1a1a1a",
|
||||
"--ss-sidebar-active-bg": "rgba(0, 0, 0, 0.08)",
|
||||
"--ss-sidebar-active-text": "#1a1a1a"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "Mica-自动",
|
||||
"id": "mica-auto",
|
||||
"mode": "light",
|
||||
"mica": {
|
||||
"effect": "mica",
|
||||
"theme": "auto",
|
||||
"radius": "medium"
|
||||
},
|
||||
"config": {
|
||||
"tdesign": {
|
||||
"brandColor": "#0052D9",
|
||||
"warningColor": "#E37318",
|
||||
"errorColor": "#D32029",
|
||||
"successColor": "#248232"
|
||||
},
|
||||
"custom": {
|
||||
"--ss-bg-color": "transparent",
|
||||
"--ss-card-bg": "rgba(255, 255, 255, 0.3)",
|
||||
"--ss-text-main": "#1a1a1a",
|
||||
"--ss-text-secondary": "#5e5e5e",
|
||||
"--ss-border-color": "rgba(0, 0, 0, 0.1)",
|
||||
"--ss-header-bg": "transparent",
|
||||
"--ss-sidebar-bg": "rgba(255, 255, 255, 0.5)",
|
||||
"--ss-item-hover": "rgba(0, 0, 0, 0.05)",
|
||||
"--ss-sidebar-text": "#1a1a1a",
|
||||
"--ss-sidebar-active-bg": "rgba(0, 0, 0, 0.08)",
|
||||
"--ss-sidebar-active-text": "#1a1a1a"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "Mica-深色",
|
||||
"id": "mica-dark",
|
||||
"mode": "dark",
|
||||
"mica": {
|
||||
"effect": "mica",
|
||||
"theme": "dark",
|
||||
"radius": "medium"
|
||||
},
|
||||
"config": {
|
||||
"tdesign": {
|
||||
"brandColor": "#4C8BF5",
|
||||
"warningColor": "#E37318",
|
||||
"errorColor": "#D54941",
|
||||
"successColor": "#2BA471"
|
||||
},
|
||||
"custom": {
|
||||
"--ss-bg-color": "transparent",
|
||||
"--ss-card-bg": "rgba(30, 30, 30, 0.3)",
|
||||
"--ss-text-main": "#e0e0e0",
|
||||
"--ss-text-secondary": "#a0a0a0",
|
||||
"--ss-border-color": "rgba(255, 255, 255, 0.1)",
|
||||
"--ss-header-bg": "transparent",
|
||||
"--ss-sidebar-bg": "rgba(30, 30, 30, 0.5)",
|
||||
"--ss-item-hover": "rgba(255, 255, 255, 0.05)",
|
||||
"--ss-sidebar-text": "#e0e0e0",
|
||||
"--ss-sidebar-active-bg": "rgba(255, 255, 255, 0.08)",
|
||||
"--ss-sidebar-active-text": "#e0e0e0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "Mica-浅色",
|
||||
"id": "mica-light",
|
||||
"mode": "light",
|
||||
"mica": {
|
||||
"effect": "mica",
|
||||
"theme": "light",
|
||||
"radius": "medium"
|
||||
},
|
||||
"config": {
|
||||
"tdesign": {
|
||||
"brandColor": "#0052D9",
|
||||
"warningColor": "#E37318",
|
||||
"errorColor": "#D32029",
|
||||
"successColor": "#248232"
|
||||
},
|
||||
"custom": {
|
||||
"--ss-bg-color": "transparent",
|
||||
"--ss-card-bg": "rgba(255, 255, 255, 0.3)",
|
||||
"--ss-text-main": "#1a1a1a",
|
||||
"--ss-text-secondary": "#5e5e5e",
|
||||
"--ss-border-color": "rgba(0, 0, 0, 0.1)",
|
||||
"--ss-header-bg": "transparent",
|
||||
"--ss-sidebar-bg": "rgba(255, 255, 255, 0.5)",
|
||||
"--ss-item-hover": "rgba(0, 0, 0, 0.05)",
|
||||
"--ss-sidebar-text": "#1a1a1a",
|
||||
"--ss-sidebar-active-bg": "rgba(0, 0, 0, 0.08)",
|
||||
"--ss-sidebar-active-text": "#1a1a1a"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "云母",
|
||||
"id": "mica",
|
||||
"mode": "light",
|
||||
"config": {
|
||||
"tdesign": {
|
||||
"brandColor": "#0052D9",
|
||||
"warningColor": "#E37318",
|
||||
"errorColor": "#D32029",
|
||||
"successColor": "#248232"
|
||||
},
|
||||
"custom": {
|
||||
"--ss-bg-color": "transparent",
|
||||
"--ss-card-bg": "rgba(255, 255, 255, 0.7)",
|
||||
"--ss-text-main": "#1a1a1a",
|
||||
"--ss-text-secondary": "#5e5e5e",
|
||||
"--ss-border-color": "rgba(0, 0, 0, 0.1)",
|
||||
"--ss-header-bg": "transparent",
|
||||
"--ss-sidebar-bg": "rgba(255, 255, 255, 0.85)",
|
||||
"--ss-item-hover": "rgba(0, 0, 0, 0.05)",
|
||||
"--ss-sidebar-text": "#1a1a1a",
|
||||
"--ss-sidebar-active-bg": "rgba(0, 0, 0, 0.08)",
|
||||
"--ss-sidebar-active-text": "#1a1a1a"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user