请输入文本

This commit is contained in:
Fox_block
2026-03-01 13:43:52 +08:00
parent 943dc1a27a
commit 38240a42ab
103 changed files with 5382 additions and 1638 deletions
+80 -70
View File
@@ -1,23 +1,22 @@
import { Layout, Dialog, Input, MessagePlugin } from 'tdesign-react'
import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from 'antd'
import { useEffect, useMemo, useState } from 'react'
import { HashRouter, useLocation, useNavigate, Routes, Route } from 'react-router-dom'
import { Sidebar } from './components/Sidebar'
import { ContentArea } from './components/ContentArea'
import { Wizard } from './components/Wizard'
import { ThemeProvider } from './contexts/ThemeContext'
import { ThemeProvider, useTheme } from './contexts/ThemeContext'
import { ThemeEditorProvider } from './contexts/ThemeEditorContext'
import { ThemeEditor } from './components/ThemeEditor'
import { useTheme } from './contexts/ThemeContext'
function MainContent(): React.JSX.Element {
const navigate = useNavigate()
const location = useLocation()
const { currentTheme } = useTheme()
const [messageApi, contextHolder] = message.useMessage()
useEffect(() => {
if (!(window as any).api) return
const unlisten = (window as any).api.onNavigate((route: string) => {
// 统一路径格式进行比对,防止 / 和 /home 导致重复跳转
const currentPath = location.pathname === '/' ? '/home' : location.pathname
const targetPath = route === '/' ? '/home' : route
@@ -83,9 +82,9 @@ function MainContent(): React.JSX.Element {
setPermission(res.data.permission)
setAuthVisible(false)
setAuthPassword('')
MessagePlugin.success('权限已解锁')
messageApi.success('权限已解锁')
} else {
MessagePlugin.error(res.message || '密码错误')
messageApi.error(res.message || '密码错误')
}
}
@@ -94,11 +93,11 @@ function MainContent(): React.JSX.Element {
const res = await (window as any).api.authLogout()
if (res?.success && res.data) {
setPermission(res.data.permission)
MessagePlugin.success('已切换为只读')
messageApi.success('已切换为只读')
}
}
const onMenuChange = (v: string | number) => {
const onMenuChange = (v: string) => {
const key = String(v)
if (key === 'home') navigate('/')
if (key === 'students') navigate('/students')
@@ -110,77 +109,90 @@ function MainContent(): React.JSX.Element {
if (key === 'settings') navigate('/settings')
}
const isDark = currentTheme?.mode === 'dark'
return (
<Layout style={{ height: '100vh', flexDirection: 'row', overflow: 'hidden' }}>
<Sidebar activeMenu={activeMenu} permission={permission} onMenuChange={onMenuChange} />
<ContentArea
permission={permission}
hasAnyPassword={hasAnyPassword}
onAuthClick={() => setAuthVisible(true)}
onLogout={logout}
/>
<ConfigProvider
theme={{
algorithm: isDark ? antTheme.darkAlgorithm : antTheme.defaultAlgorithm,
token: {
colorPrimary: 'var(--ant-color-primary, #1890ff)'
}
}}
>
{contextHolder}
<Layout style={{ height: '100vh', flexDirection: 'row', overflow: 'hidden' }}>
<Sidebar activeMenu={activeMenu} permission={permission} onMenuChange={onMenuChange} />
<ContentArea
permission={permission}
hasAnyPassword={hasAnyPassword}
onAuthClick={() => setAuthVisible(true)}
onLogout={logout}
/>
<Wizard visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
<Wizard visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
<Dialog
header="权限解锁"
visible={authVisible}
onClose={() => setAuthVisible(false)}
onConfirm={login}
confirmBtn={{ content: '解锁', loading: authLoading }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
6 ==
</div>
<Input
value={authPassword}
onChange={(v) => setAuthPassword(v)}
placeholder="例如 123456"
maxlength={6}
/>
</div>
</Dialog>
{import.meta.env.DEV ? (
<div
style={{
position: 'fixed',
display: 'flex',
bottom: '2px',
left: '20px',
opacity: 0.6,
zIndex: 9999
}}
<Modal
title="权限解锁"
open={authVisible}
onCancel={() => setAuthVisible(false)}
onOk={login}
confirmLoading={authLoading}
okText="解锁"
cancelText="取消"
>
<p
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
6 ==
</div>
<Input
value={authPassword}
onChange={(e) => setAuthPassword(e.target.value)}
placeholder="例如 123456"
maxLength={6}
/>
</div>
</Modal>
{import.meta.env.DEV ? (
<div
style={{
color: '#df0000',
fontWeight: 'bold',
fontSize: '14px',
pointerEvents: 'none'
position: 'fixed',
display: 'flex',
bottom: '2px',
left: '20px',
opacity: 0.6,
zIndex: 9999
}}
>
,
</p>
<p
style={{
color: currentTheme?.mode === 'dark' ? '#fff' : '#44474b',
fontWeight: 'bold',
fontSize: '13px',
paddingLeft: '5px'
}}
>
SecScore Dev ({getPlatform()}-{getArchitecture()})
</p>
</div>
) : null}
</Layout>
<p
style={{
color: '#df0000',
fontWeight: 'bold',
fontSize: '14px',
pointerEvents: 'none'
}}
>
,
</p>
<p
style={{
color: currentTheme?.mode === 'dark' ? '#fff' : '#44474b',
fontWeight: 'bold',
fontSize: '13px',
paddingLeft: '5px'
}}
>
SecScore Dev ({getPlatform()}-{getArchitecture()})
</p>
</div>
) : null}
</Layout>
</ConfigProvider>
)
}
function getArchitecture(): string {
// 尝试从 userAgent 中获取架构信息
const userAgent = navigator.userAgent.toLowerCase()
if (userAgent.includes('arm64') || userAgent.includes('aarch64')) {
@@ -191,12 +203,10 @@ function getArchitecture(): string {
return 'x86'
}
// 默认返回未知架构
return userAgent
}
function getPlatform(): string {
// 尝试从 userAgent 中获取平台信息
const userAgent = navigator.userAgent.toLowerCase()
if (userAgent.includes('windows')) {
+21 -62
View File
@@ -1,14 +1,4 @@
@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或者人类会用顺便帮我改一下 */
@import 'antd/dist/reset.css';
html,
body,
@@ -17,11 +7,11 @@ body,
margin: 0;
padding: 0;
overflow: hidden;
user-select: none; /* 禁用文本选择 */
user-select: none;
}
/* 如果某些输入框需要允许选择,可以单独开启 */
input, textarea {
input,
textarea {
user-select: text;
}
@@ -29,79 +19,48 @@ input, textarea {
color: var(--ss-sidebar-text, var(--ss-text-main));
}
.ss-sidebar .t-menu__item,
.ss-sidebar .t-menu__item-icon {
.ss-sidebar .ant-menu {
background-color: transparent !important;
}
.ss-sidebar .ant-menu-item {
color: var(--ss-sidebar-text, var(--ss-text-main));
}
.ss-sidebar .t-menu__item--active,
.ss-sidebar .t-menu__item.t-is-active,
.ss-sidebar .t-menu__item--active .t-menu__item-icon,
.ss-sidebar .t-menu__item.t-is-active .t-menu__item-icon,
.ss-sidebar .t-menu__item--active .t-menu__content,
.ss-sidebar .t-menu__item.t-is-active .t-menu__content {
.ss-sidebar .ant-menu-item-selected {
background-color: var(--ss-sidebar-active-bg, var(--ss-item-hover, transparent)) !important;
color: var(--ss-sidebar-active-text, var(--ss-sidebar-text, var(--ss-text-main)));
}
.ss-sidebar .t-menu__item--active,
.ss-sidebar .t-menu__item.t-is-active {
background-color: var(--ss-sidebar-active-bg, var(--ss-item-hover, transparent)) !important;
}
html[theme-mode='dark'] .ss-sidebar .t-menu__item--active,
html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active,
html[theme-mode='dark'] .ss-sidebar .t-menu__item--active .t-menu__item-icon,
html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active .t-menu__item-icon,
html[theme-mode='dark'] .ss-sidebar .t-menu__item--active .t-menu__content,
html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active .t-menu__content {
html[theme-mode='dark'] .ss-sidebar .ant-menu-item-selected {
color: #ffffff !important;
}
html[theme-mode='dark'] .ss-sidebar .t-menu__item--active svg[fill='none'],
html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active svg[fill='none'] {
stroke: currentColor !important;
fill: none !important;
}
html[theme-mode='dark'] .ss-sidebar .t-menu__item--active svg:not([fill='none']),
html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active svg:not([fill='none']) {
fill: currentColor !important;
}
.ss-sidebar .t-menu__item:hover {
color: var(--ss-sidebar-text, var(--ss-text-main));
}
@media (pointer: coarse) {
.ss-sidebar .t-menu__item {
.ss-sidebar .ant-menu-item {
min-height: 48px;
font-size: 16px;
}
.t-button {
.ant-btn {
min-height: 44px;
}
.t-input,
.t-input__inner,
.t-select-input,
.t-input-number,
.t-input-number__input {
.ant-input,
.ant-select-selector,
.ant-input-number {
min-height: 44px;
font-size: 16px;
}
.t-table th,
.t-table td {
.ant-table th,
.ant-table td {
padding-top: 14px;
padding-bottom: 14px;
}
}
.ss-table-center .t-table th,
.ss-table-center .t-table td,
.ss-table-center .t-table th .t-table__cell,
.ss-table-center .t-table td .t-table__cell {
.ss-table-center .ant-table th,
.ss-table-center .ant-table td {
text-align: center;
justify-content: center;
}
+103 -111
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { AddIcon, MoveIcon } from 'tdesign-icons-react'
import { PlusOutlined, HolderOutlined } from '@ant-design/icons'
import { allTriggers, allActions, triggerRegistry, actionRegistry } from './com.automatically'
import type { TriggerItem, ActionItem } from './com.automatically/types'
import TriggerItemComponent from './com.automatically/TriggerItem'
@@ -9,15 +9,16 @@ import {
Form,
Input,
Button,
MessagePlugin,
message,
Table,
PrimaryTableCol,
Space,
Switch,
Popconfirm,
Select,
TooltipLite
} from 'tdesign-react'
Tooltip,
Pagination
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
interface AutoScoreRule {
id: number
@@ -44,6 +45,7 @@ export const AutoScoreManager: React.FC = () => {
const [editingRuleId, setEditingRuleId] = useState<number | null>(null)
const [triggerList, setTriggerList] = useState<TriggerItem[]>([])
const [actionList, setActionList] = useState<ActionItem[]>([])
const [messageApi, contextHolder] = message.useMessage()
const fetchRules = async () => {
if (!(window as any).api) return
@@ -53,7 +55,7 @@ export const AutoScoreManager: React.FC = () => {
try {
const authRes = await (window as any).api.authGetStatus()
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
MessagePlugin.error('需要管理员权限以查看自动加分自动化,请先登录管理员账号')
messageApi.error('需要管理员权限以查看自动加分自动化,请先登录管理员账号')
setLoading(false)
return
}
@@ -68,14 +70,14 @@ export const AutoScoreManager: React.FC = () => {
if (rulesRes.success) {
setRules(rulesRes.data)
} else {
MessagePlugin.error(rulesRes.message || '获取自动化失败')
messageApi.error(rulesRes.message || '获取自动化失败')
}
if (studentsRes.success) {
setStudents(studentsRes.data)
}
} catch (error) {
console.error('Failed to fetch auto score rules:', error)
MessagePlugin.error('获取自动化失败')
messageApi.error('获取自动化失败')
} finally {
setLoading(false)
}
@@ -91,17 +93,17 @@ export const AutoScoreManager: React.FC = () => {
const values = form.getFieldsValue(true) as unknown as AutoScoreRuleFormValues
if (!values.name) {
MessagePlugin.warning('请填写自动化名称')
messageApi.warning('请填写自动化名称')
return
}
if (triggerList.length === 0) {
MessagePlugin.warning('请至少添加一个触发器')
messageApi.warning('请至少添加一个触发器')
return
}
if (actionList.length === 0) {
MessagePlugin.warning('请至少添加一个行动')
messageApi.warning('请至少添加一个行动')
return
}
@@ -129,7 +131,7 @@ export const AutoScoreManager: React.FC = () => {
try {
const authRes = await (window as any).api.authGetStatus()
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
MessagePlugin.error('需要管理员权限以创建或更新自动加分自动化')
messageApi.error('需要管理员权限以创建或更新自动加分自动化')
return
}
} catch (e) {
@@ -148,7 +150,7 @@ export const AutoScoreManager: React.FC = () => {
}
if (res.success) {
MessagePlugin.success(editingRuleId !== null ? '自动化更新成功' : '自动化创建成功')
messageApi.success(editingRuleId !== null ? '自动化更新成功' : '自动化创建成功')
form.setFieldsValue({
name: '',
studentNames: ''
@@ -158,13 +160,13 @@ export const AutoScoreManager: React.FC = () => {
setActionList([])
fetchRules()
} else {
MessagePlugin.error(
messageApi.error(
res.message || (editingRuleId !== null ? '更新自动化失败' : '创建自动化失败')
)
}
} catch (error) {
console.error('Failed to submit auto score rule:', error)
MessagePlugin.error(editingRuleId !== null ? '更新自动化失败' : '创建自动化失败')
messageApi.error(editingRuleId !== null ? '更新自动化失败' : '创建自动化失败')
}
}
@@ -203,7 +205,7 @@ export const AutoScoreManager: React.FC = () => {
try {
const authRes = await (window as any).api.authGetStatus()
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
MessagePlugin.error('需要管理员权限以删除自动加分自动化')
messageApi.error('需要管理员权限以删除自动加分自动化')
return
}
} catch (e) {
@@ -213,14 +215,14 @@ export const AutoScoreManager: React.FC = () => {
try {
const res = await (window as any).api.invoke('auto-score:deleteRule', ruleId)
if (res.success) {
MessagePlugin.success('自动化删除成功')
messageApi.success('自动化删除成功')
fetchRules()
} else {
MessagePlugin.error(res.message || '删除自动化失败')
messageApi.error(res.message || '删除自动化失败')
}
} catch (error) {
console.error('Failed to delete auto score rule:', error)
MessagePlugin.error('删除自动化失败')
messageApi.error('删除自动化失败')
}
}
@@ -229,7 +231,7 @@ export const AutoScoreManager: React.FC = () => {
try {
const authRes = await (window as any).api.authGetStatus()
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
MessagePlugin.error('需要管理员权限以启用/禁用自动加分自动化')
messageApi.error('需要管理员权限以启用/禁用自动加分自动化')
return
}
} catch (e) {
@@ -239,14 +241,14 @@ export const AutoScoreManager: React.FC = () => {
try {
const res = await (window as any).api.invoke('auto-score:toggleRule', { ruleId, enabled })
if (res.success) {
MessagePlugin.success(enabled ? '自动化已启用' : '自动化已禁用')
messageApi.success(enabled ? '自动化已启用' : '自动化已禁用')
fetchRules()
} else {
MessagePlugin.error(res.message || (enabled ? '启用自动化失败' : '禁用自动化失败'))
messageApi.error(res.message || (enabled ? '启用自动化失败' : '禁用自动化失败'))
}
} catch (error) {
console.error('Failed to toggle auto score rule:', error)
MessagePlugin.error(enabled ? '启用自动化失败' : '禁用自动化失败')
messageApi.error(enabled ? '启用自动化失败' : '禁用自动化失败')
}
}
@@ -264,7 +266,7 @@ export const AutoScoreManager: React.FC = () => {
const nextId = triggerList.length ? Math.max(...triggerList.map((t) => t.id)) + 1 : 1
const defaultTrigger = allTriggers.list[0]
if (!defaultTrigger) {
MessagePlugin.error('没有可用的触发器类型,请检查配置')
messageApi.error('没有可用的触发器类型,请检查配置')
return
}
setTriggerList((prev) => [
@@ -298,7 +300,7 @@ export const AutoScoreManager: React.FC = () => {
const nextId = actionList.length ? Math.max(...actionList.map((a) => a.id)) + 1 : 1
const defaultAction = allActions.list[0]
if (!defaultAction) {
MessagePlugin.error('没有可用的行动类型,请检查配置')
messageApi.error('没有可用的行动类型,请检查配置')
return
}
setActionList((prev) => [
@@ -328,98 +330,89 @@ export const AutoScoreManager: React.FC = () => {
setActionList((prev) => prev.map((a) => (a.id === id ? { ...a, reason } : a)))
}
const columns: PrimaryTableCol<AutoScoreRule>[] = [
const columns: ColumnsType<AutoScoreRule> = [
{
colKey: 'drag',
key: 'drag',
title: '排序',
cell: () => <MoveIcon />,
width: 60
width: 60,
render: () => <HolderOutlined style={{ cursor: 'move' }} />
},
{
colKey: 'enabled',
title: '状态',
dataIndex: 'enabled',
key: 'enabled',
width: 80,
cell: ({ row }) => (
<Switch
value={row.enabled}
onChange={(value) => handleToggle(row.id, value)}
size="small"
/>
render: (enabled: boolean, row) => (
<Switch checked={enabled} onChange={(value) => handleToggle(row.id, value)} size="small" />
)
},
{ colKey: 'name', title: '自动化名称', width: 150 },
{ title: '自动化名称', dataIndex: 'name', key: 'name', width: 150 },
{
colKey: 'triggers',
title: '触发器',
dataIndex: 'triggers',
key: 'triggers',
width: 150,
cell: ({ row }) => {
if (!row.triggers || row.triggers.length === 0) {
render: (triggers: AutoScoreRule['triggers']) => {
if (!triggers || triggers.length === 0) {
return <span></span>
}
const triggerLabels = row.triggers.map((t) => {
const triggerLabels = triggers.map((t) => {
const def = triggerRegistry.get(t.event)
return def?.label || t.event
})
return (
<TooltipLite
content={triggerLabels.join(', ')}
showArrow
placement="mouse"
theme="default"
>
{row.triggers.length}
</TooltipLite>
<Tooltip title={triggerLabels.join(', ')}>
<span>{triggers.length} </span>
</Tooltip>
)
}
},
{
colKey: 'actions',
title: '行动',
dataIndex: 'actions',
key: 'actions',
width: 150,
cell: ({ row }) => {
if (!row.actions || row.actions.length === 0) {
render: (actions: AutoScoreRule['actions']) => {
if (!actions || actions.length === 0) {
return <span></span>
}
const actionLabels = row.actions.map((a) => {
const actionLabels = actions.map((a) => {
const def = actionRegistry.get(a.event)
return def?.label || a.event
})
return (
<TooltipLite
content={actionLabels.join(', ')}
showArrow
placement="mouse"
theme="default"
>
{row.actions.length}
</TooltipLite>
<Tooltip title={actionLabels.join(', ')}>
<span>{actions.length} </span>
</Tooltip>
)
}
},
{
colKey: 'studentNames',
title: '适用学生',
dataIndex: 'studentNames',
key: 'studentNames',
width: 130,
cell: ({ row }) => {
if (!row.studentNames || row.studentNames.length === 0) {
render: (studentNames: string[]) => {
if (!studentNames || studentNames.length === 0) {
return <span></span>
}
const studentList = row.studentNames.join(',\n')
const studentList = studentNames.join(',\n')
return (
<TooltipLite content={studentList} showArrow placement="mouse" theme="default">
{row.studentNames.length}
</TooltipLite>
<Tooltip title={studentList}>
<span>{studentNames.length} </span>
</Tooltip>
)
}
},
{
colKey: 'lastExecuted',
title: '最后执行',
dataIndex: 'lastExecuted',
key: 'lastExecuted',
width: 180,
cell: ({ row }) => {
if (!row.lastExecuted) return <span></span>
render: (lastExecuted: string) => {
if (!lastExecuted) return <span></span>
try {
const date = new Date(row.lastExecuted)
const date = new Date(lastExecuted)
return date.toLocaleString()
} catch {
return <span></span>
@@ -427,16 +420,16 @@ export const AutoScoreManager: React.FC = () => {
}
},
{
colKey: 'operation',
title: '操作',
key: 'operation',
width: 150,
cell: ({ row }) => (
render: (_, row) => (
<Space>
<Button size="small" variant="outline" onClick={() => handleEdit(row)}>
<Button size="small" onClick={() => handleEdit(row)}>
</Button>
<Popconfirm content="确定要删除这条自动化吗?" onConfirm={() => handleDelete(row.id)}>
<Button size="small" variant="outline" theme="danger">
<Popconfirm title="确定要删除这条自动化吗?" onConfirm={() => handleDelete(row.id)}>
<Button size="small" danger>
</Button>
</Popconfirm>
@@ -445,8 +438,6 @@ export const AutoScoreManager: React.FC = () => {
}
]
const onDragSort = (params: any) => setRules(params.newData)
const triggerItems = triggerList
.filter((t) => t.eventName !== null)
.map((item, idx) => (
@@ -476,34 +467,35 @@ export const AutoScoreManager: React.FC = () => {
return (
<div style={{ padding: '24px' }}>
{contextHolder}
<h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}></h2>
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}>
<Form form={form} labelWidth={100} onReset={handleResetForm}>
<Form form={form} layout="vertical">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
<Form.FormItem
<Form.Item
label="自动化名称"
name="name"
rules={[{ required: true, message: '请输入自动化名称' }]}
>
<Input placeholder="例如:每日签到加分" />
</Form.FormItem>
</Form.Item>
<Form.FormItem label="适用学生" name="studentNames">
<Form.Item label="适用学生" name="studentNames">
<Select
filterable
multiple
mode="multiple"
showSearch
placeholder="请选择或搜索学生(留空表示所有学生)"
options={students.map((student) => ({ label: student.name, value: student.name }))}
/>
</Form.FormItem>
</Form.Item>
</div>
<div style={{ marginTop: '24px', display: 'flex', gap: '12px' }}>
<Button theme="primary" onClick={handleSubmit}>
<Button type="primary" onClick={handleSubmit}>
{editingRuleId !== null ? '更新自动化' : '添加自动化'}
</Button>
<Button type="reset" variant="outline">
<Button onClick={handleResetForm}>
{editingRuleId !== null ? '取消编辑' : '重置表单'}
</Button>
</div>
@@ -513,16 +505,14 @@ export const AutoScoreManager: React.FC = () => {
<Card
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
title="当以下规则触发时"
headerBordered
>
<Space style={{ display: 'grid' }}>
<Space orientation="vertical" style={{ width: '100%' }}>
{triggerItems}
<Button
theme="default"
variant="text"
style={{ fontWeight: 'bolder', fontSize: 15 }}
icon={<AddIcon strokeWidth={3} />}
type="dashed"
icon={<PlusOutlined />}
onClick={handleAddTrigger}
style={{ fontWeight: 'bolder', fontSize: 15 }}
>
</Button>
@@ -532,16 +522,14 @@ export const AutoScoreManager: React.FC = () => {
<Card
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
title="满足规则时触发的行动"
headerBordered
>
<Space style={{ display: 'grid' }}>
<Space orientation="vertical" style={{ width: '100%' }}>
{actionItems}
<Button
theme="default"
variant="text"
style={{ fontWeight: 'bolder', fontSize: 15 }}
icon={<AddIcon strokeWidth={3} />}
type="dashed"
icon={<PlusOutlined />}
onClick={handleAddAction}
style={{ fontWeight: 'bolder', fontSize: 15 }}
>
</Button>
@@ -550,22 +538,26 @@ export const AutoScoreManager: React.FC = () => {
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}>
<Table
data={rules.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
dataSource={rules.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
columns={columns}
rowKey="id"
resizable
loading={loading}
dragSort="row-handler"
onDragSort={onDragSort}
pagination={{
current: currentPage,
pageSize,
total: rules.length,
onChange: (pageInfo) => setCurrentPage(pageInfo.current),
onPageSizeChange: (size) => setPageSize(size)
}}
pagination={false}
style={{ color: 'var(--ss-text-main)' }}
/>
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={rules.length}
onChange={(page, size) => {
setCurrentPage(page)
setPageSize(size)
}}
showSizeChanger
showTotal={(total) => `${total}`}
/>
</div>
</Card>
</div>
)
+5 -6
View File
@@ -1,5 +1,5 @@
import React, { Suspense, lazy } from 'react'
import { Layout, Space, Button, Tag, Loading } from 'tdesign-react'
import { Layout, Space, Button, Tag, Spin } from 'antd'
import { Routes, Route, Navigate } from 'react-router-dom'
import { WindowControls } from './WindowControls'
import { ThemeEditor } from './ThemeEditor'
@@ -38,8 +38,7 @@ export function ContentArea({
}: ContentAreaProps): React.JSX.Element {
const permissionTag = (
<Tag
theme={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
variant="light"
color={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
>
{permission === 'admin' ? '管理权限' : permission === 'points' ? '积分权限' : '只读'}
</Tag>
@@ -83,10 +82,10 @@ export function ContentArea({
{permissionTag}
{hasAnyPassword && (
<>
<Button size="small" variant="outline" onClick={onAuthClick}>
<Button size="small" onClick={onAuthClick}>
</Button>
<Button size="small" variant="outline" theme="danger" onClick={onLogout}>
<Button size="small" danger onClick={onLogout}>
</Button>
</>
@@ -107,7 +106,7 @@ export function ContentArea({
height: '100%'
}}
>
<Loading text="正在载入页面..." />
<Spin size="large" description="正在载入页面..." />
</div>
}
>
+15 -16
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'
import { Table, PrimaryTableCol, Tag } from 'tdesign-react'
import { Table, Tag } from 'antd'
import type { ColumnsType } from 'antd/es/table'
interface scoreEvent {
id: number
@@ -30,26 +31,26 @@ export const EventHistory: React.FC = () => {
fetchEvents()
}, [])
const columns: PrimaryTableCol<scoreEvent>[] = [
{ colKey: 'student_name', title: '学生姓名', width: 120 },
{ colKey: 'reason_content', title: '积分理由', width: 200 },
const columns: ColumnsType<scoreEvent> = [
{ title: '学生姓名', dataIndex: 'student_name', key: 'student_name', width: 120 },
{ title: '积分理由', dataIndex: 'reason_content', key: 'reason_content', width: 200 },
{
colKey: 'delta',
title: '分值变动',
dataIndex: 'delta',
key: 'delta',
width: 100,
cell: ({ row }) => (
<Tag theme={row.delta > 0 ? 'success' : 'danger'} variant="light">
{row.delta > 0 ? `+${row.delta}` : row.delta}
</Tag>
render: (delta: number) => (
<Tag color={delta > 0 ? 'success' : 'error'}>{delta > 0 ? `+${delta}` : delta}</Tag>
)
},
{ colKey: 'val_prev', title: '原分值', width: 100 },
{ colKey: 'val_curr', title: '新分值', width: 100 },
{ title: '原分值', dataIndex: 'val_prev', key: 'val_prev', width: 100 },
{ title: '新分值', dataIndex: 'val_curr', key: 'val_curr', width: 100 },
{
colKey: 'event_time',
title: '发生时间',
dataIndex: 'event_time',
key: 'event_time',
width: 180,
cell: ({ row }) => new Date(row.event_time).toLocaleString()
render: (time: string) => new Date(time).toLocaleString()
}
]
@@ -57,14 +58,12 @@ export const EventHistory: React.FC = () => {
<div style={{ padding: '24px' }}>
<h2 style={{ marginBottom: '16px', color: 'var(--ss-text-main)' }}></h2>
<Table
data={data}
dataSource={data}
columns={columns}
rowKey="uuid"
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>
+57 -116
View File
@@ -1,17 +1,6 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import {
Card,
Space,
Button,
Tag,
Input,
Select,
Dialog,
MessagePlugin,
InputNumber,
Divider
} from 'tdesign-react'
import { SearchIcon, DeleteIcon } from 'tdesign-icons-react'
import { Card, Space, Button, Tag, Input, Select, Modal, message, InputNumber, Divider } from 'antd'
import { SearchOutlined, DeleteOutlined } from '@ant-design/icons'
import { match, pinyin } from 'pinyin-pro'
interface student {
@@ -38,34 +27,29 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [sortType, setSortType] = useState<SortType>('alphabet')
const [searchKeyword, setSearchKeyword] = useState('')
// 滚动容器引用
const scrollContainerRef = useRef<HTMLDivElement>(null)
const groupRefs = useRef<Record<string, HTMLDivElement | null>>({})
// 操作框状态
const [selectedStudent, setSelectedStudent] = useState<student | null>(null)
const [operationVisible, setOperationVisible] = useState(false)
const [customScore, setCustomScore] = useState<number | undefined>(undefined)
const [reasonContent, setReasonContent] = useState('')
const [submitLoading, setSubmitLoading] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
}
// 获取姓氏
const getSurname = (name: string) => {
if (!name) return ''
return name.charAt(0)
}
// 获取拼音首字母
const getFirstLetter = (name: string) => {
if (!name) return ''
const firstChar = name.charAt(0)
// 如果是英文字母
if (/^[a-zA-Z]$/.test(firstChar)) return firstChar.toUpperCase()
// 如果是中文,转拼音
const py = pinyin(firstChar, { pattern: 'first', toneType: 'none' })
return py ? py.toUpperCase() : '#'
}
@@ -102,13 +86,11 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return () => window.removeEventListener('ss:data-updated', onDataUpdated as any)
}, [fetchData])
// 获取展示用的文字
const getDisplayText = (name: string) => {
if (!name) return ''
return name.length > 2 ? name.substring(name.length - 2) : name
}
// 拼音匹配
const matchStudentName = useCallback((s: student, keyword: string) => {
const q0 = keyword.trim().toLowerCase()
if (!q0) return true
@@ -136,7 +118,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return false
}, [])
// 过滤和排序学生
const sortedStudents = useMemo(() => {
const filtered = students.filter((s) => matchStudentName(s, searchKeyword))
@@ -163,7 +144,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}
}, [students, searchKeyword, sortType, matchStudentName])
// 分组显示
const groupedStudents = useMemo(() => {
if (sortType === 'score' || (sortType === 'alphabet' && searchKeyword)) {
return [{ key: 'all', students: sortedStudents }]
@@ -181,7 +161,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
.map(([key, students]) => ({ key, students }))
}, [sortedStudents, sortType, searchKeyword])
// 按分类分组的理由
const groupedReasons = useMemo(() => {
const groups: Record<string, reason[]> = {}
reasons.forEach((r) => {
@@ -196,7 +175,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
})
}, [reasons])
// 生成头像颜色
const getAvatarColor = (name: string) => {
const colors = [
'#FF6B6B',
@@ -218,7 +196,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return colors[index]
}
// 跳转到指定分组
const scrollToGroup = (key: string) => {
const element = groupRefs.current[key]
if (element) {
@@ -226,10 +203,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}
}
// 打开操作框
const openOperation = (student: student) => {
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
setSelectedStudent(student)
@@ -238,11 +214,10 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
setOperationVisible(true)
}
// 核心提交逻辑
const performSubmit = async (student: student, delta: number, content: string) => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
@@ -254,29 +229,26 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
})
if (res.success) {
MessagePlugin.success(`已为 ${student.name} ${delta > 0 ? '加' : '扣'}${Math.abs(delta)}`)
messageApi.success(`已为 ${student.name} ${delta > 0 ? '加' : '扣'}${Math.abs(delta)}`)
setOperationVisible(false)
// 【核心改进】本地增量更新分数,避免全量刷新导致的闪烁和滚动重置
setStudents((prev) =>
prev.map((s) => (s.id === student.id ? { ...s, score: s.score + delta } : s))
)
// 通知其他组件数据已更新(但不在此处重复 fetchData)
emitDataUpdated('events')
} else {
MessagePlugin.error(res.message || '提交失败')
messageApi.error(res.message || '提交失败')
}
setSubmitLoading(false)
}
// 手动点击确定按钮提交(用于自定义分值)
const handleSubmit = async () => {
if (!selectedStudent) return
const delta = customScore
if (delta === undefined || !Number.isFinite(delta)) {
MessagePlugin.warning('请选择或输入分值')
messageApi.warning('请选择或输入分值')
return
}
@@ -284,18 +256,15 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
await performSubmit(selectedStudent, delta, content)
}
// 快捷理由选择:点击即提交
const handleReasonSelect = (reason: reason) => {
if (!selectedStudent) return
performSubmit(selectedStudent, reason.delta, reason.content)
}
// 渲染学生卡片
const renderStudentCard = (student: student, index: number) => {
const avatarText = getDisplayText(student.name)
const avatarColor = getAvatarColor(student.name)
// 排行榜勋章
let rankBadge: string | null = null
if (sortType === 'score' && !searchKeyword) {
if (index === 0) rankBadge = '🥇'
@@ -316,6 +285,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
border: '1px solid var(--ss-border-color)',
overflow: 'visible'
}}
styles={{ body: { padding: '12px' } }}
>
{rankBadge && (
<div
@@ -364,9 +334,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px' }}>
<Tag
theme={student.score > 0 ? 'success' : student.score < 0 ? 'danger' : 'default'}
variant="light-outline"
size="small"
color={student.score > 0 ? 'success' : student.score < 0 ? 'error' : 'default'}
style={{ fontWeight: 'bold' }}
>
{student.score > 0 ? `+${student.score}` : student.score}
@@ -379,7 +347,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
)
}
// 渲染分组学生卡片
const renderGroupedCards = () => {
return groupedStudents.map((group) => (
<div
@@ -399,11 +366,11 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
display: 'flex',
alignItems: 'center',
gap: '8px',
borderLeft: '4px solid var(--td-brand-color)',
borderLeft: '4px solid var(--ant-color-primary, #1890ff)',
paddingLeft: '12px'
}}
>
<span style={{ color: 'var(--td-brand-color)' }}>{group.key}</span>
<span style={{ color: 'var(--ant-color-primary, #1890ff)' }}>{group.key}</span>
<span
style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', fontWeight: 'normal' }}
>
@@ -424,7 +391,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
))
}
// 快速导航滑动处理
const navContainerRef = useRef<HTMLDivElement>(null)
const isNavDragging = useRef(false)
@@ -437,7 +403,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const itemCount = items.length
if (itemCount === 0) return
// 计算当前指向第几个项
const itemHeight = rect.height / itemCount
const index = Math.floor(y / itemHeight)
const safeIndex = Math.max(0, Math.min(itemCount - 1, index))
@@ -469,7 +434,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
document.removeEventListener('mouseup', onGlobalMouseUp)
}
// 触摸事件处理
const onNavTouchStart = (e: React.TouchEvent) => {
isNavDragging.current = true
if (e.touches[0]) {
@@ -480,7 +444,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const onNavTouchMove = (e: React.TouchEvent) => {
if (isNavDragging.current && e.touches[0]) {
handleNavAction(e.touches[0].clientY)
// 防止触摸滑动时触发页面滚动
if (e.cancelable) e.preventDefault()
}
}
@@ -489,7 +452,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
isNavDragging.current = false
}
// 渲染快速导航
const renderQuickNav = () => {
if (
groupedStudents.length <= 1 ||
@@ -521,7 +483,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
border: '1px solid var(--ss-border-color)',
cursor: 'pointer',
userSelect: 'none',
touchAction: 'none' // 关键:禁用浏览器的默认触摸处理
touchAction: 'none'
}}
>
{groupedStudents.map((group) => (
@@ -535,9 +497,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
justifyContent: 'center',
fontSize: '11px',
fontWeight: 'bold',
color: 'var(--td-brand-color)',
color: 'var(--ant-color-primary, #1890ff)',
borderRadius: '50%',
pointerEvents: 'none' // 让事件由父容器统一处理
pointerEvents: 'none'
}}
>
{group.key}
@@ -549,7 +511,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto', position: 'relative' }}>
{/* 顶部工具栏 */}
{contextHolder}
<div
style={{
marginBottom: '32px',
@@ -569,35 +531,31 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</p>
</div>
<Space size="medium">
{/* 搜索 */}
<Space size="middle">
<Input
value={searchKeyword}
onChange={setSearchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="搜索姓名/拼音..."
prefixIcon={<SearchIcon />}
clearable
prefix={<SearchOutlined />}
allowClear
style={{ width: '220px' }}
/>
{/* 排序方式 */}
<Select
value={sortType}
onChange={(v) => setSortType(v as SortType)}
style={{ width: '140px' }}
autoWidth
>
<Select.Option value="alphabet" label="姓名排序" />
<Select.Option value="surname" label="姓氏分组" />
<Select.Option value="score" label="积分排行" />
</Select>
options={[
{ value: 'alphabet', label: '姓名排序' },
{ value: 'surname', label: '姓氏分组' },
{ value: 'score', label: '积分排行' }
]}
/>
</Space>
</div>
{/* 快速导航 */}
{renderQuickNav()}
{/* 学生卡片网格 */}
<div style={{ minHeight: '400px' }} ref={scrollContainerRef}>
{loading ? (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
@@ -617,12 +575,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
{searchKeyword ? '未找到匹配的学生' : '暂无学生数据,请前往学生管理添加'}
</div>
{searchKeyword && (
<Button
variant="text"
theme="primary"
onClick={() => setSearchKeyword('')}
style={{ marginTop: '8px' }}
>
<Button type="link" onClick={() => setSearchKeyword('')} style={{ marginTop: '8px' }}>
</Button>
)}
@@ -632,20 +585,19 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
)}
</div>
{/* 操作框 */}
<Dialog
header={`积分操作:${selectedStudent?.name}`}
visible={operationVisible}
onClose={() => setOperationVisible(false)}
onConfirm={handleSubmit}
confirmBtn={{ content: '提交操作', loading: submitLoading }}
width="560px"
destroyOnClose
top="10%"
<Modal
title={`积分操作:${selectedStudent?.name}`}
open={operationVisible}
onCancel={() => setOperationVisible(false)}
onOk={handleSubmit}
confirmLoading={submitLoading}
okText="提交操作"
cancelText="取消"
width={560}
destroyOnHidden
>
{selectedStudent && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', padding: '8px 0' }}>
{/* 当前状态 */}
<div
style={{
display: 'flex',
@@ -680,14 +632,13 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</span>
<Tag
theme={
color={
selectedStudent.score > 0
? 'success'
: selectedStudent.score < 0
? 'danger'
? 'error'
: 'default'
}
variant="light"
style={{ fontWeight: 'bold' }}
>
{selectedStudent.score > 0 ? `+${selectedStudent.score}` : selectedStudent.score}
@@ -695,7 +646,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div>
</div>
{/* 快捷理由 */}
{groupedReasons.length > 0 && (
<div>
<div
@@ -731,19 +681,18 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
>
{category}
</div>
<Space breakLine size="small">
<Space wrap size="small">
{items.map((r) => (
<Button
key={r.id}
variant="outline"
size="small"
onClick={() => handleReasonSelect(r)}
style={{
borderColor:
r.delta > 0
? 'var(--td-success-color-3)'
? 'var(--ant-color-success, #52c41a)'
: r.delta < 0
? 'var(--td-error-color-3)'
? 'var(--ant-color-error, #ff4d4f)'
: undefined
}}
>
@@ -753,9 +702,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
marginLeft: '4px',
color:
r.delta > 0
? 'var(--td-success-color)'
? 'var(--ant-color-success, #52c41a)'
: r.delta < 0
? 'var(--td-error-color)'
? 'var(--ant-color-error, #ff4d4f)'
: 'inherit',
fontWeight: 'bold'
}}
@@ -771,7 +720,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div>
)}
{/* 自定义分值 */}
<div>
<div
style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}
@@ -784,20 +732,15 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
<Button
key={num}
size="small"
variant={customScore === num ? 'base' : 'outline'}
theme={num > 0 ? 'success' : 'danger'}
type={customScore === num ? 'primary' : 'default'}
danger={num < 0}
onClick={() => setCustomScore(num)}
style={{ minWidth: '42px' }}
>
{num > 0 ? `+${num}` : num}
</Button>
))}
<Button
size="small"
variant="outline"
onClick={() => setCustomScore(0)}
style={{ minWidth: '42px' }}
>
<Button size="small" onClick={() => setCustomScore(0)} style={{ minWidth: '42px' }}>
0
</Button>
</div>
@@ -817,7 +760,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div>
</div>
{/* 理由内容 */}
<div>
<div
style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}
@@ -827,12 +769,12 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div>
<Input
value={reasonContent}
onChange={setReasonContent}
onChange={(e) => setReasonContent(e.target.value)}
placeholder="输入加分/扣分的原因(可选)"
suffixIcon={
suffix={
reasonContent ? (
<DeleteIcon
onClick={() => setSearchKeyword('')}
<DeleteOutlined
onClick={() => setReasonContent('')}
style={{ cursor: 'pointer' }}
/>
) : undefined
@@ -840,19 +782,18 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
/>
</div>
{/* 变动预览 */}
{customScore !== undefined && (
<div
style={{
padding: '16px',
backgroundColor:
customScore > 0
? 'var(--td-success-color-1)'
? 'var(--ant-color-success-bg, #f6ffed)'
: customScore < 0
? 'var(--td-error-color-1)'
? 'var(--ant-color-error-bg, #fff2f0)'
: 'var(--ss-bg-color)',
borderRadius: '8px',
border: `1px solid ${customScore > 0 ? 'var(--td-success-color-2)' : customScore < 0 ? 'var(--td-error-color-2)' : 'var(--ss-border-color)'}`,
border: `1px solid ${customScore > 0 ? 'var(--ant-color-success-border, #b7eb8f)' : customScore < 0 ? 'var(--ant-color-error-border, #ffccc7)' : 'var(--ss-border-color)'}`,
marginTop: '4px'
}}
>
@@ -873,9 +814,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
fontWeight: 'bold',
color:
customScore > 0
? 'var(--td-success-color)'
? 'var(--ant-color-success, #52c41a)'
: customScore < 0
? 'var(--td-error-color)'
? 'var(--ant-color-error, #ff4d4f)'
: 'inherit'
}}
>
@@ -890,7 +831,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
)}
</div>
)}
</Dialog>
</Modal>
</div>
)
}
+38 -56
View File
@@ -1,16 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import {
Table,
PrimaryTableCol,
Tag,
Button,
Select,
Space,
Card,
MessagePlugin,
Dialog
} from 'tdesign-react'
import { ViewListIcon, DownloadIcon } from 'tdesign-icons-react'
import { Table, Tag, Button, Select, Space, Card, message, Modal } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { EyeOutlined, DownloadOutlined } from '@ant-design/icons'
import * as XLSX from 'xlsx'
interface studentRank {
@@ -28,6 +19,7 @@ export const Leaderboard: React.FC = () => {
const [historyVisible, setHistoryVisible] = useState(false)
const [historyHeader, setHistoryHeader] = useState('')
const [historyText, setHistoryText] = useState('')
const [messageApi, contextHolder] = message.useMessage()
const fetchRankings = useCallback(async () => {
if (!(window as any).api) return
@@ -66,7 +58,7 @@ export const Leaderboard: React.FC = () => {
startTime
})
if (!res.success) {
MessagePlugin.error(res.message || '查询失败')
messageApi.error(res.message || '查询失败')
return
}
@@ -82,7 +74,6 @@ export const Leaderboard: React.FC = () => {
}
const handleExport = () => {
// 使用 requestIdleCallback 或 setTimeout 避免阻塞 UI
setTimeout(() => {
const title = timeRange === 'today' ? '今天' : timeRange === 'week' ? '本周' : '本月'
@@ -125,18 +116,18 @@ export const Leaderboard: React.FC = () => {
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
MessagePlugin.success('导出成功')
messageApi.success('导出成功')
}, 0)
}
const columns: PrimaryTableCol<studentRank>[] = [
const columns: ColumnsType<studentRank> = [
{
colKey: 'rank',
title: '排名',
key: 'rank',
width: 70,
align: 'center',
cell: ({ rowIndex }) => {
const rank = rowIndex + 1
render: (_, __, index) => {
const rank = index + 1
let color = 'inherit'
if (rank === 1) color = '#FFD700'
if (rank === 2) color = '#C0C0C0'
@@ -148,40 +139,34 @@ export const Leaderboard: React.FC = () => {
)
}
},
{ colKey: 'name', title: '姓名', width: 120, align: 'center' },
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120, align: 'center' },
{
colKey: 'score',
title: '总积分',
dataIndex: 'score',
key: 'score',
width: 100,
align: 'center',
cell: ({ row }) => <span style={{ fontWeight: 'bold' }}>{row.score}</span>
render: (score: number) => <span style={{ fontWeight: 'bold' }}>{score}</span>
},
{
colKey: 'range_change',
title: timeRange === 'today' ? '今日变化' : timeRange === 'week' ? '本周变化' : '本月变化',
dataIndex: 'range_change',
key: 'range_change',
width: 100,
align: 'center',
cell: ({ row }) => (
<Tag
theme={row.range_change > 0 ? 'success' : row.range_change < 0 ? 'danger' : 'default'}
variant="light"
>
{row.range_change > 0 ? `+${row.range_change}` : row.range_change}
render: (change: number) => (
<Tag color={change > 0 ? 'success' : change < 0 ? 'error' : 'default'}>
{change > 0 ? `+${change}` : change}
</Tag>
)
},
{
colKey: 'operation',
title: '操作记录',
key: 'operation',
width: 100,
align: 'center',
cell: ({ row }) => (
<Button
variant="text"
theme="primary"
icon={<ViewListIcon />}
onClick={() => handleViewHistory(row.name)}
>
render: (_, row) => (
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewHistory(row.name)}>
</Button>
)
@@ -190,6 +175,7 @@ export const Leaderboard: React.FC = () => {
return (
<div style={{ padding: '24px' }}>
{contextHolder}
<div
style={{
marginBottom: '24px',
@@ -202,14 +188,15 @@ export const Leaderboard: React.FC = () => {
<Space>
<Select
value={timeRange}
onChange={(v) => setTimeRange(v as string)}
onChange={(v) => setTimeRange(v)}
style={{ width: '120px' }}
>
<Select.Option value="today" label="今天" />
<Select.Option value="week" label="本周" />
<Select.Option value="month" label="本月" />
</Select>
<Button variant="outline" icon={<DownloadIcon />} onClick={handleExport}>
options={[
{ value: 'today', label: '今天' },
{ value: 'week', label: '本周' },
{ value: 'month', label: '本月' }
]}
/>
<Button icon={<DownloadOutlined />} onClick={handleExport}>
XLSX
</Button>
</Space>
@@ -217,27 +204,22 @@ export const Leaderboard: React.FC = () => {
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
<Table
data={data}
dataSource={data}
columns={columns}
rowKey="id"
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)' }}
/>
</Card>
<Dialog
header={historyHeader}
visible={historyVisible}
<Modal
title={historyHeader}
open={historyVisible}
onCancel={() => setHistoryVisible(false)}
footer={<Button onClick={() => setHistoryVisible(false)}></Button>}
width="80%"
cancelBtn={null}
confirmBtn="关闭"
onClose={() => setHistoryVisible(false)}
onConfirm={() => setHistoryVisible(false)}
>
<div
style={{
@@ -254,7 +236,7 @@ export const Leaderboard: React.FC = () => {
>
{historyText}
</div>
</Dialog>
</Modal>
</div>
)
}
+69 -104
View File
@@ -1,15 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'
import {
Table,
PrimaryTableCol,
Button,
Space,
Dialog,
Form,
Input,
MessagePlugin,
Tag
} from 'tdesign-react'
import { Table, Button, Modal, Form, Input, InputNumber, message, Tag, Popconfirm } from 'antd'
import type { ColumnsType } from 'antd/es/table'
interface reason {
id: number
@@ -23,10 +14,8 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [data, setData] = useState<reason[]>([])
const [loading, setLoading] = useState(false)
const [visible, setVisible] = useState(false)
const [deleteDialogVisible, setDeleteDialogVisible] = useState(false)
const [deleteLoading, setDeleteLoading] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<reason | null>(null)
const [form] = Form.useForm()
const [messageApi, contextHolder] = message.useMessage()
const emitDataUpdated = (category: 'reasons' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
@@ -60,15 +49,15 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleAdd = async () => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
const values = form.getFieldsValue?.(true) as any
const values = await form.validateFields()
const content = values.content?.trim()
const category = values.category?.trim() || '其他'
if (data.some((r) => r.content === content && r.category === category)) {
MessagePlugin.warning('该分类下已存在相同理由')
messageApi.warning('该分类下已存在相同理由')
return
}
@@ -79,148 +68,124 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
delta: Number(values.delta)
})
if (res.success) {
MessagePlugin.success('添加成功')
messageApi.success('添加成功')
setVisible(false)
form.reset()
form.resetFields()
fetchReasons()
emitDataUpdated('reasons')
} else {
MessagePlugin.error(res.message || '添加失败')
messageApi.error(res.message || '添加失败')
}
}
const handleDelete = async (row: reason) => {
const handleDelete = async (id: number) => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
setDeleteTarget(row)
setDeleteDialogVisible(true)
const res = await (window as any).api.deleteReason(id)
if (res.success) {
messageApi.success('删除成功')
fetchReasons()
emitDataUpdated('reasons')
} else {
messageApi.error(res.message || '删除失败')
}
}
const columns: PrimaryTableCol<reason>[] = [
const columns: ColumnsType<reason> = [
{
colKey: 'category',
title: '分类',
dataIndex: 'category',
key: 'category',
width: 120,
cell: ({ row }) => <Tag variant="outline">{row.category}</Tag>
render: (category: string) => <Tag>{category}</Tag>
},
{ colKey: 'content', title: '理由内容', width: 250 },
{ title: '理由内容', dataIndex: 'content', key: 'content', width: 250 },
{
colKey: 'delta',
title: '预设分值',
dataIndex: 'delta',
key: 'delta',
width: 100,
cell: ({ row }) => (
render: (delta: number) => (
<span
style={{ color: row.delta > 0 ? 'var(--td-success-color)' : 'var(--td-error-color)' }}
style={{
color:
delta > 0 ? 'var(--ant-color-success, #52c41a)' : 'var(--ant-color-error, #ff4d4f)'
}}
>
{row.delta > 0 ? `+${row.delta}` : row.delta}
{delta > 0 ? `+${delta}` : delta}
</span>
)
},
{
colKey: 'operation',
title: '操作',
key: 'operation',
width: 150,
cell: ({ row }) => (
<Space>
<Button
theme="danger"
variant="text"
disabled={!canEdit}
onClick={() => handleDelete(row)}
>
render: (_, row) => (
<Popconfirm
title="确认删除该理由?"
onConfirm={() => handleDelete(row.id)}
disabled={!canEdit}
>
<Button type="link" danger disabled={!canEdit}>
</Button>
</Space>
</Popconfirm>
)
}
]
return (
<div style={{ padding: '24px' }}>
{contextHolder}
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}></h2>
<Button theme="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
<Button type="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
</Button>
</div>
<Table
data={data}
dataSource={data}
columns={columns}
rowKey="id"
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)' }}
/>
<Dialog
header="添加理由"
visible={visible}
onConfirm={handleAdd}
onClose={() => setVisible(false)}
destroyOnClose
<Modal
title="添加理由"
open={visible}
onOk={handleAdd}
onCancel={() => setVisible(false)}
okText="添加"
cancelText="取消"
destroyOnHidden
>
<Form form={form} labelWidth={80}>
<Form.FormItem label="分类" name="category" initialData="其他">
<Form form={form} layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
<Form.Item label="分类" name="category" initialValue="其他">
<Input placeholder="例如: 学习, 纪律" />
</Form.FormItem>
<Form.FormItem label="理由内容" name="content">
</Form.Item>
<Form.Item
label="理由内容"
name="content"
rules={[{ required: true, message: '请输入理由内容' }]}
>
<Input placeholder="请输入理由" />
</Form.FormItem>
<Form.FormItem label="预设分值" name="delta">
<Input type="number" placeholder="例如: 2 或 -2" />
</Form.FormItem>
</Form.Item>
<Form.Item
label="预设分值"
name="delta"
rules={[{ required: true, message: '请输入预设分值' }]}
>
<InputNumber placeholder="例如: 2 或 -2" style={{ width: '100%' }} />
</Form.Item>
</Form>
</Dialog>
<Dialog
header="确认删除该理由?"
visible={deleteDialogVisible}
confirmBtn="删除"
confirmLoading={deleteLoading}
onClose={() => {
if (!deleteLoading) {
setDeleteDialogVisible(false)
setDeleteTarget(null)
}
}}
onCancel={() => {
if (!deleteLoading) {
setDeleteDialogVisible(false)
setDeleteTarget(null)
}
}}
onConfirm={async () => {
if (!(window as any).api) return
if (!deleteTarget) return
setDeleteLoading(true)
const res = await (window as any).api.deleteReason(deleteTarget.id)
setDeleteLoading(false)
if (res.success) {
MessagePlugin.success('删除成功')
setDeleteDialogVisible(false)
setDeleteTarget(null)
fetchReasons()
emitDataUpdated('reasons')
} else {
MessagePlugin.error(res.message || '删除失败')
}
}}
>
<div style={{ wordBreak: 'break-all' }}>
{deleteTarget
? `${deleteTarget.category} / ${deleteTarget.content} (${
deleteTarget.delta > 0 ? `+${deleteTarget.delta}` : deleteTarget.delta
})`
: ''}
</div>
</Dialog>
</Modal>
</div>
)
}
+61 -76
View File
@@ -6,16 +6,15 @@ import {
Input,
InputNumber,
Button,
MessagePlugin,
message,
Card,
Collapse,
Table,
PrimaryTableCol,
Tag,
Space,
Popconfirm
} from 'tdesign-react'
import { RollbackIcon } from 'tdesign-icons-react'
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { UndoOutlined } from '@ant-design/icons'
import { match } from 'pinyin-pro'
const normalizeSearch = (input: unknown) =>
@@ -86,6 +85,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [loading, setLoading] = useState(false)
const [submitLoading, setSubmitLoading] = useState(false)
const [form] = Form.useForm()
const [messageApi, contextHolder] = message.useMessage()
const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
@@ -93,7 +93,6 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const fetchData = useCallback(async () => {
if (!(window as any).api) return
// 使用 setTimeout 避免 UI 阻塞
setTimeout(async () => {
setLoading(true)
try {
@@ -134,17 +133,16 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleSubmit = async () => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
const values = form.getFieldsValue(true) as any
// 支持多选学生
const studentNames = Array.isArray(values.student_name)
? values.student_name
: [values.student_name]
if (!studentNames || studentNames.length === 0 || !values.reason_content) {
MessagePlugin.warning('请填写完整信息')
messageApi.warning('请填写完整信息')
return
}
@@ -155,7 +153,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const selectedReason = Number.isFinite(reasonId) ? reasons.find((r) => r.id === reasonId) : null
if (!hasDeltaInput && !selectedReason) {
MessagePlugin.warning('请填写分值或选择预设理由')
messageApi.warning('请填写分值或选择预设理由')
return
}
@@ -166,7 +164,6 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
: Math.abs(deltaInput)
: Number(selectedReason?.delta ?? 0)
// 为每个选中的学生创建事件
try {
let successCount = 0
for (const studentName of studentNames) {
@@ -181,7 +178,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}
if (successCount === studentNames.length) {
MessagePlugin.success(`已为 ${successCount} 名学生提交积分`)
messageApi.success(`已为 ${successCount} 名学生提交积分`)
form.setFieldsValue({
student_name: [],
delta: undefined,
@@ -192,7 +189,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
fetchData()
emitDataUpdated('events')
} else {
MessagePlugin.warning(`成功提交 ${successCount}/${studentNames.length} 名学生的积分`)
messageApi.warning(`成功提交 ${successCount}/${studentNames.length} 名学生的积分`)
fetchData()
emitDataUpdated('events')
}
@@ -204,48 +201,48 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleUndo = async (uuid: string) => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
const res = await (window as any).api.deleteEvent(uuid)
if (res.success) {
MessagePlugin.success('已撤销操作')
messageApi.success('已撤销操作')
fetchData()
emitDataUpdated('events')
} else {
MessagePlugin.error(res.message || '撤销失败')
messageApi.error(res.message || '撤销失败')
}
}
const columns: PrimaryTableCol<scoreEvent>[] = [
{ colKey: 'student_name', title: '学生', width: 100 },
const columns: ColumnsType<scoreEvent> = [
{ title: '学生', dataIndex: 'student_name', key: 'student_name', width: 100 },
{
colKey: 'delta',
title: '变动',
dataIndex: 'delta',
key: 'delta',
width: 80,
cell: ({ row }) => (
<Tag theme={row.delta > 0 ? 'success' : 'danger'} variant="light">
{row.delta > 0 ? `+${row.delta}` : row.delta}
</Tag>
render: (delta: number) => (
<Tag color={delta > 0 ? 'success' : 'error'}>{delta > 0 ? `+${delta}` : delta}</Tag>
)
},
{ colKey: 'reason_content', title: '理由', ellipsis: true },
{ title: '理由', dataIndex: 'reason_content', key: 'reason_content', ellipsis: true },
{
colKey: 'event_time',
title: '时间',
dataIndex: 'event_time',
key: 'event_time',
width: 160,
cell: ({ row }) => new Date(row.event_time).toLocaleString()
render: (time: string) => new Date(time).toLocaleString()
},
{
colKey: 'operation',
title: '操作',
key: 'operation',
width: 80,
cell: ({ row }) => (
render: (_, row) => (
<Popconfirm
content="确定要撤销这条记录吗?学生积分将回滚。"
title="确定要撤销这条记录吗?学生积分将回滚。"
onConfirm={() => handleUndo(row.uuid)}
>
<Button variant="text" theme="warning" disabled={!canEdit} icon={<RollbackIcon />}>
<Button type="link" danger disabled={!canEdit} icon={<UndoOutlined />}>
</Button>
</Popconfirm>
@@ -255,43 +252,37 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return (
<div style={{ padding: '24px' }}>
{contextHolder}
<h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}></h2>
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}>
<Form
form={form}
labelWidth={80}
initialData={{ type: 'add' }}
onReset={() => form.setFieldsValue({ type: 'add' })}
>
<Form form={form} layout="vertical" initialValues={{ type: 'add' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
<Form.FormItem label="姓名" name="student_name">
<Form.Item label="姓名" name="student_name">
<Select
filterable
multiple
mode="multiple"
showSearch
placeholder="请选择或搜索学生"
filter={(filterWords, option) =>
matchStudentName(getOptionLabel(option), filterWords)
}
filterOption={(input, option) => matchStudentName(getOptionLabel(option), input)}
options={students.map((s) => ({ label: s.name, value: s.name }))}
/>
</Form.FormItem>
</Form.Item>
<Form.FormItem label="分数">
<Form.Item label="分数">
<Space>
<Form.FormItem name="type" style={{ marginBottom: 0 }}>
<Radio.Group variant="default-filled">
<Form.Item name="type" noStyle>
<Radio.Group optionType="button" buttonStyle="solid">
<Radio.Button value="add"></Radio.Button>
<Radio.Button value="subtract"></Radio.Button>
</Radio.Group>
</Form.FormItem>
<Form.FormItem name="delta" style={{ marginBottom: 0 }}>
</Form.Item>
<Form.Item name="delta" noStyle>
<InputNumber min={1} placeholder="分值" style={{ width: '120px' }} />
</Form.FormItem>
</Form.Item>
</Space>
</Form.FormItem>
</Form.Item>
<Form.FormItem label="快捷理由" name="reason_id">
<Form.Item label="快捷理由" name="reason_id">
<Select
placeholder="选择预设理由"
onChange={(v) => {
@@ -317,23 +308,21 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
type: reason.delta > 0 ? 'add' : 'subtract'
})
}}
>
{reasons.map((r) => (
<Select.Option key={r.id} value={r.id} label={r.content}>
{r.content} ({r.delta > 0 ? `+${r.delta}` : r.delta})
</Select.Option>
))}
</Select>
</Form.FormItem>
options={reasons.map((r) => ({
label: `${r.content} (${r.delta > 0 ? `+${r.delta}` : r.delta})`,
value: r.id
}))}
/>
</Form.Item>
<Form.FormItem label="理由内容" name="reason_content">
<Form.Item label="理由内容" name="reason_content">
<Input placeholder="手动输入或选择快捷理由" />
</Form.FormItem>
</Form.Item>
</div>
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'center' }}>
<Button
theme="primary"
type="primary"
size="large"
disabled={!canEdit}
onClick={handleSubmit}
@@ -347,20 +336,16 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</Card>
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
<Collapse defaultValue={[]} expandMutex>
<Collapse.Panel header="最近记录" value="recent">
<Table
data={events}
columns={columns}
rowKey="uuid"
loading={loading}
size="small"
pagination={{ pageSize: 5, total: events.length, defaultCurrent: 1 }}
scroll={{ type: 'virtual' }}
style={{ color: 'var(--ss-text-main)' }}
/>
</Collapse.Panel>
</Collapse>
<div style={{ fontWeight: 600, marginBottom: 16 }}></div>
<Table
dataSource={events}
columns={columns}
rowKey="uuid"
loading={loading}
size="small"
pagination={{ pageSize: 5, total: events.length, defaultCurrent: 1 }}
style={{ color: 'var(--ss-text-main)' }}
/>
</Card>
</div>
)
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Card, MessagePlugin, Space, Table } from 'tdesign-react'
import type { PrimaryTableCol } from 'tdesign-react'
import { Button, Card, message, Space, Table } from 'antd'
import type { ColumnsType } from 'antd/es/table'
interface settlementSummary {
id: number
@@ -26,6 +26,7 @@ export const SettlementHistory: React.FC = () => {
} | null>(null)
const [rows, setRows] = useState<settlementLeaderboardRow[]>([])
const [detailLoading, setDetailLoading] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
const formatRange = (s: { start_time: string; end_time: string }) => {
const start = new Date(s.start_time).toLocaleString()
@@ -39,7 +40,7 @@ export const SettlementHistory: React.FC = () => {
try {
const res = await (window as any).api.querySettlements()
if (!res.success) {
MessagePlugin.error(res.message || '查询失败')
messageApi.error(res.message || '查询失败')
return
}
setSettlements(res.data || [])
@@ -48,7 +49,7 @@ export const SettlementHistory: React.FC = () => {
} finally {
setLoading(false)
}
}, [])
}, [messageApi])
useEffect(() => {
fetchSettlements()
@@ -70,7 +71,7 @@ export const SettlementHistory: React.FC = () => {
try {
const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id })
if (!res.success || !res.data) {
MessagePlugin.error(res.message || '查询失败')
messageApi.error(res.message || '查询失败')
return
}
setSelectedSettlement(res.data.settlement)
@@ -82,21 +83,22 @@ export const SettlementHistory: React.FC = () => {
}
}
const columns: PrimaryTableCol<settlementLeaderboardRow>[] = useMemo(
const columns: ColumnsType<settlementLeaderboardRow> = useMemo(
() => [
{
colKey: 'rank',
title: '排名',
key: 'rank',
width: 70,
align: 'center',
cell: ({ rowIndex }) => <span style={{ fontWeight: 'bold' }}>{rowIndex + 1}</span>
render: (_, __, index) => <span style={{ fontWeight: 'bold' }}>{index + 1}</span>
},
{ colKey: 'name', title: '姓名', width: 160 },
{ title: '姓名', dataIndex: 'name', key: 'name', width: 160 },
{
colKey: 'score',
title: '阶段积分',
dataIndex: 'score',
key: 'score',
width: 120,
cell: ({ row }) => <span style={{ fontWeight: 'bold' }}>{row.score}</span>
render: (score: number) => <span style={{ fontWeight: 'bold' }}>{score}</span>
}
],
[]
@@ -105,9 +107,9 @@ export const SettlementHistory: React.FC = () => {
if (selectedId !== null && selectedSettlement) {
return (
<div style={{ padding: '24px' }}>
{contextHolder}
<Space style={{ marginBottom: '16px' }}>
<Button
variant="outline"
onClick={() => {
setSelectedId(null)
setSelectedSettlement(null)
@@ -124,14 +126,12 @@ export const SettlementHistory: React.FC = () => {
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
<Table
data={rows}
dataSource={rows}
columns={columns}
rowKey="name"
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>
@@ -141,6 +141,7 @@ export const SettlementHistory: React.FC = () => {
return (
<div style={{ padding: '24px' }}>
{contextHolder}
<h2 style={{ marginBottom: '16px', color: 'var(--ss-text-main)' }}></h2>
<div
style={{
@@ -161,7 +162,7 @@ export const SettlementHistory: React.FC = () => {
{formatRange(s)}
</div>
<Space>
<Button theme="primary" onClick={() => openSettlement(s.id)}>
<Button type="primary" onClick={() => openSettlement(s.id)}>
</Button>
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
+220 -40
View File
@@ -1,33 +1,169 @@
import { Layout, Menu } from 'tdesign-react'
import { Layout, Menu, Card, Tag, Button, Space, message } from 'antd'
import {
UserIcon,
SettingIcon,
HistoryIcon,
RootListIcon,
ViewListIcon,
HomeIcon,
ReplayIcon
} from 'tdesign-icons-react'
UserOutlined,
SettingOutlined,
HistoryOutlined,
UnorderedListOutlined,
HomeOutlined,
SyncOutlined,
FileTextOutlined,
CloudOutlined,
UploadOutlined
} from '@ant-design/icons'
import { useState, useEffect } from 'react'
import appLogo from '../assets/logoHD.svg'
const { Aside } = Layout
const { Sider } = Layout
interface SidebarProps {
activeMenu: string
permission: 'admin' | 'points' | 'view'
onMenuChange: (value: string | number) => void
onMenuChange: (value: string) => void
}
interface DbStatus {
type: 'sqlite' | 'postgresql'
connected: boolean
error?: string
}
export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps): React.JSX.Element {
const [dbStatus, setDbStatus] = useState<DbStatus>({ type: 'sqlite', connected: true })
const [syncLoading, setSyncLoading] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
useEffect(() => {
loadDbStatus()
const handleStatusChange = () => {
loadDbStatus()
}
if ((window as any).api) {
const unsubscribe = (window as any).api.onSettingChanged((change) => {
if (change.key === 'pg_connection_status') {
handleStatusChange()
}
})
return unsubscribe
}
}, [])
const loadDbStatus = async () => {
if (!(window as any).api) return
try {
const res = await (window as any).api.dbGetStatus()
if (res.success && res.data) {
setDbStatus(res.data)
}
} catch (e) {
console.error('Failed to load database status:', e)
}
}
const handleSync = async () => {
if (!(window as any).api) return
setSyncLoading(true)
try {
await loadDbStatus()
} catch (e) {
console.error('Failed to sync database status:', e)
} finally {
setSyncLoading(false)
}
}
const [forceSyncLoading, setForceSyncLoading] = useState(false)
const handleForceSync = async () => {
if (!(window as any).api) return
const statusRes = await (window as any).api.dbGetStatus()
if (!statusRes.success || !statusRes.data) {
messageApi.error('获取数据库状态失败')
return
}
if (statusRes.data.type !== 'postgresql') {
messageApi.error('当前不是远程数据库模式,请重启应用后重试')
return
}
if (!statusRes.data.connected) {
messageApi.error('数据库未连接')
return
}
setForceSyncLoading(true)
try {
const res = await (window as any).api.dbSync()
if (res.success && res.data?.success) {
messageApi.success('同步成功')
} else {
messageApi.error(res.data?.message || res.message || '同步失败')
}
} catch (e: any) {
messageApi.error(e?.message || '同步失败')
} finally {
setForceSyncLoading(false)
}
}
const menuItems = [
{
key: 'home',
icon: <HomeOutlined />,
label: '主页'
},
{
key: 'students',
icon: <UserOutlined />,
label: '学生管理',
disabled: permission !== 'admin'
},
{
key: 'score',
icon: <HistoryOutlined />,
label: '积分管理'
},
{
key: 'auto-score',
icon: <SyncOutlined />,
label: '自动加分'
},
{
key: 'leaderboard',
icon: <UnorderedListOutlined />,
label: '排行榜'
},
{
key: 'settlements',
icon: <FileTextOutlined />,
label: '结算历史'
},
{
key: 'reasons',
icon: <UnorderedListOutlined />,
label: '理由管理',
disabled: permission !== 'admin'
},
{
key: 'settings',
icon: <SettingOutlined />,
label: '系统设置',
disabled: permission !== 'admin'
}
]
return (
<Aside
<Sider
className="ss-sidebar"
width={200}
style={{
backgroundColor: 'var(--ss-sidebar-bg)',
borderRight: '1px solid var(--ss-border-color)',
display: 'flex',
flexDirection: 'column'
}}
theme="light"
>
<div
style={
@@ -67,34 +203,78 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
</div>
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<Menu value={activeMenu} onChange={onMenuChange} style={{ width: '100%', border: 'none' }}>
<Menu.MenuItem value="home" icon={<HomeIcon />}>
{' '}
</Menu.MenuItem>
<Menu.MenuItem value="students" icon={<UserIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
<Menu.MenuItem value="score" icon={<HistoryIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="auto-score" icon={<ReplayIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="leaderboard" icon={<ViewListIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="settlements" icon={<HistoryIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="reasons" icon={<RootListIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
<Menu.MenuItem value="settings" icon={<SettingIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
</Menu>
<Menu
mode="inline"
selectedKeys={[activeMenu]}
onClick={({ key }) => onMenuChange(key)}
style={{
width: '100%',
border: 'none',
backgroundColor: 'transparent'
}}
items={menuItems}
/>
</div>
</Aside>
{dbStatus.type === 'postgresql' && (
<Card
size="small"
style={{
margin: '8px',
backgroundColor: 'var(--ss-card-bg)',
border: '1px solid var(--ss-border-color)'
}}
bodyStyle={{ padding: '12px' }}
>
{contextHolder}
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space size={4}>
<CloudOutlined style={{ fontSize: '12px', color: '#1890ff' }} />
<span style={{ fontSize: '12px', fontWeight: 500 }}></span>
</Space>
<Tag
color={dbStatus.connected ? 'success' : 'error'}
style={{ margin: 0, fontSize: '10px' }}
>
{dbStatus.connected ? '已连接' : '未连接'}
</Tag>
</div>
<Button
type="text"
size="small"
icon={<SyncOutlined spin={syncLoading} />}
onClick={handleSync}
loading={syncLoading}
style={{
width: '100%',
height: '24px',
fontSize: '12px',
padding: '0 8px',
color: 'var(--ss-text-secondary)'
}}
>
</Button>
<Button
type="primary"
size="small"
icon={<UploadOutlined />}
onClick={handleForceSync}
loading={forceSyncLoading}
disabled={!dbStatus.connected}
style={{
width: '100%',
height: '24px',
fontSize: '12px',
padding: '0 8px'
}}
>
</Button>
</Space>
</Card>
)}
</Sider>
)
}
+119 -124
View File
@@ -1,9 +1,8 @@
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { Table, Button, Space, MessagePlugin, Dialog, Form, Input, Tag } from 'tdesign-react'
import type { PrimaryTableCol } from 'tdesign-react'
import { Table, Button, Space, message, Modal, Form, Input, Tag, Pagination } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { TagEditorDialog } from './TagEditorDialog'
// 创建 XLSX Worker
const createXlsxWorker = () => {
return new Worker(new URL('../workers/xlsxWorker.ts', import.meta.url), {
type: 'module'
@@ -35,8 +34,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const xlsxInputRef = useRef<HTMLInputElement | null>(null)
const xlsxWorkerRef = useRef<Worker | null>(null)
const [form] = Form.useForm()
const [messageApi, contextHolder] = message.useMessage()
// 初始化 Worker
useEffect(() => {
xlsxWorkerRef.current = createXlsxWorker()
return () => {
@@ -106,36 +105,31 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleAdd = async () => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
try {
const validateResult = await form.validate()
if (validateResult !== true) {
return
}
const values = form.getFieldsValue(true) as { name: string }
const values = await form.validateFields()
if (!values.name) {
MessagePlugin.warning('请输入姓名')
messageApi.warning('请输入姓名')
return
}
const name = values.name.trim()
if (data.some((s) => s.name === name)) {
MessagePlugin.warning('学生姓名已存在')
messageApi.warning('学生姓名已存在')
return
}
const res = await (window as any).api.createStudent({ ...values, name })
if (res.success) {
MessagePlugin.success('添加成功')
messageApi.success('添加成功')
setVisible(false)
form.reset()
form.resetFields()
fetchStudents()
emitDataUpdated('students')
} else {
MessagePlugin.error(res.message || '添加失败')
messageApi.error(res.message || '添加失败')
}
} catch (err) {
try {
@@ -155,22 +149,22 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleDelete = async (id: number) => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
const res = await (window as any).api.deleteStudent(id)
if (res.success) {
MessagePlugin.success('删除成功')
messageApi.success('删除成功')
fetchStudents()
emitDataUpdated('students')
} else {
MessagePlugin.error(res.message || '删除失败')
messageApi.error(res.message || '删除失败')
}
}
const handleOpenTagEditor = (student: student) => {
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
setEditingStudent(student)
@@ -183,18 +177,18 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
try {
const res = await (window as any).api.tagsUpdateStudentTags(editingStudent.id, tagIds)
if (res && res.success) {
MessagePlugin.success('标签保存成功')
messageApi.success('标签保存成功')
setTagEditVisible(false)
setEditingStudent(null)
fetchStudents()
emitDataUpdated('students')
} else {
const errorMsg = res?.message || '保存标签失败'
MessagePlugin.error(errorMsg)
messageApi.error(errorMsg)
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
MessagePlugin.error(`保存标签失败: ${errorMsg}`)
messageApi.error(`保存标签失败: ${errorMsg}`)
}
}
@@ -211,7 +205,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const parseXlsxFile = async (file: File) => {
if (!xlsxWorkerRef.current) {
MessagePlugin.error('Worker 未初始化')
messageApi.error('Worker 未初始化')
return
}
@@ -219,13 +213,11 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
try {
const buf = await file.arrayBuffer()
// 使用 Worker 处理文件解析,避免阻塞主线程
xlsxWorkerRef.current.postMessage({
type: 'parseXlsx',
data: { buffer: buf }
})
// 监听 Worker 消息
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'success') {
setXlsxFileName(file.name)
@@ -235,7 +227,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
setImportVisible(false)
setXlsxLoading(false)
} else if (event.data.type === 'error') {
MessagePlugin.error(event.data.error || '解析 xlsx 失败')
messageApi.error(event.data.error || '解析 xlsx 失败')
setXlsxLoading(false)
}
xlsxWorkerRef.current?.removeEventListener('message', handleMessage)
@@ -243,7 +235,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
xlsxWorkerRef.current.addEventListener('message', handleMessage)
} catch (e: any) {
MessagePlugin.error(e?.message || '解析 xlsx 失败')
messageApi.error(e?.message || '解析 xlsx 失败')
setXlsxLoading(false)
}
}
@@ -269,26 +261,34 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}, [xlsxAoa, xlsxMaxCols])
const xlsxPreviewColumns = useMemo(() => {
const cols: PrimaryTableCol<any>[] = [
{ colKey: '__row', title: '#', width: 60, align: 'center', fixed: 'left' as any }
const cols: any[] = [
{
title: '#',
dataIndex: '__row',
key: '__row',
width: 60,
align: 'center' as const,
fixed: 'left' as const
}
]
for (let c = 0; c < xlsxMaxCols; c++) {
const selected = xlsxSelectedCol === c
cols.push({
colKey: `c${c}`,
title: (
<span
style={{
cursor: 'pointer',
fontWeight: selected ? 700 : 500,
color: selected ? 'var(--td-brand-color)' : undefined
color: selected ? 'var(--ant-color-primary, #1890ff)' : undefined
}}
onClick={() => setXlsxSelectedCol(c)}
>
{excelColName(c)}
</span>
),
minWidth: 120
dataIndex: `c${c}`,
key: `c${c}`,
width: 120
})
}
return cols
@@ -313,17 +313,17 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const handleConfirmXlsxImport = async () => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
messageApi.error('当前为只读权限')
return
}
if (xlsxSelectedCol == null) {
MessagePlugin.warning('请先点击选择姓名列')
messageApi.warning('请先点击选择"姓名列"')
return
}
const names = extractNamesFromAoa(xlsxAoa, xlsxSelectedCol)
if (!names.length) {
MessagePlugin.error('所选列未解析到可导入的姓名')
messageApi.error('所选列未解析到可导入的姓名')
return
}
@@ -331,12 +331,12 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
try {
const res = await (window as any).api.importStudentsFromXlsx({ names })
if (!res?.success) {
MessagePlugin.error(res?.message || '导入失败')
messageApi.error(res?.message || '导入失败')
return
}
const inserted = Number(res?.data?.inserted ?? 0)
const skipped = Number(res?.data?.skipped ?? 0)
MessagePlugin.success(`导入完成:新增 ${inserted},跳过 ${skipped}`)
messageApi.success(`导入完成:新增 ${inserted},跳过 ${skipped}`)
setXlsxVisible(false)
setXlsxAoa([])
setXlsxFileName('')
@@ -348,75 +348,60 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}
}
const columns: PrimaryTableCol<student>[] = [
{ colKey: 'name', title: '姓名', width: 100 },
const columns: ColumnsType<student> = [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{
colKey: 'score',
title: '当前积分',
dataIndex: 'score',
key: 'score',
width: 160,
align: 'center',
cell: ({ row }) => (
render: (score: number) => (
<span
style={{
fontWeight: 'bold',
color:
row.score > 0
? 'var(--td-success-color)'
: row.score < 0
? 'var(--td-error-color)'
score > 0
? 'var(--ant-color-success, #52c41a)'
: score < 0
? 'var(--ant-color-error, #ff4d4f)'
: 'inherit'
}}
>
{row.score > 0 ? `+${row.score}` : row.score}
{score > 0 ? `+${score}` : score}
</span>
)
},
{
colKey: 'tags',
title: '标签',
dataIndex: 'tags',
key: 'tags',
width: 200,
cell: ({ row }) => {
const tags = row.tags || []
return (
<Space>
{tags.length === 0 ? (
<span style={{ color: 'var(--ss-text-secondary)' }}></span>
) : (
tags.slice(0, 3).map((tag) => (
<Tag key={tag} theme="primary" size="small">
{tag}
</Tag>
))
)}
{tags.length > 3 && (
<Tag theme="default" size="small">
+{tags.length - 3}
render: (tags: string[] = []) => (
<Space>
{tags.length === 0 ? (
<span style={{ color: 'var(--ss-text-secondary)' }}></span>
) : (
tags.slice(0, 3).map((tag) => (
<Tag key={tag} color="blue">
{tag}
</Tag>
)}
</Space>
)
}
))
)}
{tags.length > 3 && <Tag>+{tags.length - 3}</Tag>}
</Space>
)
},
{
colKey: 'operation',
title: '操作',
key: 'operation',
width: 150,
cell: ({ row }) => (
render: (_, row) => (
<Space>
<Button
theme="default"
variant="text"
disabled={!canEdit}
onClick={() => handleOpenTagEditor(row)}
>
<Button type="link" disabled={!canEdit} onClick={() => handleOpenTagEditor(row)}>
</Button>
<Button
theme="danger"
variant="text"
disabled={!canEdit}
onClick={() => handleDelete(row.id)}
>
<Button type="link" danger disabled={!canEdit} onClick={() => handleDelete(row.id)}>
</Button>
</Space>
@@ -424,59 +409,69 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}
]
const paginatedData = data.slice((currentPage - 1) * pageSize, currentPage * pageSize)
return (
<div style={{ padding: '24px' }}>
{contextHolder}
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}></h2>
<Space>
<Button variant="outline" disabled={!canEdit} onClick={() => setImportVisible(true)}>
<Button disabled={!canEdit} onClick={() => setImportVisible(true)}>
</Button>
<Button theme="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
<Button type="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
</Button>
</Space>
</div>
<Table
data={data.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
dataSource={paginatedData}
columns={columns}
rowKey="id"
loading={loading}
hover
pagination={{
current: currentPage,
pageSize,
total: data.length,
onChange: (pageInfo) => setCurrentPage(pageInfo.current),
onPageSizeChange: (size) => setPageSize(size)
}}
pagination={false}
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/>
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={data.length}
onChange={(page, size) => {
setCurrentPage(page)
setPageSize(size)
}}
showSizeChanger
showTotal={(total) => `${total}`}
/>
</div>
{/* 添加学生弹窗 */}
<Dialog
header="添加学生"
visible={visible}
onConfirm={handleAdd}
onClose={() => setVisible(false)}
destroyOnClose
<Modal
title="添加学生"
open={visible}
onOk={handleAdd}
onCancel={() => setVisible(false)}
okText="添加"
cancelText="取消"
destroyOnHidden
>
<Form form={form} labelWidth={80}>
<Form.FormItem label="姓名" name="name">
<Form form={form} layout="vertical">
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入学生姓名" />
</Form.FormItem>
</Form.Item>
</Form>
</Dialog>
</Modal>
<Dialog
header="导入名单"
visible={importVisible}
onClose={() => setImportVisible(false)}
footer={false}
destroyOnClose
<Modal
title="导入名单"
open={importVisible}
onCancel={() => setImportVisible(false)}
footer={null}
destroyOnHidden
>
<Space direction="vertical" style={{ width: '100%' }}>
<Space orientation="vertical" style={{ width: '100%' }}>
<Button
loading={xlsxLoading}
disabled={!canEdit}
@@ -498,16 +493,17 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}}
/>
</Space>
</Dialog>
</Modal>
<Dialog
header="xlsx 预览与导入"
visible={xlsxVisible}
onClose={() => setXlsxVisible(false)}
confirmBtn={{ content: '导入', loading: xlsxLoading, disabled: xlsxSelectedCol == null }}
onConfirm={handleConfirmXlsxImport}
<Modal
title="xlsx 预览与导入"
open={xlsxVisible}
onCancel={() => setXlsxVisible(false)}
onOk={handleConfirmXlsxImport}
okText="导入"
okButtonProps={{ loading: xlsxLoading, disabled: xlsxSelectedCol == null }}
width="80%"
destroyOnClose
destroyOnHidden
>
<div style={{ marginBottom: '12px', color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
<div>{xlsxFileName || '-'}</div>
@@ -517,17 +513,16 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
<div> 50 </div>
</div>
<Table
data={xlsxPreviewRows}
dataSource={xlsxPreviewRows}
columns={xlsxPreviewColumns}
rowKey="__row"
bordered
hover
maxHeight={420}
scroll={{ y: 420 }}
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
pagination={false}
/>
</Dialog>
</Modal>
{/* 标签编辑弹窗 */}
<TagEditorDialog
visible={tagEditVisible}
onClose={() => {
+31 -45
View File
@@ -1,8 +1,7 @@
import React, { useState, useEffect } from 'react'
import { Dialog, Input, Button, Space, Tag, MessagePlugin } from 'tdesign-react'
import { useTheme } from '../contexts/ThemeContext' // 导入主题上下文
import { Modal, Input, Button, Space, Tag, message } from 'antd'
interface Tag {
interface TagItem {
id: number
name: string
}
@@ -23,13 +22,10 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
title = '编辑标签'
}) => {
const [inputValue, setInputValue] = useState('')
const [allTags, setAllTags] = useState<Tag[]>([])
const [allTags, setAllTags] = useState<TagItem[]>([])
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set(initialTagIds))
const { currentTheme } = useTheme() // 获取当前主题
const [messageApi, contextHolder] = message.useMessage()
const themeMode = currentTheme?.mode || 'light' // 默认为 light
// fetchAllTags is declared as a function so it can be called from useEffect
async function fetchAllTags() {
if (!(window as any).api) return
try {
@@ -39,7 +35,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
}
} catch (e) {
console.error('Failed to fetch tags:', e)
MessagePlugin.error('获取标签列表失败')
messageApi.error('获取标签列表失败')
}
}
@@ -56,7 +52,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
if (!trimmed) return
if (trimmed.length > 30) {
MessagePlugin.error('标签名称不能超过 30 个字符')
messageApi.error('标签名称不能超过 30 个字符')
return
}
@@ -76,11 +72,11 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
setSelectedTagIds((prev) => new Set([...prev, res.data.id]))
setInputValue('')
} else {
MessagePlugin.error(res.message || '添加标签失败')
messageApi.error(res.message || '添加标签失败')
}
} catch (e) {
console.error('Failed to create tag:', e)
MessagePlugin.error('添加标签失败')
messageApi.error('添加标签失败')
}
}
@@ -106,23 +102,16 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
newSet.delete(tagId)
return newSet
})
MessagePlugin.success('标签删除成功')
messageApi.success('标签删除成功')
} else {
MessagePlugin.error(res.message || '删除标签失败')
messageApi.error(res.message || '删除标签失败')
}
} catch (e) {
console.error('Failed to delete tag:', e)
MessagePlugin.error('删除标签失败')
messageApi.error('删除标签失败')
}
}
/* const handleKeyDown = (value: string, context: { e: React.KeyboardEvent<HTMLInputElement> }) => {
if (context.e.key === 'Enter') {
context.e.preventDefault()
handleAddTag()
}
} */
const handleConfirm = () => {
onConfirm(Array.from(selectedTagIds))
onClose()
@@ -132,33 +121,31 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
const availableTags = allTags.filter((t) => !selectedTagIds.has(t.id))
return (
<Dialog
header={title}
visible={visible}
onClose={onClose}
onConfirm={handleConfirm}
confirmBtn={{ content: '保存', theme: 'primary' }}
cancelBtn={{ content: '取消' }}
destroyOnClose
<Modal
title={title}
open={visible}
onCancel={onClose}
onOk={handleConfirm}
okText="保存"
cancelText="取消"
destroyOnHidden
width={500}
>
{contextHolder}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* 标签输入区 */}
<div style={{ display: 'flex', gap: '8px' }}>
<Input
placeholder="输入标签名称,按 Enter 添加"
value={inputValue}
onChange={setInputValue}
onEnter={handleAddTag}
/* maxLength={50} */
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={handleAddTag}
style={{ flex: 1 }}
/>
<Button theme="primary" onClick={handleAddTag} disabled={!inputValue.trim()}>
<Button type="primary" onClick={handleAddTag} disabled={!inputValue.trim()}>
</Button>
</div>
{/* 已选标签区 */}
<div>
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
@@ -174,12 +161,11 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
{selectedTags.length === 0 ? (
<span style={{ color: 'var(--ss-text-secondary)' }}></span>
) : (
<Space>
<Space wrap>
{selectedTags.map((tag) => (
<Tag
key={tag.id}
theme="primary"
variant={themeMode === 'dark' ? 'outline' : 'light'} // 根据主题模式动态设置变体
color="blue"
closable
onClose={() => handleToggleTag(tag.id)}
style={{ cursor: 'pointer' }}
@@ -192,7 +178,6 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
</div>
</div>
{/* 可选标签区 */}
<div>
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
@@ -208,14 +193,15 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
{availableTags.length === 0 ? (
<span style={{ color: 'var(--ss-text-secondary)' }}></span>
) : (
<Space>
<Space wrap>
{availableTags.map((tag) => (
<Tag
key={tag.id}
theme="default"
variant={themeMode === 'dark' ? 'light' : 'outline'} // 根据主题模式动态设置变体
closable
onClose={() => handleDeleteTag(tag.id)}
onClose={(e) => {
e.preventDefault()
handleDeleteTag(tag.id)
}}
style={{ cursor: 'pointer' }}
onClick={() => handleToggleTag(tag.id)}
>
@@ -227,6 +213,6 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
</div>
</div>
</div>
</Dialog>
</Modal>
)
}
+33 -51
View File
@@ -1,16 +1,6 @@
import React, { useEffect } from 'react'
import {
Drawer,
Form,
Input,
Select,
ColorPicker,
Button,
Space,
Divider,
Row,
Col
} from 'tdesign-react'
import { Drawer, Form, Input, Select, Button, Space, Divider, Row, Col, ColorPicker } from 'antd'
import type { Color } from 'antd/es/color-picker'
import { useThemeEditor } from '../contexts/ThemeEditorContext'
import { useTheme } from '../contexts/ThemeContext'
import { generateColorMap } from '../utils/color'
@@ -59,7 +49,6 @@ export const ThemeEditor: React.FC = () => {
const { currentTheme } = useTheme()
// 实时预览逻辑
useEffect(() => {
if (!isEditing || !editingTheme) return
@@ -67,10 +56,8 @@ export const ThemeEditor: React.FC = () => {
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]) => {
@@ -78,7 +65,6 @@ export const ThemeEditor: React.FC = () => {
})
}
// 3. 应用自定义变量
Object.entries(custom).forEach(([key, value]) => {
root.style.setProperty(key, value)
})
@@ -87,7 +73,6 @@ export const ThemeEditor: React.FC = () => {
applyPreview(editingTheme)
}, [editingTheme, isEditing])
// 关闭时恢复原有主题
useEffect(() => {
if (!isEditing && currentTheme) {
const { tdesign, custom } = currentTheme.config
@@ -112,39 +97,36 @@ export const ThemeEditor: React.FC = () => {
return (
<Drawer
header="编辑主题"
visible={isEditing}
title="编辑主题"
open={isEditing}
onClose={cancelEditing}
size="500px"
width={500}
footer={
<Space>
<Button theme="primary" onClick={saveEditingTheme}>
<Button type="primary" onClick={saveEditingTheme}>
</Button>
<Button theme="default" onClick={cancelEditing}>
</Button>
<Button onClick={cancelEditing}></Button>
</Space>
}
destroyOnClose
destroyOnHidden
>
<Form labelAlign="top">
<Space direction="vertical" style={{ width: '100%' }} size="large">
{/* 基本信息 */}
<Form layout="vertical">
<Space orientation="vertical" style={{ width: '100%' }} size="large">
<div>
<Divider align="left"></Divider>
<Divider></Divider>
<Row gutter={[16, 16]}>
<Col span={12}>
<Form.FormItem label="主题名称">
<Form.Item label="主题名称">
<Input
value={editingTheme.name}
onChange={(v) => updateEditingTheme({ name: v })}
onChange={(e) => updateEditingTheme({ name: e.target.value })}
placeholder="请输入主题名称"
/>
</Form.FormItem>
</Form.Item>
</Col>
<Col span={12}>
<Form.FormItem label="色彩模式">
<Form.Item label="色彩模式">
<Select
value={editingTheme.mode}
onChange={(v) => updateEditingTheme({ mode: v as 'light' | 'dark' })}
@@ -153,36 +135,35 @@ export const ThemeEditor: React.FC = () => {
{ label: '深色 (Dark)', value: 'dark' }
]}
/>
</Form.FormItem>
</Form.Item>
</Col>
<Col span={24}>
<Form.FormItem label="主题 ID (唯一标识)" help="建议使用英文,如 my-theme">
<Form.Item label="主题 ID (唯一标识)" help="建议使用英文,如 my-theme">
<Input
value={editingTheme.id}
onChange={(v) => updateEditingTheme({ id: v })}
onChange={(e) => updateEditingTheme({ id: e.target.value })}
placeholder="请输入主题 ID"
/>
</Form.FormItem>
</Form.Item>
</Col>
</Row>
</div>
{/* TDesign 品牌色 */}
<div>
<Divider align="left"> (Brand)</Divider>
<Form.FormItem label="主品牌色" help="将自动生成一系列色阶">
<Divider> (Brand)</Divider>
<Form.Item label="主品牌色" help="将自动生成一系列色阶">
<ColorPicker
value={editingTheme.config.tdesign.brandColor}
onChange={(v) => updateConfig('tdesign', 'brandColor', v)}
enableAlpha={false}
format="HEX"
onChange={(color: Color) =>
updateConfig('tdesign', 'brandColor', color.toHexString())
}
showText
/>
</Form.FormItem>
</Form.Item>
</div>
{/* 业务自定义变量 */}
<div>
<Divider align="left"> (Custom)</Divider>
<Divider> (Custom)</Divider>
{Object.entries(variableGroups).map(([groupKey, group]) => (
<div key={groupKey} style={{ marginBottom: 24 }}>
<div
@@ -190,7 +171,7 @@ export const ThemeEditor: React.FC = () => {
fontSize: '13px',
fontWeight: 600,
marginBottom: '12px',
color: 'var(--td-text-color-primary)'
color: 'var(--ss-text-main)'
}}
>
{group.title}
@@ -202,7 +183,7 @@ export const ThemeEditor: React.FC = () => {
<div
style={{
fontSize: '12px',
color: 'var(--td-text-color-secondary)',
color: 'var(--ss-text-secondary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
@@ -213,9 +194,10 @@ export const ThemeEditor: React.FC = () => {
</div>
<ColorPicker
value={editingTheme.config.custom[item.key] || '#ffffff'}
onChange={(v) => updateConfig('custom', item.key, v)}
enableAlpha
format="HEX"
onChange={(color: Color) =>
updateConfig('custom', item.key, color.toHexString())
}
showText
/>
</div>
</Col>
+15 -14
View File
@@ -1,5 +1,10 @@
import { Button } from 'tdesign-react'
import { RemoveIcon, RectangleIcon, CloseIcon, FullscreenExitIcon } from 'tdesign-icons-react'
import { Button } from 'antd'
import {
MinusOutlined,
BorderOutlined,
CloseOutlined,
FullscreenExitOutlined
} from '@ant-design/icons'
import { useEffect, useState } from 'react'
import electronLogo from '../assets/electron.svg'
@@ -11,10 +16,9 @@ export function TitleBar({ children }: TitleBarProps): React.JSX.Element {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
if (!(window as any).api) return // Check initial state
if (!(window as any).api) return
;(window as any).api.windowIsMaximized().then((v: boolean) => setIsMaximized(v))
// Subscribe to changes
const cleanup = (window as any).api.onWindowMaximizedChanged((maximized: boolean) => {
setIsMaximized(maximized)
})
@@ -96,33 +100,30 @@ export function TitleBar({ children }: TitleBarProps): React.JSX.Element {
}
>
<Button
variant="text"
shape="square"
type="text"
onClick={minimize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<RemoveIcon />
<MinusOutlined />
</Button>
<Button
variant="text"
shape="square"
type="text"
onClick={maximize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
{isMaximized ? (
<FullscreenExitIcon style={{ transform: 'scale(0.5)' }} />
<FullscreenExitOutlined style={{ transform: 'scale(0.8)' }} />
) : (
<RectangleIcon />
<BorderOutlined style={{ transform: 'scale(0.8)' }} />
)}
</Button>
<Button
variant="text"
shape="square"
type="text"
onClick={close}
className="titlebar-close-btn"
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<CloseIcon />
<CloseOutlined />
</Button>
</div>
+15 -14
View File
@@ -1,15 +1,19 @@
import { Button } from 'tdesign-react'
import { RemoveIcon, RectangleIcon, CloseIcon, FullscreenExitIcon } from 'tdesign-icons-react'
import { Button } from 'antd'
import {
MinusOutlined,
BorderOutlined,
CloseOutlined,
FullscreenExitOutlined
} from '@ant-design/icons'
import { useEffect, useState } from 'react'
export function WindowControls(): React.JSX.Element {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
if (!(window as any).api) return // Check initial state
if (!(window as any).api) return
;(window as any).api.windowIsMaximized().then((v: boolean) => setIsMaximized(v))
// Subscribe to changes
const cleanup = (window as any).api.onWindowMaximizedChanged((maximized: boolean) => {
setIsMaximized(maximized)
})
@@ -43,33 +47,30 @@ export function WindowControls(): React.JSX.Element {
}
>
<Button
variant="text"
shape="square"
type="text"
onClick={minimize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<RemoveIcon />
<MinusOutlined />
</Button>
<Button
variant="text"
shape="square"
type="text"
onClick={maximize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
{isMaximized ? (
<FullscreenExitIcon />
<FullscreenExitOutlined />
) : (
<RectangleIcon style={{ transform: 'scale(0.7)' }} />
<BorderOutlined style={{ transform: 'scale(0.8)' }} />
)}
</Button>
<Button
variant="text"
shape="square"
type="text"
onClick={close}
className="titlebar-close-btn"
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
<CloseIcon />
<CloseOutlined />
</Button>
<style>
{`
+25 -20
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { Dialog, Form, Select, MessagePlugin, Typography } from 'tdesign-react'
import { Modal, Form, Select, message, Typography } from 'antd'
import { useTheme } from '../contexts/ThemeContext'
interface wizardProps {
@@ -10,6 +10,7 @@ interface wizardProps {
export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
const { themes, currentTheme, setTheme } = useTheme()
const [loading, setLoading] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
const handleFinish = async () => {
setLoading(true)
@@ -18,39 +19,43 @@ export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
const res = await (window as any).api.setSetting('is_wizard_completed', true)
if (!res?.success) throw new Error(res?.message || 'failed')
MessagePlugin.success('配置完成!')
messageApi.success('配置完成!')
onComplete()
} catch {
MessagePlugin.error('配置保存失败')
messageApi.error('配置保存失败')
} finally {
setLoading(false)
}
}
return (
<Dialog
header="欢迎使用 SecScore 积分管理"
visible={visible}
confirmBtn={{ content: '开启积分之旅', loading }}
cancelBtn={null}
closeOnEscKeydown={false}
closeOnOverlayClick={false}
onConfirm={handleFinish}
<Modal
title="欢迎使用 SecScore 积分管理"
open={visible}
onOk={handleFinish}
onCancel={() => {}}
confirmLoading={loading}
okText="开启积分之旅"
cancelButtonProps={{ style: { display: 'none' } }}
closable={false}
mask={{ closable: false }}
keyboard={false}
width={500}
>
{contextHolder}
<Typography.Paragraph style={{ marginBottom: '24px', color: 'var(--ss-text-secondary)' }}>
SecScore
</Typography.Paragraph>
<Form labelWidth={100}>
<Form.FormItem label="外观主题">
<Select value={currentTheme?.id} onChange={(v) => setTheme(v as string)}>
{themes.map((t) => (
<Select.Option key={t.id} value={t.id} label={t.name} />
))}
</Select>
</Form.FormItem>
<Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
<Form.Item label="外观主题">
<Select
value={currentTheme?.id}
onChange={(v) => setTheme(v as string)}
options={themes.map((t) => ({ value: t.id, label: t.name }))}
/>
</Form.Item>
</Form>
</Dialog>
</Modal>
)
}
@@ -1,5 +1,5 @@
import { Button, Select } from 'tdesign-react'
import { Delete1Icon } from 'tdesign-icons-react'
import { Button, Select } from 'antd'
import { DeleteOutlined } from '@ant-design/icons'
import { actionRegistry, allActions } from './registry'
import type { ActionItem as ActionItemType } from './types'
@@ -23,15 +23,10 @@ const ActionItem: React.FC<ActionItemProps> = ({
return (
<div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
<Button
theme="default"
variant="text"
icon={<Delete1Icon strokeWidth={2.4} />}
onClick={() => onDelete(item.id)}
/>
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(item.id)} />
<Select
value={item.eventName}
style={{ width: '200px', marginRight: 12 }}
style={{ width: '200px' }}
options={allActions.options}
placeholder="请选择触发行动"
onChange={(value) => onChange(item.id, value as string)}
@@ -1,5 +1,5 @@
import { Button, Select } from 'tdesign-react'
import { Delete1Icon } from 'tdesign-icons-react'
import { Button, Select } from 'antd'
import { DeleteOutlined } from '@ant-design/icons'
import { triggerRegistry, allTriggers } from './registry'
import type { TriggerItem as TriggerItemType } from './types'
@@ -25,25 +25,20 @@ const TriggerItem: React.FC<TriggerItemProps> = ({
const relation = item.relation || 'AND'
return (
<div style={{ display: 'flex', gap: 5 }}>
<div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
{!isFirst && (
<Button
theme={relation === 'AND' ? 'primary' : 'warning'}
variant={relation === 'AND' ? 'base' : 'outline'}
type={relation === 'AND' ? 'primary' : 'default'}
danger={relation === 'OR'}
onClick={() => onRelationChange?.(item.id, relation === 'AND' ? 'OR' : 'AND')}
>
{relation === 'AND' ? '并' : '或'}
</Button>
)}
<Button
theme="default"
variant="text"
icon={<Delete1Icon strokeWidth={2.4} />}
onClick={() => onDelete(item.id)}
/>
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(item.id)} />
<Select
value={item.eventName}
style={{ width: '200px', marginRight: 12 }}
style={{ width: '200px' }}
options={allTriggers.options}
placeholder="请选择触发规则"
onChange={(value) => onChange(item.id, value as string)}
@@ -1,4 +1,4 @@
import { Input } from 'tdesign-react'
import { Input } from 'antd'
import type { ActionComponentProps } from '../types'
export const eventName = 'add_score'
@@ -18,13 +18,13 @@ const AddScoreAction: React.FC<ActionComponentProps> = ({
placeholder="请输入分数"
style={{ width: '150px' }}
value={value ?? ''}
onChange={(v: any) => onChange(v ? String(v) : '')}
onChange={(e) => onChange(e.target.value ? String(e.target.value) : '')}
/>
<Input
placeholder="请输入理由"
style={{ width: '150px' }}
value={reason ?? ''}
onChange={(v: any) => onReasonChange?.(v ? String(v) : '')}
onChange={(e) => onReasonChange?.(e.target.value ? String(e.target.value) : '')}
/>
</>
)
@@ -1,4 +1,4 @@
import { Input } from 'tdesign-react'
import { Input } from 'antd'
import type { ActionComponentProps } from '../types'
export const eventName = 'add_tag'
@@ -12,7 +12,7 @@ const AddTagAction: React.FC<ActionComponentProps> = ({ value, onChange }) => {
placeholder="请输入标签"
style={{ width: '150px' }}
value={value ?? ''}
onChange={(v: any) => onChange(v ? String(v) : '')}
onChange={(e) => onChange(e.target.value ? String(e.target.value) : '')}
/>
)
}
@@ -1,4 +1,4 @@
import { Input } from 'tdesign-react'
import { Input } from 'antd'
import type { ActionComponentProps } from '../types'
export const eventName = 'send_notification'
@@ -12,7 +12,7 @@ const SendNotificationAction: React.FC<ActionComponentProps> = ({ value, onChang
placeholder="请输入通知内容"
style={{ width: '150px' }}
value={value ?? ''}
onChange={(v: any) => onChange(v ? String(v) : '')}
onChange={(e) => onChange(e.target.value ? String(e.target.value) : '')}
/>
)
}
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { InputNumber, Space, Radio, Form } from 'tdesign-react'
import { InputNumber, Space, Radio } from 'antd'
import type { TriggerComponentProps } from '../types'
export const eventName = 'interval_time_passed'
@@ -22,8 +22,8 @@ const IntervalTimeTrigger: React.FC<TriggerComponentProps> = ({ value, onChange
const numValue = value ? parseInt(value, 10) : undefined
const [unit, setUnit] = useState<'minutes' | 'days'>('minutes')
const handleChange = (v: any) => {
const numV = typeof v === 'number' ? v : v ? Number(v) : undefined
const handleChange = (v: number | null) => {
const numV = v
if (numV === undefined || numV === null || isNaN(numV)) {
onChange('')
return
@@ -41,37 +41,22 @@ const IntervalTimeTrigger: React.FC<TriggerComponentProps> = ({ value, onChange
return (
<Space>
<Form.FormItem
name="intervalMinutes"
rules={[
{ required: true, message: '请输入时间' },
{ min: 1, message: unit === 'minutes' ? '间隔时间至少为1分钟' : '间隔时间至少为1天' }
]}
style={{ marginBottom: 0 }}
<InputNumber
placeholder={unit === 'minutes' ? '请输入时间间隔(分钟)' : '请输入时间间隔(天)'}
style={{ width: '100px' }}
value={displayValue}
onChange={handleChange}
min={1}
/>
<Radio.Group
value={unit}
onChange={(e) => setUnit(e.target.value as 'minutes' | 'days')}
optionType="button"
buttonStyle="solid"
>
<InputNumber
placeholder={unit === 'minutes' ? '请输入时间间隔(分钟)' : '请输入时间间隔(天)'}
style={{ width: '100px' }}
value={displayValue}
onChange={handleChange}
min={1}
theme="column"
/>
</Form.FormItem>
<Form.FormItem
name="timeUnit"
initialData="minutes"
style={{ marginBottom: 0, marginLeft: -12 }}
>
<Radio.Group
variant="default-filled"
value={unit}
onChange={(v) => setUnit(String(v) as 'minutes' | 'days')}
>
<Radio.Button value="days"></Radio.Button>
<Radio.Button value="minutes"></Radio.Button>
</Radio.Group>
</Form.FormItem>
<Radio.Button value="days"></Radio.Button>
<Radio.Button value="minutes"></Radio.Button>
</Radio.Group>
</Space>
)
}
@@ -1,4 +1,4 @@
import { InputNumber, Row, Col } from 'tdesign-react'
import { InputNumber, Space } from 'antd'
import type { TriggerComponentProps } from '../types'
export const eventName = 'random_time_reached'
@@ -33,45 +33,39 @@ const RandomTimeTrigger: React.FC<TriggerComponentProps> = ({ value, onChange })
console.debug('RandomTimeTrigger parse error', e)
}
const handleChange = (key: keyof RandomTimeConfig, v: any) => {
const numV = typeof v === 'number' ? v : v ? Number(v) : undefined
const newConfig = { ...config, [key]: numV ?? (key === 'minHour' ? 0 : 23) }
const handleChange = (key: keyof RandomTimeConfig, v: number | null) => {
const numV = v ?? (key === 'minHour' ? 0 : 23)
const newConfig = { ...config, [key]: numV }
onChange(JSON.stringify(newConfig))
}
return (
<Row gutter={8}>
<Col>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
<InputNumber
placeholder="最小小时"
style={{ width: '70px' }}
value={config.minHour}
onChange={(v) => handleChange('minHour', v)}
min={0}
max={23}
theme="column"
/>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
</div>
</Col>
<Col>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
<InputNumber
placeholder="最大小时"
style={{ width: '70px' }}
value={config.maxHour}
onChange={(v) => handleChange('maxHour', v)}
min={0}
max={23}
theme="column"
/>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
</div>
</Col>
</Row>
<Space>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
<InputNumber
placeholder="最小小时"
style={{ width: '70px' }}
value={config.minHour}
onChange={(v) => handleChange('minHour', v)}
min={0}
max={23}
/>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
<InputNumber
placeholder="最大小时"
style={{ width: '70px' }}
value={config.maxHour}
onChange={(v) => handleChange('maxHour', v)}
min={0}
max={23}
/>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></span>
</div>
</Space>
)
}
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { Select } from 'tdesign-react'
import { Select } from 'antd'
import type { TriggerComponentProps } from '../types'
export const eventName = 'student_tag_matched'
export const label = '当学生匹配标签时触发'
export const description = '当学生匹配特定标签时触发自动化'
@@ -37,11 +38,11 @@ const StudentTagTrigger: React.FC<TriggerComponentProps> = ({ value, onChange })
<Select
placeholder="请选择标签"
style={{ width: '150px' }}
value={value ?? ''}
onChange={(v: any) => onChange(v ? String(v) : '')}
value={value ?? undefined}
onChange={(v) => onChange(v ? String(v) : '')}
options={tags}
filterable
clearable
showSearch
allowClear
/>
)
}
+34 -16
View File
@@ -23,31 +23,49 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
for (const k of prevKeys) root.style.removeProperty(k)
const nextKeys: string[] = []
// 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)
nextKeys.push(key)
})
}
if (tdesign.warningColor) {
root.style.setProperty('--td-warning-color', tdesign.warningColor)
nextKeys.push('--td-warning-color')
}
if (tdesign.errorColor) {
root.style.setProperty('--td-error-color', tdesign.errorColor)
nextKeys.push('--td-error-color')
}
if (tdesign.successColor) {
root.style.setProperty('--td-success-color', tdesign.successColor)
nextKeys.push('--td-success-color')
root.style.setProperty('--ant-color-primary', tdesign.brandColor)
nextKeys.push('--ant-color-primary')
const hex = tdesign.brandColor.replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
root.style.setProperty('--ant-color-primary-hover', `rgba(${r}, ${g}, ${b}, 0.85)`)
root.style.setProperty('--ant-color-primary-active', `rgba(${r}, ${g}, ${b}, 0.7)`)
root.style.setProperty('--ant-color-primary-bg', `rgba(${r}, ${g}, ${b}, 0.1)`)
root.style.setProperty('--ant-color-primary-bg-hover', `rgba(${r}, ${g}, ${b}, 0.2)`)
root.style.setProperty('--ant-color-primary-border', `rgba(${r}, ${g}, ${b}, 0.3)`)
nextKeys.push('--ant-color-primary-hover')
nextKeys.push('--ant-color-primary-active')
nextKeys.push('--ant-color-primary-bg')
nextKeys.push('--ant-color-primary-bg-hover')
nextKeys.push('--ant-color-primary-border')
}
if (tdesign.warningColor) {
root.style.setProperty('--ant-color-warning', tdesign.warningColor)
nextKeys.push('--ant-color-warning')
}
if (tdesign.errorColor) {
root.style.setProperty('--ant-color-error', tdesign.errorColor)
nextKeys.push('--ant-color-error')
}
if (tdesign.successColor) {
root.style.setProperty('--ant-color-success', tdesign.successColor)
nextKeys.push('--ant-color-success')
}
// 3. 应用自定义 CSS 变量 (用于业务 UI)
Object.entries(custom).forEach(([key, value]) => {
root.style.setProperty(key, value)
nextKeys.push(key)
@@ -85,7 +103,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
setCurrentTheme(theme)
currentThemeRef.current = theme
applyThemeConfig(theme)
loadThemes() // Refresh list in case of new files
loadThemes()
})
return () => {
-1
View File
@@ -2,7 +2,6 @@ import './assets/main.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import 'tdesign-react/es/_util/react-19-adapter'
import App from './App'
import { ClientContext } from './ClientContext'
import { StudentService } from './services/StudentService'