Initial commit

This commit is contained in:
2026-06-25 01:57:07 +08:00
commit 1c5841833f
103 changed files with 16911 additions and 0 deletions
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
out
.DS_Store
.eslintcache
*.log*
.electron-dev-data
+10
View File
@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="cn.fjdmy.uniapp.UniappProjectDataService">
<option name="type" value="no" />
</component>
</project>
+61
View File
@@ -0,0 +1,61 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="growth-report" uuid="fd014546-f98b-4a4a-97a8-8ca923061d8f">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/.electron-dev-data/growth-report.sqlite</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/growth_report.iml" filepath="$PROJECT_DIR$/.idea/growth_report.iml" />
</modules>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+3
View File
@@ -0,0 +1,3 @@
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
shamefully-hoist=true
+6
View File
@@ -0,0 +1,6 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
+4
View File
@@ -0,0 +1,4 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none
+49
View File
@@ -0,0 +1,49 @@
# growth_report
成长报告助手,一个用于导入幼儿基础数据并生成成长报告相关材料的 Electron 桌面应用。
## 技术栈
- Electron 39:桌面端应用容器
- electron-vite 5Electron + Vite 构建与开发环境
- React 19:渲染进程界面
- TypeScript 5:类型系统
- React Router 7:页面路由
- Tailwind CSS 4:样式系统
- shadcn/ui 风格本地组件:Button、Card、Table、Drawer 等
- VaulDrawer 抽屉交互
- Radix UIScrollArea、Separator、Tabs 等基础交互组件
- Lucide React:图标
- SheetJS xlsx:解析幼儿数据 Excel 文件
- pnpm:包管理器
## Recommended IDE Setup
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
## Project Setup
### Install
```bash
$ pnpm install
```
### Development
```bash
$ pnpm dev
```
### Build
```bash
# For windows
$ pnpm build:win
# For macOS
$ pnpm build:mac
# For Linux
$ pnpm build:linux
```
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/renderer/src/assets/base.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@renderer/components",
"utils": "@renderer/lib/utils",
"ui": "@renderer/components/ui",
"lib": "@renderer/lib",
"hooks": "@renderer/hooks"
},
"iconLibrary": "lucide"
}
+3
View File
@@ -0,0 +1,3 @@
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: growth_report-updater
+46
View File
@@ -0,0 +1,46 @@
appId: com.electron.app
productName: growth_report
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
- node_modules/better-sqlite3/**
win:
executableName: growth_report
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: true
publish:
provider: generic
url: https://example.com/auto-updates
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
+17
View File
@@ -0,0 +1,17 @@
import { resolve } from 'path'
import { defineConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
main: {},
preload: {},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src')
}
},
plugins: [tailwindcss(), react()]
}
})
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from 'eslint/config'
import tseslint from '@electron-toolkit/eslint-config-ts'
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
import eslintPluginReact from 'eslint-plugin-react'
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
import eslintPluginReactRefresh from 'eslint-plugin-react-refresh'
export default defineConfig(
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
tseslint.configs.recommended,
eslintPluginReact.configs.flat.recommended,
eslintPluginReact.configs.flat['jsx-runtime'],
{
settings: {
react: {
version: 'detect'
}
}
},
{
files: ['**/*.{ts,tsx}'],
plugins: {
'react-hooks': eslintPluginReactHooks,
'react-refresh': eslintPluginReactRefresh
},
rules: {
...eslintPluginReactHooks.configs.recommended.rules,
...eslintPluginReactRefresh.configs.vite.rules
}
},
eslintConfigPrettier
)
+71
View File
@@ -0,0 +1,71 @@
{
"name": "growth_report",
"version": "1.0.0",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",
"homepage": "https://electron-vite.org",
"scripts": {
"format": "prettier --write .",
"lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"start": "chcp 65001 && electron-vite preview",
"dev": "chcp 65001 && electron-vite dev",
"build": "pnpm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "pnpm run build && electron-builder --dir",
"build:win": "pnpm run build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@radix-ui/react-alert-dialog": "^1.1.17",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-scroll-area": "^1.2.12",
"@radix-ui/react-separator": "^1.1.10",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.15",
"@tailwindcss/vite": "^4.3.1",
"better-sqlite3": "^12.11.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"electron-updater": "^6.3.9",
"jszip": "^3.10.1",
"lucide-react": "^1.21.0",
"react-router-dom": "^7.18.0",
"reflect-metadata": "^0.2.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1",
"typeorm": "^0.3.30",
"vaul": "^1.1.2",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.19.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"prettier": "^3.7.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"typescript": "^5.9.3",
"vite": "^7.2.6"
}
}
+7642
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
allowBuilds:
better-sqlite3: true
electron: true
electron-winstaller: true
esbuild: true
registries:
default: https://registry.npmmirror.com/
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+32
View File
@@ -0,0 +1,32 @@
import { EntitySchema } from 'typeorm'
export type ClassEntity = {
id: string
name: string
type: string
familyPhoto: string | null
}
export const ClassEntitySchema = new EntitySchema<ClassEntity>({
name: 'ClassEntity',
tableName: 'classes',
columns: {
id: {
type: String,
primary: true
},
name: {
type: String
},
type: {
type: String,
default: 'cheap'
},
familyPhoto: {
name: 'family_photo',
type: String,
nullable: true
}
},
relations: {}
})
+85
View File
@@ -0,0 +1,85 @@
import { EntitySchema } from 'typeorm'
export type ReportEntity = {
id: string
title: string
classId: string
className: string
studentId: string
studentName: string
format: string
originalFileName: string
filePath: string
fileExtension: string
templateId: string
createdAt: string
updatedAt: string
}
export const ReportEntitySchema = new EntitySchema<ReportEntity>({
name: 'ReportEntity',
tableName: 'reports',
columns: {
id: {
type: String,
primary: true
},
title: {
type: String
},
classId: {
name: 'class_id',
type: String
},
className: {
name: 'class_name',
type: String
},
studentId: {
name: 'student_id',
type: String
},
studentName: {
name: 'student_name',
type: String
},
format: {
type: String
},
originalFileName: {
name: 'original_file_name',
type: String
},
filePath: {
name: 'file_path',
type: String
},
fileExtension: {
name: 'file_extension',
type: String
},
templateId: {
name: 'template_id',
type: String
},
createdAt: {
name: 'created_at',
type: String
},
updatedAt: {
name: 'updated_at',
type: String
}
},
indices: [
{
name: 'idx_reports_class_id',
columns: ['classId']
},
{
name: 'idx_reports_student_name',
columns: ['studentName']
}
],
relations: {}
})
+103
View File
@@ -0,0 +1,103 @@
import { EntitySchema } from 'typeorm'
export type StudentProfileEntity = {
id: string
classId: string
className: string
name: string
englishName: string
gender: string
birthday: string
zodiac: string
friends: string
hobbies: string
favoriteGames: string
favoriteFoods: string
traits: string
comment: string
commentGeneratedAt: string
importedAt: string
}
export const StudentProfileEntitySchema = new EntitySchema<StudentProfileEntity>({
name: 'StudentProfileEntity',
tableName: 'student_profiles',
columns: {
id: {
type: String,
primary: true
},
classId: {
name: 'class_id',
type: String
},
className: {
name: 'class_name',
type: String
},
name: {
type: String,
default: ''
},
englishName: {
name: 'english_name',
type: String,
default: ''
},
gender: {
type: String,
default: ''
},
birthday: {
type: String,
default: ''
},
zodiac: {
type: String,
default: ''
},
friends: {
type: String,
default: '[]'
},
hobbies: {
type: String,
default: '[]'
},
favoriteGames: {
name: 'favorite_games',
type: String,
default: '[]'
},
favoriteFoods: {
name: 'favorite_foods',
type: String,
default: '[]'
},
traits: {
type: String,
default: ''
},
comment: {
type: String,
default: ''
},
commentGeneratedAt: {
name: 'comment_generated_at',
type: String,
default: ''
},
importedAt: {
name: 'imported_at',
type: String,
default: ''
}
},
indices: [
{
name: 'idx_student_profiles_class_id',
columns: ['classId']
}
],
relations: {}
})
+76
View File
@@ -0,0 +1,76 @@
import { EntitySchema } from 'typeorm'
export type TemplateEntity = {
id: string
name: string
grade: string
semester: string
type: string
originalFileName: string
filePath: string
fileExtension: string
placeholders: string
fieldMappings: string
createdAt: string
updatedAt: string
}
export const TemplateEntitySchema = new EntitySchema<TemplateEntity>({
name: 'TemplateEntity',
tableName: 'templates',
columns: {
id: {
type: String,
primary: true
},
name: {
type: String
},
grade: {
type: String
},
semester: {
type: String,
default: 'first'
},
type: {
type: String
},
originalFileName: {
name: 'original_file_name',
type: String
},
filePath: {
name: 'file_path',
type: String
},
fileExtension: {
name: 'file_extension',
type: String
},
placeholders: {
type: String,
default: '[]'
},
fieldMappings: {
name: 'field_mappings',
type: String,
default: '{}'
},
createdAt: {
name: 'created_at',
type: String
},
updatedAt: {
name: 'updated_at',
type: String
}
},
indices: [
{
name: 'idx_templates_name',
columns: ['name']
}
],
relations: {}
})
+40
View File
@@ -0,0 +1,40 @@
import { app, BrowserWindow } from 'electron'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { mkdirSync } from 'fs'
import { join } from 'path'
import { registerIpcHandlers } from './ipc'
import { createMainWindow } from './windows/mainWindow'
if (!app.isPackaged) {
const devDataPath = join(app.getAppPath(), '.electron-dev-data')
const devCachePath = join(devDataPath, 'cache')
mkdirSync(devCachePath, { recursive: true })
app.setPath('userData', devDataPath)
app.setPath('sessionData', devDataPath)
app.setPath('cache', devCachePath)
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.electron')
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
registerIpcHandlers()
createMainWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
+86
View File
@@ -0,0 +1,86 @@
import { BrowserWindow, dialog, ipcMain, type SaveDialogOptions } from 'electron'
import { writeFile } from 'fs/promises'
import JSZip from 'jszip'
import type { ExportClassFolderPayload, ExportClassFolderResponse } from '../types/classFolder'
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g
function sanitizeFolderName(name: string): string {
return (
name
.replace(INVALID_FILENAME_CHARS, '_')
.replaceAll(/./g, (char) => (char.charCodeAt(0) < 32 ? '_' : char))
.trim() || '未命名班级'
)
}
export function registerClassFolderIpc(): void {
ipcMain.handle(
'classes:export-empty-folder',
async (event, payload: ExportClassFolderPayload): Promise<ExportClassFolderResponse> => {
const className = payload.className.trim()
if (!className) {
return {
ok: false,
message: '请先填写班级名称'
}
}
const safeClassId = sanitizeFolderName(payload.classId || className)
const parentWindow = BrowserWindow.fromWebContents(event.sender)
const dialogOptions: SaveDialogOptions = {
title: '导出班级照片目录压缩包',
defaultPath: `${safeClassId}.zip`,
filters: [
{
name: 'ZIP 压缩包',
extensions: ['zip']
}
]
}
const result = parentWindow
? await dialog.showSaveDialog(parentWindow, dialogOptions)
: await dialog.showSaveDialog(dialogOptions)
if (result.canceled || !result.filePath) {
return {
ok: false,
canceled: true,
message: '已取消导出'
}
}
const childNameCounts = new Map<string, number>()
const zip = new JSZip()
zip.folder(`${safeClassId}/`)
try {
for (const childName of payload.childNames) {
const safeChildName = sanitizeFolderName(childName || '未命名幼儿')
const nextCount = (childNameCounts.get(safeChildName) ?? 0) + 1
childNameCounts.set(safeChildName, nextCount)
const childFolderName = nextCount > 1 ? `${safeChildName}-${nextCount}` : safeChildName
zip.folder(`${safeClassId}/${childFolderName}/`)
}
const zipContent = await zip.generateAsync({ type: 'nodebuffer' })
await writeFile(result.filePath, zipContent)
return {
ok: true,
zipPath: result.filePath,
childFolderCount: payload.childNames.length
}
} catch (error) {
const maybeNodeError = error as NodeJS.ErrnoException
return {
ok: false,
message: maybeNodeError.message || '导出压缩包失败'
}
}
}
)
}
+15
View File
@@ -0,0 +1,15 @@
import { registerClassFolderIpc } from './classFolderIpc'
import { registerStudentIpc } from './studentIpc'
import { registerModelIpc } from './modelIpc'
import { registerSettingsIpc } from './settingsIpc'
import { registerTemplateIpc } from './templateIpc'
import { registerReportIpc } from './reportIpc'
export function registerIpcHandlers(): void {
registerClassFolderIpc()
registerStudentIpc()
registerModelIpc()
registerSettingsIpc()
registerTemplateIpc()
registerReportIpc()
}
+25
View File
@@ -0,0 +1,25 @@
import { ipcMain } from 'electron'
import { listModels, testModelConnection } from '../services/modelService'
import type {
ListModelsRequest,
ModelConfig,
ModelListResponse,
TestModelConnectionResponse
} from '../types/model'
export function registerModelIpc(): void {
ipcMain.handle(
'models:list',
async (_, payload: ListModelsRequest): Promise<ModelListResponse> => {
return listModels(payload)
}
)
ipcMain.handle(
'models:test-connection',
async (_, payload: ModelConfig): Promise<TestModelConnectionResponse> => {
return testModelConnection(payload)
}
)
}
+75
View File
@@ -0,0 +1,75 @@
import { ipcMain } from 'electron'
import { deleteReport, generateReports, loadReports, openReport } from '../services/reportService'
import type {
DeleteReportResponse,
GenerateReportsInput,
GenerateReportsResponse,
LoadReportsResponse,
OpenReportResponse
} from '../types/report'
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : '报告操作失败'
}
export function registerReportIpc(): void {
ipcMain.handle('reports:load', async (): Promise<LoadReportsResponse> => {
try {
return {
ok: true,
reports: await loadReports()
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
})
ipcMain.handle(
'reports:generate',
async (_, payload: GenerateReportsInput): Promise<GenerateReportsResponse> => {
try {
return {
ok: true,
...(await generateReports(payload))
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
ipcMain.handle('reports:open', async (_, filePath: string): Promise<OpenReportResponse> => {
try {
await openReport(filePath)
return {
ok: true
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
})
ipcMain.handle('reports:delete', async (_, id: string): Promise<DeleteReportResponse> => {
try {
await deleteReport(id)
return {
ok: true
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
})
}
+17
View File
@@ -0,0 +1,17 @@
import { ipcMain } from 'electron'
import { loadSettings, saveSettings } from '../services/settingsService'
import type { ModelConfig } from '../types/model'
import type { LoadSettingsResponse, SaveSettingsResponse } from '../types/settings'
export function registerSettingsIpc(): void {
ipcMain.handle('settings:load', async (): Promise<LoadSettingsResponse> => {
return loadSettings()
})
ipcMain.handle(
'settings:save',
async (_, modelConfig: ModelConfig): Promise<SaveSettingsResponse> => {
return saveSettings(modelConfig)
})
}
+191
View File
@@ -0,0 +1,191 @@
import { ipcMain } from 'electron'
import {
deleteClassWithProfiles,
deleteStudentProfile,
generateStudentComment,
listStudentProfiles,
loadStudentData,
migrateLocalStudentData,
replaceProfilesForClass,
saveClasses,
updateStudentProfile
} from '../services/studentService'
import type {
ClassProfile,
ChildProfile,
DeleteClassResponse,
DeleteStudentProfileResponse,
GenerateStudentCommentInput,
GenerateStudentCommentResponse,
ListStudentProfilesInput,
ListStudentProfilesResponse,
LoadStudentDataResponse,
ReplaceClassProfilesResponse,
SaveClassesResponse,
UpdateStudentProfileInput,
UpdateStudentProfileResponse
} from '../types/student'
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : '数据库操作失败'
}
export function registerStudentIpc(): void {
ipcMain.handle('student:load', async (): Promise<LoadStudentDataResponse> => {
try {
const studentData = await loadStudentData()
return {
ok: true,
...studentData
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
})
ipcMain.handle(
'student:list',
async (_, payload: ListStudentProfilesInput): Promise<ListStudentProfilesResponse> => {
try {
return {
ok: true,
...(await listStudentProfiles(payload))
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
ipcMain.handle(
'student:update-profile',
async (_, payload: UpdateStudentProfileInput): Promise<UpdateStudentProfileResponse> => {
try {
return {
ok: true,
profile: await updateStudentProfile(payload)
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
ipcMain.handle(
'student:generate-comment',
async (_, payload: GenerateStudentCommentInput): Promise<GenerateStudentCommentResponse> => {
try {
return {
ok: true,
profile: await generateStudentComment(payload.profileId)
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
ipcMain.handle(
'student:save-classes',
async (_, classes: ClassProfile[]): Promise<SaveClassesResponse> => {
try {
await saveClasses(classes)
return {
ok: true
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
ipcMain.handle(
'student:replace-class-profiles',
async (
_,
payload: { classItem: ClassProfile; profiles: ChildProfile[] }
): Promise<ReplaceClassProfilesResponse> => {
try {
await replaceProfilesForClass(payload.classItem, payload.profiles)
return {
ok: true
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
ipcMain.handle(
'student:delete-class',
async (_, classId: string): Promise<DeleteClassResponse> => {
try {
await deleteClassWithProfiles(classId)
return {
ok: true
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
ipcMain.handle(
'student:delete-profile',
async (_, profileId: string): Promise<DeleteStudentProfileResponse> => {
try {
await deleteStudentProfile(profileId)
return {
ok: true
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
ipcMain.handle(
'student:migrate-local',
async (
_,
payload: { classes: ClassProfile[]; profiles: ChildProfile[] }
): Promise<SaveClassesResponse> => {
try {
await migrateLocalStudentData(payload.classes, payload.profiles)
return {
ok: true
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
}
+125
View File
@@ -0,0 +1,125 @@
import { ipcMain } from 'electron'
import {
deleteTemplate,
loadTemplates,
openTemplate,
parseTemplatePlaceholders,
saveTemplate,
selectTemplateFile
} from '../services/templateService'
import type {
DeleteTemplateResponse,
LoadTemplatesResponse,
OpenTemplateResponse,
ParseTemplatePlaceholdersResponse,
SaveTemplateResponse,
SelectTemplateFileResponse,
TemplateInput
} from '../types/template'
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : '模板操作失败'
}
export function registerTemplateIpc(): void {
ipcMain.handle('templates:load', async (): Promise<LoadTemplatesResponse> => {
try {
return {
ok: true,
templates: await loadTemplates()
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
})
ipcMain.handle('templates:select-file', async (): Promise<SelectTemplateFileResponse> => {
try {
const selectedFile = await selectTemplateFile()
if (!selectedFile) {
return {
ok: false,
canceled: true,
message: '已取消选择'
}
}
return {
ok: true,
...selectedFile
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
})
ipcMain.handle(
'templates:parse-placeholders',
async (_, filePath: string): Promise<ParseTemplatePlaceholdersResponse> => {
try {
return {
ok: true,
placeholders: await parseTemplatePlaceholders(filePath)
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
ipcMain.handle(
'templates:save',
async (_, payload: TemplateInput): Promise<SaveTemplateResponse> => {
try {
return {
ok: true,
template: await saveTemplate(payload)
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
ipcMain.handle('templates:open', async (_, filePath: string): Promise<OpenTemplateResponse> => {
try {
await openTemplate(filePath)
return {
ok: true
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
})
ipcMain.handle('templates:delete', async (_, id: string): Promise<DeleteTemplateResponse> => {
try {
await deleteTemplate(id)
return {
ok: true
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
})
}
+49
View File
@@ -0,0 +1,49 @@
import 'reflect-metadata'
import { app } from 'electron'
import { join } from 'path'
import { DataSource } from 'typeorm'
import { ClassEntitySchema } from '../entities/ClassEntity'
import { StudentProfileEntitySchema } from '../entities/StudentProfileEntity'
import { TemplateEntitySchema } from '../entities/TemplateEntity'
import { ReportEntitySchema } from '../entities/ReportEntity'
const DATABASE_FILENAME = 'growth-report.sqlite'
let dataSource: DataSource | null = null
function getDatabasePath(): string {
return join(app.getPath('userData'), DATABASE_FILENAME)
}
export async function getAppDataSource(): Promise<DataSource> {
if (dataSource?.isInitialized) {
return dataSource
}
dataSource = new DataSource({
type: 'better-sqlite3',
database: getDatabasePath(),
entities: [
ClassEntitySchema,
StudentProfileEntitySchema,
TemplateEntitySchema,
ReportEntitySchema
],
synchronize: true,
logging: false,
prepareDatabase(database): void {
database.pragma('journal_mode = WAL')
database.pragma('foreign_keys = ON')
database.exec('DROP INDEX IF EXISTS idx_child_profiles_class_id')
}
})
await dataSource.initialize()
console.info(
'[数据库] 已注册实体',
dataSource.entityMetadatas.map((metadata) => metadata.name)
)
return dataSource
}
+187
View File
@@ -0,0 +1,187 @@
import type {
ListModelsRequest,
ModelConfig,
ModelListResponse,
TestModelConnectionResponse
} from '../types/model'
const DEFAULT_TEST_PROMPT = '请回复“连接成功”。'
function normalizeBaseUrl(baseUrl: string): string {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, '')
if (normalizedBaseUrl.endsWith('/chat/completions')) {
return normalizedBaseUrl.slice(0, -'/chat/completions'.length)
}
if (normalizedBaseUrl.endsWith('/models')) {
return normalizedBaseUrl.slice(0, -'/models'.length)
}
return normalizedBaseUrl
}
function getModelsUrl(baseUrl: string): string {
return `${normalizeBaseUrl(baseUrl)}/models`
}
export function getChatCompletionsUrl(baseUrl: string): string {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
if (/\/v\d+$/i.test(normalizedBaseUrl)) {
return `${normalizedBaseUrl}/chat/completions`
}
return `${normalizedBaseUrl}/v1/chat/completions`
}
function maskApiKey(apiKey: string): string {
const trimmedKey = apiKey.trim()
if (trimmedKey.length <= 8) {
return `${trimmedKey.slice(0, 2)}***`
}
return `${trimmedKey.slice(0, 6)}...${trimmedKey.slice(-4)}`
}
function truncatePrompt(prompt: string, maxLength = 240): string {
const normalizedPrompt = prompt.replace(/\s+/g, ' ').trim()
if (normalizedPrompt.length <= maxLength) {
return normalizedPrompt
}
return `${normalizedPrompt.slice(0, maxLength)}...`
}
export function logLargeModelRequest({
label,
url,
apiKey,
model,
prompt
}: {
label: string
url: string
apiKey: string
model: string
prompt: string
}): void {
console.info(`[大模型请求] ${label}`, {
requestUrl: url,
apiKey: maskApiKey(apiKey),
modelId: model,
prompt: truncatePrompt(prompt)
})
}
export async function listModels(payload: ListModelsRequest): Promise<ModelListResponse> {
if (!payload.baseUrl.trim()) {
return { ok: false, message: '请先填写接口地址' }
}
if (!payload.apiKey.trim()) {
return { ok: false, message: '请先填写 API Key' }
}
console.info('[大模型请求] 获取模型列表', {
requestUrl: getModelsUrl(payload.baseUrl),
apiKey: maskApiKey(payload.apiKey)
})
try {
const response = await fetch(getModelsUrl(payload.baseUrl), {
headers: {
Authorization: `Bearer ${payload.apiKey}`,
'Content-Type': 'application/json'
}
})
if (!response.ok) {
return { ok: false, message: `获取模型列表失败:HTTP ${response.status}` }
}
const data = (await response.json()) as { data?: Array<{ id?: unknown }> }
const models = Array.from(
new Set(
(data.data ?? [])
.map((model) => model.id)
.filter((id): id is string => typeof id === 'string')
)
).sort((a, b) => a.localeCompare(b))
if (models.length === 0) {
return { ok: false, message: '接口返回成功,但没有找到可用模型' }
}
return { ok: true, models }
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : '获取模型列表失败'
}
}
}
export async function testModelConnection(
modelConfig: ModelConfig
): Promise<TestModelConnectionResponse> {
if (!modelConfig.baseUrl.trim()) {
return { ok: false, message: '请先填写接口地址' }
}
if (!modelConfig.apiKey.trim()) {
return { ok: false, message: '请先填写 API Key' }
}
if (!modelConfig.model.trim()) {
return { ok: false, message: '请先填写模型名称' }
}
const requestUrl = getChatCompletionsUrl(modelConfig.baseUrl)
logLargeModelRequest({
label: '测试连接',
url: requestUrl,
apiKey: modelConfig.apiKey,
model: modelConfig.model,
prompt: DEFAULT_TEST_PROMPT
})
try {
const response = await fetch(requestUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${modelConfig.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: modelConfig.model,
temperature: 0,
max_tokens: 16,
messages: [
{
role: 'user',
content: DEFAULT_TEST_PROMPT
}
]
})
})
if (!response.ok) {
const message = await response.text().catch(() => '')
return {
ok: false,
message: `测试连接失败:HTTP ${response.status}${message ? `${message.slice(0, 160)}` : ''}`
}
}
return { ok: true, message: '测试连接成功' }
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : '测试连接失败'
}
}
}
+642
View File
@@ -0,0 +1,642 @@
import { randomUUID } from 'crypto'
import { access, mkdir, readFile, unlink, writeFile } from 'fs/promises'
import { basename, isAbsolute, join } from 'path'
import 'reflect-metadata'
import { app, shell } from 'electron'
import JSZip from 'jszip'
import type { Repository } from 'typeorm'
import { ClassEntitySchema, type ClassEntity } from '../entities/ClassEntity'
import { ReportEntitySchema, type ReportEntity } from '../entities/ReportEntity'
import {
StudentProfileEntitySchema,
type StudentProfileEntity
} from '../entities/StudentProfileEntity'
import { TemplateEntitySchema, type TemplateEntity } from '../entities/TemplateEntity'
import type { GenerateReportsInput, ReportFormat, ReportItem } from '../types/report'
import { getAppDataSource } from './databaseService'
const REPORT_FOLDER_NAME = 'reports'
const TEMPLATE_FOLDER_NAME = 'templates'
const CONTENT_TYPES_PATH = '[Content_Types].xml'
type ShapeLocation = {
relsPath: string
shapeXml: string
embedRelId?: string
}
type ReportStudentData = {
id: string
classId: string
className: string
name: string
englishName: string
sex: string
gender: string
birthday: string
zodiac: string
friends: string
friend: string
hobbies: string
hobby: string
favoriteGames: string
game: string
favoriteFoods: string
food: string
traits: string
comment: string
comments: string
teacherName: string
teacher_name: string
}
function getReportStoragePath(): string {
return join(app.getPath('userData'), REPORT_FOLDER_NAME)
}
function resolveTemplateFilePath(filePath: string): string {
if (!isAbsolute(filePath)) {
return join(app.getPath('userData'), filePath)
}
return join(app.getPath('userData'), TEMPLATE_FOLDER_NAME, basename(filePath))
}
async function getReportRepository(): Promise<Repository<ReportEntity>> {
const source = await getAppDataSource()
return source.getRepository<ReportEntity>(ReportEntitySchema)
}
function normalizeReportFormat(format: string): ReportFormat {
if (format === 'word' || format === 'pdf' || format === 'excel') {
return format
}
return 'ppt'
}
function mapReportEntity(entity: ReportEntity): ReportItem {
return {
id: entity.id,
title: entity.title,
classId: entity.classId,
className: entity.className,
studentId: entity.studentId,
studentName: entity.studentName,
format: normalizeReportFormat(entity.format),
originalFileName: entity.originalFileName,
filePath: entity.filePath,
fileExtension: entity.fileExtension,
templateId: entity.templateId,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt
}
}
function escapeXml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function sanitizeFileName(value: string): string {
return (
[...value]
.filter((character) => !'<>:"/\\|?*'.includes(character) && character.charCodeAt(0) >= 32)
.join('')
.trim() || '未命名'
)
}
function getFormatFromExtension(extension: string): ReportFormat {
if (['.doc', '.docx'].includes(extension)) {
return 'word'
}
if (['.xls', '.xlsx'].includes(extension)) {
return 'excel'
}
if (extension === '.pdf') {
return 'pdf'
}
return 'ppt'
}
function getStudentData(student: StudentProfileEntity): ReportStudentData {
const friends = parseJsonList(student.friends).join('、') || ' '
const hobbies = parseJsonList(student.hobbies).join('、') || ' '
const favoriteGames = parseJsonList(student.favoriteGames).join('、') || ' '
const favoriteFoods = parseJsonList(student.favoriteFoods).join('、') || ' '
return {
id: student.id,
classId: student.classId,
className: student.className,
name: student.name || '未填写',
englishName: student.englishName || ' ',
sex: student.gender || '男',
gender: student.gender || '男',
birthday: student.birthday || ' ',
zodiac: student.zodiac || ' ',
friends,
friend: friends,
hobbies,
hobby: hobbies,
favoriteGames,
game: favoriteGames,
favoriteFoods,
food: favoriteFoods,
traits: student.traits || ' ',
comment: student.comment || '暂无评语',
comments: student.comment || '暂无评语',
teacherName: '',
teacher_name: ''
}
}
function parseTemplateFieldMappings(value: string): Record<string, string> {
try {
const parsed = JSON.parse(value)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
return Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, string] =>
typeof entry[0] === 'string' && typeof entry[1] === 'string'
)
)
} catch {
return {}
}
}
function normalizeFieldKey(value: string): string {
return value.replace(/[_\s-]+(.)?/g, (_, next: string | undefined) =>
next ? next.toUpperCase() : ''
)
}
function getMappedValue(
fieldKey: string,
studentData: ReportStudentData,
classItem: ClassEntity
): string {
if (fieldKey === 'class' || fieldKey === 'className') {
return classItem.name
}
const directValue = studentData[fieldKey as keyof ReportStudentData]
if (typeof directValue === 'string') {
return directValue
}
const normalizedValue = studentData[normalizeFieldKey(fieldKey) as keyof ReportStudentData]
return typeof normalizedValue === 'string' ? normalizedValue : ' '
}
function buildTextReplacements(
template: TemplateEntity,
studentData: ReportStudentData,
classItem: ClassEntity
): Record<string, string> {
const defaultReplacements: Record<string, string> = {
name: studentData.name,
class: classItem.name,
comments: studentData.comments,
teacher_name: studentData.teacher_name,
english_name: studentData.englishName,
sex: studentData.sex,
birthday: studentData.birthday,
zodiac: studentData.zodiac,
friend: studentData.friend,
hobby: studentData.hobby,
game: studentData.game,
food: studentData.food
}
const fieldMappings = parseTemplateFieldMappings(template.fieldMappings)
for (const [placeholder, fieldKey] of Object.entries(fieldMappings)) {
if (fieldKey === 'classImage' || fieldKey === 'familyPhoto') {
continue
}
defaultReplacements[placeholder] = getMappedValue(fieldKey, studentData, classItem)
}
return defaultReplacements
}
function buildImageReplacements(template: TemplateEntity): Record<string, string> {
const imageReplacements: Record<string, string> = {
class_image: 'familyPhoto'
}
const fieldMappings = parseTemplateFieldMappings(template.fieldMappings)
for (const [placeholder, fieldKey] of Object.entries(fieldMappings)) {
if (fieldKey === 'classImage' || fieldKey === 'familyPhoto') {
imageReplacements[placeholder] = 'familyPhoto'
}
}
return imageReplacements
}
function parseJsonList(value: string): string[] {
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []
} catch {
return []
}
}
function getSlidePaths(zip: JSZip): string[] {
return Object.keys(zip.files)
.filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path))
.sort((first, second) => {
const firstNumber = Number(first.match(/slide(\d+)\.xml$/)?.[1] ?? 0)
const secondNumber = Number(second.match(/slide(\d+)\.xml$/)?.[1] ?? 0)
return firstNumber - secondNumber
})
}
function getRelsPath(slidePath: string): string {
return slidePath.replace('ppt/slides/', 'ppt/slides/_rels/') + '.rels'
}
function replaceShapeText(slideXml: string, shapeName: string, text: string): string {
return slideXml.replace(/<p:sp[\s\S]*?<\/p:sp>/g, (shapeXml: string) => {
if (!hasShapeName(shapeXml, shapeName)) {
return shapeXml
}
const escapedText = escapeXml(text)
const textMatches = [...shapeXml.matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)]
if (textMatches.length === 0) {
return shapeXml
}
let replacedFirstText = false
return shapeXml.replace(/<a:t>[\s\S]*?<\/a:t>/g, () => {
if (replacedFirstText) {
return '<a:t></a:t>'
}
replacedFirstText = true
return `<a:t>${escapedText}</a:t>`
})
})
}
function hasShapeName(shapeXml: string, shapeName: string): boolean {
return new RegExp(`<p:cNvPr[^>]*name="${escapeRegExp(shapeName)}"[^>]*>`).test(shapeXml)
}
function replaceInlineTextPlaceholder(slideXml: string, placeholder: string, text: string): string {
const placeholderPattern = new RegExp(`\\{\\{\\s*${escapeRegExp(placeholder)}\\s*\\}\\}`, 'g')
return slideXml.replace(/<a:t>([\s\S]*?)<\/a:t>/g, (textNode, value: string) => {
placeholderPattern.lastIndex = 0
if (!placeholderPattern.test(value)) {
return textNode
}
placeholderPattern.lastIndex = 0
return `<a:t>${value.replace(placeholderPattern, escapeXml(text))}</a:t>`
})
}
async function replaceTextPlaceholders(
zip: JSZip,
replacements: Record<string, string>
): Promise<void> {
for (const slidePath of getSlidePaths(zip)) {
const slideFile = zip.file(slidePath)
if (!slideFile) {
continue
}
let slideXml = await slideFile.async('text')
for (const [shapeName, text] of Object.entries(replacements)) {
slideXml = replaceShapeText(slideXml, shapeName, text)
slideXml = replaceInlineTextPlaceholder(slideXml, shapeName, text)
}
zip.file(slidePath, slideXml)
}
}
function findPictureShape(slideXml: string, shapeName: string): ShapeLocation['shapeXml'] | null {
const pictureShapes = slideXml.match(/<p:pic[\s\S]*?<\/p:pic>/g) ?? []
return pictureShapes.find((shapeXml) => hasShapeName(shapeXml, shapeName)) ?? null
}
function getEmbedRelId(shapeXml: string): string | undefined {
return shapeXml.match(/r:embed="([^"]+)"/)?.[1]
}
function findShapeLocation(
shapeName: string,
slideXmlByPath: Map<string, string>
): ShapeLocation | null {
for (const [slidePath, slideXml] of slideXmlByPath.entries()) {
const shapeXml = findPictureShape(slideXml, shapeName)
if (!shapeXml) {
continue
}
return {
relsPath: getRelsPath(slidePath),
shapeXml,
embedRelId: getEmbedRelId(shapeXml)
}
}
return null
}
async function loadSlideXmlByPath(zip: JSZip): Promise<Map<string, string>> {
const slideXmlByPath = new Map<string, string>()
for (const slidePath of getSlidePaths(zip)) {
const slideFile = zip.file(slidePath)
if (slideFile) {
slideXmlByPath.set(slidePath, await slideFile.async('text'))
}
}
return slideXmlByPath
}
async function replacePicturePlaceholder(
zip: JSZip,
slideXmlByPath: Map<string, string>,
shapeName: string,
imageDataUrl?: string
): Promise<void> {
if (!imageDataUrl) {
return
}
const location = findShapeLocation(shapeName, slideXmlByPath)
if (!location?.embedRelId) {
return
}
const relsFile = zip.file(location.relsPath)
if (!relsFile) {
return
}
const relsXml = await relsFile.async('text')
const relationshipPattern = new RegExp(
`(<Relationship[^>]*Id="${escapeRegExp(location.embedRelId)}"[^>]*Target=")([^"]+)("[^>]*/>)`
)
const target = relsXml.match(relationshipPattern)?.[2]
if (!target) {
return
}
const mediaPath = `ppt/${target.replace(/^\.\.\//, '')}`
const imageMatch = imageDataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/)
if (!imageMatch) {
return
}
const mimeType = imageMatch[1]
const extension = mimeType.includes('png') ? 'png' : mimeType.includes('bmp') ? 'bmp' : 'jpg'
const targetMediaPath = mediaPath.replace(/\.[^.]+$/, `.${extension}`)
zip.file(targetMediaPath, Buffer.from(imageMatch[2], 'base64'))
if (targetMediaPath !== mediaPath) {
zip.remove(mediaPath)
zip.file(
location.relsPath,
relsXml.replace(relationshipPattern, `$1${target.replace(/\.[^.]+$/, `.${extension}`)}$3`)
)
await ensureImageContentType(zip, extension, mimeType)
}
}
async function ensureImageContentType(
zip: JSZip,
extension: string,
mimeType: string
): Promise<void> {
const contentTypesFile = zip.file(CONTENT_TYPES_PATH)
if (!contentTypesFile) {
return
}
const contentTypesXml = await contentTypesFile.async('text')
if (contentTypesXml.includes(`Extension="${extension}"`)) {
return
}
zip.file(
CONTENT_TYPES_PATH,
contentTypesXml.replace(
'</Types>',
`<Default Extension="${extension}" ContentType="${mimeType}"/></Types>`
)
)
}
async function buildPptxReport(
template: TemplateEntity,
outputPath: string,
student: StudentProfileEntity,
classItem: ClassEntity
): Promise<void> {
const templateBuffer = await readFile(resolveTemplateFilePath(template.filePath))
const zip = await JSZip.loadAsync(templateBuffer)
const studentData = getStudentData(student)
await replaceTextPlaceholders(zip, buildTextReplacements(template, studentData, classItem))
const slideXmlByPath = await loadSlideXmlByPath(zip)
for (const [placeholder, fieldKey] of Object.entries(buildImageReplacements(template))) {
if (fieldKey === 'familyPhoto') {
await replacePicturePlaceholder(
zip,
slideXmlByPath,
placeholder,
classItem.familyPhoto ?? undefined
)
}
}
const outputBuffer = await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE'
})
await writeFile(outputPath, outputBuffer)
}
async function removeFileIfExists(filePath: string): Promise<void> {
try {
await unlink(filePath)
} catch {
// Report files may have been moved or removed by the user.
}
}
export async function loadReports(): Promise<ReportItem[]> {
const repository = await getReportRepository()
const reports = await repository.find({
order: {
updatedAt: 'DESC'
}
})
return reports.map(mapReportEntity)
}
export async function generateReports(input: GenerateReportsInput): Promise<{
reports: ReportItem[]
skipped: Array<{ studentName: string; reason: string }>
}> {
const source = await getAppDataSource()
const templateRepository = source.getRepository<TemplateEntity>(TemplateEntitySchema)
const classRepository = source.getRepository<ClassEntity>(ClassEntitySchema)
const studentRepository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
const reportRepository = source.getRepository<ReportEntity>(ReportEntitySchema)
const template = await templateRepository.findOneBy({ id: input.templateId })
if (!template) {
throw new Error('请选择报告模板')
}
if (!['.ppt', '.pptx'].includes(template.fileExtension.toLowerCase())) {
throw new Error('当前生成方式仅支持 PPT/PPTX 模板')
}
if (template.fileExtension.toLowerCase() !== '.pptx') {
throw new Error('请使用 .pptx 模板生成报告,旧版 .ppt 暂不支持直接写入')
}
await access(resolveTemplateFilePath(template.filePath))
const classItem = await classRepository.findOneBy({ id: input.classId })
if (!classItem) {
throw new Error('请选择班级')
}
const selectedIds = new Set(input.studentIds ?? [])
const students = await studentRepository.find({
where: {
classId: input.classId
},
order: {
name: 'ASC',
id: 'ASC'
}
})
const targetStudents =
selectedIds.size > 0 ? students.filter((student) => selectedIds.has(student.id)) : students
if (targetStudents.length === 0) {
throw new Error('当前班级没有可生成的学生')
}
await mkdir(getReportStoragePath(), { recursive: true })
const now = new Date().toISOString()
const reports: ReportItem[] = []
const skipped: Array<{ studentName: string; reason: string }> = []
for (const student of targetStudents) {
const studentName = student.name || student.englishName || '未命名学生'
const title = `${classItem.name} ${studentName} 幼儿成长报告`
const reportId = randomUUID()
const fileName = `${sanitizeFileName(title)}${template.fileExtension}`
const outputPath = join(getReportStoragePath(), `${reportId}${template.fileExtension}`)
try {
await buildPptxReport(template, outputPath, student, classItem)
const entity: ReportEntity = {
id: reportId,
title,
classId: classItem.id,
className: classItem.name,
studentId: student.id,
studentName,
format: getFormatFromExtension(template.fileExtension),
originalFileName: fileName,
filePath: outputPath,
fileExtension: template.fileExtension,
templateId: template.id,
createdAt: now,
updatedAt: now
}
await reportRepository.save(entity)
reports.push(mapReportEntity(entity))
} catch (error) {
skipped.push({
studentName,
reason: error instanceof Error ? error.message : '生成失败'
})
await removeFileIfExists(outputPath)
}
}
return { reports, skipped }
}
export async function openReport(filePath: string): Promise<void> {
await access(filePath)
const errorMessage = await shell.openPath(filePath)
if (errorMessage) {
throw new Error(errorMessage)
}
}
export async function deleteReport(id: string): Promise<void> {
const repository = await getReportRepository()
const report = await repository.findOneBy({ id })
if (!report) {
return
}
await repository.delete({ id })
await removeFileIfExists(report.filePath)
}
+98
View File
@@ -0,0 +1,98 @@
import { app, safeStorage } from 'electron'
import { readFile, writeFile } from 'fs/promises'
import { join } from 'path'
import type { ModelConfig } from '../types/model'
import type {
LoadSettingsResponse,
SaveSettingsResponse,
SettingsFile,
StoredApiKey
} from '../types/settings'
function getSettingsPath(): string {
return join(app.getPath('userData'), 'settings.json')
}
function encodeApiKey(apiKey: string): StoredApiKey {
if (safeStorage.isEncryptionAvailable()) {
return {
encoding: 'safeStorage',
value: safeStorage.encryptString(apiKey).toString('base64')
}
}
return {
encoding: 'plain',
value: apiKey
}
}
function decodeApiKey(apiKey?: StoredApiKey): string {
if (!apiKey) return ''
if (apiKey.encoding === 'safeStorage') {
return safeStorage.decryptString(Buffer.from(apiKey.value, 'base64'))
}
return apiKey.value
}
export async function loadSettings(): Promise<LoadSettingsResponse> {
try {
const raw = await readFile(getSettingsPath(), 'utf-8').catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return null
throw error
})
if (!raw) {
return { ok: true, modelConfig: null }
}
const settings = JSON.parse(raw) as SettingsFile
if (!settings.modelConfig) {
return { ok: true, modelConfig: null }
}
const { apiKey, ...modelConfig } = settings.modelConfig
return {
ok: true,
modelConfig: {
...modelConfig,
apiKey: decodeApiKey(apiKey)
}
}
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : '读取配置失败'
}
}
}
export async function saveSettings(modelConfig: ModelConfig): Promise<SaveSettingsResponse> {
try {
const settings: SettingsFile = {
modelConfig: {
provider: modelConfig.provider,
baseUrl: modelConfig.baseUrl,
model: modelConfig.model,
temperature: modelConfig.temperature,
maxTokens: modelConfig.maxTokens,
systemPrompt: modelConfig.systemPrompt,
apiKey: encodeApiKey(modelConfig.apiKey)
}
}
await writeFile(getSettingsPath(), JSON.stringify(settings, null, 2), 'utf-8')
return { ok: true }
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : '保存配置失败'
}
}
}
+374
View File
@@ -0,0 +1,374 @@
import 'reflect-metadata'
import { Brackets, type Repository } from 'typeorm'
import {
StudentProfileEntitySchema,
type StudentProfileEntity
} from '../entities/StudentProfileEntity'
import { ClassEntitySchema, type ClassEntity } from '../entities/ClassEntity'
import type { ChildProfile, ClassProfile, ListStudentProfilesInput } from '../types/student'
import { getAppDataSource } from './databaseService'
import { getChatCompletionsUrl, logLargeModelRequest } from './modelService'
import { loadSettings } from './settingsService'
function parseJsonList(value: string): string[] {
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function stringifyList(items: string[]): string {
return JSON.stringify(items)
}
function normalizeClassType(type: string): 'cheap' | 'noble' {
return type === 'noble' ? 'noble' : 'cheap'
}
function mapClassEntity(entity: ClassEntity): ClassProfile {
return {
id: entity.id,
name: entity.name,
type: normalizeClassType(entity.type),
familyPhoto: entity.familyPhoto ?? undefined
}
}
function mapChildEntity(entity: StudentProfileEntity): ChildProfile {
return {
id: entity.id,
classId: entity.classId,
className: entity.className,
name: entity.name,
englishName: entity.englishName,
gender: entity.gender,
birthday: entity.birthday,
zodiac: entity.zodiac,
friends: parseJsonList(entity.friends),
hobbies: parseJsonList(entity.hobbies),
favoriteGames: parseJsonList(entity.favoriteGames),
favoriteFoods: parseJsonList(entity.favoriteFoods),
traits: entity.traits,
comment: entity.comment ?? '',
commentGeneratedAt: entity.commentGeneratedAt ?? '',
importedAt: entity.importedAt
}
}
function mapClassProfile(profile: ClassProfile): ClassEntity {
return {
id: profile.id,
name: profile.name,
type: profile.type,
familyPhoto: profile.familyPhoto ?? null
}
}
function mapChildProfile(profile: ChildProfile, classItem?: ClassProfile): StudentProfileEntity {
return {
id: profile.id,
classId: classItem?.id ?? profile.classId,
className: classItem?.name ?? profile.className,
name: profile.name,
englishName: profile.englishName,
gender: profile.gender,
birthday: profile.birthday,
zodiac: profile.zodiac,
friends: stringifyList(profile.friends),
hobbies: stringifyList(profile.hobbies),
favoriteGames: stringifyList(profile.favoriteGames),
favoriteFoods: stringifyList(profile.favoriteFoods),
traits: profile.traits,
comment: profile.comment ?? '',
commentGeneratedAt: profile.commentGeneratedAt ?? '',
importedAt: profile.importedAt
}
}
function buildStudentCommentPrompt(profile: ChildProfile): string {
return [
'请为这名幼儿生成一段成长报告评语。',
'要求:语气温暖、具体、积极,适合幼儿园成长报告;不要编造资料里没有的姓名、日期或家庭信息;长度控制在 120 到 180 字。',
'',
`姓名:${profile.name || '未填写'}`,
`英文名:${profile.englishName || '未填写'}`,
`班级:${profile.className || '未分班'}`,
`性别:${profile.gender || '未填写'}`,
`生日:${profile.birthday || '未填写'}`,
`属相:${profile.zodiac || '未填写'}`,
`好朋友:${profile.friends.length > 0 ? profile.friends.join('、') : '未填写'}`,
`爱好:${profile.hobbies.length > 0 ? profile.hobbies.join('、') : '未填写'}`,
`喜欢的游戏:${profile.favoriteGames.length > 0 ? profile.favoriteGames.join('、') : '未填写'}`,
`喜欢吃的食物:${profile.favoriteFoods.length > 0 ? profile.favoriteFoods.join('、') : '未填写'}`,
`表现特征:${profile.traits || '未填写'}`
].join('\n')
}
async function getRepositories(): Promise<{
classRepository: Repository<ClassEntity>
childRepository: Repository<StudentProfileEntity>
}> {
const source = await getAppDataSource()
return {
classRepository: source.getRepository<ClassEntity>(ClassEntitySchema),
childRepository: source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
}
}
export async function loadStudentData(): Promise<{
classes: ClassProfile[]
profiles: ChildProfile[]
}> {
const { classRepository, childRepository } = await getRepositories()
const classes = await classRepository.find({
order: {
id: 'ASC'
}
})
const profiles = await childRepository.find({
order: {
id: 'ASC'
}
})
return {
classes: classes.map(mapClassEntity),
profiles: profiles.map(mapChildEntity)
}
}
export async function listStudentProfiles(input: ListStudentProfilesInput): Promise<{
profiles: ChildProfile[]
total: number
page: number
pageSize: number
}> {
const pageSize = Math.min(Math.max(Number(input.pageSize) || 10, 1), 100)
const page = Math.max(Number(input.page) || 1, 1)
const keyword = input.query?.trim()
const source = await getAppDataSource()
const queryBuilder = source
.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
.createQueryBuilder('profile')
.orderBy('profile.className', 'ASC')
.addOrderBy('profile.name', 'ASC')
.addOrderBy('profile.id', 'ASC')
if (input.classId && input.classId !== 'all') {
queryBuilder.andWhere('profile.classId = :classId', { classId: input.classId })
}
if (keyword) {
queryBuilder.andWhere(
new Brackets((builder) => {
builder
.where('profile.className LIKE :keyword', { keyword: `%${keyword}%` })
.orWhere('profile.name LIKE :keyword', { keyword: `%${keyword}%` })
.orWhere('profile.englishName LIKE :keyword', { keyword: `%${keyword}%` })
.orWhere('profile.gender LIKE :keyword', { keyword: `%${keyword}%` })
.orWhere('profile.birthday LIKE :keyword', { keyword: `%${keyword}%` })
.orWhere('profile.zodiac LIKE :keyword', { keyword: `%${keyword}%` })
.orWhere('profile.friends LIKE :keyword', { keyword: `%${keyword}%` })
.orWhere('profile.hobbies LIKE :keyword', { keyword: `%${keyword}%` })
.orWhere('profile.favoriteGames LIKE :keyword', { keyword: `%${keyword}%` })
.orWhere('profile.favoriteFoods LIKE :keyword', { keyword: `%${keyword}%` })
.orWhere('profile.traits LIKE :keyword', { keyword: `%${keyword}%` })
})
)
}
const [profiles, total] = await queryBuilder
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount()
return {
profiles: profiles.map(mapChildEntity),
total,
page,
pageSize
}
}
export async function updateStudentProfile(profile: ChildProfile): Promise<ChildProfile> {
const source = await getAppDataSource()
const repository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
const existingProfile = await repository.findOneBy({ id: profile.id })
if (!existingProfile) {
throw new Error('学生信息不存在')
}
const nextProfile: ChildProfile = {
...profile,
comment: profile.comment ?? '',
commentGeneratedAt: profile.commentGeneratedAt ?? existingProfile.commentGeneratedAt ?? ''
}
await repository.save(mapChildProfile(nextProfile))
return nextProfile
}
export async function generateStudentComment(profileId: string): Promise<ChildProfile> {
const source = await getAppDataSource()
const repository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
const entity = await repository.findOneBy({ id: profileId })
if (!entity) {
throw new Error('学生信息不存在')
}
const profile = mapChildEntity(entity)
const settings = await loadSettings()
if (!settings.ok) {
throw new Error(settings.message)
}
const modelConfig = settings.modelConfig
if (!modelConfig?.baseUrl || !modelConfig.apiKey || !modelConfig.model) {
throw new Error('请先在设置中配置模型接口、API Key 和模型')
}
const requestUrl = getChatCompletionsUrl(modelConfig.baseUrl)
const userPrompt = buildStudentCommentPrompt(profile)
logLargeModelRequest({
label: '生成学生评语',
url: requestUrl,
apiKey: modelConfig.apiKey,
model: modelConfig.model,
prompt: userPrompt
})
const response = await fetch(requestUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${modelConfig.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: modelConfig.model,
temperature: Number(modelConfig.temperature || 0.7),
max_tokens: Number(modelConfig.maxTokens || 512),
messages: [
{
role: 'system',
content:
modelConfig.systemPrompt ||
'你是一名幼儿园老师,擅长撰写真诚、具体、积极的成长报告评语。'
},
{
role: 'user',
content: userPrompt
}
]
})
})
if (!response.ok) {
throw new Error(`生成评语失败:HTTP ${response.status}`)
}
const result = (await response.json()) as {
choices?: Array<{ message?: { content?: unknown } }>
}
const comment = result.choices?.[0]?.message?.content
if (typeof comment !== 'string' || !comment.trim()) {
throw new Error('模型没有返回可用评语')
}
const nextProfile: ChildProfile = {
...profile,
comment: comment.trim(),
commentGeneratedAt: new Date().toLocaleString('zh-CN', { hour12: false })
}
await repository.save(mapChildProfile(nextProfile))
return nextProfile
}
export async function saveClasses(classes: ClassProfile[]): Promise<void> {
const source = await getAppDataSource()
await source.transaction(async (manager) => {
const classRepository = manager.getRepository<ClassEntity>(ClassEntitySchema)
const childRepository = manager.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
const nextClassIds = new Set(classes.map((classItem) => classItem.id))
const existingClasses = await classRepository.find()
for (const classItem of existingClasses) {
if (!nextClassIds.has(classItem.id)) {
await childRepository.delete({ classId: classItem.id })
await classRepository.delete({ id: classItem.id })
}
}
for (const classItem of classes) {
await classRepository.save(mapClassProfile(classItem))
await childRepository.update({ classId: classItem.id }, { className: classItem.name })
}
})
}
export async function migrateLocalStudentData(
classes: ClassProfile[],
profiles: ChildProfile[]
): Promise<void> {
const source = await getAppDataSource()
await source.transaction(async (manager) => {
const classRepository = manager.getRepository<ClassEntity>(ClassEntitySchema)
const childRepository = manager.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
const classCount = await classRepository.count()
const profileCount = await childRepository.count()
if (classCount > 0 || profileCount > 0) {
return
}
await classRepository.save(classes.map(mapClassProfile))
await childRepository.save(profiles.map((profile) => mapChildProfile(profile)))
})
}
export async function replaceProfilesForClass(
classItem: ClassProfile,
profiles: ChildProfile[]
): Promise<void> {
const source = await getAppDataSource()
await source.transaction(async (manager) => {
const childRepository = manager.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
await childRepository.delete({ classId: classItem.id })
await childRepository.save(profiles.map((profile) => mapChildProfile(profile, classItem)))
})
}
export async function deleteClassWithProfiles(classId: string): Promise<void> {
const source = await getAppDataSource()
await source.transaction(async (manager) => {
await manager.getRepository<StudentProfileEntity>(StudentProfileEntitySchema).delete({ classId })
await manager.getRepository<ClassEntity>(ClassEntitySchema).delete({ id: classId })
})
}
export async function deleteStudentProfile(profileId: string): Promise<void> {
const source = await getAppDataSource()
const result = await source
.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
.delete({ id: profileId })
if (!result.affected) {
throw new Error('学生信息不存在')
}
}
+363
View File
@@ -0,0 +1,363 @@
import { randomUUID } from 'crypto'
import { access, copyFile, mkdir, readFile, unlink } from 'fs/promises'
import { basename, extname, isAbsolute, join } from 'path'
import 'reflect-metadata'
import { app, dialog, shell } from 'electron'
import JSZip from 'jszip'
import type { Repository } from 'typeorm'
import { TemplateEntitySchema, type TemplateEntity } from '../entities/TemplateEntity'
import type {
TemplateFieldMapping,
TemplateInput,
TemplateItem,
TemplatePlaceholder,
TemplateSemester,
TemplateType
} from '../types/template'
import { getAppDataSource } from './databaseService'
const TEMPLATE_FOLDER_NAME = 'templates'
const SUPPORTED_TEMPLATE_EXTENSIONS = ['.ppt', '.pptx', '.doc', '.docx', '.xls', '.xlsx']
const BUILT_IN_PPT_PLACEHOLDERS = new Set([
'name',
'class',
'comments',
'teacher_name',
'english_name',
'sex',
'birthday',
'zodiac',
'friend',
'hobby',
'game',
'food',
'class_image'
])
function getTemplateStoragePath(): string {
return join(app.getPath('userData'), TEMPLATE_FOLDER_NAME)
}
function getStoredTemplateFilePath(id: string, extension: string): string {
return join(TEMPLATE_FOLDER_NAME, `${id}${extension}`)
}
function resolveTemplateFilePath(filePath: string): string {
if (!isAbsolute(filePath)) {
return join(app.getPath('userData'), filePath)
}
return join(getTemplateStoragePath(), basename(filePath))
}
async function getTemplateRepository(): Promise<Repository<TemplateEntity>> {
const source = await getAppDataSource()
return source.getRepository<TemplateEntity>(TemplateEntitySchema)
}
function normalizeTemplateType(type: string): TemplateType {
return type === 'landscape' ? 'landscape' : 'portrait'
}
function normalizeTemplateSemester(semester: string): TemplateSemester {
return semester === 'second' ? 'second' : 'first'
}
function parseStoredPlaceholders(value: string): TemplatePlaceholder[] {
try {
const parsed = JSON.parse(value)
if (!Array.isArray(parsed)) {
return []
}
return parsed.filter(
(item): item is TemplatePlaceholder =>
item && typeof item.name === 'string' && (item.kind === 'text' || item.kind === 'image')
)
} catch {
return []
}
}
function parseStoredFieldMappings(value: string): TemplateFieldMapping {
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, string] =>
typeof entry[0] === 'string' && typeof entry[1] === 'string'
)
)
: {}
} catch {
return {}
}
}
function mapTemplateEntity(entity: TemplateEntity): TemplateItem {
return {
id: entity.id,
name: entity.name,
grade: entity.grade,
semester: normalizeTemplateSemester(entity.semester),
type: normalizeTemplateType(entity.type),
originalFileName: entity.originalFileName,
filePath: resolveTemplateFilePath(entity.filePath),
fileExtension: entity.fileExtension,
placeholders: parseStoredPlaceholders(entity.placeholders),
fieldMappings: parseStoredFieldMappings(entity.fieldMappings),
createdAt: entity.createdAt,
updatedAt: entity.updatedAt
}
}
function ensureSupportedTemplateFile(filePath: string): string {
const extension = extname(filePath).toLowerCase()
if (!SUPPORTED_TEMPLATE_EXTENSIONS.includes(extension)) {
throw new Error('仅支持 PPT、Word、Excel 模板文件')
}
return extension
}
async function removeFileIfExists(filePath: string): Promise<void> {
try {
await unlink(filePath)
} catch {
// Removing an old copied template is best-effort; metadata remains the source of truth.
}
}
function getSlidePaths(zip: JSZip): string[] {
return Object.keys(zip.files)
.filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path))
.sort((first, second) => {
const firstNumber = Number(first.match(/slide(\d+)\.xml$/)?.[1] ?? 0)
const secondNumber = Number(second.match(/slide(\d+)\.xml$/)?.[1] ?? 0)
return firstNumber - secondNumber
})
}
function decodeXml(value: string): string {
return value
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&')
}
function isPictureShape(slideXml: string, shapeNameIndex: number): boolean {
return (
slideXml.lastIndexOf('<p:pic', shapeNameIndex) >
slideXml.lastIndexOf('</p:pic>', shapeNameIndex)
)
}
function upsertPlaceholder(
placeholdersByName: Map<string, TemplatePlaceholder>,
name: string,
kind: TemplatePlaceholder['kind']
): void {
const trimmedName = decodeXml(name).trim()
if (!trimmedName) {
return
}
const existing = placeholdersByName.get(trimmedName)
if (existing?.kind === 'image') {
return
}
placeholdersByName.set(trimmedName, { name: trimmedName, kind })
}
async function parsePptxPlaceholders(filePath: string): Promise<TemplatePlaceholder[]> {
const templateBuffer = await readFile(filePath)
const zip = await JSZip.loadAsync(templateBuffer)
const placeholdersByName = new Map<string, TemplatePlaceholder>()
for (const slidePath of getSlidePaths(zip)) {
const slideFile = zip.file(slidePath)
if (!slideFile) {
continue
}
const slideXml = await slideFile.async('text')
for (const shapeMatch of slideXml.matchAll(/<p:cNvPr[^>]*name="([^"]+)"/g)) {
const rawName = shapeMatch[1]
const name = decodeXml(rawName)
if (!BUILT_IN_PPT_PLACEHOLDERS.has(name) && !/^field[:_-]/i.test(name)) {
continue
}
upsertPlaceholder(
placeholdersByName,
name.replace(/^field[:_-]/i, ''),
isPictureShape(slideXml, shapeMatch.index ?? 0) ? 'image' : 'text'
)
}
for (const placeholderMatch of slideXml.matchAll(/\{\{\s*([^{}\s][^{}]*?)\s*\}\}/g)) {
upsertPlaceholder(placeholdersByName, placeholderMatch[1], 'text')
}
}
return [...placeholdersByName.values()].sort((first, second) =>
first.name.localeCompare(second.name, 'zh-CN')
)
}
export async function parseTemplatePlaceholders(filePath: string): Promise<TemplatePlaceholder[]> {
await access(filePath)
const extension = ensureSupportedTemplateFile(filePath)
if (extension !== '.pptx') {
return []
}
return parsePptxPlaceholders(filePath)
}
export async function selectTemplateFile(): Promise<{ filePath: string; fileName: string } | null> {
const result = await dialog.showOpenDialog({
title: '选择模板文件',
buttonLabel: '选择模板',
properties: ['openFile'],
filters: [
{
name: 'Office 模板文件',
extensions: ['ppt', 'pptx', 'doc', 'docx', 'xls', 'xlsx']
}
]
})
if (result.canceled || result.filePaths.length === 0) {
return null
}
const filePath = result.filePaths[0]
ensureSupportedTemplateFile(filePath)
return {
filePath,
fileName: basename(filePath)
}
}
export async function loadTemplates(): Promise<TemplateItem[]> {
const repository = await getTemplateRepository()
const templates = await repository.find({
order: {
updatedAt: 'DESC'
}
})
return templates.map(mapTemplateEntity)
}
export async function saveTemplate(input: TemplateInput): Promise<TemplateItem> {
const name = input.name.trim()
const grade = input.grade.trim()
if (!name) {
throw new Error('请输入模板名称')
}
if (!grade) {
throw new Error('请输入适用年级')
}
await access(input.sourceFilePath)
const repository = await getTemplateRepository()
const existingTemplate = input.id ? await repository.findOneBy({ id: input.id }) : null
const extension = ensureSupportedTemplateFile(input.sourceFilePath)
const id = existingTemplate?.id ?? randomUUID()
const now = new Date().toISOString()
const nextStoredFilePath = getStoredTemplateFilePath(id, extension)
const nextAbsoluteFilePath = resolveTemplateFilePath(nextStoredFilePath)
const existingAbsoluteFilePath = existingTemplate
? resolveTemplateFilePath(existingTemplate.filePath)
: null
const shouldCopyFile = existingAbsoluteFilePath !== input.sourceFilePath
if (shouldCopyFile) {
await mkdir(getTemplateStoragePath(), { recursive: true })
await copyFile(input.sourceFilePath, nextAbsoluteFilePath)
if (existingAbsoluteFilePath && existingAbsoluteFilePath !== nextAbsoluteFilePath) {
await removeFileIfExists(existingAbsoluteFilePath)
}
}
const placeholders =
extension === '.pptx'
? await parsePptxPlaceholders(nextAbsoluteFilePath)
: existingTemplate
? parseStoredPlaceholders(existingTemplate.placeholders)
: []
const placeholderNames = new Set(placeholders.map((placeholder) => placeholder.name))
const previousMappings = existingTemplate
? parseStoredFieldMappings(existingTemplate.fieldMappings)
: {}
const inputMappings = input.fieldMappings ?? previousMappings
const fieldMappings = Object.fromEntries(
Object.entries(inputMappings)
.map(([placeholder, field]) => [placeholder.trim(), field.trim()])
.filter(([placeholder, field]) => placeholderNames.has(placeholder) && field.length > 0)
)
const entity: TemplateEntity = {
id,
name,
grade,
semester: normalizeTemplateSemester(input.semester),
type: normalizeTemplateType(input.type),
originalFileName: basename(input.sourceFilePath),
filePath: nextStoredFilePath,
fileExtension: extension,
placeholders: JSON.stringify(placeholders),
fieldMappings: JSON.stringify(fieldMappings),
createdAt: existingTemplate?.createdAt ?? now,
updatedAt: now
}
await repository.save(entity)
return mapTemplateEntity(entity)
}
export async function openTemplate(filePath: string): Promise<void> {
const absoluteFilePath = resolveTemplateFilePath(filePath)
await access(absoluteFilePath)
const errorMessage = await shell.openPath(absoluteFilePath)
if (errorMessage) {
throw new Error(errorMessage)
}
}
export async function deleteTemplate(id: string): Promise<void> {
const repository = await getTemplateRepository()
const template = await repository.findOneBy({ id })
if (!template) {
return
}
await repository.delete({ id })
await removeFileIfExists(resolveTemplateFilePath(template.filePath))
}
+17
View File
@@ -0,0 +1,17 @@
export type ExportClassFolderPayload = {
className: string
classId: string
childNames: string[]
}
export type ExportClassFolderResponse =
| {
ok: true
zipPath: string
childFolderCount: number
}
| {
ok: false
canceled?: boolean
message: string
}
+34
View File
@@ -0,0 +1,34 @@
export type ListModelsRequest = {
baseUrl: string
apiKey: string
}
export type ModelListResponse =
| {
ok: true
models: string[]
}
| {
ok: false
message: string
}
export type ModelConfig = {
provider: string
baseUrl: string
apiKey: string
model: string
temperature: string
maxTokens: string
systemPrompt: string
}
export type TestModelConnectionResponse =
| {
ok: true
message: string
}
| {
ok: false
message: string
}
+62
View File
@@ -0,0 +1,62 @@
export type ReportFormat = 'word' | 'ppt' | 'pdf' | 'excel'
export type ReportItem = {
id: string
title: string
classId: string
className: string
studentId: string
studentName: string
format: ReportFormat
originalFileName: string
filePath: string
fileExtension: string
templateId: string
createdAt: string
updatedAt: string
}
export type GenerateReportsInput = {
templateId: string
classId: string
studentIds?: string[]
}
export type LoadReportsResponse =
| {
ok: true
reports: ReportItem[]
}
| {
ok: false
message: string
}
export type GenerateReportsResponse =
| {
ok: true
reports: ReportItem[]
skipped: Array<{ studentName: string; reason: string }>
}
| {
ok: false
message: string
}
export type OpenReportResponse =
| {
ok: true
}
| {
ok: false
message: string
}
export type DeleteReportResponse =
| {
ok: true
}
| {
ok: false
message: string
}
+33
View File
@@ -0,0 +1,33 @@
import type { ModelConfig } from './model'
export type StoredApiKey = {
encoding: 'safeStorage' | 'plain'
value: string
}
export type StoredModelConfig = Omit<ModelConfig, 'apiKey'> & {
apiKey?: StoredApiKey
}
export type SettingsFile = {
modelConfig?: StoredModelConfig
}
export type SaveSettingsResponse =
| {
ok: true
}
| {
ok: false
message: string
}
export type LoadSettingsResponse =
| {
ok: true
modelConfig: ModelConfig | null
}
| {
ok: false
message: string
}
+120
View File
@@ -0,0 +1,120 @@
export type ClassType = 'cheap' | 'noble'
export type ClassProfile = {
id: string
name: string
type: ClassType
familyPhoto?: string
}
export type ChildProfile = {
id: string
classId: string
className: string
name: string
englishName: string
gender: string
birthday: string
zodiac: string
friends: string[]
hobbies: string[]
favoriteGames: string[]
favoriteFoods: string[]
traits: string
comment: string
commentGeneratedAt: string
importedAt: string
}
export type LoadStudentDataResponse =
| {
ok: true
classes: ClassProfile[]
profiles: ChildProfile[]
}
| {
ok: false
message: string
}
export type ListStudentProfilesInput = {
page: number
pageSize: number
query?: string
classId?: string
}
export type ListStudentProfilesResponse =
| {
ok: true
profiles: ChildProfile[]
total: number
page: number
pageSize: number
}
| {
ok: false
message: string
}
export type UpdateStudentProfileInput = ChildProfile
export type UpdateStudentProfileResponse =
| {
ok: true
profile: ChildProfile
}
| {
ok: false
message: string
}
export type GenerateStudentCommentInput = {
profileId: string
}
export type GenerateStudentCommentResponse =
| {
ok: true
profile: ChildProfile
}
| {
ok: false
message: string
}
export type SaveClassesResponse =
| {
ok: true
}
| {
ok: false
message: string
}
export type ReplaceClassProfilesResponse =
| {
ok: true
}
| {
ok: false
message: string
}
export type DeleteClassResponse =
| {
ok: true
}
| {
ok: false
message: string
}
export type DeleteStudentProfileResponse =
| {
ok: true
}
| {
ok: false
message: string
}
+94
View File
@@ -0,0 +1,94 @@
export type TemplateType = 'portrait' | 'landscape'
export type TemplateSemester = 'first' | 'second'
export type TemplatePlaceholder = {
name: string
kind: 'text' | 'image'
}
export type TemplateFieldMapping = Record<string, string>
export type TemplateItem = {
id: string
name: string
grade: string
semester: TemplateSemester
type: TemplateType
originalFileName: string
filePath: string
fileExtension: string
placeholders: TemplatePlaceholder[]
fieldMappings: TemplateFieldMapping
createdAt: string
updatedAt: string
}
export type TemplateInput = {
id?: string
name: string
grade: string
semester: TemplateSemester
type: TemplateType
sourceFilePath: string
fieldMappings?: TemplateFieldMapping
}
export type LoadTemplatesResponse =
| {
ok: true
templates: TemplateItem[]
}
| {
ok: false
message: string
}
export type SelectTemplateFileResponse =
| {
ok: true
filePath: string
fileName: string
}
| {
ok: false
canceled?: boolean
message: string
}
export type SaveTemplateResponse =
| {
ok: true
template: TemplateItem
}
| {
ok: false
message: string
}
export type ParseTemplatePlaceholdersResponse =
| {
ok: true
placeholders: TemplatePlaceholder[]
}
| {
ok: false
message: string
}
export type OpenTemplateResponse =
| {
ok: true
}
| {
ok: false
message: string
}
export type DeleteTemplateResponse =
| {
ok: true
}
| {
ok: false
message: string
}
+39
View File
@@ -0,0 +1,39 @@
import { BrowserWindow, shell } from 'electron'
import { is } from '@electron-toolkit/utils'
import { join } from 'path'
import icon from '../../../resources/icon.png?asset'
export function createMainWindow(): BrowserWindow {
const mainWindow = new BrowserWindow({
width: 1500,
height: 950,
minWidth: 720,
minHeight: 620,
show: false,
title: '成长报告助手',
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
return mainWindow
}
+303
View File
@@ -0,0 +1,303 @@
import { ElectronAPI } from '@electron-toolkit/preload'
type ModelListResponse =
| {
ok: true
models: string[]
}
| {
ok: false
message: string
}
type TestModelConnectionResponse =
| {
ok: true
message: string
}
| {
ok: false
message: string
}
type ModelConfig = {
provider: string
baseUrl: string
apiKey: string
model: string
temperature: string
maxTokens: string
systemPrompt: string
}
type ClassType = 'cheap' | 'noble'
type ClassProfile = {
id: string
name: string
type: ClassType
familyPhoto?: string
}
type TemplateType = 'portrait' | 'landscape'
type TemplateSemester = 'first' | 'second'
type TemplatePlaceholder = {
name: string
kind: 'text' | 'image'
}
type TemplateFieldMapping = Record<string, string>
type TemplateItem = {
id: string
name: string
grade: string
semester: TemplateSemester
type: TemplateType
originalFileName: string
filePath: string
fileExtension: string
placeholders: TemplatePlaceholder[]
fieldMappings: TemplateFieldMapping
createdAt: string
updatedAt: string
}
type ReportFormat = 'word' | 'ppt' | 'pdf' | 'excel'
type ReportItem = {
id: string
title: string
classId: string
className: string
studentId: string
studentName: string
format: ReportFormat
originalFileName: string
filePath: string
fileExtension: string
templateId: string
createdAt: string
updatedAt: string
}
type ChildProfile = {
id: string
classId: string
className: string
name: string
englishName: string
gender: string
birthday: string
zodiac: string
friends: string[]
hobbies: string[]
favoriteGames: string[]
favoriteFoods: string[]
traits: string
comment: string
commentGeneratedAt: string
importedAt: string
}
type SaveSettingsResponse =
| {
ok: true
}
| {
ok: false
message: string
}
type LoadSettingsResponse =
| {
ok: true
modelConfig: ModelConfig | null
}
| {
ok: false
message: string
}
type ExportClassFolderResponse =
| {
ok: true
zipPath: string
childFolderCount: number
}
| {
ok: false
canceled?: boolean
message: string
}
type LoadStudentDataResponse =
| {
ok: true
classes: ClassProfile[]
profiles: ChildProfile[]
}
| {
ok: false
message: string
}
type ListStudentProfilesResponse =
| {
ok: true
profiles: ChildProfile[]
total: number
page: number
pageSize: number
}
| {
ok: false
message: string
}
type StudentProfileMutationResponse =
| {
ok: true
profile: ChildProfile
}
| {
ok: false
message: string
}
type BasicMutationResponse =
| {
ok: true
}
| {
ok: false
message: string
}
type LoadTemplatesResponse =
| {
ok: true
templates: TemplateItem[]
}
| {
ok: false
message: string
}
type SelectTemplateFileResponse =
| {
ok: true
filePath: string
fileName: string
}
| {
ok: false
canceled?: boolean
message: string
}
type SaveTemplateResponse =
| {
ok: true
template: TemplateItem
}
| {
ok: false
message: string
}
type ParseTemplatePlaceholdersResponse =
| {
ok: true
placeholders: TemplatePlaceholder[]
}
| {
ok: false
message: string
}
type LoadReportsResponse =
| {
ok: true
reports: ReportItem[]
}
| {
ok: false
message: string
}
type GenerateReportsResponse =
| {
ok: true
reports: ReportItem[]
skipped: Array<{ studentName: string; reason: string }>
}
| {
ok: false
message: string
}
type AppAPI = {
deleteClass: (classId: string) => Promise<BasicMutationResponse>
deleteStudentProfile: (profileId: string) => Promise<BasicMutationResponse>
exportEmptyClassFolder: (payload: {
className: string
classId: string
childNames: string[]
}) => Promise<ExportClassFolderResponse>
loadStudentData: () => Promise<LoadStudentDataResponse>
listStudentProfiles: (payload: {
page: number
pageSize: number
query?: string
classId?: string
}) => Promise<ListStudentProfilesResponse>
updateStudentProfile: (profile: ChildProfile) => Promise<StudentProfileMutationResponse>
generateStudentComment: (payload: {
profileId: string
}) => Promise<StudentProfileMutationResponse>
listModels: (payload: { baseUrl: string; apiKey: string }) => Promise<ModelListResponse>
testModelConnection: (modelConfig: ModelConfig) => Promise<TestModelConnectionResponse>
replaceClassProfiles: (payload: {
classItem: ClassProfile
profiles: ChildProfile[]
}) => Promise<BasicMutationResponse>
migrateLocalStudentData: (payload: {
classes: ClassProfile[]
profiles: ChildProfile[]
}) => Promise<BasicMutationResponse>
saveClasses: (classes: ClassProfile[]) => Promise<BasicMutationResponse>
loadSettings: () => Promise<LoadSettingsResponse>
saveSettings: (modelConfig: ModelConfig) => Promise<SaveSettingsResponse>
loadTemplates: () => Promise<LoadTemplatesResponse>
selectTemplateFile: () => Promise<SelectTemplateFileResponse>
parseTemplatePlaceholders: (filePath: string) => Promise<ParseTemplatePlaceholdersResponse>
getDroppedFilePath: (file: File) => string
saveTemplate: (template: {
id?: string
name: string
grade: string
semester: TemplateSemester
type: TemplateType
sourceFilePath: string
fieldMappings?: TemplateFieldMapping
}) => Promise<SaveTemplateResponse>
openTemplate: (filePath: string) => Promise<BasicMutationResponse>
deleteTemplate: (id: string) => Promise<BasicMutationResponse>
loadReports: () => Promise<LoadReportsResponse>
generateReports: (payload: {
templateId: string
classId: string
studentIds?: string[]
}) => Promise<GenerateReportsResponse>
openReport: (filePath: string) => Promise<BasicMutationResponse>
deleteReport: (id: string) => Promise<BasicMutationResponse>
}
declare global {
interface Window {
electron: ElectronAPI
api: AppAPI
}
}
+153
View File
@@ -0,0 +1,153 @@
import { contextBridge, ipcRenderer, webUtils } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// Custom APIs for renderer
const api = {
deleteClass: (classId: string) => ipcRenderer.invoke('student:delete-class', classId),
deleteStudentProfile: (profileId: string) =>
ipcRenderer.invoke('student:delete-profile', profileId),
exportEmptyClassFolder: (payload: { className: string; classId: string; childNames: string[] }) =>
ipcRenderer.invoke('classes:export-empty-folder', payload),
loadStudentData: () => ipcRenderer.invoke('student:load'),
listStudentProfiles: (payload: {
page: number
pageSize: number
query?: string
classId?: string
}) => ipcRenderer.invoke('student:list', payload),
updateStudentProfile: (profile: {
id: string
classId: string
className: string
name: string
englishName: string
gender: string
birthday: string
zodiac: string
friends: string[]
hobbies: string[]
favoriteGames: string[]
favoriteFoods: string[]
traits: string
comment: string
commentGeneratedAt: string
importedAt: string
}) => ipcRenderer.invoke('student:update-profile', profile),
generateStudentComment: (payload: { profileId: string }) =>
ipcRenderer.invoke('student:generate-comment', payload),
listModels: (payload: { baseUrl: string; apiKey: string }) =>
ipcRenderer.invoke('models:list', payload),
testModelConnection: (modelConfig: {
provider: string
baseUrl: string
apiKey: string
model: string
temperature: string
maxTokens: string
systemPrompt: string
}) => ipcRenderer.invoke('models:test-connection', modelConfig),
replaceClassProfiles: (payload: {
classItem: {
id: string
name: string
type: 'cheap' | 'noble'
familyPhoto?: string
}
profiles: Array<{
id: string
classId: string
className: string
name: string
englishName: string
gender: string
birthday: string
zodiac: string
friends: string[]
hobbies: string[]
favoriteGames: string[]
favoriteFoods: string[]
traits: string
importedAt: string
}>
}) => ipcRenderer.invoke('student:replace-class-profiles', payload),
migrateLocalStudentData: (payload: {
classes: Array<{
id: string
name: string
type: 'cheap' | 'noble'
familyPhoto?: string
}>
profiles: Array<{
id: string
classId: string
className: string
name: string
englishName: string
gender: string
birthday: string
zodiac: string
friends: string[]
hobbies: string[]
favoriteGames: string[]
favoriteFoods: string[]
traits: string
importedAt: string
}>
}) => ipcRenderer.invoke('student:migrate-local', payload),
saveClasses: (
classes: Array<{
id: string
name: string
type: 'cheap' | 'noble'
familyPhoto?: string
}>
) => ipcRenderer.invoke('student:save-classes', classes),
loadSettings: () => ipcRenderer.invoke('settings:load'),
saveSettings: (modelConfig: {
provider: string
baseUrl: string
apiKey: string
model: string
temperature: string
maxTokens: string
systemPrompt: string
}) => ipcRenderer.invoke('settings:save', modelConfig),
loadTemplates: () => ipcRenderer.invoke('templates:load'),
selectTemplateFile: () => ipcRenderer.invoke('templates:select-file'),
parseTemplatePlaceholders: (filePath: string) =>
ipcRenderer.invoke('templates:parse-placeholders', filePath),
getDroppedFilePath: (file: File) => webUtils.getPathForFile(file),
saveTemplate: (template: {
id?: string
name: string
grade: string
semester: 'first' | 'second'
type: 'portrait' | 'landscape'
sourceFilePath: string
fieldMappings?: Record<string, string>
}) => ipcRenderer.invoke('templates:save', template),
openTemplate: (filePath: string) => ipcRenderer.invoke('templates:open', filePath),
deleteTemplate: (id: string) => ipcRenderer.invoke('templates:delete', id),
loadReports: () => ipcRenderer.invoke('reports:load'),
generateReports: (payload: { templateId: string; classId: string; studentIds?: string[] }) =>
ipcRenderer.invoke('reports:generate', payload),
openReport: (filePath: string) => ipcRenderer.invoke('reports:open', filePath),
deleteReport: (id: string) => ipcRenderer.invoke('reports:delete', id)
}
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI
// @ts-ignore (define in dts)
window.api = api
}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>成长报告助手</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { Toaster } from '@renderer/components/ui/sonner'
import { AppLayout } from '@renderer/layouts/AppLayout'
import { ClassPage } from '@renderer/pages/ClassPage'
import { StudentPage } from '@renderer/pages/StudentPage'
import { PlaceholderPage } from '@renderer/pages/PlaceholderPage'
import { SettingsPage } from '@renderer/pages/SettingsPage'
import { ToolsPage } from '@renderer/pages/ToolsPage'
import { TemplatePage } from '@renderer/pages/TemplatePage'
import { ReportPage } from '@renderer/pages/ReportPage'
function App(): React.JSX.Element {
return (
<>
<Routes>
<Route element={<AppLayout />}>
<Route index element={<Navigate to="/student/list" replace />} />
<Route path="tools" element={<Navigate to="/tools/comments" replace />} />
<Route path="tools/image-paths" element={<PlaceholderPage title="生成图片路径" />} />
<Route path="tools/comments" element={<ToolsPage />} />
<Route path="tools/reports" element={<PlaceholderPage title="生成报告" />} />
<Route path="tools/convert" element={<PlaceholderPage title="格式转换" />} />
<Route path="tools/signature" element={<PlaceholderPage title="园长签名" />} />
<Route path="student" element={<Navigate to="/student/list" replace />} />
<Route path="student/list" element={<StudentPage />} />
<Route path="student/classes" element={<ClassPage />} />
<Route path="student/templates" element={<TemplatePage />} />
<Route path="student/reports" element={<ReportPage />} />
<Route path="student/backup" element={<PlaceholderPage title="备份恢复" />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/student/list" replace />} />
</Route>
</Routes>
<Toaster />
</>
)
}
export default App
+85
View File
@@ -0,0 +1,85 @@
@import 'tailwindcss';
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--background: #f8fafc;
--foreground: #0f172a;
--card: #ffffff;
--card-foreground: #0f172a;
--popover: #ffffff;
--popover-foreground: #0f172a;
--primary: #0f766e;
--primary-foreground: #ffffff;
--secondary: #f1f5f9;
--secondary-foreground: #0f172a;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: #e0f2fe;
--accent-foreground: #075985;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #e2e8f0;
--input: #cbd5e1;
--ring: #0f766e;
--radius: 0.5rem;
}
@layer base {
* {
border-color: var(--border);
}
body {
min-height: 100vh;
margin: 0;
overflow: hidden;
background: var(--background);
color: var(--foreground);
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="64" cy="64" r="64" fill="#2F3242"/>
<ellipse cx="63.9835" cy="23.2036" rx="4.48794" ry="4.495" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.6153 43.5751L30.1748 44.4741L30.1748 44.4741L28.6153 43.5751ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM53.7489 81.7014L52.8478 83.2597L53.7489 81.7014ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM99.3169 43.6354L97.7574 44.5344L99.3169 43.6354ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM53.7836 46.3728L54.6847 47.931L53.7836 46.3728ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM84.3832 64.0673H82.5832H84.3832ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077V84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027V89.4027V89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M64.8501 68.0857C62.6341 68.5652 60.451 67.1547 59.9713 64.9353C59.4934 62.7159 60.9007 60.5293 63.1167 60.0489C65.3326 59.5693 67.5157 60.9798 67.9954 63.1992C68.4742 65.4186 67.066 67.6052 64.8501 68.0857Z" fill="#A2ECFB"/>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

+1
View File
@@ -0,0 +1 @@
@import './base.css';
+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1422 800" opacity="0.3">
<defs>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="oooscillate-grad">
<stop stop-color="hsl(206, 75%, 49%)" stop-opacity="1" offset="0%"></stop>
<stop stop-color="hsl(331, 90%, 56%)" stop-opacity="1" offset="100%"></stop>
</linearGradient>
</defs>
<g stroke-width="1" stroke="url(#oooscillate-grad)" fill="none" stroke-linecap="round">
<path d="M 0 448 Q 355.5 -100 711 400 Q 1066.5 900 1422 448" opacity="0.05"></path>
<path d="M 0 420 Q 355.5 -100 711 400 Q 1066.5 900 1422 420" opacity="0.11"></path>
<path d="M 0 392 Q 355.5 -100 711 400 Q 1066.5 900 1422 392" opacity="0.18"></path>
<path d="M 0 364 Q 355.5 -100 711 400 Q 1066.5 900 1422 364" opacity="0.24"></path>
<path d="M 0 336 Q 355.5 -100 711 400 Q 1066.5 900 1422 336" opacity="0.30"></path>
<path d="M 0 308 Q 355.5 -100 711 400 Q 1066.5 900 1422 308" opacity="0.37"></path>
<path d="M 0 280 Q 355.5 -100 711 400 Q 1066.5 900 1422 280" opacity="0.43"></path>
<path d="M 0 252 Q 355.5 -100 711 400 Q 1066.5 900 1422 252" opacity="0.49"></path>
<path d="M 0 224 Q 355.5 -100 711 400 Q 1066.5 900 1422 224" opacity="0.56"></path>
<path d="M 0 196 Q 355.5 -100 711 400 Q 1066.5 900 1422 196" opacity="0.62"></path>
<path d="M 0 168 Q 355.5 -100 711 400 Q 1066.5 900 1422 168" opacity="0.68"></path>
<path d="M 0 140 Q 355.5 -100 711 400 Q 1066.5 900 1422 140" opacity="0.75"></path>
<path d="M 0 112 Q 355.5 -100 711 400 Q 1066.5 900 1422 112" opacity="0.81"></path>
<path d="M 0 84 Q 355.5 -100 711 400 Q 1066.5 900 1422 84" opacity="0.87"></path>
<path d="M 0 56 Q 355.5 -100 711 400 Q 1066.5 900 1422 56" opacity="0.94"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,10 @@
// import { LogPanel } from './LogPanel'
// import { TaskQueue } from './TaskQueue'
export function DashboardFooter(): React.JSX.Element {
return (
<section className="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.4fr)] gap-4 max-[1100px]:grid-cols-1">
</section>
)
}
@@ -0,0 +1,43 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { levelClass, logs } from '@renderer/student/mockData'
import { cn } from '@renderer/lib/utils'
export function LogPanel(): React.JSX.Element {
return (
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardDescription></CardDescription>
<CardTitle className="mt-1 text-base"></CardTitle>
</div>
<Button size="sm" variant="destructive">
</Button>
</CardHeader>
<CardContent>
<ScrollArea className="h-40 rounded-md bg-slate-950 p-3">
<div className="space-y-1 font-mono text-xs">
{logs.map((log) => (
<div
className="grid grid-cols-[64px_72px_minmax(0,1fr)] gap-3"
key={`${log.time}-${log.message}`}
>
<span className="text-slate-400">{log.time}</span>
<strong className={cn('font-semibold', levelClass[log.level])}>{log.level}</strong>
<span className="truncate text-slate-300">{log.message}</span>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
)
}
@@ -0,0 +1,25 @@
import type { LucideIcon } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader } from '@renderer/components/ui/card'
type MetricCardProps = {
title: string
value: string
description: string
icon: LucideIcon
}
export function MetricCard({ title, value, description, icon: Icon }: MetricCardProps): React.JSX.Element {
return (
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardDescription>{title}</CardDescription>
<Icon className="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">{value}</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
)
}
@@ -0,0 +1,19 @@
import { metrics } from '@renderer/student/mockData'
import { MetricCard } from './MetricCard'
export function MetricsGrid(): React.JSX.Element {
return (
<section className="grid grid-cols-[repeat(auto-fit,minmax(170px,1fr))] gap-4">
{metrics.map((metric) => (
<MetricCard
key={metric.title}
title={metric.title}
value={metric.value}
description={metric.description}
icon={metric.icon}
/>
))}
</section>
)
}
@@ -0,0 +1,32 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { recentTasks } from '@renderer/student/mockData'
import { TaskRow } from './TaskRow'
export function TaskQueue(): React.JSX.Element {
return (
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardDescription></CardDescription>
<CardTitle className="mt-1 text-base"></CardTitle>
</div>
<Button size="sm" variant="outline">
</Button>
</CardHeader>
<CardContent className="space-y-3">
{recentTasks.map((task) => (
<TaskRow key={task.title} icon={task.icon} title={task.title} value={task.value} />
))}
</CardContent>
</Card>
)
}
@@ -0,0 +1,21 @@
import type { LucideIcon } from 'lucide-react'
import { Badge } from '@renderer/components/ui/badge'
type TaskRowProps = {
icon: LucideIcon
title: string
value: string
}
export function TaskRow({ icon: Icon, title, value }: TaskRowProps): React.JSX.Element {
return (
<div className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2">
<div className="flex items-center gap-2">
<Icon className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">{title}</span>
</div>
<Badge variant="outline">{value}</Badge>
</div>
)
}
@@ -0,0 +1,36 @@
import { FileArchive, Search, Sparkles } from 'lucide-react'
import { useLocation } from 'react-router-dom'
import { Button } from '@renderer/components/ui/button'
import { Input } from '@renderer/components/ui/input'
import { findMenuItemByPath } from '@renderer/student/menu'
export function AppHeader(): React.JSX.Element {
const { pathname } = useLocation()
const activeMenuItem = findMenuItemByPath(pathname)
return (
<header className="flex items-center justify-between gap-4 border-b bg-card px-6 py-4 max-[900px]:flex-wrap max-[760px]:px-4">
<div className="min-w-0">
<h2 className="text-2xl font-semibold tracking-normal">
{activeMenuItem?.title ?? '成长报告助手'}
</h2>
</div>
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2 max-[900px]:w-full max-[900px]:justify-start">
<div className="relative w-[min(22rem,34vw)] min-w-56 max-[900px]:min-w-0 max-[900px]:flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input className="pl-9" placeholder="搜索工具、幼儿、模板" />
</div>
<Button variant="outline">
<FileArchive className="size-4" />
</Button>
<Button>
<Sparkles className="size-4" />
</Button>
</div>
</header>
)
}
@@ -0,0 +1,157 @@
import { useMemo, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { Link, useLocation } from 'react-router-dom'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { Separator } from '@renderer/components/ui/separator'
import { getOpenMenuIds, menuTree } from '@renderer/student/menu'
import { cn } from '@renderer/lib/utils'
import type { MenuItem } from '@renderer/types/app'
export function AppSidebar(): React.JSX.Element {
const { pathname } = useLocation()
const [openIds, setOpenIds] = useState<Set<string>>(() => new Set(getOpenMenuIds(pathname)))
const visibleOpenIds = useMemo(
() => new Set([...openIds, ...getOpenMenuIds(pathname)]),
[openIds, pathname]
)
function toggleMenu(id: string): void {
setOpenIds((currentIds) => {
const nextIds = new Set(currentIds)
if (nextIds.has(id)) {
nextIds.delete(id)
} else {
nextIds.add(id)
}
return nextIds
})
}
return (
<aside className="flex min-h-0 flex-col border-r bg-card">
<div className="flex items-center gap-3 px-5 py-5">
<div className="grid size-10 place-items-center rounded-lg bg-primary text-base font-bold text-primary-foreground">
</div>
<div className="min-w-0 max-[760px]:hidden">
<h1 className="truncate text-base font-semibold"></h1>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
<Separator />
<div className="px-3 py-4">
<p className="mb-2 px-3 text-xs font-medium text-muted-foreground max-[760px]:sr-only">
</p>
<nav className="flex h-auto w-full flex-col items-stretch justify-start gap-1 bg-transparent p-0">
{menuTree.map((item) => (
<MenuNode
key={item.id}
item={item}
level={0}
openIds={visibleOpenIds}
onToggle={toggleMenu}
/>
))}
</nav>
</div>
<div className="mt-auto p-4 max-[760px]:hidden">
<Card className="shadow-none">
<CardHeader className="p-4">
<CardDescription></CardDescription>
<CardTitle className="text-base">2026 </CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 text-sm text-muted-foreground">
20
</CardContent>
</Card>
</div>
</aside>
)
}
function MenuNode({
item,
level,
openIds,
onToggle
}: {
item: MenuItem
level: number
openIds: Set<string>
onToggle: (id: string) => void
}): React.JSX.Element {
const Icon = item.icon
const hasChildren = Boolean(item.children?.length)
const isOpen = openIds.has(item.id)
const paddingLeft = level === 0 ? undefined : `${level * 0.875 + 0.75}rem`
const { pathname } = useLocation()
const isActive = Boolean(item.path && item.path === pathname)
const sharedClassName =
'inline-flex h-10 w-full items-center justify-start gap-2 rounded-md px-3 text-sm font-medium transition-colors hover:bg-muted max-[760px]:justify-center max-[760px]:px-0'
return (
<div className="space-y-1">
{item.path && !hasChildren ? (
<Link
to={item.path}
className={cn(sharedClassName, isActive && 'bg-muted text-primary')}
style={{ paddingLeft }}
title={item.title}
>
{Icon && <Icon className="size-4" />}
<span className="min-w-0 flex-1 truncate text-left max-[760px]:sr-only">
{item.title}
</span>
</Link>
) : (
<button
type="button"
className={cn(sharedClassName, isOpen && level === 0 && 'bg-muted/70')}
style={{ paddingLeft }}
title={item.title}
onClick={() => onToggle(item.id)}
>
{Icon && <Icon className="size-4" />}
<span className="min-w-0 flex-1 truncate text-left max-[760px]:sr-only">
{item.title}
</span>
{hasChildren && (
<ChevronDown
className={cn(
'size-4 text-muted-foreground transition-transform max-[760px]:hidden',
isOpen && 'rotate-180'
)}
/>
)}
</button>
)}
{hasChildren && isOpen && (
<div className="space-y-1 max-[760px]:hidden">
{item.children?.map((child) => (
<MenuNode
key={child.id}
item={child}
level={level + 1}
openIds={openIds}
onToggle={onToggle}
/>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,13 @@
type FieldProps = {
label: string
children: React.ReactNode
}
export function Field({ label, children }: FieldProps): React.JSX.Element {
return (
<label className="grid gap-2">
<span className="text-sm font-medium">{label}</span>
{children}
</label>
)
}
@@ -0,0 +1,265 @@
import { Bot, Eye, KeyRound, ListRestart, Loader2, Save, SlidersHorizontal } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@renderer/components/ui/button'
import { Card, CardContent, CardHeader } from '@renderer/components/ui/card'
import { Input } from '@renderer/components/ui/input'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import type { ModelConfig, SettingKey } from '@renderer/types/app'
import { Field } from './Field'
const defaultModelConfig: ModelConfig = {
provider: 'OpenAI Compatible',
baseUrl: 'https://api.openai.com/v1',
apiKey: '',
model: 'gpt-4.1-mini',
temperature: '0.7',
maxTokens: '1200',
systemPrompt: '你是一名幼儿园教师,请根据幼儿观察记录生成温暖、具体、正向的成长评语。'
}
export function ModelSettingsForm(): React.JSX.Element {
const [setting, setSetting] = useState<SettingKey>('model')
const [modelConfig, setModelConfig] = useState<ModelConfig>(defaultModelConfig)
const [modelOptions, setModelOptions] = useState<string[]>([])
const [modelListLoading, setModelListLoading] = useState(false)
const [settingsSaving, setSettingsSaving] = useState(false)
const [connectionTesting, setConnectionTesting] = useState(false)
const updateModelConfig = (key: keyof ModelConfig, value: string): void => {
setModelConfig((current) => ({ ...current, [key]: value }))
}
useEffect(() => {
window.api.loadSettings().then((response) => {
if (!response.ok) {
toast.error(response.message)
return
}
if (response.modelConfig) {
setModelConfig(response.modelConfig)
setModelOptions((options) =>
response.modelConfig && !options.includes(response.modelConfig.model)
? [response.modelConfig.model, ...options]
: options
)
toast.success('已加载本地配置')
}
})
}, [])
const fetchModelList = async (): Promise<void> => {
setModelListLoading(true)
try {
const response = await window.api.listModels({
baseUrl: modelConfig.baseUrl,
apiKey: modelConfig.apiKey
})
if (!response.ok) {
toast.error(response.message)
return
}
setModelOptions(response.models)
toast.success(`已获取 ${response.models.length} 个模型`)
if (!response.models.includes(modelConfig.model)) {
updateModelConfig('model', response.models[0])
}
} finally {
setModelListLoading(false)
}
}
const saveSettings = async (): Promise<void> => {
setSettingsSaving(true)
try {
const response = await window.api.saveSettings(modelConfig)
if (!response.ok) {
toast.error(response.message)
return
}
toast.success('配置已保存')
} finally {
setSettingsSaving(false)
}
}
const testConnection = async (): Promise<void> => {
setConnectionTesting(true)
try {
const response = await window.api.testModelConnection(modelConfig)
if (!response.ok) {
toast.error(response.message)
return
}
toast.success(response.message)
} finally {
setConnectionTesting(false)
}
}
return (
<section className="grid h-[calc(100vh-9rem)] min-h-130 grid-cols-[240px_minmax(0,1fr)] overflow-hidden rounded-lg border bg-card max-[900px]:h-auto max-[900px]:grid-cols-1">
<aside className="flex min-h-0 flex-col border-r bg-card max-[900px]:border-b max-[900px]:border-r-0">
<div className="shrink-0 px-4 py-4">
<h3 className="text-base font-semibold"></h3>
</div>
<ScrollArea className="min-h-0 flex-1 px-3 py-3">
<nav className="space-y-1">
<Button
className="h-10 w-full justify-start gap-2 px-3"
variant={setting === 'model' ? 'secondary' : 'ghost'}
type="button"
onClick={() => setSetting('model')}
>
<Bot className="size-4" />
</Button>
</nav>
</ScrollArea>
</aside>
<Card className="min-h-0 overflow-hidden rounded-none border-0 shadow-none">
<CardHeader className="flex flex-row items-center justify-between gap-4 space-y-0 border-b">
<div></div>
<div className="flex shrink-0 gap-2">
<Button
variant="outline"
type="button"
onClick={testConnection}
disabled={connectionTesting}
>
{connectionTesting && <Loader2 className="size-4 animate-spin" />}
</Button>
<Button type="button" onClick={saveSettings} disabled={settingsSaving}>
{settingsSaving ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
</Button>
</div>
</CardHeader>
<ScrollArea className="h-[calc(100%-5.75rem)] max-[900px]:h-auto">
<CardContent className="space-y-6 p-6">
<div className="grid grid-cols-2 gap-4 max-[900px]:grid-cols-1">
<Field label="供应商">
<Input
value={modelConfig.provider}
onChange={(event) => updateModelConfig('provider', event.target.value)}
placeholder="OpenAI Compatible"
/>
</Field>
<Field label="模型名称">
<Input
list="model-options"
value={modelConfig.model}
onChange={(event) => updateModelConfig('model', event.target.value)}
placeholder="输入或从候选模型中选择"
/>
<datalist id="model-options">
{modelOptions.map((model) => (
<option value={model} key={model} />
))}
</datalist>
</Field>
</div>
<Field label="接口地址">
<Input
value={modelConfig.baseUrl}
onChange={(event) => updateModelConfig('baseUrl', event.target.value)}
placeholder="https://api.openai.com/v1"
/>
</Field>
<Field label="API Key">
<div className="flex gap-2">
<div className="relative flex-1">
<KeyRound className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
type="password"
value={modelConfig.apiKey}
onChange={(event) => updateModelConfig('apiKey', event.target.value)}
placeholder="sk-..."
/>
</div>
<Button variant="outline" size="icon" type="button" title="后续可切换明文显示">
<Eye className="size-4" />
</Button>
</div>
</Field>
<div className="flex flex-wrap items-center gap-3 rounded-md border bg-muted/30 p-3">
<Button
type="button"
variant="outline"
onClick={fetchModelList}
disabled={modelListLoading}
>
{modelListLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ListRestart className="size-4" />
)}
</Button>
<p className="text-sm text-muted-foreground">
API Key
</p>
</div>
<div className="grid grid-cols-2 gap-4 max-[900px]:grid-cols-1">
<Field label="温度">
<div className="relative">
<SlidersHorizontal className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
value={modelConfig.temperature}
onChange={(event) => updateModelConfig('temperature', event.target.value)}
placeholder="0.7"
/>
</div>
</Field>
<Field label="最大输出 Token">
<Input
value={modelConfig.maxTokens}
onChange={(event) => updateModelConfig('maxTokens', event.target.value)}
placeholder="1200"
/>
</Field>
</div>
<Field label="系统提示词">
<textarea
className="min-h-28 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring"
value={modelConfig.systemPrompt}
onChange={(event) => updateModelConfig('systemPrompt', event.target.value)}
placeholder="请输入生成评语时使用的系统提示词"
/>
</Field>
<p className="border-t pt-5 text-sm text-muted-foreground">
</p>
</CardContent>
</ScrollArea>
</Card>
</section>
)
}
@@ -0,0 +1,120 @@
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { buttonVariants } from '@renderer/components/ui/button'
import { cn } from '@renderer/lib/utils'
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogCancel = AlertDialogPrimitive.Cancel
const AlertDialogAction = AlertDialogPrimitive.Action
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-slate-950/45', className)}
{...props}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-[min(420px,92vw)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-md border bg-card p-6 text-card-foreground shadow-2xl outline-none',
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
function AlertDialogHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element {
return <div className={cn('grid gap-2 text-left', className)} {...props} />
}
function AlertDialogFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element {
return (
<div
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
)
}
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm leading-6 text-muted-foreground', className)}
{...props}
/>
))
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
const AlertDialogCancelButton = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogCancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
))
AlertDialogCancelButton.displayName = AlertDialogPrimitive.Cancel.displayName
const AlertDialogActionButton = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogAction
ref={ref}
className={cn(buttonVariants({ variant: 'destructive' }), className)}
{...props}
/>
))
AlertDialogActionButton.displayName = AlertDialogPrimitive.Action.displayName
export {
AlertDialog,
AlertDialogActionButton as AlertDialogAction,
AlertDialogCancelButton as AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
}
+31
View File
@@ -0,0 +1,31 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@renderer/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
success: 'border-transparent bg-emerald-100 text-emerald-700',
warning: 'border-transparent bg-amber-100 text-amber-700'
}
},
defaultVariants: {
variant: 'default'
}
}
)
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps): React.JSX.Element {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }
+49
View File
@@ -0,0 +1,49 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@renderer/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
+45
View File
@@ -0,0 +1,45 @@
import * as React from 'react'
import { cn } from '@renderer/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)} {...props} />
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-3', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('font-semibold leading-none tracking-normal', className)} {...props} />
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
)
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+98
View File
@@ -0,0 +1,98 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@renderer/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-slate-950/45', className)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 flex max-h-[calc(100vh-2rem)] w-[min(760px,94vw)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-hidden rounded-md border bg-card p-6 text-card-foreground shadow-2xl outline-none',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 outline-none transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring">
<X className="size-4" />
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
function DialogHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element {
return <div className={cn('grid gap-2 text-left', className)} {...props} />
}
function DialogFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element {
return (
<div
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
)
}
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm leading-6 text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger
}
+82
View File
@@ -0,0 +1,82 @@
import * as React from 'react'
import { Drawer as DrawerPrimitive } from 'vaul'
import { cn } from '@renderer/lib/utils'
function Drawer({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>): React.JSX.Element {
return <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
}
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>): React.JSX.Element {
return (
<DrawerPrimitive.Overlay
className={cn('fixed inset-0 z-50 bg-slate-950/30', className)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>): React.JSX.Element {
return (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
className={cn(
'fixed inset-y-0 right-0 z-50 flex h-full w-[min(440px,92vw)] flex-col border-l bg-card shadow-2xl outline-none',
className
)}
{...props}
>
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element {
return <div className={cn('border-b p-6 text-left', className)} {...props} />
}
function DrawerFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element {
return <div className={cn('mt-auto flex flex-col gap-2 p-6', className)} {...props} />
}
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title ref={ref} className={cn('text-lg font-semibold leading-none', className)} {...props} />
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
DrawerPortal,
DrawerTitle,
DrawerTrigger
}
@@ -0,0 +1,86 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { cn } from '@renderer/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 6, align = 'end', ...props }, ref) => (
<DropdownMenuPortal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
align={align}
className={cn(
'z-50 min-w-44 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg outline-none',
className
)}
{...props}
/>
</DropdownMenuPortal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-xs font-medium text-muted-foreground',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from 'react'
import { cn } from '@renderer/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
)
Input.displayName = 'Input'
export { Input }
@@ -0,0 +1,105 @@
import * as React from 'react'
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
import { Button, type ButtonProps } from '@renderer/components/ui/button'
import { cn } from '@renderer/lib/utils'
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>): React.JSX.Element => (
<nav
role="navigation"
aria-label="分页"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
)
Pagination.displayName = 'Pagination'
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
({ className, ...props }, ref) => (
<ul ref={ref} className={cn('flex flex-row items-center gap-1', className)} {...props} />
)
)
PaginationContent.displayName = 'PaginationContent'
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<'li'>>(
({ className, ...props }, ref) => <li ref={ref} className={cn('', className)} {...props} />
)
PaginationItem.displayName = 'PaginationItem'
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'button'>
const PaginationLink = ({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps): React.JSX.Element => (
<Button
aria-current={isActive ? 'page' : undefined}
className={className}
size={size}
variant={isActive ? 'outline' : 'ghost'}
{...props}
/>
)
PaginationLink.displayName = 'PaginationLink'
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>): React.JSX.Element => (
<PaginationLink
aria-label="上一页"
className={cn('w-auto px-2.5', className)}
size="default"
{...props}
>
<ChevronLeft className="size-4" />
<span></span>
</PaginationLink>
)
PaginationPrevious.displayName = 'PaginationPrevious'
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>): React.JSX.Element => (
<PaginationLink
aria-label="下一页"
className={cn('w-auto px-2.5', className)}
size="default"
{...props}
>
<span></span>
<ChevronRight className="size-4" />
</PaginationLink>
)
PaginationNext.displayName = 'PaginationNext'
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>): React.JSX.Element => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only"></span>
</span>
)
PaginationEllipsis.displayName = 'PaginationEllipsis'
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious
}
@@ -0,0 +1,38 @@
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@renderer/lib/utils'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-px',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@renderer/lib/utils'
const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(
({ className, children, ...props }, ref) => (
<select
ref={ref}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm outline-none transition-colors focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
{children}
</select>
)
)
Select.displayName = 'Select'
export { Select }
@@ -0,0 +1,20 @@
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@renderer/lib/utils'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn('shrink-0 bg-border', orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px', className)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
@@ -0,0 +1,7 @@
import { Toaster as Sonner, type ToasterProps } from 'sonner'
function Toaster(props: ToasterProps): React.JSX.Element {
return <Sonner richColors closeButton position="top-center" {...props} />
}
export { Toaster }
+53
View File
@@ -0,0 +1,53 @@
import * as React from 'react'
import { cn } from '@renderer/lib/utils'
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
)
)
Table.displayName = 'Table'
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
)
TableHeader.displayName = 'TableHeader'
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
)
)
TableBody.displayName = 'TableBody'
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
{...props}
/>
)
)
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn('h-10 px-2 text-left align-middle font-medium text-muted-foreground', className)}
{...props}
/>
)
)
TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => <td ref={ref} className={cn('p-2 align-middle', className)} {...props} />
)
TableCell.displayName = 'TableCell'
export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell }
+47
View File
@@ -0,0 +1,47 @@
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@renderer/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn('inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground', className)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn('mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', className)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+22
View File
@@ -0,0 +1,22 @@
import { Outlet } from 'react-router-dom'
import { AppHeader } from '@renderer/components/layout/AppHeader'
import { AppSidebar } from '@renderer/components/layout/AppSidebar'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
export function AppLayout(): React.JSX.Element {
return (
<div className="grid h-screen min-w-0 grid-cols-[clamp(220px,20vw,248px)_minmax(0,1fr)] bg-background text-foreground max-[760px]:grid-cols-[76px_minmax(0,1fr)]">
<AppSidebar />
<section className="flex min-h-0 flex-col">
<AppHeader />
<ScrollArea className="min-h-0 flex-1">
<main className="space-y-5 p-6 max-[760px]:p-4">
<Outlet />
</main>
</ScrollArea>
</section>
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
+14
View File
@@ -0,0 +1,14 @@
import './assets/main.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<HashRouter>
<App />
</HashRouter>
</StrictMode>
)
+845
View File
@@ -0,0 +1,845 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
Camera,
Construction,
FolderPlus,
MoreHorizontal,
Pencil,
Plus,
Search,
Trash2,
Upload,
X
} from 'lucide-react'
import { DashboardFooter } from '@renderer/components/dashboard/DashboardFooter'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from '@renderer/components/ui/alert-dialog'
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle
} from '@renderer/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@renderer/components/ui/dropdown-menu'
import { Input } from '@renderer/components/ui/input'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Select } from '@renderer/components/ui/select'
import {
classTypeOptions,
createUuid,
getClassTypeLabel
} from '@renderer/student/classes'
import {
readChildProfilesFromSpreadsheet,
replaceProfilesForClass
} from '@renderer/student/children'
import type { ChildProfile, ClassProfile, ClassType } from '@renderer/types/app'
type EditingClass = {
id: string
name: string
type: ClassType
familyPhoto?: string
}
export function ClassPage(): React.JSX.Element {
const fileInputRef = useRef<HTMLInputElement>(null)
const classPhotoInputRef = useRef<HTMLInputElement>(null)
const editPhotoInputRef = useRef<HTMLInputElement>(null)
const [classes, setClasses] = useState<ClassProfile[]>([])
const [profiles, setProfiles] = useState<ChildProfile[]>([])
const [className, setClassName] = useState('')
const [classType, setClassType] = useState<ClassType>('cheap')
const [classFamilyPhoto, setClassFamilyPhoto] = useState('')
const [query, setQuery] = useState('')
const [createDrawerOpen, setCreateDrawerOpen] = useState(false)
const [exportingId, setExportingId] = useState('')
const [editingClass, setEditingClass] = useState<EditingClass | null>(null)
const [uploadingClass, setUploadingClass] = useState<ClassProfile | null>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [isDragActive, setIsDragActive] = useState(false)
useEffect(() => {
async function loadStudentData(): Promise<void> {
const response = await window.api.loadStudentData()
if (!response.ok) {
showError('读取数据库失败', response.message)
return
}
setClasses(response.classes)
setProfiles(response.profiles)
}
loadStudentData()
}, [])
const profileCountByClass = useMemo(() => {
const countMap = new Map<string, number>()
for (const profile of profiles) {
countMap.set(profile.classId, (countMap.get(profile.classId) ?? 0) + 1)
}
return countMap
}, [profiles])
const filteredClasses = useMemo(() => {
const keyword = query.trim().toLowerCase()
if (!keyword) {
return classes
}
return classes.filter((classItem) =>
[classItem.name, classItem.id, getClassTypeLabel(classItem.type)]
.join(' ')
.toLowerCase()
.includes(keyword)
)
}, [classes, query])
function getProfilesByClass(classId: string): ChildProfile[] {
return profiles.filter((profile) => profile.classId === classId)
}
function showSuccess(message: string, description?: string): void {
toast.success(message, description ? { description } : undefined)
}
function showError(message: string, description?: string): void {
toast.error(message, description ? { description } : undefined)
}
function readImageFile(file: File, onLoad: (dataUrl: string) => void): void {
if (!file.type.startsWith('image/')) {
showError('请选择图片文件')
return
}
const reader = new FileReader()
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') {
onLoad(reader.result)
}
})
reader.readAsDataURL(file)
}
function handleCreatePhotoChange(event: React.ChangeEvent<HTMLInputElement>): void {
const file = event.target.files?.[0]
if (!file) {
return
}
readImageFile(file, setClassFamilyPhoto)
}
function handleEditPhotoChange(event: React.ChangeEvent<HTMLInputElement>): void {
const file = event.target.files?.[0]
if (!file || !editingClass) {
return
}
readImageFile(file, (dataUrl) =>
setEditingClass({
...editingClass,
familyPhoto: dataUrl
})
)
}
async function handleCreateClass(): Promise<void> {
const nextName = className.trim()
if (!nextName) {
showError('请输入班级名称')
return
}
const existingClass = classes.find((classItem) => classItem.name === nextName)
if (existingClass) {
showError('班级已存在', '可以编辑类型或直接上传幼儿信息')
return
}
const nextClasses = [
...classes,
{
id: createUuid(),
name: nextName,
type: classType,
familyPhoto: classFamilyPhoto || undefined
}
]
const response = await window.api.saveClasses(nextClasses)
if (!response.ok) {
showError('新建班级失败', response.message)
return
}
setClasses(nextClasses)
setClassName('')
setClassType('cheap')
setClassFamilyPhoto('')
if (classPhotoInputRef.current) {
classPhotoInputRef.current.value = ''
}
setCreateDrawerOpen(false)
showSuccess('班级已新建')
}
function handleOpenEdit(classItem: ClassProfile): void {
setEditingClass({
id: classItem.id,
name: classItem.name,
type: classItem.type,
familyPhoto: classItem.familyPhoto
})
}
async function handleSaveEdit(): Promise<void> {
if (!editingClass) {
return
}
const nextName = editingClass.name.trim()
if (!nextName) {
showError('班级名称不能为空')
return
}
const duplicateClass = classes.find(
(classItem) => classItem.id !== editingClass.id && classItem.name === nextName
)
if (duplicateClass) {
showError('班级名称已存在')
return
}
const nextClasses = classes.map((classItem) =>
classItem.id === editingClass.id
? {
...classItem,
name: nextName,
type: editingClass.type,
familyPhoto: editingClass.familyPhoto
}
: classItem
)
const nextProfiles = profiles.map((profile) =>
profile.classId === editingClass.id
? {
...profile,
className: nextName
}
: profile
)
const saveClassesResponse = await window.api.saveClasses(nextClasses)
if (!saveClassesResponse.ok) {
showError('保存班级失败', saveClassesResponse.message)
return
}
setClasses(nextClasses)
setProfiles(nextProfiles)
setEditingClass(null)
showSuccess('班级已更新')
}
async function handleDeleteClass(classItem: ClassProfile): Promise<void> {
const response = await window.api.deleteClass(classItem.id)
if (!response.ok) {
showError('删除班级失败', response.message)
return
}
setClasses(classes.filter((item) => item.id !== classItem.id))
setProfiles(profiles.filter((profile) => profile.classId !== classItem.id))
showSuccess(`已删除班级「${classItem.name}」及其幼儿数据`)
}
function selectImportFile(file?: File): void {
if (!file) {
return
}
if (!/\.(xlsx|xls)$/i.test(file.name)) {
showError('请选择 .xlsx 或 .xls 文件')
return
}
setSelectedFile(file)
}
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>): void {
selectImportFile(event.target.files?.[0])
}
function handleDrop(event: React.DragEvent<HTMLButtonElement>): void {
event.preventDefault()
setIsDragActive(false)
selectImportFile(event.dataTransfer.files[0])
}
function openUploadDrawer(classItem: ClassProfile): void {
setUploadingClass(classItem)
setSelectedFile(null)
setIsDragActive(false)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
async function handleUploadSubmit(): Promise<void> {
if (!uploadingClass) {
return
}
if (!selectedFile) {
showError('请选择要上传的 xlsx 文件')
return
}
try {
const nextProfiles = await readChildProfilesFromSpreadsheet(selectedFile, uploadingClass)
const response = await window.api.replaceClassProfiles({
classItem: uploadingClass,
profiles: nextProfiles
})
if (!response.ok) {
showError('保存幼儿信息失败', response.message)
return
}
setProfiles(replaceProfilesForClass(profiles, uploadingClass, nextProfiles))
setUploadingClass(null)
setSelectedFile(null)
setIsDragActive(false)
showSuccess(`已上传「${uploadingClass.name}」幼儿信息`, `${nextProfiles.length}`)
} catch (error) {
console.error(error)
showError('上传失败', '请确认表头和示例一致,并使用 .xlsx 文件')
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
async function handleExportClass(classItem: ClassProfile): Promise<void> {
setExportingId(classItem.id)
try {
const result = await window.api.exportEmptyClassFolder({
classId: classItem.id,
className: classItem.name,
childNames: getProfilesByClass(classItem.id).map(
(profile) => profile.name || profile.englishName
)
})
if (result.ok) {
showSuccess(
'已导出压缩包',
`${result.zipPath},包含 ${result.childFolderCount} 个幼儿文件夹`
)
return
}
if (!result.canceled) {
showError(result.message)
}
} finally {
setExportingId('')
}
}
return (
<>
<section className="space-y-4 bg-white px-4 py-4 ">
<div className="fle flex-col gap-3">
<CardTitle>
<div className="flex items-center gap-2">
<Construction className="size-5 text-primary" />
</div>
</CardTitle>
<CardDescription></CardDescription>
</div>
<div className="grid gap-3 rounded-md border border-dashed bg-muted/30 p-4">
<div className="relative min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索班级名称、类型或 UUID"
/>
</div>
<div className="flex justify-end">
<Button onClick={() => setCreateDrawerOpen(true)}>
<Plus />
</Button>
</div>
</div>
<ScrollArea className="h-140">
<div className="grid gap-3 pr-3 md:grid-cols-2 xl:grid-cols-3">
{filteredClasses.map((classItem) => {
const childCount = profileCountByClass.get(classItem.id) ?? 0
return (
<Card key={classItem.id} className="shadow-none">
<div className="aspect-video overflow-hidden rounded-t-lg bg-muted">
{classItem.familyPhoto ? (
<img
className="h-full w-full object-cover"
src={classItem.familyPhoto}
alt={classItem.name}
/>
) : (
<div className="grid h-full place-items-center text-sm text-muted-foreground">
<Camera className="mb-2 size-6" />
</div>
)}
</div>
<CardHeader className="space-y-3 pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="truncate text-base">{classItem.name}</CardTitle>
<CardDescription className="mt-1 break-all font-mono text-xs">
{classItem.id}
</CardDescription>
</div>
<Badge variant={classItem.type === 'noble' ? 'warning' : 'secondary'}>
{getClassTypeLabel(classItem.type)}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md bg-muted px-3 py-2">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-xl font-semibold">{childCount} </p>
</div>
<div className="rounded-md bg-muted px-3 py-2">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 font-medium">{getClassTypeLabel(classItem.type)}</p>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => openUploadDrawer(classItem)}>
<Upload />
</DropdownMenuItem>
<DropdownMenuItem
disabled={exportingId === classItem.id}
onSelect={() => handleExportClass(classItem)}
>
<FolderPlus />
{exportingId === classItem.id ? '导出中' : '导出 ZIP'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleOpenEdit(classItem)}>
<Pencil />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive">
<Trash2 />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{classItem.name}</AlertDialogTitle>
<AlertDialogDescription>
{getClassTypeLabel(classItem.type)} {childCount}{' '}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => handleDeleteClass(classItem)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
)
})}
{filteredClasses.length === 0 && (
<div className="col-span-full grid h-28 place-items-center rounded-md border text-sm text-muted-foreground">
</div>
)}
</div>
</ScrollArea>
</section>
<Drawer direction="right" open={createDrawerOpen} onOpenChange={setCreateDrawerOpen}>
<DrawerContent>
<DrawerHeader>
<div className="flex items-start justify-between gap-3">
<div>
<DrawerTitle></DrawerTitle>
<DrawerDescription className="mt-1">
</DrawerDescription>
</div>
<Button
size="icon"
variant="ghost"
aria-label="关闭新建班级"
onClick={() => setCreateDrawerOpen(false)}
>
<X />
</Button>
</div>
</DrawerHeader>
<div className="space-y-5 p-6">
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="new-class-name">
</label>
<Input
id="new-class-name"
value={className}
onChange={(event) => setClassName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleCreateClass()
}
}}
placeholder="输入班级名称,例如:星星二班"
/>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="new-class-type">
</label>
<Select
id="new-class-type"
value={classType}
onChange={(event) => setClassType(event.target.value as ClassType)}
>
{classTypeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</div>
<div className="grid gap-2">
<p className="text-sm font-medium"></p>
<button
type="button"
className="relative grid aspect-video overflow-hidden rounded-md border border-dashed bg-muted transition-colors hover:border-primary"
onClick={() => classPhotoInputRef.current?.click()}
>
{classFamilyPhoto ? (
<img
className="h-full w-full object-cover"
src={classFamilyPhoto}
alt="班级全家福预览"
/>
) : (
<span className="grid place-items-center gap-2 p-4 text-center text-sm text-muted-foreground">
<Camera className="mx-auto size-5" />
</span>
)}
</button>
<input
ref={classPhotoInputRef}
className="hidden"
type="file"
accept="image/*"
onChange={handleCreatePhotoChange}
/>
{classFamilyPhoto && (
<div className="flex justify-end">
<Button size="sm" variant="outline" onClick={() => setClassFamilyPhoto('')}>
</Button>
</div>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCreateDrawerOpen(false)}>
</Button>
<Button onClick={handleCreateClass}>
<Plus />
</Button>
</div>
</div>
</DrawerContent>
</Drawer>
<Drawer
direction="right"
open={Boolean(editingClass)}
onOpenChange={() => setEditingClass(null)}
>
<DrawerContent>
{editingClass && (
<>
<DrawerHeader>
<div className="flex items-start justify-between gap-3">
<div>
<DrawerTitle></DrawerTitle>
<DrawerDescription className="mt-1">
</DrawerDescription>
</div>
<Button
size="icon"
variant="ghost"
aria-label="关闭编辑班级"
onClick={() => setEditingClass(null)}
>
<X />
</Button>
</div>
</DrawerHeader>
<div className="space-y-5 p-6">
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="edit-class-name">
</label>
<Input
id="edit-class-name"
value={editingClass.name}
onChange={(event) =>
setEditingClass({
...editingClass,
name: event.target.value
})
}
/>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="edit-class-type">
</label>
<Select
id="edit-class-type"
value={editingClass.type}
onChange={(event) =>
setEditingClass({
...editingClass,
type: event.target.value as ClassType
})
}
>
{classTypeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</div>
<div className="grid gap-2">
<p className="text-sm font-medium"></p>
<button
type="button"
className="relative grid aspect-[16/9] overflow-hidden rounded-md border border-dashed bg-muted transition-colors hover:border-primary"
onClick={() => editPhotoInputRef.current?.click()}
>
{editingClass.familyPhoto ? (
<img
className="h-full w-full object-cover"
src={editingClass.familyPhoto}
alt="班级全家福预览"
/>
) : (
<span className="grid place-items-center gap-2 p-4 text-center text-sm text-muted-foreground">
<Camera className="mx-auto size-5" />
</span>
)}
</button>
<input
ref={editPhotoInputRef}
className="hidden"
type="file"
accept="image/*"
onChange={handleEditPhotoChange}
/>
{editingClass.familyPhoto && (
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
onClick={() =>
setEditingClass({
...editingClass,
familyPhoto: undefined
})
}
>
</Button>
</div>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingClass(null)}>
</Button>
<Button onClick={handleSaveEdit}></Button>
</div>
</div>
</>
)}
</DrawerContent>
</Drawer>
<Drawer
direction="right"
open={Boolean(uploadingClass)}
onOpenChange={() => setUploadingClass(null)}
>
<DrawerContent>
{uploadingClass && (
<>
<DrawerHeader>
<div className="flex items-start justify-between gap-3">
<div>
<DrawerTitle></DrawerTitle>
<DrawerDescription className="mt-1">
{uploadingClass.name}
</DrawerDescription>
</div>
<Button
size="icon"
variant="ghost"
aria-label="关闭上传幼儿信息"
onClick={() => setUploadingClass(null)}
>
<X />
</Button>
</div>
</DrawerHeader>
<div className="space-y-5 p-6">
<button
type="button"
className={[
'grid min-h-[180px] place-items-center rounded-md border border-dashed bg-muted/30 p-6 text-center transition-colors',
isDragActive
? 'border-primary bg-accent'
: 'hover:border-primary hover:bg-muted/50'
].join(' ')}
onClick={() => fileInputRef.current?.click()}
onDragEnter={(event) => {
event.preventDefault()
setIsDragActive(true)
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsDragActive(false)}
onDrop={handleDrop}
>
<div className="space-y-3">
<div className="mx-auto grid size-12 place-items-center rounded-full bg-primary text-primary-foreground">
<Upload className="size-5" />
</div>
<div>
<p className="font-medium">
{selectedFile ? selectedFile.name : '点击或拖拽 XLSX 文件到这里'}
</p>
<p className="mt-1 text-sm text-muted-foreground"> .xlsx .xls </p>
</div>
</div>
</button>
<input
ref={fileInputRef}
className="hidden"
type="file"
accept=".xlsx,.xls"
onChange={handleFileChange}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setUploadingClass(null)}>
</Button>
<Button onClick={handleUploadSubmit}>
<Upload />
</Button>
</div>
</div>
</>
)}
</DrawerContent>
</Drawer>
<DashboardFooter />
</>
)
}
@@ -0,0 +1,31 @@
import { Construction } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@renderer/components/ui/card'
export function PlaceholderPage({
title,
description = '该功能页面已预留,后续可以在这里接入具体业务。'
}: {
title: string
description?: string
}): React.JSX.Element {
return (
<Card className="shadow-none">
<CardHeader>
<div className="flex items-center gap-2">
<Construction className="size-5 text-primary" />
<CardTitle>{title}</CardTitle>
</div>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid min-h-[360px] place-items-center rounded-md border border-dashed bg-muted/30 p-8 text-center">
<div className="max-w-[360px] space-y-2">
<p className="text-lg font-semibold">{title}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
</CardContent>
</Card>
)
}
+373
View File
@@ -0,0 +1,373 @@
import { useEffect, useMemo, useState } from 'react'
import {
ExternalLink,
File,
FileSpreadsheet,
FileText,
Files,
Presentation,
Search,
Trash2,
Wand2
} from 'lucide-react'
import { toast } from 'sonner'
import { DashboardFooter } from '@renderer/components/dashboard/DashboardFooter'
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { Input } from '@renderer/components/ui/input'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Select } from '@renderer/components/ui/select'
import type { ClassProfile, ReportFormat, ReportItem, TemplateItem } from '@renderer/types/app'
const formatOptions: Array<{ value: ReportFormat | 'all'; label: string }> = [
{ value: 'all', label: '全部格式' },
{ value: 'word', label: 'Word' },
{ value: 'ppt', label: 'PPT' },
{ value: 'pdf', label: 'PDF' },
{ value: 'excel', label: 'Excel' }
]
function getReportFormatLabel(format: ReportFormat): string {
const option = formatOptions.find((item) => item.value === format)
return option?.label ?? '文件'
}
function getReportIcon(format: ReportFormat): typeof FileText {
if (format === 'ppt') {
return Presentation
}
if (format === 'excel') {
return FileSpreadsheet
}
if (format === 'pdf') {
return File
}
return FileText
}
function formatDateTime(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value || '未生成'
}
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
export function ReportPage(): React.JSX.Element {
const [classes, setClasses] = useState<ClassProfile[]>([])
const [templates, setTemplates] = useState<TemplateItem[]>([])
const [reports, setReports] = useState<ReportItem[]>([])
const [query, setQuery] = useState('')
const [selectedClassId, setSelectedClassId] = useState('all')
const [selectedFormat, setSelectedFormat] = useState<ReportFormat | 'all'>('all')
const [selectedTemplateId, setSelectedTemplateId] = useState('')
const [generating, setGenerating] = useState(false)
const [openingId, setOpeningId] = useState('')
const [deletingId, setDeletingId] = useState('')
useEffect(() => {
async function loadPageData(): Promise<void> {
const [studentResponse, templateResponse, reportResponse] = await Promise.all([
window.api.loadStudentData(),
window.api.loadTemplates(),
window.api.loadReports()
])
if (!studentResponse.ok) {
toast.error('读取班级失败', { description: studentResponse.message })
} else {
setClasses(studentResponse.classes)
}
if (!templateResponse.ok) {
toast.error('读取模板失败', { description: templateResponse.message })
} else {
setTemplates(templateResponse.templates)
setSelectedTemplateId(
templateResponse.templates.find((template) => template.fileExtension === '.pptx')?.id ??
''
)
}
if (!reportResponse.ok) {
toast.error('读取报告失败', { description: reportResponse.message })
} else {
setReports(reportResponse.reports)
}
}
loadPageData()
}, [])
const pptxTemplates = templates.filter((template) => template.fileExtension === '.pptx')
const filteredReports = useMemo(() => {
const keyword = query.trim().toLowerCase()
return reports.filter((report) => {
const matchesKeyword =
!keyword ||
[
report.title,
report.className,
report.studentName,
report.originalFileName,
getReportFormatLabel(report.format)
]
.join(' ')
.toLowerCase()
.includes(keyword)
const matchesClass = selectedClassId === 'all' || report.classId === selectedClassId
const matchesFormat = selectedFormat === 'all' || report.format === selectedFormat
return matchesKeyword && matchesClass && matchesFormat
})
}, [query, reports, selectedClassId, selectedFormat])
async function handleGenerateReports(): Promise<void> {
if (!selectedTemplateId) {
toast.error('请先选择 PPTX 报告模板')
return
}
if (selectedClassId === 'all') {
toast.error('请选择要生成报告的班级')
return
}
setGenerating(true)
try {
const response = await window.api.generateReports({
templateId: selectedTemplateId,
classId: selectedClassId
})
if (!response.ok) {
toast.error('生成报告失败', { description: response.message })
return
}
setReports((currentReports) => [...response.reports, ...currentReports])
toast.success('报告生成完成', {
description:
response.skipped.length > 0
? `成功 ${response.reports.length} 份,跳过 ${response.skipped.length}`
: `成功 ${response.reports.length}`
})
} finally {
setGenerating(false)
}
}
async function handleOpenReport(report: ReportItem): Promise<void> {
setOpeningId(report.id)
try {
const response = await window.api.openReport(report.filePath)
if (!response.ok) {
toast.error('打开报告失败', { description: response.message })
}
} finally {
setOpeningId('')
}
}
async function handleDeleteReport(report: ReportItem): Promise<void> {
setDeletingId(report.id)
try {
const response = await window.api.deleteReport(report.id)
if (!response.ok) {
toast.error('删除报告失败', { description: response.message })
return
}
setReports((currentReports) =>
currentReports.filter((currentReport) => currentReport.id !== report.id)
)
toast.success(`已删除报告「${report.title}`)
} finally {
setDeletingId('')
}
}
return (
<>
<section className="space-y-4 bg-white px-4 py-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle>
<div className="flex items-center gap-2">
<Files className="size-5 text-primary" />
</div>
</CardTitle>
<CardDescription className="mt-1">
</CardDescription>
</div>
<Badge variant={filteredReports.length > 0 ? 'success' : 'secondary'}>
{filteredReports.length}
</Badge>
</div>
<div className="grid gap-3 rounded-md border border-dashed bg-muted/30 p-4">
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_220px_180px]">
<div className="relative min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索学生姓名、班级、报告名称或文件名"
/>
</div>
<Select
value={selectedClassId}
onChange={(event) => setSelectedClassId(event.target.value)}
>
<option value="all"></option>
{classes.map((classItem) => (
<option key={classItem.id} value={classItem.id}>
{classItem.name}
</option>
))}
</Select>
<Select
value={selectedFormat}
onChange={(event) => setSelectedFormat(event.target.value as ReportFormat | 'all')}
>
{formatOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto]">
<Select
value={selectedTemplateId}
onChange={(event) => setSelectedTemplateId(event.target.value)}
>
<option value=""> PPTX </option>
{pptxTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.name} - {template.originalFileName}
</option>
))}
</Select>
<Button
disabled={generating || selectedClassId === 'all' || !selectedTemplateId}
onClick={handleGenerateReports}
>
<Wand2 />
{generating ? '生成中' : '生成当前班级报告'}
</Button>
</div>
</div>
<ScrollArea className="h-140">
<div className="grid gap-3 pr-3 md:grid-cols-2 xl:grid-cols-3">
{filteredReports.map((report) => {
const ReportIcon = getReportIcon(report.format)
return (
<Card key={report.id} className="shadow-none">
<CardHeader className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-muted text-primary">
<ReportIcon className="size-5" />
</div>
<Badge variant="secondary">{getReportFormatLabel(report.format)}</Badge>
</div>
<div className="min-w-0">
<CardTitle className="truncate text-base">{report.title}</CardTitle>
<CardDescription className="mt-2 truncate">
{report.originalFileName}
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md bg-muted px-3 py-2">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 truncate font-medium">{report.className}</p>
</div>
<div className="rounded-md bg-muted px-3 py-2">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 truncate font-medium">{report.studentName}</p>
</div>
<div className="col-span-2 rounded-md bg-muted px-3 py-2">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 truncate text-sm">{formatDateTime(report.createdAt)}</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
size="sm"
disabled={openingId === report.id}
onClick={() => handleOpenReport(report)}
>
<ExternalLink />
{openingId === report.id ? '打开中' : '打开报告'}
</Button>
<Button
size="sm"
variant="destructive"
disabled={deletingId === report.id}
onClick={() => handleDeleteReport(report)}
>
<Trash2 />
{deletingId === report.id ? '删除中' : '删除'}
</Button>
</div>
</CardContent>
</Card>
)
})}
{filteredReports.length === 0 && (
<div className="col-span-full grid min-h-52 place-items-center rounded-md border bg-background p-6 text-center text-sm text-muted-foreground">
<div className="space-y-3">
<div className="mx-auto grid size-12 place-items-center rounded-full bg-muted text-primary">
<Files className="size-5" />
</div>
<div>
<p className="font-medium text-foreground"></p>
<p className="mt-1"> PPTX </p>
</div>
</div>
</div>
)}
</div>
</ScrollArea>
</section>
<DashboardFooter />
</>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { ModelSettingsForm } from '@renderer/components/settings/ModelSettingsForm'
export function SettingsPage(): React.JSX.Element {
return <ModelSettingsForm />
}
+855
View File
@@ -0,0 +1,855 @@
import { useEffect, useMemo, useState } from 'react'
import {
Bot,
Construction,
FileSearch,
MoreHorizontal,
Pencil,
Presentation,
Save,
Search,
Trash2,
UserRound,
Wand2,
X
} from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { DashboardFooter } from '@renderer/components/dashboard/DashboardFooter'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from '@renderer/components/ui/alert-dialog'
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle
} from '@renderer/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@renderer/components/ui/dropdown-menu'
import { Input } from '@renderer/components/ui/input'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious
} from '@renderer/components/ui/pagination'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Select } from '@renderer/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@renderer/components/ui/table'
import { getClassTypeLabel } from '@renderer/student/classes'
import type { ChildProfile, ClassProfile } from '@renderer/types/app'
function formatList(items: string[]): string {
return items.length > 0 ? items.join('、') : '未填写'
}
function parseList(value: string): string[] {
return value
.split(/[、,]/)
.map((item) => item.trim())
.filter(Boolean)
}
function getVisiblePages(currentPage: number, totalPages: number): Array<number | 'ellipsis'> {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1)
}
const pages = new Set([1, totalPages, currentPage - 1, currentPage, currentPage + 1])
const validPages = Array.from(pages)
.filter((page) => page >= 1 && page <= totalPages)
.sort((first, second) => first - second)
const visiblePages: Array<number | 'ellipsis'> = []
for (const page of validPages) {
const previousPage = visiblePages[visiblePages.length - 1]
if (typeof previousPage === 'number' && page - previousPage > 1) {
visiblePages.push('ellipsis')
}
visiblePages.push(page)
}
return visiblePages
}
export function StudentPage(): React.JSX.Element {
const navigate = useNavigate()
const [profiles, setProfiles] = useState<ChildProfile[]>([])
const [classes, setClasses] = useState<ClassProfile[]>([])
const [totalProfiles, setTotalProfiles] = useState(0)
const [studentDataReady, setStudentDataReady] = useState(false)
const [selectedId, setSelectedId] = useState<string>('')
const [selectedProfileIds, setSelectedProfileIds] = useState<string[]>([])
const [generatingIds, setGeneratingIds] = useState<string[]>([])
const [editingProfile, setEditingProfile] = useState<ChildProfile | null>(null)
const [query, setQuery] = useState('')
const [selectedClassFilter, setSelectedClassFilter] = useState('all')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
useEffect(() => {
async function loadStudentData(): Promise<void> {
const response = await window.api.loadStudentData()
if (!response.ok) {
toast.error('读取数据库失败', { description: response.message })
return
}
setClasses(response.classes)
setStudentDataReady(true)
}
loadStudentData()
}, [])
useEffect(() => {
async function loadPagedStudentProfiles(): Promise<void> {
if (!studentDataReady) {
return
}
const response = await window.api.listStudentProfiles({
page,
pageSize,
query,
classId: selectedClassFilter
})
if (!response.ok) {
toast.error('读取学生列表失败', { description: response.message })
return
}
setProfiles(response.profiles)
setTotalProfiles(response.total)
}
loadPagedStudentProfiles()
}, [page, pageSize, query, selectedClassFilter, studentDataReady])
const classOptions = useMemo(() => {
const classMap = new Map<string, ClassProfile>()
for (const classItem of classes) {
classMap.set(classItem.id, classItem)
}
for (const profile of profiles) {
if (profile.classId && profile.className) {
classMap.set(profile.classId, {
id: profile.classId,
name: profile.className,
type: 'cheap'
})
}
}
return Array.from(classMap.values())
}, [classes, profiles])
const selectedProfile = profiles.find((profile) => profile.id === selectedId)
const activeProfile = editingProfile ?? selectedProfile
const selectedProfileClass = selectedProfile
? classOptions.find((classItem) => classItem.id === selectedProfile.classId)
: undefined
const totalPages = Math.max(Math.ceil(totalProfiles / pageSize), 1)
const visiblePages = getVisiblePages(page, totalPages)
const currentStart = totalProfiles === 0 ? 0 : (page - 1) * pageSize + 1
const currentEnd = Math.min(page * pageSize, totalProfiles)
function changePage(nextPage: number): void {
setPage(Math.min(Math.max(nextPage, 1), totalPages))
}
function closeProfilePanel(): void {
setSelectedId('')
setEditingProfile(null)
}
function handleDrawerOpenChange(open: boolean): void {
if (!open) {
closeProfilePanel()
}
}
function updateProfileInPage(nextProfile: ChildProfile): void {
setProfiles((currentProfiles) =>
currentProfiles.map((profile) => (profile.id === nextProfile.id ? nextProfile : profile))
)
setEditingProfile((currentProfile) =>
currentProfile?.id === nextProfile.id ? nextProfile : currentProfile
)
}
function toggleSelectedProfile(profileId: string): void {
setSelectedProfileIds((currentIds) =>
currentIds.includes(profileId)
? currentIds.filter((currentId) => currentId !== profileId)
: [...currentIds, profileId]
)
}
function toggleSelectedPageProfiles(checked: boolean): void {
const pageProfileIds = profiles.map((profile) => profile.id)
setSelectedProfileIds((currentIds) => {
if (!checked) {
return currentIds.filter((profileId) => !pageProfileIds.includes(profileId))
}
return Array.from(new Set([...currentIds, ...pageProfileIds]))
})
}
async function handleGenerateComment(profile: ChildProfile): Promise<boolean> {
setGeneratingIds((currentIds) => [...currentIds, profile.id])
try {
const response = await window.api.generateStudentComment({ profileId: profile.id })
if (!response.ok) {
toast.error(`生成「${profile.name || profile.englishName || '学生'}」评语失败`, {
description: response.message
})
return false
}
updateProfileInPage(response.profile)
toast.success(`已生成「${response.profile.name || '学生'}」评语`)
return true
} finally {
setGeneratingIds((currentIds) => currentIds.filter((profileId) => profileId !== profile.id))
}
}
async function handleBatchGenerateComments(): Promise<void> {
const selectedProfiles = profiles.filter((profile) => selectedProfileIds.includes(profile.id))
if (selectedProfiles.length === 0) {
toast.error('请先选择要生成评语的学生')
return
}
let successCount = 0
let failedCount = 0
for (const profile of selectedProfiles) {
const ok = await handleGenerateComment(profile)
if (ok) {
successCount += 1
} else {
failedCount += 1
}
}
toast.success('批量生成完成', {
description: `成功 ${successCount} 个,失败 ${failedCount}`
})
}
function hasGeneratedReport(profile: ChildProfile): boolean {
const profileWithReport = profile as ChildProfile & {
reportPath?: string
reportGeneratedAt?: string
reportFilePath?: string
}
return Boolean(
profileWithReport.reportPath ||
profileWithReport.reportGeneratedAt ||
profileWithReport.reportFilePath
)
}
function handleGenerateReport(profile: ChildProfile): void {
navigate('/tools/reports', {
state: {
profileId: profile.id
}
})
}
function handleViewReport(profile: ChildProfile): void {
if (!hasGeneratedReport(profile)) {
return
}
navigate('/tools/reports', {
state: {
profileId: profile.id,
mode: 'view'
}
})
}
async function handleDeleteProfile(profile: ChildProfile): Promise<void> {
const response = await window.api.deleteStudentProfile(profile.id)
if (!response.ok) {
toast.error('删除学生失败', { description: response.message })
return
}
setProfiles((currentProfiles) =>
currentProfiles.filter((currentProfile) => currentProfile.id !== profile.id)
)
setSelectedProfileIds((currentIds) =>
currentIds.filter((profileId) => profileId !== profile.id)
)
setTotalProfiles((currentTotal) => Math.max(currentTotal - 1, 0))
if (selectedId === profile.id) {
closeProfilePanel()
}
toast.success(`已删除「${profile.name || profile.englishName || '学生'}`)
}
async function handleSaveProfile(): Promise<void> {
if (!editingProfile) {
return
}
const response = await window.api.updateStudentProfile(editingProfile)
if (!response.ok) {
toast.error('保存学生信息失败', { description: response.message })
return
}
updateProfileInPage(response.profile)
setEditingProfile(null)
toast.success('学生信息已保存')
}
return (
<>
<section>
<Card className="shadow-none">
<CardHeader className="gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="fle flex-col gap-3">
<CardTitle>
<div className="flex items-center gap-2">
<Construction className="size-5 text-primary" />
</div>
</CardTitle>
<CardDescription></CardDescription>
</div>
<Badge variant={totalProfiles > 0 ? 'success' : 'warning'}>
{totalProfiles}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 rounded-md border border-dashed bg-muted/30 p-4">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_220px]">
<div className="relative min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
value={query}
onChange={(event) => {
setQuery(event.target.value)
setPage(1)
}}
placeholder="搜索姓名、英文名、爱好、朋友或表现特征"
/>
</div>
<Select
value={selectedClassFilter}
onChange={(event) => {
setSelectedClassFilter(event.target.value)
setPage(1)
}}
>
<option value="all"></option>
{classOptions.map((classItem) => (
<option key={classItem.id} value={classItem.id}>
{classItem.name}
</option>
))}
</Select>
</div>
<div className="flex justify-end">
<Button
size="sm"
disabled={selectedProfileIds.length === 0 || generatingIds.length > 0}
onClick={handleBatchGenerateComments}
>
<Wand2 />
</Button>
</div>
</div>
<div className="overflow-hidden rounded-md border">
<Table className="min-w-[1080px]">
<TableHeader>
<TableRow>
<TableHead className="w-12">
<input
className="size-4 rounded border-input accent-primary"
type="checkbox"
checked={
profiles.length > 0 &&
profiles.every((profile) => selectedProfileIds.includes(profile.id))
}
onChange={(event) => toggleSelectedPageProfiles(event.target.checked)}
aria-label="选择当前页学生"
/>
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-36 pr-6 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profiles.map((profile) => {
const isGenerating = generatingIds.includes(profile.id)
const reportReady = hasGeneratedReport(profile)
return (
<TableRow
key={profile.id}
className={profile.id === selectedProfile?.id ? 'bg-muted/70' : undefined}
>
<TableCell>
<input
className="size-4 rounded border-input accent-primary"
type="checkbox"
checked={selectedProfileIds.includes(profile.id)}
onChange={() => toggleSelectedProfile(profile.id)}
aria-label={`选择${profile.name || profile.englishName || '学生'}`}
/>
</TableCell>
<TableCell>{profile.className || '未分班'}</TableCell>
<TableCell className="font-medium">{profile.name || '未填写'}</TableCell>
<TableCell>{profile.englishName || '未填写'}</TableCell>
<TableCell>{profile.gender || '未填写'}</TableCell>
<TableCell>{profile.birthday || '未填写'}</TableCell>
<TableCell>{profile.zodiac || '未填写'}</TableCell>
<TableCell className="max-w-[160px] truncate">
{formatList(profile.hobbies)}
</TableCell>
<TableCell className="max-w-[260px] truncate">
{profile.comment || '未生成'}
</TableCell>
<TableCell className="pr-6 text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedId(profile.id)
setEditingProfile(null)
}}
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
setSelectedId(profile.id)
setEditingProfile(profile)
}}
>
<Pencil />
</DropdownMenuItem>
<DropdownMenuItem
disabled={isGenerating}
onSelect={() => handleGenerateComment(profile)}
>
<Bot />
{isGenerating ? '生成中' : '生成评语'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleGenerateReport(profile)}>
<Presentation />
</DropdownMenuItem>
<DropdownMenuItem
disabled={!reportReady}
onSelect={() => handleViewReport(profile)}
>
<FileSearch />
</DropdownMenuItem>
<DropdownMenuSeparator />
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={(event) => event.preventDefault()}
>
<Trash2 />
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{profile.name || profile.englishName || '学生'}
</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteProfile(profile)}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
)
})}
{profiles.length === 0 && (
<TableRow>
<TableCell className="h-28 text-center text-muted-foreground" colSpan={10}>
xlsx
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="grid gap-3 rounded-md border bg-background px-3 py-3 lg:grid-cols-[1fr_auto_1fr] lg:items-center">
<div className="hidden lg:block" />
<Pagination className="justify-center">
<PaginationContent>
<PaginationItem>
<PaginationPrevious disabled={page <= 1} onClick={() => changePage(page - 1)} />
</PaginationItem>
{visiblePages.map((visiblePage, index) => (
<PaginationItem key={`${visiblePage}-${index}`}>
{visiblePage === 'ellipsis' ? (
<PaginationEllipsis />
) : (
<PaginationLink
isActive={visiblePage === page}
onClick={() => changePage(visiblePage)}
>
{visiblePage}
</PaginationLink>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
disabled={page >= totalPages}
onClick={() => changePage(page + 1)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
<div className="flex flex-wrap items-center justify-center gap-3 text-sm text-muted-foreground lg:justify-end">
<span>
{currentStart}-{currentEnd} {totalProfiles}
</span>
<label className="flex items-center gap-2">
<Select
className="h-8 w-24"
value={String(pageSize)}
onChange={(event) => {
setPageSize(Number(event.target.value))
setPage(1)
}}
>
<option value="10">10 </option>
<option value="20">20 </option>
<option value="50">50 </option>
</Select>
</label>
</div>
</div>
</CardContent>
</Card>
</section>
<Drawer direction="right" open={Boolean(activeProfile)} onOpenChange={handleDrawerOpenChange}>
<DrawerContent>
{activeProfile && (
<>
<DrawerHeader>
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<UserRound className="size-5 text-primary" />
<DrawerTitle></DrawerTitle>
</div>
<DrawerDescription className="mt-1"></DrawerDescription>
</div>
<div className="flex gap-2">
{editingProfile ? (
<Button size="sm" onClick={handleSaveProfile}>
<Save />
</Button>
) : null}
<Button
size="icon"
variant="ghost"
aria-label="关闭幼儿信息"
onClick={closeProfilePanel}
>
<X />
</Button>
</div>
</div>
</DrawerHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-6">
<div className="rounded-md bg-muted p-4">
<p className="text-2xl font-semibold">{activeProfile.name || '未填写姓名'}</p>
<p className="mt-1 text-sm text-muted-foreground">
{activeProfile.englishName || '未填写英文名'} ·{' '}
{activeProfile.gender || '未填写性别'} ·{' '}
{activeProfile.birthday || '未填写生日'}
</p>
</div>
{editingProfile ? (
<StudentProfileForm profile={editingProfile} onChange={setEditingProfile} />
) : (
<dl className="grid gap-3 text-sm">
<ProfileField label="班级" value={activeProfile.className || '未分班'} />
<ProfileField
label="班级类型"
value={
selectedProfileClass
? getClassTypeLabel(selectedProfileClass.type)
: '未记录'
}
/>
<ProfileField label="班级 UUID" value={activeProfile.classId || '未记录'} />
<ProfileField label="属相" value={activeProfile.zodiac || '未填写'} />
<ProfileField label="我的好朋友" value={formatList(activeProfile.friends)} />
<ProfileField label="我的爱好" value={formatList(activeProfile.hobbies)} />
<ProfileField
label="喜欢的游戏"
value={formatList(activeProfile.favoriteGames)}
/>
<ProfileField
label="喜欢吃的食物"
value={formatList(activeProfile.favoriteFoods)}
/>
<ProfileField label="表现特征" value={activeProfile.traits || '未填写'} />
<ProfileField label="评语" value={activeProfile.comment || '未生成评语'} />
<ProfileField
label="评语生成时间"
value={activeProfile.commentGeneratedAt || '未生成'}
/>
<ProfileField label="导入时间" value={activeProfile.importedAt} />
</dl>
)}
</div>
</ScrollArea>
</>
)}
</DrawerContent>
</Drawer>
<DashboardFooter />
</>
)
}
function ProfileField({ label, value }: { label: string; value: string }): React.JSX.Element {
return (
<div className="grid gap-1 rounded-md border bg-background p-3">
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
<dd className="leading-6">{value}</dd>
</div>
)
}
function StudentProfileForm({
profile,
onChange
}: {
profile: ChildProfile
onChange: (profile: ChildProfile) => void
}): React.JSX.Element {
function updateTextField(field: keyof ChildProfile, value: string): void {
onChange({
...profile,
[field]: value
})
}
function updateListField(
field: 'friends' | 'hobbies' | 'favoriteGames' | 'favoriteFoods',
value: string
): void {
onChange({
...profile,
[field]: parseList(value)
})
}
return (
<div className="grid gap-4">
<div className="grid gap-3 sm:grid-cols-2">
<EditableField
label="姓名"
value={profile.name}
onChange={(value) => updateTextField('name', value)}
/>
<EditableField
label="英文名"
value={profile.englishName}
onChange={(value) => updateTextField('englishName', value)}
/>
<EditableField
label="性别"
value={profile.gender}
onChange={(value) => updateTextField('gender', value)}
/>
<EditableField
label="生日"
value={profile.birthday}
onChange={(value) => updateTextField('birthday', value)}
/>
<EditableField
label="属相"
value={profile.zodiac}
onChange={(value) => updateTextField('zodiac', value)}
/>
<EditableField
label="班级"
value={profile.className}
onChange={(value) => updateTextField('className', value)}
/>
</div>
<EditableField
label="我的好朋友"
value={profile.friends.join('、')}
onChange={(value) => updateListField('friends', value)}
/>
<EditableField
label="我的爱好"
value={profile.hobbies.join('、')}
onChange={(value) => updateListField('hobbies', value)}
/>
<EditableField
label="喜欢的游戏"
value={profile.favoriteGames.join('、')}
onChange={(value) => updateListField('favoriteGames', value)}
/>
<EditableField
label="喜欢吃的食物"
value={profile.favoriteFoods.join('、')}
onChange={(value) => updateListField('favoriteFoods', value)}
/>
<EditableField
label="表现特征"
value={profile.traits}
multiline
onChange={(value) => updateTextField('traits', value)}
/>
<EditableField
label="评语"
value={profile.comment}
multiline
onChange={(value) => updateTextField('comment', value)}
/>
</div>
)
}
function EditableField({
label,
value,
multiline = false,
onChange
}: {
label: string
value: string
multiline?: boolean
onChange: (value: string) => void
}): React.JSX.Element {
return (
<label className="grid gap-2 text-sm">
<span className="font-medium">{label}</span>
{multiline ? (
<textarea
className="min-h-24 rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
) : (
<Input value={value} onChange={(event) => onChange(event.target.value)} />
)}
</label>
)
}
+977
View File
@@ -0,0 +1,977 @@
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
ExternalLink,
FileSpreadsheet,
FileText,
LayoutPanelTop,
ListTree,
MoreHorizontal,
Pencil,
Plus,
Presentation,
Search,
Trash2,
Upload,
X
} from 'lucide-react'
import { DashboardFooter } from '@renderer/components/dashboard/DashboardFooter'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@renderer/components/ui/alert-dialog'
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@renderer/components/ui/dialog'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle
} from '@renderer/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@renderer/components/ui/dropdown-menu'
import { Input } from '@renderer/components/ui/input'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Select } from '@renderer/components/ui/select'
import type {
TemplateFieldMapping,
TemplateItem,
TemplatePlaceholder,
TemplateSemester,
TemplateType
} from '@renderer/types/app'
type TemplateForm = {
id?: string
name: string
grade: string
semester: TemplateSemester
type: TemplateType
sourceFilePath: string
fileName: string
fieldMappings: TemplateFieldMapping
}
const emptyTemplateForm: TemplateForm = {
name: '',
grade: '',
semester: 'first',
type: 'portrait',
sourceFilePath: '',
fileName: '',
fieldMappings: {}
}
type MappingDialogState = {
open: boolean
template: TemplateItem | null
placeholders: TemplatePlaceholder[]
fieldMappings: TemplateFieldMapping
}
const emptyMappingDialog: MappingDialogState = {
open: false,
template: null,
placeholders: [],
fieldMappings: {}
}
const templateTypeOptions: Array<{ value: TemplateType; label: string }> = [
{ value: 'portrait', label: '竖版' },
{ value: 'landscape', label: '横版' }
]
const templateSemesterOptions: Array<{ value: TemplateSemester; label: string }> = [
{ value: 'first', label: '上学期' },
{ value: 'second', label: '下学期' }
]
const studentFieldOptions: Array<{ key: string; label: string; kind: 'text' | 'image' }> = [
{ key: 'name', label: '中文姓名', kind: 'text' },
{ key: 'englishName', label: '英文名', kind: 'text' },
{ key: 'gender', label: '性别', kind: 'text' },
{ key: 'birthday', label: '生日', kind: 'text' },
{ key: 'zodiac', label: '生肖', kind: 'text' },
{ key: 'friends', label: '好朋友', kind: 'text' },
{ key: 'hobbies', label: '兴趣爱好', kind: 'text' },
{ key: 'favoriteGames', label: '喜欢的游戏', kind: 'text' },
{ key: 'favoriteFoods', label: '喜欢的食物', kind: 'text' },
{ key: 'traits', label: '表现特征', kind: 'text' },
{ key: 'comment', label: '成长评语', kind: 'text' },
{ key: 'className', label: '班级名称', kind: 'text' },
{ key: 'familyPhoto', label: '班级图片', kind: 'image' }
]
function getTemplateTypeLabel(type: TemplateType): string {
return type === 'landscape' ? '横版' : '竖版'
}
function getTemplateSemesterLabel(semester: TemplateSemester): string {
return semester === 'second' ? '下学期' : '上学期'
}
function isSupportedTemplateFile(fileName: string): boolean {
return /\.(ppt|pptx|doc|docx|xls|xlsx)$/i.test(fileName)
}
function getFileKind(extension: string): string {
if (['.ppt', '.pptx'].includes(extension)) {
return 'PPT'
}
if (['.doc', '.docx'].includes(extension)) {
return 'Word'
}
if (['.xls', '.xlsx'].includes(extension)) {
return 'Excel'
}
return '文件'
}
function getFileIcon(extension: string): typeof Presentation {
if (['.ppt', '.pptx'].includes(extension)) {
return Presentation
}
if (['.xls', '.xlsx'].includes(extension)) {
return FileSpreadsheet
}
return FileText
}
function getStudentFieldLabel(fieldKey?: string): string {
return studentFieldOptions.find((field) => field.key === fieldKey)?.label ?? ''
}
function getCompatibleStudentFields(kind: TemplatePlaceholder['kind']): typeof studentFieldOptions {
return studentFieldOptions.filter((field) => field.kind === kind)
}
function formatDateTime(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
export function TemplatePage(): React.JSX.Element {
const [templates, setTemplates] = useState<TemplateItem[]>([])
const [query, setQuery] = useState('')
const [drawerOpen, setDrawerOpen] = useState(false)
const [templateForm, setTemplateForm] = useState<TemplateForm>(emptyTemplateForm)
const [mappingDialog, setMappingDialog] = useState<MappingDialogState>(emptyMappingDialog)
const [saving, setSaving] = useState(false)
const [mappingSaving, setMappingSaving] = useState(false)
const [openingId, setOpeningId] = useState('')
const [deletingTemplate, setDeletingTemplate] = useState<TemplateItem | null>(null)
const [isDragActive, setIsDragActive] = useState(false)
useEffect(() => {
async function loadTemplatesOnMount(): Promise<void> {
const response = await window.api.loadTemplates()
if (!response.ok) {
showError('读取模板失败', response.message)
return
}
setTemplates(response.templates)
}
loadTemplatesOnMount()
}, [])
const filteredTemplates = useMemo(() => {
const keyword = query.trim().toLowerCase()
if (!keyword) {
return templates
}
return templates.filter((template) =>
[
template.name,
template.grade,
getTemplateSemesterLabel(template.semester),
getTemplateTypeLabel(template.type),
template.originalFileName,
getFileKind(template.fileExtension)
]
.join(' ')
.toLowerCase()
.includes(keyword)
)
}, [templates, query])
function showSuccess(message: string, description?: string): void {
toast.success(message, description ? { description } : undefined)
}
function showError(message: string, description?: string): void {
toast.error(message, description ? { description } : undefined)
}
function openCreateDrawer(): void {
setTemplateForm(emptyTemplateForm)
setDrawerOpen(true)
}
function openEditDrawer(template: TemplateItem): void {
setTemplateForm({
id: template.id,
name: template.name,
grade: template.grade,
semester: template.semester,
type: template.type,
sourceFilePath: template.filePath,
fileName: template.originalFileName,
fieldMappings: template.fieldMappings
})
setDrawerOpen(true)
}
async function openMappingDialog(template: TemplateItem): Promise<void> {
setMappingDialog({
open: true,
template,
placeholders: template.placeholders,
fieldMappings: template.fieldMappings
})
const placeholders = await parsePlaceholdersForFile(template.filePath)
setMappingDialog((currentDialog) => {
if (currentDialog.template?.id !== template.id) {
return currentDialog
}
return {
...currentDialog,
placeholders,
fieldMappings: mergeFieldMappings(placeholders, currentDialog.fieldMappings)
}
})
}
async function parsePlaceholdersForFile(filePath: string): Promise<TemplatePlaceholder[]> {
const response = await window.api.parseTemplatePlaceholders(filePath)
if (!response.ok) {
showError('解析占位符失败', response.message)
return []
}
return response.placeholders
}
function mergeFieldMappings(
placeholders: TemplatePlaceholder[],
currentMappings: TemplateFieldMapping
): TemplateFieldMapping {
const placeholderNames = new Set(placeholders.map((placeholder) => placeholder.name))
return Object.fromEntries(
Object.entries(currentMappings).filter(
([placeholder, fieldKey]) => placeholderNames.has(placeholder) && Boolean(fieldKey)
)
)
}
async function handleSelectFile(): Promise<void> {
const response = await window.api.selectTemplateFile()
if (!response.ok) {
if (!response.canceled) {
showError('选择模板失败', response.message)
}
return
}
const placeholders = await parsePlaceholdersForFile(response.filePath)
setTemplateForm((currentForm) => ({
...currentForm,
sourceFilePath: response.filePath,
fileName: response.fileName,
fieldMappings: mergeFieldMappings(placeholders, currentForm.fieldMappings)
}))
}
async function selectDroppedTemplateFile(file?: File): Promise<void> {
if (!file) {
return
}
if (!isSupportedTemplateFile(file.name)) {
showError('请选择 PPT、Word、Excel 模板文件')
return
}
const filePath = window.api.getDroppedFilePath(file)
if (!filePath) {
showError('无法读取拖拽文件路径')
return
}
const placeholders = await parsePlaceholdersForFile(filePath)
setTemplateForm((currentForm) => ({
...currentForm,
sourceFilePath: filePath,
fileName: file.name,
fieldMappings: mergeFieldMappings(placeholders, currentForm.fieldMappings)
}))
}
function handleDrop(event: React.DragEvent<HTMLButtonElement>): void {
event.preventDefault()
setIsDragActive(false)
void selectDroppedTemplateFile(event.dataTransfer.files[0])
}
function updateFieldMapping(placeholder: string, fieldKey: string): void {
setMappingDialog((currentDialog) => {
const fieldMappings = { ...currentDialog.fieldMappings }
if (fieldKey) {
fieldMappings[placeholder] = fieldKey
} else {
delete fieldMappings[placeholder]
}
return {
...currentDialog,
fieldMappings
}
})
}
async function handleSaveFieldMappings(): Promise<void> {
const template = mappingDialog.template
if (!template) {
return
}
setMappingSaving(true)
try {
const response = await window.api.saveTemplate({
id: template.id,
name: template.name,
grade: template.grade,
semester: template.semester,
type: template.type,
sourceFilePath: template.filePath,
fieldMappings: mappingDialog.fieldMappings
})
if (!response.ok) {
showError('保存字段关联失败', response.message)
return
}
setTemplates((currentTemplates) =>
currentTemplates.map((currentTemplate) =>
currentTemplate.id === response.template.id ? response.template : currentTemplate
)
)
setMappingDialog(emptyMappingDialog)
showSuccess('字段关联已保存')
} finally {
setMappingSaving(false)
}
}
function handleFieldDragStart(event: React.DragEvent<HTMLButtonElement>, fieldKey: string): void {
event.dataTransfer.setData('application/x-student-field', fieldKey)
event.dataTransfer.effectAllowed = 'copy'
}
function handlePlaceholderDrop(
event: React.DragEvent<HTMLDivElement>,
placeholder: TemplatePlaceholder
): void {
event.preventDefault()
const fieldKey = event.dataTransfer.getData('application/x-student-field')
const field = studentFieldOptions.find((item) => item.key === fieldKey)
if (!field) {
return
}
if (field.kind !== placeholder.kind) {
showError('字段类型不匹配')
return
}
updateFieldMapping(placeholder.name, field.key)
}
async function handleSaveTemplate(): Promise<void> {
const name = templateForm.name.trim()
const grade = templateForm.grade.trim()
if (!name) {
showError('请输入模板名称')
return
}
if (!grade) {
showError('请输入适用年级')
return
}
if (!templateForm.sourceFilePath) {
showError('请选择模板文件')
return
}
setSaving(true)
try {
const response = await window.api.saveTemplate({
id: templateForm.id,
name,
grade,
semester: templateForm.semester,
type: templateForm.type,
sourceFilePath: templateForm.sourceFilePath,
fieldMappings: templateForm.fieldMappings
})
if (!response.ok) {
showError('保存模板失败', response.message)
return
}
setTemplates((currentTemplates) => {
const exists = currentTemplates.some((template) => template.id === response.template.id)
const nextTemplates = exists
? currentTemplates.map((template) =>
template.id === response.template.id ? response.template : template
)
: [response.template, ...currentTemplates]
return nextTemplates.sort((first, second) =>
second.updatedAt.localeCompare(first.updatedAt)
)
})
setDrawerOpen(false)
setTemplateForm(emptyTemplateForm)
showSuccess(templateForm.id ? '模板已更新' : '模板已添加')
} finally {
setSaving(false)
}
}
async function handleOpenTemplate(template: TemplateItem): Promise<void> {
setOpeningId(template.id)
try {
const response = await window.api.openTemplate(template.filePath)
if (!response.ok) {
showError('打开模板失败', response.message)
}
} finally {
setOpeningId('')
}
}
async function handleDeleteTemplate(template: TemplateItem): Promise<void> {
const response = await window.api.deleteTemplate(template.id)
if (!response.ok) {
showError('删除模板失败', response.message)
return
}
setTemplates((currentTemplates) =>
currentTemplates.filter((currentTemplate) => currentTemplate.id !== template.id)
)
setDeletingTemplate(null)
showSuccess(`已删除模板「${template.name}`)
}
return (
<>
<section className="space-y-4 bg-white px-4 py-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle>
<div className="flex items-center gap-2">
<LayoutPanelTop className="size-5 text-primary" />
</div>
</CardTitle>
<CardDescription className="mt-1">
PPTWordExcel
</CardDescription>
</div>
<Button onClick={openCreateDrawer}>
<Plus />
</Button>
</div>
<div className="grid gap-3 rounded-md border border-dashed bg-muted/30 p-4">
<div className="relative min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索模板名称、年级、学期、类型或文件名"
/>
</div>
</div>
<ScrollArea className="h-140">
<div className="grid gap-3 pr-3 md:grid-cols-2 xl:grid-cols-3">
{filteredTemplates.map((template) => {
const FileIcon = getFileIcon(template.fileExtension)
const fileKind = getFileKind(template.fileExtension)
return (
<Card key={template.id} className="shadow-none">
<CardHeader className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-muted text-primary">
<FileIcon className="size-5" />
</div>
<div className="flex flex-wrap justify-end gap-2">
<Badge variant="secondary">{fileKind}</Badge>
<Badge variant={template.type === 'landscape' ? 'warning' : 'outline'}>
{getTemplateTypeLabel(template.type)}
</Badge>
<Badge variant="outline">
{getTemplateSemesterLabel(template.semester)}
</Badge>
</div>
</div>
<div className="min-w-0">
<CardTitle className="truncate text-base">{template.name}</CardTitle>
<CardDescription className="mt-2 truncate">
{template.originalFileName}
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md bg-muted px-3 py-2">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 truncate font-medium">{template.grade}</p>
</div>
<div className="rounded-md bg-muted px-3 py-2">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 truncate font-medium">
{getTemplateSemesterLabel(template.semester)}
</p>
</div>
<div className="rounded-md bg-muted px-3 py-2">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 truncate text-sm">
{formatDateTime(template.updatedAt)}
</p>
</div>
<div className="rounded-md bg-muted px-3 py-2">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 truncate text-sm">
{Object.keys(template.fieldMappings).length}/
{template.placeholders.length}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
disabled={openingId === template.id}
onClick={() => handleOpenTemplate(template)}
>
<ExternalLink />
{openingId === template.id ? '打开中' : '打开模板'}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => openEditDrawer(template)}>
<Pencil />
</DropdownMenuItem>
<DropdownMenuItem
disabled={template.fileExtension !== '.pptx'}
onSelect={() => void openMappingDialog(template)}
>
<ListTree />
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onSelect={() => setDeletingTemplate(template)}
>
<Trash2 />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardContent>
</Card>
)
})}
{filteredTemplates.length === 0 && (
<div className="col-span-full grid h-32 place-items-center rounded-md border text-sm text-muted-foreground">
</div>
)}
</div>
</ScrollArea>
</section>
<Drawer direction="right" open={drawerOpen} onOpenChange={setDrawerOpen}>
<DrawerContent>
<DrawerHeader>
<div className="flex items-start justify-between gap-3">
<div>
<DrawerTitle>{templateForm.id ? '编辑模板' : '添加模板'}</DrawerTitle>
<DrawerDescription className="mt-1">
Office
</DrawerDescription>
</div>
<Button
size="icon"
variant="ghost"
aria-label="关闭模板表单"
onClick={() => setDrawerOpen(false)}
>
<X />
</Button>
</div>
</DrawerHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto p-6">
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="template-name">
</label>
<Input
id="template-name"
value={templateForm.name}
onChange={(event) =>
setTemplateForm({
...templateForm,
name: event.target.value
})
}
placeholder="例如:大班成长报告横版模板"
/>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="template-grade">
</label>
<Input
id="template-grade"
value={templateForm.grade}
onChange={(event) =>
setTemplateForm({
...templateForm,
grade: event.target.value
})
}
placeholder="例如:小班、中班、大班"
/>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="template-semester">
</label>
<Select
id="template-semester"
value={templateForm.semester}
onChange={(event) =>
setTemplateForm({
...templateForm,
semester: event.target.value as TemplateSemester
})
}
>
{templateSemesterOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="template-type">
</label>
<Select
id="template-type"
value={templateForm.type}
onChange={(event) =>
setTemplateForm({
...templateForm,
type: event.target.value as TemplateType
})
}
>
{templateTypeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</div>
<div className="grid gap-2">
<p className="text-sm font-medium"></p>
<button
type="button"
className={[
'grid min-h-[150px] place-items-center rounded-md border border-dashed bg-muted/30 p-5 text-center transition-colors',
isDragActive
? 'border-primary bg-accent'
: 'hover:border-primary hover:bg-muted/50'
].join(' ')}
onClick={handleSelectFile}
onDragEnter={(event) => {
event.preventDefault()
setIsDragActive(true)
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsDragActive(false)}
onDrop={handleDrop}
>
<div className="space-y-3">
<div className="mx-auto grid size-12 place-items-center rounded-full bg-primary text-primary-foreground">
<Upload className="size-5" />
</div>
<div>
<p className="font-medium">
{templateForm.fileName || '点击或拖拽 PPT、Word、Excel 文件到这里'}
</p>
<p className="mt-1 text-sm text-muted-foreground">
.ppt.pptx.doc.docx.xls.xlsx
</p>
</div>
</div>
</button>
</div>
<div className="sticky bottom-0 -mx-6 flex justify-end gap-2 border-t bg-card px-6 py-4">
<Button variant="outline" onClick={() => setDrawerOpen(false)}>
</Button>
<Button disabled={saving} onClick={handleSaveTemplate}>
<Upload />
{saving ? '保存中' : '保存模板'}
</Button>
</div>
</div>
</DrawerContent>
</Drawer>
<Dialog
open={mappingDialog.open}
onOpenChange={(open) => {
if (!open) {
setMappingDialog(emptyMappingDialog)
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{mappingDialog.template
? `为「${mappingDialog.template.name}」选择模板占位符和学生字段的对应关系。`
: '选择模板占位符和学生字段的对应关系。'}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-hidden rounded-md border">
<div className="grid grid-cols-[minmax(0,1fr)_220px] border-b bg-muted/30 px-4 py-2 text-xs font-medium text-muted-foreground">
<span></span>
<span></span>
</div>
<div className="max-h-[42vh] overflow-y-auto">
{mappingDialog.placeholders.map((placeholder) => {
const mappedField = mappingDialog.fieldMappings[placeholder.name] ?? ''
return (
<div
key={placeholder.name}
className="grid grid-cols-[minmax(0,1fr)_220px] items-center gap-3 border-b px-4 py-3 last:border-b-0"
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => handlePlaceholderDrop(event, placeholder)}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Badge variant={placeholder.kind === 'image' ? 'warning' : 'outline'}>
{placeholder.kind === 'image' ? '图片' : '文本'}
</Badge>
<p className="truncate text-sm font-medium">{placeholder.name}</p>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{mappedField ? `已绑定:${getStudentFieldLabel(mappedField)}` : '未绑定'}
</p>
</div>
<div className="flex items-center gap-2">
<Select
value={mappedField}
onChange={(event) =>
updateFieldMapping(placeholder.name, event.target.value)
}
>
<option value=""></option>
{getCompatibleStudentFields(placeholder.kind).map((field) => (
<option key={field.key} value={field.key}>
{field.label}
</option>
))}
</Select>
<Button
size="sm"
variant="ghost"
disabled={!mappedField}
onClick={() => updateFieldMapping(placeholder.name, '')}
>
</Button>
</div>
</div>
)
})}
{mappingDialog.placeholders.length === 0 && (
<div className="grid min-h-40 place-items-center px-4 text-center text-sm text-muted-foreground">
</div>
)}
</div>
</div>
<div className="shrink-0 rounded-md border bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">
{Object.keys(mappingDialog.fieldMappings).length}/
{mappingDialog.placeholders.length}
</p>
</div>
<div className="flex flex-wrap gap-2">
{studentFieldOptions.map((field) => (
<button
key={field.key}
type="button"
draggable
className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-2 text-xs transition-colors hover:border-primary hover:bg-accent"
onDragStart={(event) => handleFieldDragStart(event, field.key)}
>
<span>{field.label}</span>
<Badge variant={field.kind === 'image' ? 'warning' : 'secondary'}>
{field.kind === 'image' ? '图' : '文'}
</Badge>
</button>
))}
</div>
</div>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setMappingDialog(emptyMappingDialog)}>
</Button>
<Button
disabled={mappingSaving || !mappingDialog.template}
onClick={handleSaveFieldMappings}
>
<Upload />
{mappingSaving ? '保存中' : '保存关联'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={Boolean(deletingTemplate)} onOpenChange={() => setDeletingTemplate(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{deletingTemplate?.name}</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (deletingTemplate) {
void handleDeleteTemplate(deletingTemplate)
}
}}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DashboardFooter />
</>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { Settings } from 'lucide-react'
import { DashboardFooter } from '@renderer/components/dashboard/DashboardFooter'
import { MetricsGrid } from '@renderer/components/dashboard/MetricsGrid'
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { statusVariant, tools } from '@renderer/student/mockData'
export function ToolsPage(): React.JSX.Element {
return (
<>
<MetricsGrid />
<section className="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-4">
{tools.map((tool) => {
const Icon = tool.icon
return (
<Card key={tool.title} className="shadow-none">
<CardHeader className="space-y-4">
<div className="flex items-center justify-between">
<div className="grid size-10 place-items-center rounded-md bg-muted text-primary">
<Icon className="size-5" />
</div>
<Badge variant={statusVariant[tool.status]}>{tool.status}</Badge>
</div>
<div>
<CardTitle className="text-base">{tool.title}</CardTitle>
<CardDescription className="mt-2 min-h-10 leading-6">
{tool.description}
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex gap-2">
<Button size="sm"></Button>
<Button size="sm" variant="outline">
<Settings className="size-4" />
</Button>
</CardContent>
</Card>
)
})}
</section>
<DashboardFooter />
</>
)
}
+154
View File
@@ -0,0 +1,154 @@
import * as XLSX from 'xlsx'
import type { ChildProfile, ClassProfile } from '@renderer/types/app'
export const CHILD_PROFILES_STORAGE_KEY = 'growth-report:student-profiles'
const LEGACY_CHILD_PROFILES_STORAGE_KEY = 'growth-report:child-profiles'
type SpreadsheetRow = Record<string, unknown>
function asText(value: unknown): string {
if (value === null || value === undefined) {
return ''
}
if (value instanceof Date) {
return value.toISOString().slice(0, 10)
}
return String(value).trim()
}
function splitList(value: unknown): string[] {
return asText(value)
.split(/[、,]/)
.map((item) => item.trim())
.filter(Boolean)
}
function createChildId(profile: Omit<ChildProfile, 'id'>, index: number): string {
return [profile.classId, profile.name, profile.englishName, profile.birthday, index]
.filter(Boolean)
.join('-')
}
function mapRowToChild(
row: SpreadsheetRow,
index: number,
importedAt: string,
selectedClass: ClassProfile
): ChildProfile | null {
const profile = {
classId: selectedClass.id,
className: selectedClass.name,
name: asText(row['姓名']),
englishName: asText(row['英文名']),
gender: asText(row['性别']),
birthday: asText(row['生日']),
zodiac: asText(row['属相']),
friends: splitList(row['我的好朋友']),
hobbies: splitList(row['我的爱好']),
favoriteGames: splitList(row['喜欢的游戏']),
favoriteFoods: splitList(row['喜欢吃的食物']),
traits: asText(row['表现特征']),
comment: '',
commentGeneratedAt: '',
importedAt
}
if (!profile.name && !profile.englishName) {
return null
}
return {
...profile,
id: createChildId(profile, index)
}
}
export function loadProfiles(): ChildProfile[] {
const rawProfiles =
window.localStorage.getItem(CHILD_PROFILES_STORAGE_KEY) ??
window.localStorage.getItem(LEGACY_CHILD_PROFILES_STORAGE_KEY)
if (!rawProfiles) {
return []
}
try {
const profiles = JSON.parse(rawProfiles)
return Array.isArray(profiles)
? profiles
.map(normalizeProfile)
.filter((profile): profile is ChildProfile => profile !== null)
: []
} catch {
return []
}
}
function normalizeProfile(profile: Partial<ChildProfile>): ChildProfile | null {
if (!profile.id) {
return null
}
return {
id: profile.id,
classId: profile.classId ?? '',
className: profile.className ?? '',
name: profile.name ?? '',
englishName: profile.englishName ?? '',
gender: profile.gender ?? '',
birthday: profile.birthday ?? '',
zodiac: profile.zodiac ?? '',
friends: Array.isArray(profile.friends) ? profile.friends : [],
hobbies: Array.isArray(profile.hobbies) ? profile.hobbies : [],
favoriteGames: Array.isArray(profile.favoriteGames) ? profile.favoriteGames : [],
favoriteFoods: Array.isArray(profile.favoriteFoods) ? profile.favoriteFoods : [],
traits: profile.traits ?? '',
comment: profile.comment ?? '',
commentGeneratedAt: profile.commentGeneratedAt ?? '',
importedAt: profile.importedAt ?? ''
}
}
export function loadLocalProfilesForMigration(): ChildProfile[] {
return loadProfiles()
}
export function clearLocalProfilesForMigration(): void {
window.localStorage.removeItem(CHILD_PROFILES_STORAGE_KEY)
window.localStorage.removeItem(LEGACY_CHILD_PROFILES_STORAGE_KEY)
}
export function saveProfiles(profiles: ChildProfile[]): void {
window.localStorage.setItem(CHILD_PROFILES_STORAGE_KEY, JSON.stringify(profiles))
}
export async function readChildProfilesFromSpreadsheet(
file: File,
selectedClass: ClassProfile
): Promise<ChildProfile[]> {
const buffer = await file.arrayBuffer()
const workbook = XLSX.read(buffer, { cellDates: false })
const worksheet = workbook.Sheets[workbook.SheetNames[0]]
const importedAt = new Date().toLocaleString('zh-CN', { hour12: false })
const rows = XLSX.utils.sheet_to_json<SpreadsheetRow>(worksheet, { defval: '' })
return rows
.map((row, index) => mapRowToChild(row, index, importedAt, selectedClass))
.filter((profile): profile is ChildProfile => profile !== null)
}
export function replaceProfilesForClass(
profiles: ChildProfile[],
selectedClass: ClassProfile,
nextProfiles: ChildProfile[]
): ChildProfile[] {
const otherClassProfiles = profiles.filter((profile) => profile.classId !== selectedClass.id)
return [...otherClassProfiles, ...nextProfiles]
}
export function deleteProfilesForClass(profiles: ChildProfile[], classId: string): ChildProfile[] {
return profiles.filter((profile) => profile.classId !== classId)
}
+103
View File
@@ -0,0 +1,103 @@
import type { ClassProfile, ClassType } from '@renderer/types/app'
export const CLASSES_STORAGE_KEY = 'growth-report:student-classes'
const LEGACY_CLASSES_STORAGE_KEY = 'growth-report:classes'
export const classTypeOptions: { value: ClassType; label: string }[] = [
{ value: 'cheap', label: '便宜班' },
{ value: 'noble', label: '贵族班' }
]
const defaultClassNames = ['云朵一班', '星星二班', '彩虹三班']
export function createUuid(): string {
return crypto.randomUUID()
}
export function createDefaultClasses(): ClassProfile[] {
return defaultClassNames.map((name) => ({
id: createUuid(),
name,
type: 'cheap'
}))
}
export function normalizeClass(classItem: Partial<ClassProfile>): ClassProfile | null {
if (!classItem.id || !classItem.name) {
return null
}
return {
id: classItem.id,
name: classItem.name,
type: classItem.type === 'noble' ? 'noble' : 'cheap',
familyPhoto: classItem.familyPhoto
}
}
export function getClassTypeLabel(type: ClassType): string {
return classTypeOptions.find((item) => item.value === type)?.label ?? '便宜班'
}
export function loadClasses(): ClassProfile[] {
const rawClasses =
window.localStorage.getItem(CLASSES_STORAGE_KEY) ??
window.localStorage.getItem(LEGACY_CLASSES_STORAGE_KEY)
if (!rawClasses) {
const classes = createDefaultClasses()
saveClasses(classes)
return classes
}
try {
const classes = JSON.parse(rawClasses)
if (Array.isArray(classes) && classes.length > 0) {
const normalizedClasses = classes
.map((classItem) => normalizeClass(classItem))
.filter((classItem): classItem is ClassProfile => classItem !== null)
if (normalizedClasses.length > 0) {
saveClasses(normalizedClasses)
return normalizedClasses
}
}
const nextClasses = createDefaultClasses()
saveClasses(nextClasses)
return nextClasses
} catch {
const nextClasses = createDefaultClasses()
saveClasses(nextClasses)
return nextClasses
}
}
export function loadLocalClassesForMigration(): ClassProfile[] {
const rawClasses =
window.localStorage.getItem(CLASSES_STORAGE_KEY) ??
window.localStorage.getItem(LEGACY_CLASSES_STORAGE_KEY)
if (!rawClasses) {
return []
}
try {
const classes = JSON.parse(rawClasses)
return Array.isArray(classes)
? classes
.map((classItem) => normalizeClass(classItem))
.filter((classItem): classItem is ClassProfile => classItem !== null)
: []
} catch {
return []
}
}
export function clearLocalClassesForMigration(): void {
window.localStorage.removeItem(CLASSES_STORAGE_KEY)
window.localStorage.removeItem(LEGACY_CLASSES_STORAGE_KEY)
}
export function saveClasses(classes: ClassProfile[]): void {
window.localStorage.setItem(CLASSES_STORAGE_KEY, JSON.stringify(classes))
}
+97
View File
@@ -0,0 +1,97 @@
import {
Bot,
Database,
FileArchive,
FileImage,
FileSignature,
Files,
FolderPlus,
FolderOpen,
Presentation,
Settings,
UserRound,
Wand2
} from 'lucide-react'
import type { MenuItem } from '@renderer/types/app'
export const menuTree: MenuItem[] = [
{
id: 'tools',
title: '小工具',
icon: Wand2,
children: [
{
id: 'tool-image-paths',
title: '生成图片路径',
path: '/tools/image-paths',
icon: FileImage
},
{ id: 'tool-comments', title: '生成评语', path: '/tools/comments', icon: Bot },
{ id: 'tool-reports', title: '生成报告', path: '/tools/reports', icon: Presentation },
{ id: 'tool-convert', title: '格式转换', path: '/tools/convert', icon: FileArchive },
{ id: 'tool-signature', title: '园长签名', path: '/tools/signature', icon: FileSignature }
]
},
{
id: 'student',
title: '学生管理',
icon: Database,
children: [
{ id: 'classes', title: '班级管理', path: '/student/classes', icon: FolderPlus },
{ id: 'students', title: '学生数据', path: '/student/list', icon: UserRound },
{ id: 'template', title: '模板管理', path: '/student/templates', icon: FolderOpen },
{ id: 'reports', title: '报告管理', path: '/student/reports', icon: Files },
{ id: 'backup', title: '备份恢复', path: '/student/backup', icon: FileArchive }
]
},
{
id: 'settings',
title: '设置',
path: '/settings',
icon: Settings
}
]
export function findMenuItemByPath(
pathname: string,
menuItems: MenuItem[] = menuTree
): MenuItem | undefined {
for (const item of menuItems) {
if (item.path === pathname) {
return item
}
const childMatch = item.children ? findMenuItemByPath(pathname, item.children) : undefined
if (childMatch) {
return childMatch
}
}
return undefined
}
export function getOpenMenuIds(
pathname: string,
menuItems: MenuItem[] = menuTree,
parents: string[] = []
): string[] {
for (const item of menuItems) {
const nextParents = [...parents, item.id]
if (item.path === pathname) {
return parents
}
if (item.children) {
const childOpenIds = getOpenMenuIds(pathname, item.children, nextParents)
if (childOpenIds.length > 0) {
return childOpenIds
}
}
}
return []
}
+122
View File
@@ -0,0 +1,122 @@
import {
BadgeCheck,
Bot,
CalendarDays,
Database,
FileArchive,
FileImage,
FileSignature,
FileText,
Loader2,
Presentation,
ShieldCheck
} from 'lucide-react'
import type { LogEntry, SectionKey, Status, StudentDataItem, ToolItem } from '@renderer/types/app'
export const sectionTitle: Record<SectionKey, string> = {
tools: '小工具',
student: '学生管理',
settings: '设置'
}
export const statusVariant: Record<Status, 'success' | 'secondary' | 'warning' | 'outline'> = {
: 'success',
: 'secondary',
: 'warning',
: 'outline'
}
export const levelClass = {
INFO: 'text-sky-400',
SUCCESS: 'text-emerald-400',
WARNING: 'text-amber-300',
ERROR: 'text-red-400'
} as const
export const tools: ToolItem[] = [
{
title: '生成图片路径',
description: '批量扫描幼儿照片目录,生成可用于报告模板的图片路径数据。',
icon: FileImage,
status: '待配置'
},
{
title: '生成评语',
description: '根据幼儿观察记录、关键词和模板要求生成成长评语。',
icon: Bot,
status: '可运行'
},
{
title: '生成报告',
description: '将幼儿资料、图片和评语合成为成长报告 PPT。',
icon: Presentation,
status: '可运行'
},
{
title: '格式转换',
description: '把 PPT、Word 等报告文件批量转换为 PDF 归档文件。',
icon: FileArchive,
status: '可运行'
},
{
title: '生肖生成',
description: '依据出生日期自动生成生肖、年龄和学期信息。',
icon: CalendarDays,
status: '待导入'
},
{
title: '园长签名',
description: '为已审核的成长报告批量追加园长签名或签章。',
icon: FileSignature,
status: '待审核'
}
]
export const studentDataItems: StudentDataItem[] = [
{
title: '幼儿基础数据',
description: '姓名、班级、生日、学号、家长联系方式等核心档案。',
amount: '128 条记录',
updatedAt: '今天 09:12'
},
{
title: '报告模板',
description: 'PPT 模板、封面模板、签名模板和导出规则。',
amount: '6 个模板',
updatedAt: '昨天 18:40'
},
{
title: '素材目录',
description: '幼儿照片、班级活动图片和临时生成文件。',
amount: '3 个目录',
updatedAt: '今天 08:30'
},
{
title: '备份与恢复',
description: '导出当前配置与数据快照,支持后续恢复。',
amount: '今日未备份',
updatedAt: '2026-06-23'
}
]
export const logs: LogEntry[] = [
{ time: '09:29:17', level: 'SUCCESS', message: '学生:刘逸喻,评语生成完毕' },
{ time: '09:29:18', level: 'INFO', message: '正在生成第 7 份成长评语' },
{ time: '09:29:18', level: 'ERROR', message: '生成评语失败:response choices 为空' },
{ time: '09:29:19', level: 'INFO', message: '正在生成第 8 份成长评语' },
{ time: '09:29:20', level: 'WARNING', message: '任务正在停止中,正在中断' }
]
export const metrics = [
{ title: '幼儿档案', value: '128', description: '已导入基础数据', icon: Database },
{ title: '报告任务', value: '20', description: '本轮待生成', icon: FileText },
{ title: '完成率', value: '72%', description: '今日批处理进度', icon: BadgeCheck },
{ title: '异常', value: '2', description: '需要人工确认', icon: ShieldCheck }
]
export const recentTasks = [
{ icon: Bot, title: '生成评语', value: '8/20' },
{ icon: Presentation, title: '生成报告', value: '3/20' },
{ icon: Loader2, title: '格式转换', value: '排队中' }
]
+115
View File
@@ -0,0 +1,115 @@
import type { LucideIcon } from 'lucide-react'
export type SectionKey = 'tools' | 'student' | 'settings'
export type Status = '可运行' | '待配置' | '待导入' | '待审核'
export type LogLevel = 'INFO' | 'SUCCESS' | 'WARNING' | 'ERROR'
export type SettingKey = 'model'
export type MenuItem = {
id: string
title: string
path?: string
icon?: LucideIcon
children?: MenuItem[]
}
export type ToolItem = {
title: string
description: string
icon: LucideIcon
status: Status
}
export type StudentDataItem = {
title: string
description: string
amount: string
updatedAt: string
}
export type ChildProfile = {
id: string
classId: string
className: string
name: string
englishName: string
gender: string
birthday: string
zodiac: string
friends: string[]
hobbies: string[]
favoriteGames: string[]
favoriteFoods: string[]
traits: string
comment: string
commentGeneratedAt: string
importedAt: string
}
export type ClassType = 'cheap' | 'noble'
export type ClassProfile = {
id: string
name: string
type: ClassType
familyPhoto?: string
}
export type TemplateType = 'portrait' | 'landscape'
export type TemplateSemester = 'first' | 'second'
export type TemplatePlaceholder = {
name: string
kind: 'text' | 'image'
}
export type TemplateFieldMapping = Record<string, string>
export type TemplateItem = {
id: string
name: string
grade: string
semester: TemplateSemester
type: TemplateType
originalFileName: string
filePath: string
fileExtension: string
placeholders: TemplatePlaceholder[]
fieldMappings: TemplateFieldMapping
createdAt: string
updatedAt: string
}
export type ReportFormat = 'word' | 'ppt' | 'pdf' | 'excel'
export type ReportItem = {
id: string
title: string
classId: string
className: string
studentId: string
studentName: string
format: ReportFormat
originalFileName: string
filePath: string
fileExtension: string
templateId: string
createdAt: string
updatedAt: string
}
export type LogEntry = {
time: string
level: LogLevel
message: string
}
export type ModelConfig = {
provider: string
baseUrl: string
apiKey: string
model: string
temperature: string
maxTokens: string
systemPrompt: string
}

Some files were not shown because too many files have changed in this diff Show More