mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 06:04:22 +08:00
21c1183b1d
本次提交包含多项改进: 1. 新增Vitest测试配置与相关测试用例 2. 完善Deep Link监听与OAuth回调支持 3. 修复并优化多个React组件的依赖与性能 4. 更新后端API域名与端点地址 5. 优化KV存储服务实现与类型定义 6. 调整用户信息展示与权限相关逻辑 7. 重构部分业务逻辑,使用useCallback优化性能 8. 更新依赖包与Cargo.lock依赖版本
333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
import React, { useEffect, useMemo, useState } from "react"
|
|
import { Button, ColorPicker, Input, Segmented, Space, Typography, message } from "antd"
|
|
import type { Color } from "antd/es/color-picker"
|
|
import { useTranslation } from "react-i18next"
|
|
import { useTheme } from "../contexts/ThemeContext"
|
|
import type { themeConfig } from "../preload/types"
|
|
|
|
type props = {
|
|
compact?: boolean
|
|
}
|
|
|
|
const presetPrimaryColors = [
|
|
"#1677FF",
|
|
"#2F54EB",
|
|
"#722ED1",
|
|
"#EB2F96",
|
|
"#F5222D",
|
|
"#FA8C16",
|
|
"#FADB14",
|
|
"#52C41A",
|
|
"#13C2C2",
|
|
]
|
|
|
|
const presetGradients: {
|
|
id: string
|
|
labelKey: string
|
|
light: string
|
|
dark: string
|
|
}[] = [
|
|
{
|
|
id: "blue",
|
|
labelKey: "blue",
|
|
light: "linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)",
|
|
dark: "linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)",
|
|
},
|
|
{
|
|
id: "pink",
|
|
labelKey: "pink",
|
|
light: "linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)",
|
|
dark: "linear-gradient(180deg, #1a0f14 0%, #1d1218 55%, #120c10 100%)",
|
|
},
|
|
{
|
|
id: "cyan",
|
|
labelKey: "cyan",
|
|
light: "linear-gradient(180deg, #f0f9ff 0%, #e0f2fe 55%, #f0f9ff 100%)",
|
|
dark: "linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)",
|
|
},
|
|
{
|
|
id: "purple",
|
|
labelKey: "purple",
|
|
light: "linear-gradient(180deg, #faf5ff 0%, #f3e8ff 55%, #faf5ff 100%)",
|
|
dark: "linear-gradient(180deg, #0f0a14 0%, #151020 55%, #0d0910 100%)",
|
|
},
|
|
]
|
|
|
|
const deepClone = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T
|
|
|
|
const normalizeHex = (value: string): string | null => {
|
|
const s = String(value || "").trim()
|
|
if (!s) return null
|
|
const m = s.match(/^#?([0-9a-fA-F]{6})$/)
|
|
if (!m) return null
|
|
return `#${m[1].toUpperCase()}`
|
|
}
|
|
|
|
const buildGradient = (a: string, b: string, dir: "v" | "h" | "d"): string => {
|
|
const angle = dir === "h" ? "90deg" : dir === "d" ? "135deg" : "180deg"
|
|
return `linear-gradient(${angle}, ${a} 0%, ${b} 100%)`
|
|
}
|
|
|
|
export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
|
const { t } = useTranslation()
|
|
const { currentTheme, setTheme, themes, applyTheme } = useTheme()
|
|
const [messageApi, holder] = message.useMessage()
|
|
|
|
const [workingTheme, setWorkingTheme] = useState<themeConfig | null>(null)
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
const [primaryInput, setPrimaryInput] = useState("")
|
|
const primaryColor = useMemo(() => {
|
|
const fromTheme = workingTheme?.config?.tdesign?.brandColor
|
|
return normalizeHex(primaryInput) || normalizeHex(fromTheme || "") || "#1677FF"
|
|
}, [primaryInput, workingTheme])
|
|
|
|
const [gradientDir, setGradientDir] = useState<"v" | "h" | "d">("v")
|
|
const [g1, setG1] = useState<string>("#f7fbff")
|
|
const [g2, setG2] = useState<string>("#f8f9fc")
|
|
|
|
const currentBg = workingTheme?.config?.custom?.["--ss-bg-color"] || ""
|
|
|
|
useEffect(() => {
|
|
if (!currentTheme) return
|
|
const base = deepClone(currentTheme)
|
|
const editable =
|
|
base.id === "custom-default" || base.id.startsWith("custom-")
|
|
? base
|
|
: { ...base, id: "custom-default", name: t("theme.myTheme") }
|
|
setWorkingTheme(editable)
|
|
setPrimaryInput(editable.config?.tdesign?.brandColor || "")
|
|
}, [currentTheme, t])
|
|
|
|
// Real-time theme preview
|
|
useEffect(() => {
|
|
if (!workingTheme) return
|
|
applyTheme(workingTheme)
|
|
}, [workingTheme, applyTheme])
|
|
|
|
const ensureCustomThemeSelected = async (theme: themeConfig): Promise<boolean> => {
|
|
if (!(window as any).api) return false
|
|
const exists = themes.some((t) => t.id === theme.id)
|
|
if (!exists) {
|
|
const res = await (window as any).api.saveTheme(theme)
|
|
if (!res?.success) return false
|
|
}
|
|
if (currentTheme?.id !== theme.id) {
|
|
await setTheme(theme.id)
|
|
}
|
|
return true
|
|
}
|
|
|
|
const saveThemeToDb = async (theme: themeConfig) => {
|
|
if (!(window as any).api) return false
|
|
try {
|
|
const exists = themes.some((t) => t.id === theme.id)
|
|
if (!exists) {
|
|
const res = await (window as any).api.saveTheme(theme)
|
|
if (!res?.success) return false
|
|
}
|
|
if (currentTheme?.id !== theme.id) {
|
|
await setTheme(theme.id)
|
|
}
|
|
const res = await (window as any).api.saveTheme(theme)
|
|
return res?.success
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
const save = async () => {
|
|
if (!(window as any).api) return
|
|
if (!workingTheme) return
|
|
setSaving(true)
|
|
try {
|
|
const ok = await ensureCustomThemeSelected(workingTheme)
|
|
if (!ok) throw new Error("保存失败")
|
|
const res = await (window as any).api.saveTheme(workingTheme)
|
|
if (!res?.success) throw new Error(res?.message || "保存失败")
|
|
messageApi.success(t("theme.saved"))
|
|
} catch (e: any) {
|
|
messageApi.error(e?.message || t("theme.saveFailed"))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const setPrimary = async (hex: string) => {
|
|
setPrimaryInput(hex)
|
|
setWorkingTheme((prev: themeConfig | null) => {
|
|
if (!prev) return prev
|
|
const next = deepClone(prev)
|
|
next.config = next.config || ({} as any)
|
|
next.config.tdesign = { ...(next.config.tdesign || {}), brandColor: hex }
|
|
saveThemeToDb(next)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const setGradientPreset = async (value: string) => {
|
|
setWorkingTheme((prev) => {
|
|
if (!prev) return prev
|
|
const next = deepClone(prev)
|
|
next.config = next.config || ({} as any)
|
|
next.config.custom = { ...(next.config.custom || {}), "--ss-bg-color": value }
|
|
saveThemeToDb(next)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const setGradientFromPickers = async () => {
|
|
const g = buildGradient(g1, g2, gradientDir)
|
|
setWorkingTheme((prev: themeConfig | null) => {
|
|
if (!prev) return prev
|
|
const next = deepClone(prev)
|
|
next.config = next.config || ({} as any)
|
|
next.config.custom = { ...(next.config.custom || {}), "--ss-bg-color": g }
|
|
saveThemeToDb(next)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const setMode = async (mode: "light" | "dark") => {
|
|
const targetThemeId = mode === "light" ? "light-default" : "dark-default"
|
|
await setTheme(targetThemeId)
|
|
}
|
|
|
|
if (!workingTheme) return null
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: compact ? 12 : 16 }}>
|
|
{holder}
|
|
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
|
<Typography.Text strong>{t("theme.colorAndBackground")}</Typography.Text>
|
|
<Space>
|
|
<Button type="primary" loading={saving} onClick={save}>
|
|
{t("common.save")}
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
<Typography.Text>{t("theme.mode")}</Typography.Text>
|
|
<Segmented
|
|
value={workingTheme.mode}
|
|
onChange={(v) => setMode(v as any)}
|
|
options={[
|
|
{ label: t("settings.lightMode"), value: "light" },
|
|
{ label: t("settings.darkMode"), value: "dark" },
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
<Typography.Text>{t("settings.primaryColor")}</Typography.Text>
|
|
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
|
|
<Input
|
|
value={primaryInput}
|
|
onChange={(e) => setPrimaryInput(e.target.value)}
|
|
onBlur={() => {
|
|
const n = normalizeHex(primaryInput)
|
|
if (n) setPrimary(n)
|
|
else setPrimary(primaryColor)
|
|
}}
|
|
style={{ width: 120 }}
|
|
/>
|
|
{presetPrimaryColors.map((c) => (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
onClick={() => setPrimary(c)}
|
|
style={{
|
|
width: 18,
|
|
height: 18,
|
|
borderRadius: 999,
|
|
border:
|
|
c === primaryColor ? "2px solid rgba(0,0,0,0.35)" : "1px solid rgba(0,0,0,0.18)",
|
|
background: c,
|
|
cursor: "pointer",
|
|
padding: 0,
|
|
}}
|
|
/>
|
|
))}
|
|
<ColorPicker
|
|
value={primaryColor}
|
|
onChange={(color: Color) => setPrimary(color.toHexString().toUpperCase())}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
<Typography.Text>{t("settings.backgroundGradient")}</Typography.Text>
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
|
|
gap: 10,
|
|
}}
|
|
>
|
|
{presetGradients.map((g) => {
|
|
const gradientValue = g[workingTheme.mode]
|
|
const active = currentBg === gradientValue
|
|
return (
|
|
<button
|
|
key={g.id}
|
|
type="button"
|
|
onClick={() => setGradientPreset(gradientValue)}
|
|
style={{
|
|
borderRadius: 10,
|
|
border: active
|
|
? "2px solid var(--ant-color-primary)"
|
|
: "1px solid var(--ss-border-color)",
|
|
padding: 10,
|
|
background: "transparent",
|
|
cursor: "pointer",
|
|
textAlign: "left",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
height: 56,
|
|
borderRadius: 8,
|
|
border: "1px solid var(--ss-border-color)",
|
|
background: gradientValue,
|
|
}}
|
|
/>
|
|
<div style={{ marginTop: 8, fontSize: 12, color: "var(--ss-text-secondary)" }}>
|
|
{t(`theme.gradientLabels.${g.labelKey}`)}
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
|
|
<Segmented
|
|
value={gradientDir}
|
|
onChange={(v) => setGradientDir(v as any)}
|
|
options={[
|
|
{ label: t("theme.gradientDirections.vertical"), value: "v" },
|
|
{ label: t("theme.gradientDirections.horizontal"), value: "h" },
|
|
{ label: t("theme.gradientDirections.diagonal"), value: "d" },
|
|
]}
|
|
/>
|
|
<Space size="small">
|
|
<ColorPicker value={g1} onChange={(c: Color) => setG1(c.toHexString())} />
|
|
<ColorPicker value={g2} onChange={(c: Color) => setG2(c.toHexString())} />
|
|
<Button onClick={setGradientFromPickers}>{t("theme.generate")}</Button>
|
|
</Space>
|
|
<div
|
|
style={{
|
|
width: 160,
|
|
height: 32,
|
|
borderRadius: 8,
|
|
border: "1px solid var(--ss-border-color)",
|
|
background: buildGradient(g1, g2, gradientDir),
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|