mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 09:39:03 +08:00
Dev:施工中......
This commit is contained in:
@@ -17,7 +17,8 @@ export {
|
||||
WindowManagerToken,
|
||||
TrayServiceToken,
|
||||
AutoScoreServiceToken,
|
||||
HttpServerServiceToken
|
||||
HttpServerServiceToken,
|
||||
FileSystemServiceToken
|
||||
} from './tokens'
|
||||
export type {
|
||||
appRuntimeContext,
|
||||
|
||||
@@ -15,4 +15,5 @@ export const ThemeServiceToken = Symbol.for('secscore.themeService')
|
||||
export const WindowManagerToken = Symbol.for('secscore.windowManager')
|
||||
export const TrayServiceToken = Symbol.for('secscore.trayService')
|
||||
export const AutoScoreServiceToken = Symbol.for('secscore.autoScoreService')
|
||||
export const HttpServerServiceToken = Symbol.for('secscore.httpServerService')
|
||||
export const HttpServerServiceToken = Symbol.for('secscore.httpServerService')
|
||||
export const FileSystemServiceToken = Symbol.for('secscore.fileSystemService')
|
||||
+26
-9
@@ -17,6 +17,7 @@ import { HttpServerService } from './services/HttpServerService'
|
||||
import { WindowManager, type windowManagerOptions } from './services/WindowManager'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { AutoScoreService } from './services/AutoScoreService'
|
||||
import { FileSystemService } from './services/FileSystemService'
|
||||
import { StudentRepository } from './repos/StudentRepository'
|
||||
import { ReasonRepository } from './repos/ReasonRepository'
|
||||
import { EventRepository } from './repos/EventRepository'
|
||||
@@ -39,13 +40,15 @@ import {
|
||||
WindowManagerToken,
|
||||
TrayServiceToken,
|
||||
AutoScoreServiceToken,
|
||||
HttpServerServiceToken
|
||||
HttpServerServiceToken,
|
||||
FileSystemServiceToken
|
||||
} from './hosting'
|
||||
|
||||
type mainAppConfig = {
|
||||
isDev: boolean
|
||||
appRoot: string
|
||||
dataRoot: string
|
||||
configDir: string
|
||||
logDir: string
|
||||
themeDir: string
|
||||
dbPath: string
|
||||
@@ -184,6 +187,7 @@ app.whenReady().then(async () => {
|
||||
: ensureWritableDir(join(appRoot, 'data'), join(app.getPath('userData'), 'secscore-data'))
|
||||
|
||||
const logDir = is.dev ? join(process.cwd(), 'logs') : join(dataRoot, 'logs')
|
||||
const configDir = is.dev ? join(process.cwd(), 'configs') : join(dataRoot, 'configs')
|
||||
const themeDir = is.dev
|
||||
? join(process.cwd(), 'themes')
|
||||
: ensureWritableDir(join(appRoot, 'themes'), join(dataRoot, 'themes'))
|
||||
@@ -194,6 +198,7 @@ app.whenReady().then(async () => {
|
||||
appRoot,
|
||||
dataRoot,
|
||||
logDir,
|
||||
configDir,
|
||||
themeDir,
|
||||
dbPath,
|
||||
window: {
|
||||
@@ -269,6 +274,10 @@ app.whenReady().then(async () => {
|
||||
HttpServerServiceToken,
|
||||
(p) => new HttpServerService(p.get(MainContext))
|
||||
)
|
||||
services.addSingleton(
|
||||
FileSystemServiceToken,
|
||||
(p) => new FileSystemService(p.get(MainContext), config.configDir)
|
||||
)
|
||||
})
|
||||
.configure(async (_builderContext, appCtx) => {
|
||||
const services = appCtx.services
|
||||
@@ -285,13 +294,17 @@ app.whenReady().then(async () => {
|
||||
services.get(ReasonRepositoryToken)
|
||||
services.get(EventRepositoryToken)
|
||||
services.get(SettlementRepositoryToken)
|
||||
services.get(ThemeServiceToken)
|
||||
services.get(WindowManagerToken)
|
||||
const tray = services.get(TrayServiceToken) as TrayService
|
||||
tray.initialize()
|
||||
const theme = services.get(ThemeServiceToken) as import('./services/ThemeService').ThemeService
|
||||
await theme.init()
|
||||
if (!process.env.HEADLESS) {
|
||||
services.get(WindowManagerToken)
|
||||
const tray = services.get(TrayServiceToken) as TrayService
|
||||
tray.initialize()
|
||||
}
|
||||
const autoScore = services.get(AutoScoreServiceToken) as AutoScoreService
|
||||
autoScore.initialize?.()
|
||||
services.get(HttpServerServiceToken)
|
||||
services.get(FileSystemServiceToken)
|
||||
})
|
||||
.configure(async (_builderContext, appCtx) => {
|
||||
const services = appCtx.services
|
||||
@@ -308,10 +321,14 @@ app.whenReady().then(async () => {
|
||||
services.get(ReasonRepositoryToken)
|
||||
services.get(EventRepositoryToken)
|
||||
services.get(SettlementRepositoryToken)
|
||||
services.get(ThemeServiceToken)
|
||||
services.get(WindowManagerToken)
|
||||
const tray = services.get(TrayServiceToken) as TrayService
|
||||
tray.initialize()
|
||||
const theme = services.get(ThemeServiceToken) as import('./services/ThemeService').ThemeService
|
||||
await theme.init()
|
||||
if (!process.env.HEADLESS) {
|
||||
services.get(WindowManagerToken)
|
||||
const tray = services.get(TrayServiceToken) as TrayService
|
||||
tray.initialize()
|
||||
}
|
||||
services.get(FileSystemServiceToken)
|
||||
})
|
||||
|
||||
const host = await builder.build()
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { AddIcon, Delete1Icon, MoveIcon } from 'tdesign-icons-react'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { prism } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Button,
|
||||
MessagePlugin,
|
||||
Table,
|
||||
PrimaryTableCol,
|
||||
Tag,
|
||||
Space,
|
||||
Switch,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Select,
|
||||
TooltipLite
|
||||
} from 'tdesign-react'
|
||||
|
||||
interface AutoScoreRule {
|
||||
id: number
|
||||
enabled: boolean
|
||||
name: string
|
||||
intervalMinutes: number
|
||||
studentNames: string[]
|
||||
scoreValue: number
|
||||
reason: string
|
||||
lastExecuted?: string
|
||||
}
|
||||
|
||||
interface AutoScoreRuleFormValues {
|
||||
name: string
|
||||
intervalMinutes: number
|
||||
studentNames: string
|
||||
scoreValue: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export const AutoScoreManager: React.FC = () => {
|
||||
const [rules, setRules] = useState<AutoScoreRule[]>([])
|
||||
const [students, setStudents] = useState<{ id: number; name: string }[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState<number>(50)
|
||||
const [form] = Form.useForm()
|
||||
const [editingRuleId, setEditingRuleId] = useState<number | null>(null)
|
||||
|
||||
const fetchRules = async () => {
|
||||
if (!(window as any).api) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
// 权限检查:确保当前为 admin
|
||||
try {
|
||||
const authRes = await (window as any).api.authGetStatus()
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||
MessagePlugin.error('需要管理员权限以查看自动加分自动化,请先登录管理员账号')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果权限检查失败,继续让后端返回更明确的错误
|
||||
console.warn('Auth check failed', e)
|
||||
}
|
||||
|
||||
const [rulesRes, studentsRes] = await Promise.all([
|
||||
(window as any).api.invoke('auto-score:getRules', {}),
|
||||
(window as any).api.queryStudents({})
|
||||
])
|
||||
if (rulesRes.success) {
|
||||
setRules(rulesRes.data)
|
||||
} else {
|
||||
MessagePlugin.error(rulesRes.message || '获取自动化失败')
|
||||
}
|
||||
if (studentsRes.success) {
|
||||
setStudents(studentsRes.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch auto score rules:', error)
|
||||
MessagePlugin.error('获取自动化失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchRules()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!(window as any).api) return;
|
||||
|
||||
const values = form.getFieldsValue(true) as unknown as AutoScoreRuleFormValues & { timeUnit: string };
|
||||
|
||||
if (!values.name || values.intervalMinutes == null || values.scoreValue == null) {
|
||||
MessagePlugin.warning('请填写完整信息');
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据单位转换间隔时间
|
||||
const intervalMinutes = values.timeUnit === 'days' ? values.intervalMinutes * 1440 : values.intervalMinutes;
|
||||
|
||||
// 确保 studentNames 是数组类型
|
||||
const studentNames = Array.isArray(values.studentNames) ? values.studentNames : [];
|
||||
|
||||
const ruleData = {
|
||||
enabled: true,
|
||||
name: values.name,
|
||||
intervalMinutes,
|
||||
studentNames,
|
||||
scoreValue: values.scoreValue,
|
||||
reason: values.reason || `自动化加分 - ${values.name}`,
|
||||
};
|
||||
|
||||
// 权限检查:仅管理员可创建/更新自动化
|
||||
try {
|
||||
const authRes = await (window as any).api.authGetStatus()
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||
MessagePlugin.error('需要管理员权限以创建或更新自动加分自动化')
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Auth check failed', e)
|
||||
}
|
||||
|
||||
try {
|
||||
let res
|
||||
if (editingRuleId !== null) {
|
||||
// 更新现有自动化
|
||||
res = await (window as any).api.invoke('auto-score:updateRule', {
|
||||
id: editingRuleId,
|
||||
...ruleData
|
||||
})
|
||||
} else {
|
||||
// 创建新自动化
|
||||
res = await (window as any).api.invoke('auto-score:addRule', ruleData)
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
MessagePlugin.success(editingRuleId !== null ? '自动化更新成功' : '自动化创建成功')
|
||||
// 手动清空表单字段,避免 form.reset() 导致的栈溢出
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
intervalMinutes: undefined,
|
||||
studentNames: '',
|
||||
scoreValue: undefined,
|
||||
reason: '',
|
||||
timeUnit: 'minutes'
|
||||
})
|
||||
setEditingRuleId(null)
|
||||
fetchRules() // 刷新自动化列表
|
||||
} else {
|
||||
MessagePlugin.error(res.message || (editingRuleId !== null ? '更新自动化失败' : '创建自动化失败'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to submit auto score rule:', error)
|
||||
MessagePlugin.error(editingRuleId !== null ? '更新自动化失败' : '创建自动化失败')
|
||||
}
|
||||
}
|
||||
const handleEdit = (rule: AutoScoreRule) => {
|
||||
setEditingRuleId(rule.id)
|
||||
form.setFieldsValue({
|
||||
name: rule.name,
|
||||
intervalMinutes: rule.intervalMinutes,
|
||||
studentNames: rule.studentNames.join(', '),
|
||||
scoreValue: rule.scoreValue,
|
||||
reason: rule.reason
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = async (ruleId: number) => {
|
||||
if (!(window as any).api) return
|
||||
// 权限检查
|
||||
try {
|
||||
const authRes = await (window as any).api.authGetStatus()
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||
MessagePlugin.error('需要管理员权限以删除自动加分自动化')
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Auth check failed', e)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await (window as any).api.invoke('auto-score:deleteRule', ruleId)
|
||||
if (res.success) {
|
||||
MessagePlugin.success('自动化删除成功')
|
||||
fetchRules() // 刷新自动化列表
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '删除自动化失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete auto score rule:', error)
|
||||
MessagePlugin.error('删除自动化失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (ruleId: number, enabled: boolean) => {
|
||||
if (!(window as any).api) return
|
||||
// 权限检查
|
||||
try {
|
||||
const authRes = await (window as any).api.authGetStatus()
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||
MessagePlugin.error('需要管理员权限以启用/禁用自动加分自动化')
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Auth check failed', e)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await (window as any).api.invoke('auto-score:toggleRule', { ruleId, enabled })
|
||||
if (res.success) {
|
||||
MessagePlugin.success(enabled ? '自动化已启用' : '自动化已禁用')
|
||||
fetchRules() // 刷新自动化列表
|
||||
} else {
|
||||
MessagePlugin.error(res.message || (enabled ? '启用自动化失败' : '禁用自动化失败'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle auto score rule:', error)
|
||||
MessagePlugin.error(enabled ? '启用自动化失败' : '禁用自动化失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetForm = () => {
|
||||
// 手动清空表单字段,避免 form.reset() 导致的栈溢出
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
intervalMinutes: undefined,
|
||||
studentNames: '',
|
||||
scoreValue: undefined,
|
||||
reason: ''
|
||||
})
|
||||
setEditingRuleId(null)
|
||||
}
|
||||
|
||||
const columns: PrimaryTableCol<AutoScoreRule>[] = [
|
||||
{
|
||||
colKey: 'drag',
|
||||
title: '排序',
|
||||
cell: () => <MoveIcon />,
|
||||
width: 60
|
||||
},
|
||||
{
|
||||
colKey: 'enabled',
|
||||
title: '状态',
|
||||
width: 80,
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
value={row.enabled}
|
||||
onChange={(value) => handleToggle(row.id, value)}
|
||||
size="small"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{ colKey: 'name', title: '自动化名称', width: 150 },
|
||||
{
|
||||
colKey: 'intervalMinutes',
|
||||
title: '间隔',
|
||||
width: 100,
|
||||
cell: ({ row }) => {
|
||||
const isDays = row.intervalMinutes >= 1440
|
||||
const value = isDays ? row.intervalMinutes / 1440 : row.intervalMinutes
|
||||
const unit = isDays ? '天' : '分钟'
|
||||
return `${value} ${unit}`
|
||||
}
|
||||
},
|
||||
{
|
||||
colKey: 'scoreValue',
|
||||
title: '分值',
|
||||
width: 80,
|
||||
cell: ({ row }) => (
|
||||
<Tag theme={row.scoreValue > 0 ? 'success' : 'danger'} variant="light">
|
||||
{row.scoreValue > 0 ? `+${row.scoreValue}` : row.scoreValue}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
colKey: 'studentNames',
|
||||
title: '适用学生',
|
||||
width: 130,
|
||||
cell: ({ row }) => {
|
||||
if (row.studentNames.length === 0) {
|
||||
return <span>所有学生</span>
|
||||
}
|
||||
const studentList = row.studentNames.join(',\n')
|
||||
return (
|
||||
<TooltipLite
|
||||
content={studentList}
|
||||
showArrow
|
||||
placement="mouse"
|
||||
theme="default"
|
||||
>
|
||||
{row.studentNames.length} 名学生
|
||||
</TooltipLite>
|
||||
)
|
||||
}
|
||||
},
|
||||
{ colKey: 'reason', title: '理由', width: 130, ellipsis: true },
|
||||
{
|
||||
colKey: 'lastExecuted',
|
||||
title: '最后执行',
|
||||
width: 180,
|
||||
cell: ({ row }) => {
|
||||
if (!row.lastExecuted) return <span>未执行</span>
|
||||
try {
|
||||
const date = new Date(row.lastExecuted)
|
||||
return date.toLocaleString()
|
||||
} catch {
|
||||
return <span>无效时间</span>
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
colKey: 'operation',
|
||||
title: '操作',
|
||||
width: 150,
|
||||
cell: ({ row }) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(row)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
content="确定要删除这条自动化吗?"
|
||||
onConfirm={() => handleDelete(row.id)}
|
||||
>
|
||||
<Button size="small" variant="outline" theme="danger">
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
const onDragSort = (params: any) => setRules(params.newData);
|
||||
|
||||
// 定义触发规则选项
|
||||
const triggerOptions = [
|
||||
{ label: '学生注册', value: 'student_registered' },
|
||||
{ label: '学生登录', value: 'student_logged_in' },
|
||||
{ label: '完成作业', value: 'homework_completed' },
|
||||
{ label: '考试通过', value: 'exam_passed' },
|
||||
{ label: '参与活动', value: 'event_participated' },
|
||||
{ label: '签到', value: 'check_in' },
|
||||
{ label: '其他自定义事件', value: 'custom_event' }
|
||||
];
|
||||
|
||||
const initialTriggers = [
|
||||
{ id: 1, triggerEvent: triggerOptions[1], description: '当学生登录时触发自动化', haveValue: true, value: 1 },
|
||||
{ id: 2, triggerEvent: triggerOptions[2], description: '当学生完成作业时触发自动化', haveValue: true, value: '12' },
|
||||
{ id: 3, triggerEvent: triggerOptions[3], description: '当学生考试通过时触发自动化', haveValue: false, value: null }
|
||||
]
|
||||
|
||||
const [triggerList, setTriggerList] = useState(initialTriggers)
|
||||
|
||||
const handleTriggerChange = (id: number, value: string) => {
|
||||
setTriggerList((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === id ? { ...t, triggerEvent: triggerOptions.find((o) => o.value === value) || t.triggerEvent } : t
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const handleValueChange = (id: number, val: string) => {
|
||||
setTriggerList((prev) => prev.map((t) => (t.id === id ? { ...t, value: val } : t)))
|
||||
}
|
||||
|
||||
const handleDeleteTrigger = (id: number) => {
|
||||
setTriggerList((prev) => prev.filter((t) => t.id !== id))
|
||||
}
|
||||
|
||||
const handleAddTrigger = () => {
|
||||
const nextId = triggerList.length ? Math.max(...triggerList.map((t) => t.id)) + 1 : 1
|
||||
setTriggerList((prev) => [...prev, { id: nextId, triggerEvent: triggerOptions[0], description: '', haveValue: false, value: '' }])
|
||||
}
|
||||
|
||||
const triggerItems = triggerList
|
||||
.filter((t) => t.description !== null)
|
||||
.map((triggerTest) => (
|
||||
<div key={triggerTest.id} style={{ display: 'flex', gap: 5 }}>
|
||||
<Button
|
||||
theme="default"
|
||||
variant="text"
|
||||
icon={<Delete1Icon strokeWidth={2.4} />}
|
||||
onClick={() => handleDeleteTrigger(triggerTest.id)}
|
||||
/>
|
||||
<Select
|
||||
value={triggerTest.triggerEvent.value}
|
||||
style={{ width: '200px' }}
|
||||
options={triggerOptions}
|
||||
placeholder="请选择触发规则"
|
||||
onChange={(value) => handleTriggerChange(triggerTest.id, value as string)}
|
||||
/>
|
||||
{triggerTest.haveValue === true ? (
|
||||
<Input
|
||||
placeholder="请输入Value"
|
||||
style={{ width: '150px' }}
|
||||
value={String(triggerTest.value)}
|
||||
onChange={(v) => handleValueChange(triggerTest.id, String((v as any).target?.value ?? v))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<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}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
|
||||
<Form.FormItem
|
||||
label="自动化名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入自动化名称' }]}
|
||||
>
|
||||
<Input placeholder="例如:每日签到加分" />
|
||||
</Form.FormItem>
|
||||
|
||||
<Form.FormItem>
|
||||
<Space>
|
||||
<Form.FormItem
|
||||
label="间隔时间"
|
||||
name="intervalMinutes"
|
||||
rules={[
|
||||
{ required: true, message: '请输入间隔时间' },
|
||||
{ min: 1, message: '间隔时间至少为1分钟' }
|
||||
]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber min={1} placeholder="例如:1(每隔1分钟/天执行一次)" />
|
||||
</Form.FormItem>
|
||||
<Form.FormItem name="timeUnit" initialData="minutes" style={{ marginBottom: 0 }}>
|
||||
<Radio.Group variant="default-filled">
|
||||
<Radio.Button value="days">天</Radio.Button>
|
||||
<Radio.Button value="minutes">分钟</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.FormItem>
|
||||
</Space>
|
||||
</Form.FormItem>
|
||||
|
||||
<Form.FormItem
|
||||
label="加分值"
|
||||
name="scoreValue"
|
||||
rules={[{ required: true, message: '请输入加分值' }]}
|
||||
>
|
||||
<InputNumber placeholder="例如:1(每次加1分)" />
|
||||
</Form.FormItem>
|
||||
|
||||
<Form.FormItem
|
||||
label="适用学生"
|
||||
name="studentNames"
|
||||
>
|
||||
<Select
|
||||
filterable
|
||||
multiple
|
||||
placeholder="请选择或搜索学生(留空表示所有学生)"
|
||||
options={students.map((student) => ({ label: student.name, value: student.name }))}
|
||||
/>
|
||||
</Form.FormItem>
|
||||
|
||||
<Form.FormItem
|
||||
label="加分理由"
|
||||
name="reason"
|
||||
>
|
||||
<Input placeholder="例如:每日签到奖励" />
|
||||
</Form.FormItem>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '24px', display: 'flex', gap: '12px' }}>
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{editingRuleId !== null ? '更新自动化' : '添加自动化'}
|
||||
</Button>
|
||||
<Button
|
||||
type="reset"
|
||||
variant="outline"
|
||||
>
|
||||
{editingRuleId !== null ? '取消编辑' : '重置表单'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card style={{ marginBottom: '24px',backgroundColor: 'var(--ss-card-bg)' }}>
|
||||
<Table
|
||||
data={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),
|
||||
}}
|
||||
style={{ color: 'var(--ss-text-main)' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }} title="当以下事件触发时" headerBordered>
|
||||
<Space style={{ display: "grid" }}>
|
||||
{triggerItems}
|
||||
<Button
|
||||
theme="default"
|
||||
variant="text"
|
||||
style={{ fontWeight: 'bolder', fontSize: 15 }}
|
||||
icon={<AddIcon strokeWidth={3}/>}
|
||||
onClick={handleAddTrigger}>
|
||||
添加触发器
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginBottom: '24px' , backgroundColor: 'var(--ss-card-bg)' }}>
|
||||
<SyntaxHighlighter language="javascript" style={prism} showLineNumbers>
|
||||
println("这是一个示例代码块,展示如何使用自动化加分功能的API接口")
|
||||
</SyntaxHighlighter>
|
||||
</Card>
|
||||
{/* <div style={{ marginTop: '24px', padding: '16px', backgroundColor: 'var(--ss-card-bg)', borderRadius: '8px' }}>
|
||||
<h3 style={{ marginBottom: '12px', color: 'var(--ss-text-main)' }}>使用说明</h3>
|
||||
<ul style={{ color: 'var(--ss-text-secondary)', lineHeight: '1.6' }}>
|
||||
<li>自动化加分功能会按照设定的时间间隔自动为学生加分</li>
|
||||
<li>间隔时间以分钟为单位,例如1440表示每24小时(一天)执行一次</li>
|
||||
<li>如果"适用学生"字段为空,则自动化适用于所有学生</li>
|
||||
<li>可以随时启用/禁用自动化,不会影响已保存的自动化配置</li>
|
||||
</ul>
|
||||
</div> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { join } from 'path'
|
||||
import fs from 'fs/promises'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
fileSystem: FileSystemService
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConfigFileInfo {
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
modified: Date
|
||||
}
|
||||
|
||||
export interface ConfigFolderStructure {
|
||||
configRoot: string
|
||||
automatic: string
|
||||
script: string
|
||||
}
|
||||
|
||||
export class FileSystemService extends Service {
|
||||
private configRoot: string
|
||||
private automaticDir: string
|
||||
private scriptDir: string
|
||||
private initPromise: Promise<void> | null = null
|
||||
|
||||
constructor(ctx: MainContext, ConfigRoot: string) {
|
||||
super(ctx, 'fileSystem')
|
||||
this.configRoot = ConfigRoot
|
||||
this.automaticDir = join(this.configRoot, 'automatic')
|
||||
this.scriptDir = join(this.configRoot, 'script')
|
||||
this.initPromise = this.initialize()
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
await this.ensureDirectories()
|
||||
}
|
||||
|
||||
private async ensureDirectories(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(this.configRoot, { recursive: true })
|
||||
await fs.mkdir(this.automaticDir, { recursive: true })
|
||||
await fs.mkdir(this.scriptDir, { recursive: true })
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn('FileSystemService: Failed to create config directories', { error })
|
||||
}
|
||||
}
|
||||
|
||||
getConfigStructure(): ConfigFolderStructure {
|
||||
return {
|
||||
configRoot: this.configRoot,
|
||||
automatic: this.automaticDir,
|
||||
script: this.scriptDir
|
||||
}
|
||||
}
|
||||
|
||||
async readJsonFile<T = any>(relativePath: string, folder: 'automatic' | 'script' = 'automatic'): Promise<T | null> {
|
||||
await this.initPromise
|
||||
const baseDir = folder === 'automatic' ? this.automaticDir : this.scriptDir
|
||||
const filePath = join(baseDir, relativePath)
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
return JSON.parse(content) as T
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
this.ctx.logger.warn('FileSystemService: Failed to read JSON file', { path: filePath, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async writeJsonFile(
|
||||
relativePath: string,
|
||||
data: any,
|
||||
folder: 'automatic' | 'script' = 'automatic'
|
||||
): Promise<boolean> {
|
||||
await this.initPromise
|
||||
const baseDir = folder === 'automatic' ? this.automaticDir : this.scriptDir
|
||||
const filePath = join(baseDir, relativePath)
|
||||
|
||||
try {
|
||||
const content = JSON.stringify(data, null, 2)
|
||||
await fs.writeFile(filePath, content, 'utf-8')
|
||||
return true
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn('FileSystemService: Failed to write JSON file', { path: filePath, error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async readTextFile(
|
||||
relativePath: string,
|
||||
folder: 'automatic' | 'script' = 'automatic'
|
||||
): Promise<string | null> {
|
||||
await this.initPromise
|
||||
const baseDir = folder === 'automatic' ? this.automaticDir : this.scriptDir
|
||||
const filePath = join(baseDir, relativePath)
|
||||
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf-8')
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
this.ctx.logger.warn('FileSystemService: Failed to read text file', { path: filePath, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async writeTextFile(
|
||||
content: string,
|
||||
relativePath: string,
|
||||
folder: 'automatic' | 'script' = 'automatic'
|
||||
): Promise<boolean> {
|
||||
await this.initPromise
|
||||
const baseDir = folder === 'automatic' ? this.automaticDir : this.scriptDir
|
||||
const filePath = join(baseDir, relativePath)
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, content, 'utf-8')
|
||||
return true
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn('FileSystemService: Failed to write text file', { path: filePath, error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(relativePath: string, folder: 'automatic' | 'script' = 'automatic'): Promise<boolean> {
|
||||
await this.initPromise
|
||||
const baseDir = folder === 'automatic' ? this.automaticDir : this.scriptDir
|
||||
const filePath = join(baseDir, relativePath)
|
||||
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') {
|
||||
return false
|
||||
}
|
||||
this.ctx.logger.warn('FileSystemService: Failed to delete file', { path: filePath, error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async listFiles(folder: 'automatic' | 'script' = 'automatic'): Promise<ConfigFileInfo[]> {
|
||||
await this.initPromise
|
||||
const baseDir = folder === 'automatic' ? this.automaticDir : this.scriptDir
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(baseDir, { withFileTypes: true })
|
||||
const files: ConfigFileInfo[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile()) {
|
||||
const filePath = join(baseDir, entry.name)
|
||||
const stats = await fs.stat(filePath)
|
||||
files.push({
|
||||
name: entry.name,
|
||||
path: filePath,
|
||||
size: stats.size,
|
||||
modified: stats.mtime
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') {
|
||||
return []
|
||||
}
|
||||
this.ctx.logger.warn('FileSystemService: Failed to list files', { path: baseDir, error })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async fileExists(relativePath: string, folder: 'automatic' | 'script' = 'automatic'): Promise<boolean> {
|
||||
await this.initPromise
|
||||
const baseDir = folder === 'automatic' ? this.automaticDir : this.scriptDir
|
||||
const filePath = join(baseDir, relativePath)
|
||||
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private registerIpc(): void {
|
||||
this.mainCtx.handle('fs:getConfigStructure', async () => {
|
||||
await this.initPromise
|
||||
return { success: true, data: this.getConfigStructure() }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('fs:readJson', async (_event, relativePath: string, folder: 'automatic' | 'script') => {
|
||||
await this.initPromise
|
||||
const data = await this.readJsonFile(relativePath, folder)
|
||||
return { success: true, data }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('fs:writeJson', async (_event, relativePath: string, data: any, folder: 'automatic' | 'script') => {
|
||||
await this.initPromise
|
||||
const success = await this.writeJsonFile(relativePath, data, folder)
|
||||
return { success }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('fs:readText', async (_event, relativePath: string, folder: 'automatic' | 'script') => {
|
||||
await this.initPromise
|
||||
const content = await this.readTextFile(relativePath, folder)
|
||||
return { success: true, data: content }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('fs:writeText', async (_event, content: string, relativePath: string, folder: 'automatic' | 'script') => {
|
||||
await this.initPromise
|
||||
const success = await this.writeTextFile(content, relativePath, folder)
|
||||
return { success }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('fs:deleteFile', async (_event, relativePath: string, folder: 'automatic' | 'script') => {
|
||||
await this.initPromise
|
||||
const success = await this.deleteFile(relativePath, folder)
|
||||
return { success }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('fs:listFiles', async (_event, folder: 'automatic' | 'script') => {
|
||||
await this.initPromise
|
||||
const files = await this.listFiles(folder)
|
||||
return { success: true, data: files }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('fs:fileExists', async (_event, relativePath: string, folder: 'automatic' | 'script') => {
|
||||
await this.initPromise
|
||||
const exists = await this.fileExists(relativePath, folder)
|
||||
return { success: true, data: exists }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,11 @@ export class SettingsService extends Service {
|
||||
kind: 'json',
|
||||
defaultValue: [],
|
||||
writePermission: 'admin'
|
||||
},
|
||||
current_theme_id: {
|
||||
kind: 'string',
|
||||
defaultValue: 'light-default',
|
||||
writePermission: 'admin'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,31 @@ export class ThemeService extends Service {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
public init() {
|
||||
// Already inited in constructor
|
||||
public async init() {
|
||||
await this.loadSavedTheme()
|
||||
}
|
||||
|
||||
private async loadSavedTheme() {
|
||||
try {
|
||||
const savedThemeId = await this.mainCtx.settings.getValue('current_theme_id' as any)
|
||||
if (savedThemeId && typeof savedThemeId === 'string') {
|
||||
const themes = this.getThemeList()
|
||||
const exists = themes.some(t => t.id === savedThemeId)
|
||||
if (exists) {
|
||||
this.currentThemeId = savedThemeId
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.mainCtx.logger.warn('Failed to load saved theme', { meta: e })
|
||||
}
|
||||
}
|
||||
|
||||
private async saveCurrentTheme() {
|
||||
try {
|
||||
await this.mainCtx.settings.setValue('current_theme_id' as any, this.currentThemeId)
|
||||
} catch (e) {
|
||||
this.mainCtx.logger.warn('Failed to save theme', { meta: e })
|
||||
}
|
||||
}
|
||||
|
||||
private setupWatcher() {
|
||||
@@ -83,6 +106,7 @@ export class ThemeService extends Service {
|
||||
return { success: false, message: 'Permission denied' }
|
||||
}
|
||||
this.currentThemeId = themeId
|
||||
await this.saveCurrentTheme()
|
||||
this.applyMicaEffect(themeId)
|
||||
this.notifyThemeUpdate()
|
||||
return { success: true }
|
||||
@@ -139,6 +163,7 @@ export class ThemeService extends Service {
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
this.currentThemeId = 'custom'
|
||||
await this.saveCurrentTheme()
|
||||
this.applyMicaConfig(config)
|
||||
this.notifyThemeUpdate()
|
||||
return { success: true }
|
||||
|
||||
@@ -118,6 +118,23 @@ const api = {
|
||||
ipcRenderer.invoke('http:server:start', config),
|
||||
httpServerStop: () => ipcRenderer.invoke('http:server:stop'),
|
||||
httpServerStatus: () => ipcRenderer.invoke('http:server:status'),
|
||||
|
||||
// File System
|
||||
fsGetConfigStructure: () => ipcRenderer.invoke('fs:getConfigStructure'),
|
||||
fsReadJson: (relativePath: string, folder?: 'automatic' | 'script') =>
|
||||
ipcRenderer.invoke('fs:readJson', relativePath, folder ?? 'automatic'),
|
||||
fsWriteJson: (relativePath: string, data: any, folder?: 'automatic' | 'script') =>
|
||||
ipcRenderer.invoke('fs:writeJson', relativePath, data, folder ?? 'automatic'),
|
||||
fsReadText: (relativePath: string, folder?: 'automatic' | 'script') =>
|
||||
ipcRenderer.invoke('fs:readText', relativePath, folder ?? 'automatic'),
|
||||
fsWriteText: (content: string, relativePath: string, folder?: 'automatic' | 'script') =>
|
||||
ipcRenderer.invoke('fs:writeText', content, relativePath, folder ?? 'automatic'),
|
||||
fsDeleteFile: (relativePath: string, folder?: 'automatic' | 'script') =>
|
||||
ipcRenderer.invoke('fs:deleteFile', relativePath, folder ?? 'automatic'),
|
||||
fsListFiles: (folder?: 'automatic' | 'script') =>
|
||||
ipcRenderer.invoke('fs:listFiles', folder ?? 'automatic'),
|
||||
fsFileExists: (relativePath: string, folder?: 'automatic' | 'script') =>
|
||||
ipcRenderer.invoke('fs:fileExists', relativePath, folder ?? 'automatic'),
|
||||
|
||||
// Generic invoke wrapper for backward compatibility with callers using `api.invoke`
|
||||
invoke: (channel: string, ...args: any[]) => ipcRenderer.invoke(channel, ...args)
|
||||
|
||||
@@ -31,10 +31,24 @@ export type settingsSpec = {
|
||||
window_radius: 'rounded' | 'small' | 'square'
|
||||
auto_score_enabled: boolean
|
||||
auto_score_rules: any[]
|
||||
current_theme_id: string
|
||||
}
|
||||
|
||||
export type settingsKey = keyof settingsSpec
|
||||
|
||||
export interface ConfigFileInfo {
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
modified: string
|
||||
}
|
||||
|
||||
export interface ConfigFolderStructure {
|
||||
configRoot: string
|
||||
automatic: string
|
||||
sscript: string
|
||||
}
|
||||
|
||||
export type settingChange<K extends settingsKey = settingsKey> = {
|
||||
key: K
|
||||
value: settingsSpec[K]
|
||||
@@ -174,6 +188,16 @@ export interface electronApi {
|
||||
url: string | null
|
||||
}>
|
||||
>
|
||||
|
||||
// File System
|
||||
fsGetConfigStructure: () => Promise<ipcResponse<ConfigFolderStructure>>
|
||||
fsReadJson: (relativePath: string, folder?: 'automatic' | 'sscript') => Promise<ipcResponse<any>>
|
||||
fsWriteJson: (relativePath: string, data: any, folder?: 'automatic' | 'sscript') => Promise<ipcResponse<void>>
|
||||
fsReadText: (relativePath: string, folder?: 'automatic' | 'sscript') => Promise<ipcResponse<string | null>>
|
||||
fsWriteText: (content: string, relativePath: string, folder?: 'automatic' | 'sscript') => Promise<ipcResponse<void>>
|
||||
fsDeleteFile: (relativePath: string, folder?: 'automatic' | 'sscript') => Promise<ipcResponse<void>>
|
||||
fsListFiles: (folder?: 'automatic' | 'sscript') => Promise<ipcResponse<ConfigFileInfo[]>>
|
||||
fsFileExists: (relativePath: string, folder?: 'automatic' | 'sscript') => Promise<ipcResponse<boolean>>
|
||||
|
||||
// Generic invoke wrapper (minimal compatibility API)
|
||||
invoke?: (channel: string, ...args: any[]) => Promise<any>
|
||||
|
||||
@@ -139,10 +139,43 @@ function MainContent(): React.JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
{/* 开发中画面水印 */}
|
||||
<p
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '2px',
|
||||
left: '20px',
|
||||
opacity: 0.6,
|
||||
color: '#de2611',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '13px',
|
||||
pointerEvents: 'none', // 防止水印干扰页面交互
|
||||
zIndex: 9999 // 确保水印在最顶层显示
|
||||
}}
|
||||
>
|
||||
开发中画面,不代表真正品质 ({getArchitecture()})
|
||||
</p>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
function getArchitecture(): string {
|
||||
|
||||
// 尝试从 userAgent 中获取架构信息
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
if (userAgent.includes('arm64') || userAgent.includes('aarch64')) {
|
||||
return 'ARM64';
|
||||
} else if (userAgent.includes('x86_64') || userAgent.includes('amd64')) {
|
||||
return 'x86_64';
|
||||
} else if (userAgent.includes('i386') || userAgent.includes('i686')) {
|
||||
return 'x86';
|
||||
}
|
||||
|
||||
// 默认返回未知架构
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { MoveIcon } from 'tdesign-icons-react'
|
||||
import { AddIcon, Delete1Icon, MoveIcon } from 'tdesign-icons-react'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { prism } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
@@ -27,6 +29,7 @@ interface AutoScoreRule {
|
||||
scoreValue: number
|
||||
reason: string
|
||||
lastExecuted?: string
|
||||
triggers?: { event: string; value?: string }[]
|
||||
}
|
||||
|
||||
interface AutoScoreRuleFormValues {
|
||||
@@ -55,7 +58,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('需要管理员权限以查看自动加分规则,请先登录管理员账号')
|
||||
MessagePlugin.error('需要管理员权限以查看自动加分自动化,请先登录管理员账号')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -71,14 +74,14 @@ export const AutoScoreManager: React.FC = () => {
|
||||
if (rulesRes.success) {
|
||||
setRules(rulesRes.data)
|
||||
} else {
|
||||
MessagePlugin.error(rulesRes.message || '获取规则失败')
|
||||
MessagePlugin.error(rulesRes.message || '获取自动化失败')
|
||||
}
|
||||
if (studentsRes.success) {
|
||||
setStudents(studentsRes.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch auto score rules:', error)
|
||||
MessagePlugin.error('获取规则失败')
|
||||
MessagePlugin.error('获取自动化失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -104,6 +107,8 @@ export const AutoScoreManager: React.FC = () => {
|
||||
// 确保 studentNames 是数组类型
|
||||
const studentNames = Array.isArray(values.studentNames) ? values.studentNames : [];
|
||||
|
||||
const triggersPayload = triggerList.map((t) => ({ event: t.eventName, value: t.value }))
|
||||
|
||||
const ruleData = {
|
||||
enabled: true,
|
||||
name: values.name,
|
||||
@@ -111,13 +116,23 @@ export const AutoScoreManager: React.FC = () => {
|
||||
studentNames,
|
||||
scoreValue: values.scoreValue,
|
||||
reason: values.reason || `自动化加分 - ${values.name}`,
|
||||
triggers: triggersPayload
|
||||
};
|
||||
|
||||
// 权限检查:仅管理员可创建/更新规则
|
||||
// 权限检查:仅管理员可创建/更新自动化
|
||||
try {
|
||||
// 验证触发器必填项(如果某个触发器需要 value,则确保已填写)
|
||||
for (const t of triggerList) {
|
||||
const def = allTriggers.find((a) => a.eventName === t.eventName)
|
||||
if (def?.haveValue && (!t.value || String(t.value).trim() === '')) {
|
||||
MessagePlugin.warning(`触发器 ${def.label} 需要填写 value`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const authRes = await (window as any).api.authGetStatus()
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||
MessagePlugin.error('需要管理员权限以创建或更新自动加分规则')
|
||||
MessagePlugin.error('需要管理员权限以创建或更新自动加分自动化')
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -127,18 +142,18 @@ export const AutoScoreManager: React.FC = () => {
|
||||
try {
|
||||
let res
|
||||
if (editingRuleId !== null) {
|
||||
// 更新现有规则
|
||||
// 更新现有自动化
|
||||
res = await (window as any).api.invoke('auto-score:updateRule', {
|
||||
id: editingRuleId,
|
||||
...ruleData
|
||||
})
|
||||
} else {
|
||||
// 创建新规则
|
||||
// 创建新自动化
|
||||
res = await (window as any).api.invoke('auto-score:addRule', ruleData)
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
MessagePlugin.success(editingRuleId !== null ? '规则更新成功' : '规则创建成功')
|
||||
MessagePlugin.success(editingRuleId !== null ? '自动化更新成功' : '自动化创建成功')
|
||||
// 手动清空表单字段,避免 form.reset() 导致的栈溢出
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
@@ -149,13 +164,14 @@ export const AutoScoreManager: React.FC = () => {
|
||||
timeUnit: 'minutes'
|
||||
})
|
||||
setEditingRuleId(null)
|
||||
fetchRules() // 刷新规则列表
|
||||
setTriggerList([])
|
||||
fetchRules() // 刷新自动化列表
|
||||
} else {
|
||||
MessagePlugin.error(res.message || (editingRuleId !== null ? '更新规则失败' : '创建规则失败'))
|
||||
MessagePlugin.error(res.message || (editingRuleId !== null ? '更新自动化失败' : '创建自动化失败'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to submit auto score rule:', error)
|
||||
MessagePlugin.error(editingRuleId !== null ? '更新规则失败' : '创建规则失败')
|
||||
MessagePlugin.error(editingRuleId !== null ? '更新自动化失败' : '创建自动化失败')
|
||||
}
|
||||
}
|
||||
const handleEdit = (rule: AutoScoreRule) => {
|
||||
@@ -167,6 +183,21 @@ export const AutoScoreManager: React.FC = () => {
|
||||
scoreValue: rule.scoreValue,
|
||||
reason: rule.reason
|
||||
})
|
||||
// 如果后端返回了 triggers 字段,把它加载到 triggerList
|
||||
if (rule.triggers && Array.isArray(rule.triggers)) {
|
||||
const mapped = rule.triggers.map((t, idx) => {
|
||||
const found = allTriggers.find((a) => a.eventName === t.event)
|
||||
return {
|
||||
id: idx + 1,
|
||||
eventName: t.event,
|
||||
haveValue: !!found?.haveValue,
|
||||
value: t.value ?? ''
|
||||
}
|
||||
})
|
||||
setTriggerList(mapped)
|
||||
} else {
|
||||
setTriggerList([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (ruleId: number) => {
|
||||
@@ -175,7 +206,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('需要管理员权限以删除自动加分规则')
|
||||
MessagePlugin.error('需要管理员权限以删除自动加分自动化')
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -185,14 +216,14 @@ export const AutoScoreManager: React.FC = () => {
|
||||
try {
|
||||
const res = await (window as any).api.invoke('auto-score:deleteRule', ruleId)
|
||||
if (res.success) {
|
||||
MessagePlugin.success('规则删除成功')
|
||||
fetchRules() // 刷新规则列表
|
||||
MessagePlugin.success('自动化删除成功')
|
||||
fetchRules() // 刷新自动化列表
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '删除规则失败')
|
||||
MessagePlugin.error(res.message || '删除自动化失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete auto score rule:', error)
|
||||
MessagePlugin.error('删除规则失败')
|
||||
MessagePlugin.error('删除自动化失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +233,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('需要管理员权限以启用/禁用自动加分规则')
|
||||
MessagePlugin.error('需要管理员权限以启用/禁用自动加分自动化')
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -212,14 +243,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 ? '规则已启用' : '规则已禁用')
|
||||
fetchRules() // 刷新规则列表
|
||||
MessagePlugin.success(enabled ? '自动化已启用' : '自动化已禁用')
|
||||
fetchRules() // 刷新自动化列表
|
||||
} else {
|
||||
MessagePlugin.error(res.message || (enabled ? '启用规则失败' : '禁用规则失败'))
|
||||
MessagePlugin.error(res.message || (enabled ? '启用自动化失败' : '禁用自动化失败'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle auto score rule:', error)
|
||||
MessagePlugin.error(enabled ? '启用规则失败' : '禁用规则失败')
|
||||
MessagePlugin.error(enabled ? '启用自动化失败' : '禁用自动化失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +285,7 @@ export const AutoScoreManager: React.FC = () => {
|
||||
/>
|
||||
)
|
||||
},
|
||||
{ colKey: 'name', title: '规则名称', width: 150 },
|
||||
{ colKey: 'name', title: '自动化名称', width: 150 },
|
||||
{
|
||||
colKey: 'intervalMinutes',
|
||||
title: '间隔',
|
||||
@@ -326,7 +357,7 @@ export const AutoScoreManager: React.FC = () => {
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
content="确定要删除这条规则吗?"
|
||||
content="确定要删除这条自动化吗?"
|
||||
onConfirm={() => handleDelete(row.id)}
|
||||
>
|
||||
<Button size="small" variant="outline" theme="danger">
|
||||
@@ -337,8 +368,76 @@ export const AutoScoreManager: React.FC = () => {
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const onDragSort = (params: any) => setRules(params.newData);
|
||||
|
||||
type TriggerDef = {
|
||||
id: number
|
||||
label: string
|
||||
description: string
|
||||
eventName: string
|
||||
haveValue: boolean
|
||||
valueType?: string
|
||||
}
|
||||
|
||||
const allTriggers: TriggerDef[] = [
|
||||
{ id: 1, label: '根据间隔时间触发', description: '当学生注册时触发自动化', eventName: 'interval_time_passed', haveValue: false, valueType: 'number' },
|
||||
{ id: 2, label: '按照学生标签触发', description: '当学生完成作业时触发自动化', eventName: 'student_tag_matched', haveValue: true, valueType: 'number' },
|
||||
{ id: 3, label: '随机时间触发', description: '当随机时间到达时触发自动化', eventName: 'random_time_reached', haveValue: false, valueType: 'number' }
|
||||
]
|
||||
|
||||
const triggerOptions = allTriggers.map((t) => ({ label: t.label, value: t.eventName }))
|
||||
|
||||
// triggerList items only need id, eventName, haveValue and value
|
||||
type TriggerItem = { id: number; eventName: string; haveValue: boolean; value?: string }
|
||||
const [triggerList, setTriggerList] = useState<TriggerItem[]>([])
|
||||
|
||||
const handleTriggerChange = (id: number, value: string) => {
|
||||
const found = allTriggers.find((a) => a.eventName === value)
|
||||
setTriggerList((prev) => prev.map((t) => (t.id === id ? { ...t, eventName: value, haveValue: !!found?.haveValue, value: t.value ?? '' } : t)))
|
||||
}
|
||||
|
||||
const handleValueChange = (id: number, val: string) => {
|
||||
setTriggerList((prev) => prev.map((t) => (t.id === id ? { ...t, value: val } : t)))
|
||||
}
|
||||
|
||||
const handleDeleteTrigger = (id: number) => {
|
||||
setTriggerList((prev) => prev.filter((t) => t.id !== id))
|
||||
}
|
||||
|
||||
const handleAddTrigger = () => {
|
||||
const nextId = triggerList.length ? Math.max(...triggerList.map((t) => t.id)) + 1 : 1
|
||||
const defaultTrigger = allTriggers[0]
|
||||
setTriggerList((prev) => [...prev, { id: nextId, eventName: defaultTrigger.eventName, haveValue: defaultTrigger.haveValue, value: '' }])
|
||||
}
|
||||
|
||||
const triggerItems = triggerList
|
||||
.filter((t) => t.eventName !== null)
|
||||
.map((triggerTest) => (
|
||||
<div key={triggerTest.id} style={{ display: 'flex', gap: 5 }}>
|
||||
<Button
|
||||
theme="default"
|
||||
variant="text"
|
||||
icon={<Delete1Icon strokeWidth={2.4} />}
|
||||
onClick={() => handleDeleteTrigger(triggerTest.id)}
|
||||
/>
|
||||
<Select
|
||||
value={triggerTest.eventName}
|
||||
style={{ width: '200px' }}
|
||||
options={triggerOptions}
|
||||
placeholder="请选择触发规则"
|
||||
onChange={(value) => handleTriggerChange(triggerTest.id, value as string)}
|
||||
/>
|
||||
{triggerTest.haveValue ? (
|
||||
<Input
|
||||
placeholder="请输入Value"
|
||||
style={{ width: '150px' }}
|
||||
value={String(triggerTest.value ?? '')}
|
||||
onChange={(v) => handleValueChange(triggerTest.id, String((v as any).target?.value ?? v))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
@@ -352,9 +451,9 @@ export const AutoScoreManager: React.FC = () => {
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
|
||||
<Form.FormItem
|
||||
label="规则名称"
|
||||
label="自动化名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入规则名称' }]}
|
||||
rules={[{ required: true, message: '请输入自动化名称' }]}
|
||||
>
|
||||
<Input placeholder="例如:每日签到加分" />
|
||||
</Form.FormItem>
|
||||
@@ -414,7 +513,7 @@ export const AutoScoreManager: React.FC = () => {
|
||||
theme="primary"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{editingRuleId !== null ? '更新规则' : '添加规则'}
|
||||
{editingRuleId !== null ? '更新自动化' : '添加自动化'}
|
||||
</Button>
|
||||
<Button
|
||||
type="reset"
|
||||
@@ -425,8 +524,7 @@ export const AutoScoreManager: React.FC = () => {
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
|
||||
<Card style={{ marginBottom: '24px',backgroundColor: 'var(--ss-card-bg)' }}>
|
||||
<Table
|
||||
data={rules.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
|
||||
columns={columns}
|
||||
@@ -446,13 +544,32 @@ export const AutoScoreManager: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }} title="当以下事件触发时" headerBordered>
|
||||
<Space style={{ display: "grid" }}>
|
||||
{triggerItems}
|
||||
<Button
|
||||
theme="default"
|
||||
variant="text"
|
||||
style={{ fontWeight: 'bolder', fontSize: 15 }}
|
||||
icon={<AddIcon strokeWidth={3}/>}
|
||||
onClick={handleAddTrigger}>
|
||||
添加触发器
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginBottom: '24px' , backgroundColor: 'var(--ss-card-bg)' }}>
|
||||
<SyntaxHighlighter language="javascript" style={prism} showLineNumbers>
|
||||
println("这是一个示例代码块,展示如何使用自动化加分功能的API接口")
|
||||
</SyntaxHighlighter>
|
||||
</Card>
|
||||
{/* <div style={{ marginTop: '24px', padding: '16px', backgroundColor: 'var(--ss-card-bg)', borderRadius: '8px' }}>
|
||||
<h3 style={{ marginBottom: '12px', color: 'var(--ss-text-main)' }}>使用说明</h3>
|
||||
<ul style={{ color: 'var(--ss-text-secondary)', lineHeight: '1.6' }}>
|
||||
<li>自动化加分功能会按照设定的时间间隔自动为学生加分</li>
|
||||
<li>间隔时间以分钟为单位,例如1440表示每24小时(一天)执行一次</li>
|
||||
<li>如果"适用学生"字段为空,则规则适用于所有学生</li>
|
||||
<li>可以随时启用/禁用规则,不会影响已保存的规则配置</li>
|
||||
<li>如果"适用学生"字段为空,则自动化适用于所有学生</li>
|
||||
<li>可以随时启用/禁用自动化,不会影响已保存的自动化配置</li>
|
||||
</ul>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
@@ -76,11 +76,14 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
<Menu.MenuItem value="score" icon={<HistoryIcon />}>
|
||||
积分管理
|
||||
</Menu.MenuItem>
|
||||
<Menu.MenuItem value="leaderboard" icon={<ViewListIcon />}> 排行榜
|
||||
<Menu.MenuItem value="auto-score" icon={<ReplayIcon />}>
|
||||
自动加分
|
||||
</Menu.MenuItem>
|
||||
<Menu.MenuItem value="auto-score" icon={<ReplayIcon />}> 自动加分
|
||||
<Menu.MenuItem value="leaderboard" icon={<ViewListIcon />}>
|
||||
排行榜
|
||||
</Menu.MenuItem>
|
||||
<Menu.MenuItem value="settlements" icon={<HistoryIcon />}> 结算历史
|
||||
<Menu.MenuItem value="settlements" icon={<HistoryIcon />}>
|
||||
结算历史
|
||||
</Menu.MenuItem>
|
||||
<Menu.MenuItem value="reasons" icon={<RootListIcon />} disabled={permission !== 'admin'}>
|
||||
理由管理
|
||||
|
||||
Reference in New Issue
Block a user