Initial commit
This commit is contained in:
Binary file not shown.
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
out
|
||||||
|
.DS_Store
|
||||||
|
.eslintcache
|
||||||
|
*.log*
|
||||||
|
.electron-dev-data
|
||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# 已忽略包含查询文件的默认文件夹
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
Generated
+6
@@ -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>
|
||||||
Generated
+61
@@ -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>
|
||||||
Generated
+5
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
Generated
+12
@@ -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>
|
||||||
Generated
+8
@@ -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
@@ -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>
|
||||||
Generated
+8
@@ -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>
|
||||||
Generated
+6
@@ -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
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
out
|
||||||
|
dist
|
||||||
|
pnpm-lock.yaml
|
||||||
|
LICENSE.md
|
||||||
|
tsconfig.json
|
||||||
|
tsconfig.*.json
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
singleQuote: true
|
||||||
|
semi: false
|
||||||
|
printWidth: 100
|
||||||
|
trailingComma: none
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# growth_report
|
||||||
|
|
||||||
|
成长报告助手,一个用于导入幼儿基础数据并生成成长报告相关材料的 Electron 桌面应用。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- Electron 39:桌面端应用容器
|
||||||
|
- electron-vite 5:Electron + Vite 构建与开发环境
|
||||||
|
- React 19:渲染进程界面
|
||||||
|
- TypeScript 5:类型系统
|
||||||
|
- React Router 7:页面路由
|
||||||
|
- Tailwind CSS 4:样式系统
|
||||||
|
- shadcn/ui 风格本地组件:Button、Card、Table、Drawer 等
|
||||||
|
- Vaul:Drawer 抽屉交互
|
||||||
|
- Radix UI:ScrollArea、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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
provider: generic
|
||||||
|
url: https://example.com/auto-updates
|
||||||
|
updaterCacheDirName: growth_report-updater
|
||||||
@@ -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/
|
||||||
@@ -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()]
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+7642
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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: {}
|
||||||
|
})
|
||||||
@@ -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: {}
|
||||||
|
})
|
||||||
@@ -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: {}
|
||||||
|
})
|
||||||
@@ -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: {}
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 || '导出压缩包失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 : '测试连接失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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 : '保存配置失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('学生信息不存在')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Vendored
+303
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -0,0 +1 @@
|
|||||||
|
@import './base.css';
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ModelSettingsForm } from '@renderer/components/settings/ModelSettingsForm'
|
||||||
|
|
||||||
|
export function SettingsPage(): React.JSX.Element {
|
||||||
|
return <ModelSettingsForm />
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
管理报告模板文件,支持 PPT、Word、Excel。
|
||||||
|
</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 />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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 []
|
||||||
|
}
|
||||||
@@ -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: '排队中' }
|
||||||
|
]
|
||||||
@@ -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
Reference in New Issue
Block a user