feat: improve report generation workflow

This commit is contained in:
2026-06-26 00:11:29 +08:00
parent 1c5841833f
commit 0b2a8b0190
27 changed files with 2662 additions and 717 deletions
+2
View File
@@ -34,9 +34,11 @@
"better-sqlite3": "^12.11.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"electron-log": "^5.4.4",
"electron-updater": "^6.3.9",
"jszip": "^3.10.1",
"lucide-react": "^1.21.0",
"node-pptx-templater": "^1.1.8",
"react-router-dom": "^7.18.0",
"reflect-metadata": "^0.2.2",
"sonner": "^2.0.7",
+198
View File
@@ -47,6 +47,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
electron-log:
specifier: ^5.4.4
version: 5.4.4
electron-updater:
specifier: ^6.3.9
version: 6.8.9
@@ -56,6 +59,9 @@ importers:
lucide-react:
specifier: ^1.21.0
version: 1.21.0(react@19.2.7)
node-pptx-templater:
specifier: ^1.1.8
version: 1.1.8
react-router-dom:
specifier: ^7.18.0
version: 7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -750,6 +756,9 @@ packages:
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
engines: {node: '>= 20.19.0'}
'@nodable/entities@2.2.0':
resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==}
'@peculiar/asn1-schema@2.8.0':
resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==}
@@ -1540,6 +1549,9 @@ packages:
resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==}
engines: {node: '>=14'}
anynum@1.0.1:
resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==}
app-builder-lib@26.15.3:
resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==}
engines: {node: '>=14.0.0'}
@@ -1640,6 +1652,9 @@ packages:
bl@2.2.0:
resolution: {integrity: sha512-wbgvOpqopSr7uq6fJrLH8EsvYMJf9gzfo2jCsL2eTy75qXPukA4pCgHamOQkZtY5vmfVtjB+P3LNlMHW5CEZXA==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
bluebird@3.7.2:
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
@@ -1668,6 +1683,9 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
@@ -1743,6 +1761,14 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
cli-cursor@3.1.0:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'}
cli-spinners@2.5.0:
resolution: {integrity: sha512-PC+AmIuK04E6aeSs/pUccSujsTzBhu4HzC2dL+CfJB/Jcc2qTRbEwZQDfIUpt2Xl8BodYBEq8w4fc0kU2I9DjQ==}
engines: {node: '>=6'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -1750,6 +1776,10 @@ packages:
clone-response@1.0.3:
resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==}
clone@1.0.4:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1769,6 +1799,10 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@12.0.0:
resolution: {integrity: sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==}
engines: {node: '>=18'}
commander@5.0.0:
resolution: {integrity: sha512-JrDGPAKjMGSP1G0DUoaceEJ3DZgAfr/q6X7FVk4+U5KxUSKviYGM2k6zWkfyyBHy5rAtzgYJFa1ro2O9PtoxwQ==}
engines: {node: '>= 6'}
@@ -1855,6 +1889,9 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
defaults@1.0.4:
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
defer-to-connect@2.0.1:
resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==}
engines: {node: '>=10'}
@@ -1926,6 +1963,10 @@ packages:
engines: {node: '>=14.0.0'}
hasBin: true
electron-log@5.4.4:
resolution: {integrity: sha512-istWgaXjBfURBSS8LWVW9C3jsc6+ac+tY1lXrQEOTp0lVj+a4OlO1Tmqb36GgnEUDv92DGC9VI1HNXwJinWpgA==}
engines: {node: '>= 14'}
electron-publish@26.15.3:
resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==}
@@ -2146,6 +2187,13 @@ packages:
fast-uri@3.1.2:
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
fast-xml-builder@1.2.0:
resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
fast-xml-parser@5.9.3:
resolution: {integrity: sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==}
hasBin: true
fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
@@ -2468,6 +2516,10 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-interactive@1.0.0:
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
engines: {node: '>=8'}
is-map@2.0.3:
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
engines: {node: '>= 0.4'}
@@ -2504,6 +2556,13 @@ packages:
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
engines: {node: '>= 0.4'}
is-unicode-supported@0.1.0:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'}
is-unsafe@1.0.1:
resolution: {integrity: sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==}
is-weakmap@2.0.2:
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
engines: {node: '>= 0.4'}
@@ -2713,6 +2772,10 @@ packages:
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
log-symbols@4.1.0:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@@ -2761,6 +2824,10 @@ packages:
engines: {node: '>=4.0.0'}
hasBin: true
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
mimic-response@1.0.1:
resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
engines: {node: '>=4'}
@@ -2839,6 +2906,11 @@ packages:
node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
node-pptx-templater@1.1.8:
resolution: {integrity: sha512-BN5DWSlTQQCJTwV8fLhMc+J3pM1aeKb1yOVzmKhIYaVYUELRr5SYHYNDP0P5FhgZCWEFKUKgehHxSeMYmHis2A==}
engines: {node: '>=18.0.0'}
hasBin: true
node-releases@2.0.48:
resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==}
engines: {node: '>=18'}
@@ -2887,10 +2959,18 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
onetime@5.1.0:
resolution: {integrity: sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==}
engines: {node: '>=6'}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
ora@5.4.1:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'}
own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
@@ -2929,6 +3009,10 @@ packages:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-expression-matcher@1.6.1:
resolution: {integrity: sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==}
engines: {node: '>=14.0.0'}
path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
@@ -3164,6 +3248,10 @@ packages:
responselike@2.0.1:
resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==}
restore-cursor@3.1.0:
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
engines: {node: '>=8'}
retry@0.12.0:
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
engines: {node: '>= 4'}
@@ -3394,6 +3482,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strnum@2.4.1:
resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==}
sumchecker@3.0.1:
resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==}
engines: {node: '>= 8.0'}
@@ -3679,6 +3770,9 @@ packages:
yaml:
optional: true
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
webcrypto-core@1.9.2:
resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==}
@@ -3741,6 +3835,10 @@ packages:
engines: {node: '>=0.8'}
hasBin: true
xml-naming@0.1.0:
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
engines: {node: '>=16.0.0'}
xmlbuilder@15.1.1:
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
engines: {node: '>=8.0'}
@@ -4335,6 +4433,8 @@ snapshots:
'@noble/hashes@2.2.0': {}
'@nodable/entities@2.2.0': {}
'@peculiar/asn1-schema@2.8.0':
dependencies:
'@peculiar/utils': 2.0.3
@@ -5072,6 +5172,8 @@ snapshots:
ansis@4.3.1: {}
anynum@1.0.1: {}
app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3):
dependencies:
'@electron/asar': 3.4.1
@@ -5229,6 +5331,12 @@ snapshots:
readable-stream: 2.3.8
safe-buffer: 5.1.2
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
bluebird@3.7.2: {}
boolean@3.2.0:
@@ -5259,6 +5367,11 @@ snapshots:
buffer-from@1.1.2: {}
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
@@ -5351,6 +5464,12 @@ snapshots:
dependencies:
clsx: 2.1.1
cli-cursor@3.1.0:
dependencies:
restore-cursor: 3.1.0
cli-spinners@2.5.0: {}
cliui@8.0.1:
dependencies:
string-width: 4.2.0
@@ -5361,6 +5480,8 @@ snapshots:
dependencies:
mimic-response: 1.0.1
clone@1.0.4: {}
clsx@2.1.1: {}
codepage@1.15.0: {}
@@ -5375,6 +5496,8 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
commander@12.0.0: {}
commander@5.0.0: {}
commander@9.4.0:
@@ -5443,6 +5566,10 @@ snapshots:
deep-is@0.1.4: {}
defaults@1.0.4:
dependencies:
clone: 1.0.4
defer-to-connect@2.0.1: {}
define-data-property@1.1.4:
@@ -5534,6 +5661,8 @@ snapshots:
- electron-builder-squirrel-windows
- supports-color
electron-log@5.4.4: {}
electron-publish@26.15.3:
dependencies:
'@types/fs-extra': 9.0.13
@@ -5934,6 +6063,20 @@ snapshots:
fast-uri@3.1.2: {}
fast-xml-builder@1.2.0:
dependencies:
path-expression-matcher: 1.6.1
xml-naming: 0.1.0
fast-xml-parser@5.9.3:
dependencies:
'@nodable/entities': 2.2.0
fast-xml-builder: 1.2.0
is-unsafe: 1.0.1
path-expression-matcher: 1.6.1
strnum: 2.4.1
xml-naming: 0.1.0
fd-slicer@1.1.0:
dependencies:
pend: 1.2.0
@@ -6303,6 +6446,8 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-interactive@1.0.0: {}
is-map@2.0.3: {}
is-negative-zero@2.0.3: {}
@@ -6340,6 +6485,10 @@ snapshots:
dependencies:
which-typed-array: 1.1.22
is-unicode-supported@0.1.0: {}
is-unsafe@1.0.1: {}
is-weakmap@2.0.2: {}
is-weakref@1.1.1:
@@ -6516,6 +6665,11 @@ snapshots:
lodash@4.18.1: {}
log-symbols@4.1.0:
dependencies:
chalk: 4.1.2
is-unicode-supported: 0.1.0
loose-envify@1.4.0:
dependencies:
js-tokens: 3.0.0
@@ -6555,6 +6709,8 @@ snapshots:
mime@2.6.0: {}
mimic-fn@2.1.0: {}
mimic-response@1.0.1: {}
mimic-response@3.1.0: {}
@@ -6631,6 +6787,15 @@ snapshots:
node-int64@0.4.0: {}
node-pptx-templater@1.1.8:
dependencies:
chalk: 4.1.2
commander: 12.0.0
fast-xml-parser: 5.9.3
fs-extra: 11.3.5
jszip: 3.10.1
ora: 5.4.1
node-releases@2.0.48: {}
nopt@9.0.0:
@@ -6685,6 +6850,10 @@ snapshots:
dependencies:
wrappy: 1.0.2
onetime@5.1.0:
dependencies:
mimic-fn: 2.1.0
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -6694,6 +6863,18 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
ora@5.4.1:
dependencies:
bl: 4.1.0
chalk: 4.1.2
cli-cursor: 3.1.0
cli-spinners: 2.5.0
is-interactive: 1.0.0
is-unicode-supported: 0.1.0
log-symbols: 4.1.0
strip-ansi: 6.0.1
wcwidth: 1.0.1
own-keys@1.0.1:
dependencies:
get-intrinsic: 1.3.0
@@ -6726,6 +6907,8 @@ snapshots:
path-exists@4.0.0: {}
path-expression-matcher@1.6.1: {}
path-is-absolute@1.0.1: {}
path-key@3.1.1: {}
@@ -6974,6 +7157,11 @@ snapshots:
dependencies:
lowercase-keys: 2.0.0
restore-cursor@3.1.0:
dependencies:
onetime: 5.1.0
signal-exit: 3.0.7
retry@0.12.0: {}
rimraf@2.6.2:
@@ -7265,6 +7453,10 @@ snapshots:
strip-json-comments@3.1.1: {}
strnum@2.4.1:
dependencies:
anynum: 1.0.1
sumchecker@3.0.1:
dependencies:
debug: 4.4.3
@@ -7515,6 +7707,10 @@ snapshots:
jiti: 2.7.0
lightningcss: 1.32.0
wcwidth@1.0.1:
dependencies:
defaults: 1.0.4
webcrypto-core@1.9.2:
dependencies:
'@peculiar/asn1-schema': 2.8.0
@@ -7606,6 +7802,8 @@ snapshots:
wmf: 1.0.2
word: 0.3.0
xml-naming@0.1.0: {}
xmlbuilder@15.1.1: {}
y18n@5.0.8: {}
+12
View File
@@ -4,6 +4,8 @@ export type ClassEntity = {
id: string
name: string
type: string
teacherName: string | null
teacherNames: string
familyPhoto: string | null
}
@@ -22,6 +24,16 @@ export const ClassEntitySchema = new EntitySchema<ClassEntity>({
type: String,
default: 'cheap'
},
teacherNames: {
name: 'teacher_names',
type: String,
default: ''
},
teacherName: {
name: 'teacher_name',
type: String,
nullable: true
},
familyPhoto: {
name: 'family_photo',
type: String,
+24
View File
@@ -16,6 +16,10 @@ export type StudentProfileEntity = {
traits: string
comment: string
commentGeneratedAt: string
reportGenerated: boolean
meImage: string
workImage1: string
workImage2: string
importedAt: string
}
@@ -87,6 +91,26 @@ export const StudentProfileEntitySchema = new EntitySchema<StudentProfileEntity>
type: String,
default: ''
},
reportGenerated: {
name: 'report_generated',
type: Boolean,
default: false
},
meImage: {
name: 'me_image',
type: String,
default: ''
},
workImage1: {
name: 'work_image_1',
type: String,
default: ''
},
workImage2: {
name: 'work_image_2',
type: String,
default: ''
},
importedAt: {
name: 'imported_at',
type: String,
+9
View File
@@ -4,6 +4,7 @@ import { mkdirSync } from 'fs'
import { join } from 'path'
import { registerIpcHandlers } from './ipc'
import { getLogger, initializeLogger } from './services/loggerService'
import { createMainWindow } from './windows/mainWindow'
if (!app.isPackaged) {
@@ -16,6 +17,14 @@ if (!app.isPackaged) {
app.setPath('cache', devCachePath)
}
initializeLogger()
const logger = getLogger('app')
logger.info('应用启动', {
userData: app.getPath('userData'),
isPackaged: app.isPackaged
})
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.electron')
+66 -1
View File
@@ -1,8 +1,18 @@
import { ipcMain } from 'electron'
import { deleteReport, generateReports, loadReports, openReport } from '../services/reportService'
import {
deleteReport,
downloadClassReports,
downloadReport,
generateReports,
loadReports,
openReport
} from '../services/reportService'
import type {
DeleteReportResponse,
DownloadClassReportsInput,
DownloadClassReportsResponse,
DownloadReportResponse,
GenerateReportsInput,
GenerateReportsResponse,
LoadReportsResponse,
@@ -59,6 +69,61 @@ export function registerReportIpc(): void {
}
})
ipcMain.handle(
'reports:download',
async (_, id: string): Promise<DownloadReportResponse> => {
try {
const result = await downloadReport(id)
if (result.canceled) {
return {
ok: false,
canceled: true,
message: '已取消下载'
}
}
return {
ok: true,
filePath: result.filePath
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
ipcMain.handle(
'reports:download-class',
async (_, payload: DownloadClassReportsInput): Promise<DownloadClassReportsResponse> => {
try {
const result = await downloadClassReports(payload)
if (result.canceled) {
return {
ok: false,
canceled: true,
message: '已取消下载'
}
}
return {
ok: true,
filePath: result.filePath,
reportCount: result.reportCount
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
ipcMain.handle('reports:delete', async (_, id: string): Promise<DeleteReportResponse> => {
try {
await deleteReport(id)
+27 -2
View File
@@ -3,9 +3,11 @@ import { ipcMain } from 'electron'
import {
deleteClassWithProfiles,
deleteStudentProfile,
getStudentProfile,
generateStudentComment,
listStudentProfiles,
loadStudentData,
migrateStoredImagesToFiles,
migrateLocalStudentData,
replaceProfilesForClass,
saveClasses,
@@ -18,6 +20,8 @@ import type {
DeleteStudentProfileResponse,
GenerateStudentCommentInput,
GenerateStudentCommentResponse,
GetStudentProfileResponse,
LoadStudentDataInput,
ListStudentProfilesInput,
ListStudentProfilesResponse,
LoadStudentDataResponse,
@@ -32,9 +36,13 @@ function getErrorMessage(error: unknown): string {
}
export function registerStudentIpc(): void {
ipcMain.handle('student:load', async (): Promise<LoadStudentDataResponse> => {
void migrateStoredImagesToFiles().catch((error) => {
console.error('[图片迁移] 迁移 SQLite 内图片到文件失败', error)
})
ipcMain.handle('student:load', async (_, payload?: LoadStudentDataInput): Promise<LoadStudentDataResponse> => {
try {
const studentData = await loadStudentData()
const studentData = await loadStudentData(payload)
return {
ok: true,
...studentData
@@ -64,6 +72,23 @@ export function registerStudentIpc(): void {
}
)
ipcMain.handle(
'student:get-profile',
async (_, profileId: string): Promise<GetStudentProfileResponse> => {
try {
return {
ok: true,
profile: await getStudentProfile(profileId)
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
ipcMain.handle(
'student:update-profile',
async (_, payload: UpdateStudentProfileInput): Promise<UpdateStudentProfileResponse> => {
+24 -7
View File
@@ -8,10 +8,13 @@ import { ClassEntitySchema } from '../entities/ClassEntity'
import { StudentProfileEntitySchema } from '../entities/StudentProfileEntity'
import { TemplateEntitySchema } from '../entities/TemplateEntity'
import { ReportEntitySchema } from '../entities/ReportEntity'
import { getLogger } from './loggerService'
const DATABASE_FILENAME = 'growth-report.sqlite'
const logger = getLogger('database')
let dataSource: DataSource | null = null
let dataSourcePromise: Promise<DataSource> | null = null
function getDatabasePath(): string {
return join(app.getPath('userData'), DATABASE_FILENAME)
@@ -22,7 +25,11 @@ export async function getAppDataSource(): Promise<DataSource> {
return dataSource
}
dataSource = new DataSource({
if (dataSourcePromise) {
return dataSourcePromise
}
const nextDataSource = new DataSource({
type: 'better-sqlite3',
database: getDatabasePath(),
entities: [
@@ -40,10 +47,20 @@ export async function getAppDataSource(): Promise<DataSource> {
}
})
await dataSource.initialize()
console.info(
'[数据库] 已注册实体',
dataSource.entityMetadatas.map((metadata) => metadata.name)
)
return dataSource
dataSourcePromise = nextDataSource
.initialize()
.then((initializedDataSource) => {
dataSource = initializedDataSource
logger.info('数据库初始化完成', {
databasePath: getDatabasePath(),
entities: initializedDataSource.entityMetadatas.map((metadata) => metadata.name)
})
return initializedDataSource
})
.catch((error) => {
dataSourcePromise = null
throw error
})
return dataSourcePromise
}
+131
View File
@@ -0,0 +1,131 @@
import { mkdir, readFile, writeFile } from 'fs/promises'
import { dirname, extname, join } from 'path'
import { app } from 'electron'
const IMAGE_FOLDER_NAME = 'images'
type ImageScope = 'classes' | 'students'
const MIME_EXTENSION_MAP: Record<string, string> = {
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/bmp': 'bmp',
'image/gif': 'gif'
}
const EXTENSION_MIME_MAP: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
bmp: 'image/bmp',
gif: 'image/gif'
}
function parseDataUrl(value: string): { mimeType: string; extension: string; buffer: Buffer } | null {
const match = value.match(/^data:(image\/[^;]+);base64,(.+)$/)
if (!match) {
return null
}
const mimeType = match[1].toLowerCase()
const extension = MIME_EXTENSION_MAP[mimeType] ?? 'jpg'
return {
mimeType,
extension,
buffer: Buffer.from(match[2], 'base64')
}
}
function sanitizePathPart(value: string): string {
return (
[...value]
.filter((character) => !'<>:"/\\|?*'.includes(character) && character.charCodeAt(0) >= 32)
.join('')
.trim() || 'unknown'
)
}
function getRelativeImagePath(
scope: ImageScope,
ownerId: string,
fieldName: string,
extension: string
): string {
return [
IMAGE_FOLDER_NAME,
scope,
sanitizePathPart(ownerId),
`${sanitizePathPart(fieldName)}.${extension}`
].join('/')
}
function resolveImagePath(value: string): string {
return join(app.getPath('userData'), ...value.split('/').filter(Boolean))
}
function getMimeTypeFromPath(value: string): string {
const extension = extname(value).replace(/^\./, '').toLowerCase()
return EXTENSION_MIME_MAP[extension] ?? 'image/jpeg'
}
export function isDataUrlImage(value?: string | null): boolean {
return Boolean(value?.startsWith('data:image/'))
}
export async function storeImageValue(
scope: ImageScope,
ownerId: string,
fieldName: string,
value?: string | null
): Promise<string> {
if (!value) {
return ''
}
const parsed = parseDataUrl(value)
if (!parsed) {
return value
}
const relativePath = getRelativeImagePath(scope, ownerId, fieldName, parsed.extension)
const absolutePath = resolveImagePath(relativePath)
await mkdir(dirname(absolutePath), { recursive: true })
await writeFile(absolutePath, parsed.buffer)
return relativePath
}
export async function readImageAsDataUrl(value?: string | null): Promise<string> {
if (!value) {
return ''
}
if (isDataUrlImage(value)) {
return value
}
const buffer = await readFile(resolveImagePath(value))
return `data:${getMimeTypeFromPath(value)};base64,${buffer.toString('base64')}`
}
export async function readImageBuffer(value?: string | null): Promise<Buffer | null> {
if (!value) {
return null
}
const parsed = parseDataUrl(value)
if (parsed) {
return parsed.buffer
}
return readFile(resolveImagePath(value))
}
+12
View File
@@ -0,0 +1,12 @@
import log from 'electron-log/main'
export function initializeLogger(): void {
log.initialize()
log.transports.file.level = false
log.transports.console.level = 'debug'
log.transports.console.format = '[{h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}'
}
export function getLogger(scope: string): ReturnType<typeof log.scope> {
return log.scope(scope)
}
+4 -2
View File
@@ -4,8 +4,10 @@ import type {
ModelListResponse,
TestModelConnectionResponse
} from '../types/model'
import { getLogger } from './loggerService'
const DEFAULT_TEST_PROMPT = '请回复“连接成功”。'
const logger = getLogger('model')
function normalizeBaseUrl(baseUrl: string): string {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, '')
@@ -68,7 +70,7 @@ export function logLargeModelRequest({
model: string
prompt: string
}): void {
console.info(`[大模型请求] ${label}`, {
logger.info(`[大模型请求] ${label}`, {
requestUrl: url,
apiKey: maskApiKey(apiKey),
modelId: model,
@@ -85,7 +87,7 @@ export async function listModels(payload: ListModelsRequest): Promise<ModelListR
return { ok: false, message: '请先填写 API Key' }
}
console.info('[大模型请求] 获取模型列表', {
logger.info('[大模型请求] 获取模型列表', {
requestUrl: getModelsUrl(payload.baseUrl),
apiKey: maskApiKey(payload.apiKey)
})
File diff suppressed because it is too large Load Diff
+206 -20
View File
@@ -7,8 +7,14 @@ import {
type StudentProfileEntity
} from '../entities/StudentProfileEntity'
import { ClassEntitySchema, type ClassEntity } from '../entities/ClassEntity'
import type { ChildProfile, ClassProfile, ListStudentProfilesInput } from '../types/student'
import type {
ChildProfile,
ClassProfile,
ListStudentProfilesInput,
LoadStudentDataInput
} from '../types/student'
import { getAppDataSource } from './databaseService'
import { isDataUrlImage, readImageAsDataUrl, storeImageValue } from './imageStorageService'
import { getChatCompletionsUrl, logLargeModelRequest } from './modelService'
import { loadSettings } from './settingsService'
@@ -29,16 +35,94 @@ function normalizeClassType(type: string): 'cheap' | 'noble' {
return type === 'noble' ? 'noble' : 'cheap'
}
function parseTeacherNames(value: string): string[] {
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
return parsed
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean)
}
} catch {
// Older data may contain a single teacher name instead of a JSON array.
}
return value.trim() ? [value.trim()] : []
}
function stringifyTeacherNames(teacherNames: string[]): string {
return JSON.stringify(
teacherNames.map((teacherName) => teacherName.trim()).filter((teacherName) => teacherName)
)
}
async function normalizeStoredClassImages(entity: ClassEntity): Promise<ClassEntity> {
if (!isDataUrlImage(entity.familyPhoto)) {
return entity
}
return {
...entity,
familyPhoto: await storeImageValue('classes', entity.id, 'familyPhoto', entity.familyPhoto)
}
}
async function hydrateClassImages(entity: ClassEntity): Promise<ClassEntity> {
if (!entity.familyPhoto) {
return entity
}
return {
...entity,
familyPhoto: await readImageAsDataUrl(entity.familyPhoto)
}
}
async function normalizeStoredStudentImages(
entity: StudentProfileEntity
): Promise<StudentProfileEntity> {
const [meImage, workImage1, workImage2] = await Promise.all([
storeImageValue('students', entity.id, 'meImage', entity.meImage),
storeImageValue('students', entity.id, 'workImage1', entity.workImage1),
storeImageValue('students', entity.id, 'workImage2', entity.workImage2)
])
return {
...entity,
meImage,
workImage1,
workImage2
}
}
async function hydrateStudentImages(entity: StudentProfileEntity): Promise<StudentProfileEntity> {
const [meImage, workImage1, workImage2] = await Promise.all([
readImageAsDataUrl(entity.meImage),
readImageAsDataUrl(entity.workImage1),
readImageAsDataUrl(entity.workImage2)
])
return {
...entity,
meImage,
workImage1,
workImage2
}
}
function mapClassEntity(entity: ClassEntity): ClassProfile {
return {
id: entity.id,
name: entity.name,
type: normalizeClassType(entity.type),
teacherNames: parseTeacherNames(entity.teacherNames || entity.teacherName || ''),
familyPhoto: entity.familyPhoto ?? undefined
}
}
function mapChildEntity(entity: StudentProfileEntity): ChildProfile {
function mapChildEntity(entity: StudentProfileEntity, options: { includeImages?: boolean } = {}): ChildProfile {
return {
id: entity.id,
classId: entity.classId,
@@ -55,21 +139,30 @@ function mapChildEntity(entity: StudentProfileEntity): ChildProfile {
traits: entity.traits,
comment: entity.comment ?? '',
commentGeneratedAt: entity.commentGeneratedAt ?? '',
reportGenerated: Boolean(entity.reportGenerated),
meImage: options.includeImages === false ? '' : (entity.meImage ?? ''),
workImage1: options.includeImages === false ? '' : (entity.workImage1 ?? ''),
workImage2: options.includeImages === false ? '' : (entity.workImage2 ?? ''),
importedAt: entity.importedAt
}
}
function mapClassProfile(profile: ClassProfile): ClassEntity {
async function mapClassProfile(profile: ClassProfile): Promise<ClassEntity> {
return {
id: profile.id,
name: profile.name,
type: profile.type,
familyPhoto: profile.familyPhoto ?? null
teacherName: (profile.teacherNames ?? []).join(' ') || null,
teacherNames: stringifyTeacherNames(profile.teacherNames ?? []),
familyPhoto: (await storeImageValue('classes', profile.id, 'familyPhoto', profile.familyPhoto)) || null
}
}
function mapChildProfile(profile: ChildProfile, classItem?: ClassProfile): StudentProfileEntity {
return {
async function mapChildProfile(
profile: ChildProfile,
classItem?: ClassProfile
): Promise<StudentProfileEntity> {
return normalizeStoredStudentImages({
id: profile.id,
classId: classItem?.id ?? profile.classId,
className: classItem?.name ?? profile.className,
@@ -85,8 +178,12 @@ function mapChildProfile(profile: ChildProfile, classItem?: ClassProfile): Stude
traits: profile.traits,
comment: profile.comment ?? '',
commentGeneratedAt: profile.commentGeneratedAt ?? '',
reportGenerated: profile.reportGenerated ?? false,
meImage: profile.meImage ?? '',
workImage1: profile.workImage1 ?? '',
workImage2: profile.workImage2 ?? '',
importedAt: profile.importedAt
}
})
}
function buildStudentCommentPrompt(profile: ChildProfile): string {
@@ -120,25 +217,60 @@ async function getRepositories(): Promise<{
}
}
export async function loadStudentData(): Promise<{
export async function loadStudentData(input: LoadStudentDataInput = {}): Promise<{
classes: ClassProfile[]
profiles: ChildProfile[]
}> {
const includeProfiles = input.includeProfiles ?? true
const includeImages = input.includeImages ?? true
const { classRepository, childRepository } = await getRepositories()
const classes = await classRepository.find({
order: {
id: 'ASC'
}
})
const profiles = await childRepository.find({
const profiles = includeProfiles
? await childRepository.find({
order: {
id: 'ASC'
}
})
: []
const normalizedClasses = await Promise.all(classes.map(normalizeStoredClassImages))
const normalizedProfiles = includeImages
? await Promise.all(profiles.map(normalizeStoredStudentImages))
: profiles
const hydratedProfiles = includeImages
? await Promise.all(normalizedProfiles.map(hydrateStudentImages))
: normalizedProfiles
for (const normalizedClass of normalizedClasses) {
const originalClass = classes.find((classItem) => classItem.id === normalizedClass.id)
if (originalClass && normalizedClass.familyPhoto !== originalClass.familyPhoto) {
await classRepository.save(normalizedClass)
}
}
if (includeImages) {
for (const normalizedProfile of normalizedProfiles) {
const originalProfile = profiles.find((profile) => profile.id === normalizedProfile.id)
if (
originalProfile &&
(normalizedProfile.meImage !== originalProfile.meImage ||
normalizedProfile.workImage1 !== originalProfile.workImage1 ||
normalizedProfile.workImage2 !== originalProfile.workImage2)
) {
await childRepository.save(normalizedProfile)
}
}
}
return {
classes: classes.map(mapClassEntity),
profiles: profiles.map(mapChildEntity)
classes: (await Promise.all(normalizedClasses.map(hydrateClassImages))).map(mapClassEntity),
profiles: hydratedProfiles.map((profile) => mapChildEntity(profile, { includeImages }))
}
}
@@ -188,13 +320,35 @@ export async function listStudentProfiles(input: ListStudentProfilesInput): Prom
.getManyAndCount()
return {
profiles: profiles.map(mapChildEntity),
profiles: profiles.map((profile) => mapChildEntity(profile, { includeImages: false })),
total,
page,
pageSize
}
}
export async function getStudentProfile(profileId: string): Promise<ChildProfile> {
const source = await getAppDataSource()
const repository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
const profile = await repository.findOneBy({ id: profileId })
if (!profile) {
throw new Error('学生信息不存在')
}
const normalizedProfile = await normalizeStoredStudentImages(profile)
if (
normalizedProfile.meImage !== profile.meImage ||
normalizedProfile.workImage1 !== profile.workImage1 ||
normalizedProfile.workImage2 !== profile.workImage2
) {
await repository.save(normalizedProfile)
}
return mapChildEntity(await hydrateStudentImages(normalizedProfile))
}
export async function updateStudentProfile(profile: ChildProfile): Promise<ChildProfile> {
const source = await getAppDataSource()
const repository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
@@ -210,8 +364,9 @@ export async function updateStudentProfile(profile: ChildProfile): Promise<Child
commentGeneratedAt: profile.commentGeneratedAt ?? existingProfile.commentGeneratedAt ?? ''
}
await repository.save(mapChildProfile(nextProfile))
return nextProfile
const storedProfile = await mapChildProfile(nextProfile)
await repository.save(storedProfile)
return mapChildEntity(await hydrateStudentImages(storedProfile))
}
export async function generateStudentComment(profileId: string): Promise<ChildProfile> {
@@ -291,8 +446,9 @@ export async function generateStudentComment(profileId: string): Promise<ChildPr
commentGeneratedAt: new Date().toLocaleString('zh-CN', { hour12: false })
}
await repository.save(mapChildProfile(nextProfile))
return nextProfile
const storedProfile = await mapChildProfile(nextProfile)
await repository.save(storedProfile)
return mapChildEntity(await hydrateStudentImages(storedProfile))
}
export async function saveClasses(classes: ClassProfile[]): Promise<void> {
@@ -312,7 +468,7 @@ export async function saveClasses(classes: ClassProfile[]): Promise<void> {
}
for (const classItem of classes) {
await classRepository.save(mapClassProfile(classItem))
await classRepository.save(await mapClassProfile(classItem))
await childRepository.update({ classId: classItem.id }, { className: classItem.name })
}
})
@@ -334,8 +490,8 @@ export async function migrateLocalStudentData(
return
}
await classRepository.save(classes.map(mapClassProfile))
await childRepository.save(profiles.map((profile) => mapChildProfile(profile)))
await classRepository.save(await Promise.all(classes.map(mapClassProfile)))
await childRepository.save(await Promise.all(profiles.map((profile) => mapChildProfile(profile))))
})
}
@@ -349,7 +505,9 @@ export async function replaceProfilesForClass(
const childRepository = manager.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
await childRepository.delete({ classId: classItem.id })
await childRepository.save(profiles.map((profile) => mapChildProfile(profile, classItem)))
await childRepository.save(
await Promise.all(profiles.map((profile) => mapChildProfile(profile, classItem)))
)
})
}
@@ -372,3 +530,31 @@ export async function deleteStudentProfile(profileId: string): Promise<void> {
throw new Error('学生信息不存在')
}
}
export async function migrateStoredImagesToFiles(): Promise<void> {
const source = await getAppDataSource()
const classRepository = source.getRepository<ClassEntity>(ClassEntitySchema)
const childRepository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
const classes = await classRepository.find()
const profiles = await childRepository.find()
for (const classItem of classes) {
const nextClassItem = await normalizeStoredClassImages(classItem)
if (nextClassItem.familyPhoto !== classItem.familyPhoto) {
await classRepository.save(nextClassItem)
}
}
for (const profile of profiles) {
const nextProfile = await normalizeStoredStudentImages(profile)
if (
nextProfile.meImage !== profile.meImage ||
nextProfile.workImage1 !== profile.workImage1 ||
nextProfile.workImage2 !== profile.workImage2
) {
await childRepository.save(nextProfile)
}
}
}
+44 -36
View File
@@ -9,7 +9,6 @@ import type { Repository } from 'typeorm'
import { TemplateEntitySchema, type TemplateEntity } from '../entities/TemplateEntity'
import type {
TemplateFieldMapping,
TemplateInput,
TemplateItem,
TemplatePlaceholder,
@@ -33,8 +32,15 @@ const BUILT_IN_PPT_PLACEHOLDERS = new Set([
'hobby',
'game',
'food',
'class_image'
'class_image',
'meImage',
'me_image',
'workImage1',
'work_image_1',
'workImage2',
'work_image_2'
])
const BUILT_IN_PPT_IMAGE_PLACEHOLDERS = ['class_image', 'meImage', 'workImage1', 'workImage2']
function getTemplateStoragePath(): string {
return join(app.getPath('userData'), TEMPLATE_FOLDER_NAME)
@@ -82,23 +88,6 @@ function parseStoredPlaceholders(value: string): TemplatePlaceholder[] {
}
}
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,
@@ -110,7 +99,6 @@ function mapTemplateEntity(entity: TemplateEntity): TemplateItem {
filePath: resolveTemplateFilePath(entity.filePath),
fileExtension: entity.fileExtension,
placeholders: parseStoredPlaceholders(entity.placeholders),
fieldMappings: parseStoredFieldMappings(entity.fieldMappings),
createdAt: entity.createdAt,
updatedAt: entity.updatedAt
}
@@ -161,6 +149,25 @@ function isPictureShape(slideXml: string, shapeNameIndex: number): boolean {
)
}
function getXmlAttributeValue(xml: string, attributeName: string): string | null {
const match = xml.match(new RegExp(`${attributeName}="([^"]*)"`))
return match ? decodeXml(match[1]) : null
}
function normalizePlaceholderName(name: string): string {
return decodeXml(name).trim().replace(/^field[:_-]/i, '')
}
function getKnownPlaceholderName(name: string): string | null {
const placeholderName = normalizePlaceholderName(name)
if (BUILT_IN_PPT_PLACEHOLDERS.has(name) || BUILT_IN_PPT_PLACEHOLDERS.has(placeholderName)) {
return placeholderName
}
return null
}
function upsertPlaceholder(
placeholdersByName: Map<string, TemplatePlaceholder>,
name: string,
@@ -195,17 +202,24 @@ async function parsePptxPlaceholders(filePath: string): Promise<TemplatePlacehol
const slideXml = await slideFile.async('text')
for (const shapeMatch of slideXml.matchAll(/<p:cNvPr[^>]*name="([^"]+)"/g)) {
const rawName = shapeMatch[1]
const name = decodeXml(rawName)
for (const shapeMatch of slideXml.matchAll(/<p:cNvPr[^>]*>/g)) {
const shapeXml = shapeMatch[0]
const candidateNames = [
getXmlAttributeValue(shapeXml, 'name'),
getXmlAttributeValue(shapeXml, 'descr'),
getXmlAttributeValue(shapeXml, 'title')
].filter((value): value is string => Boolean(value))
const placeholderName = candidateNames
.map(getKnownPlaceholderName)
.find((value): value is string => Boolean(value))
if (!BUILT_IN_PPT_PLACEHOLDERS.has(name) && !/^field[:_-]/i.test(name)) {
if (!placeholderName) {
continue
}
upsertPlaceholder(
placeholdersByName,
name.replace(/^field[:_-]/i, ''),
placeholderName,
isPictureShape(slideXml, shapeMatch.index ?? 0) ? 'image' : 'text'
)
}
@@ -215,6 +229,10 @@ async function parsePptxPlaceholders(filePath: string): Promise<TemplatePlacehol
}
}
for (const placeholderName of BUILT_IN_PPT_IMAGE_PLACEHOLDERS) {
upsertPlaceholder(placeholdersByName, placeholderName, 'image')
}
return [...placeholdersByName.values()].sort((first, second) =>
first.name.localeCompare(second.name, 'zh-CN')
)
@@ -309,16 +327,6 @@ export async function saveTemplate(input: TemplateInput): Promise<TemplateItem>
: 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,
@@ -330,7 +338,7 @@ export async function saveTemplate(input: TemplateInput): Promise<TemplateItem>
filePath: nextStoredFilePath,
fileExtension: extension,
placeholders: JSON.stringify(placeholders),
fieldMappings: JSON.stringify(fieldMappings),
fieldMappings: '{}',
createdAt: existingTemplate?.createdAt ?? now,
updatedAt: now
}
+40
View File
@@ -22,6 +22,19 @@ export type GenerateReportsInput = {
studentIds?: string[]
}
export type ReportGenerationProgress = {
batchId: string
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
classId: string
className: string
studentId?: string
studentName?: string
current: number
total: number
report?: ReportItem
error?: string
}
export type LoadReportsResponse =
| {
ok: true
@@ -60,3 +73,30 @@ export type DeleteReportResponse =
ok: false
message: string
}
export type DownloadReportResponse =
| {
ok: true
filePath: string
}
| {
ok: false
canceled?: boolean
message: string
}
export type DownloadClassReportsInput = {
classId: string
}
export type DownloadClassReportsResponse =
| {
ok: true
filePath: string
reportCount: number
}
| {
ok: false
canceled?: boolean
message: string
}
+20
View File
@@ -4,6 +4,7 @@ export type ClassProfile = {
id: string
name: string
type: ClassType
teacherNames: string[]
familyPhoto?: string
}
@@ -23,6 +24,10 @@ export type ChildProfile = {
traits: string
comment: string
commentGeneratedAt: string
reportGenerated: boolean
meImage: string
workImage1: string
workImage2: string
importedAt: string
}
@@ -37,6 +42,11 @@ export type LoadStudentDataResponse =
message: string
}
export type LoadStudentDataInput = {
includeProfiles?: boolean
includeImages?: boolean
}
export type ListStudentProfilesInput = {
page: number
pageSize: number
@@ -69,6 +79,16 @@ export type UpdateStudentProfileResponse =
message: string
}
export type GetStudentProfileResponse =
| {
ok: true
profile: ChildProfile
}
| {
ok: false
message: string
}
export type GenerateStudentCommentInput = {
profileId: string
}
-4
View File
@@ -6,8 +6,6 @@ export type TemplatePlaceholder = {
kind: 'text' | 'image'
}
export type TemplateFieldMapping = Record<string, string>
export type TemplateItem = {
id: string
name: string
@@ -18,7 +16,6 @@ export type TemplateItem = {
filePath: string
fileExtension: string
placeholders: TemplatePlaceholder[]
fieldMappings: TemplateFieldMapping
createdAt: string
updatedAt: string
}
@@ -30,7 +27,6 @@ export type TemplateInput = {
semester: TemplateSemester
type: TemplateType
sourceFilePath: string
fieldMappings?: TemplateFieldMapping
}
export type LoadTemplatesResponse =
+53 -5
View File
@@ -36,6 +36,7 @@ type ClassProfile = {
id: string
name: string
type: ClassType
teacherNames: string[]
familyPhoto?: string
}
@@ -47,8 +48,6 @@ type TemplatePlaceholder = {
kind: 'text' | 'image'
}
type TemplateFieldMapping = Record<string, string>
type TemplateItem = {
id: string
name: string
@@ -59,7 +58,6 @@ type TemplateItem = {
filePath: string
fileExtension: string
placeholders: TemplatePlaceholder[]
fieldMappings: TemplateFieldMapping
createdAt: string
updatedAt: string
}
@@ -82,6 +80,19 @@ type ReportItem = {
updatedAt: string
}
type ReportGenerationProgress = {
batchId: string
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
classId: string
className: string
studentId?: string
studentName?: string
current: number
total: number
report?: ReportItem
error?: string
}
type ChildProfile = {
id: string
classId: string
@@ -98,6 +109,10 @@ type ChildProfile = {
traits: string
comment: string
commentGeneratedAt: string
reportGenerated: boolean
meImage: string
workImage1: string
workImage2: string
importedAt: string
}
@@ -143,6 +158,11 @@ type LoadStudentDataResponse =
message: string
}
type LoadStudentDataInput = {
includeProfiles?: boolean
includeImages?: boolean
}
type ListStudentProfilesResponse =
| {
ok: true
@@ -238,6 +258,29 @@ type GenerateReportsResponse =
message: string
}
type DownloadReportResponse =
| {
ok: true
filePath: string
}
| {
ok: false
canceled?: boolean
message: string
}
type DownloadClassReportsResponse =
| {
ok: true
filePath: string
reportCount: number
}
| {
ok: false
canceled?: boolean
message: string
}
type AppAPI = {
deleteClass: (classId: string) => Promise<BasicMutationResponse>
deleteStudentProfile: (profileId: string) => Promise<BasicMutationResponse>
@@ -246,13 +289,14 @@ type AppAPI = {
classId: string
childNames: string[]
}) => Promise<ExportClassFolderResponse>
loadStudentData: () => Promise<LoadStudentDataResponse>
loadStudentData: (payload?: LoadStudentDataInput) => Promise<LoadStudentDataResponse>
listStudentProfiles: (payload: {
page: number
pageSize: number
query?: string
classId?: string
}) => Promise<ListStudentProfilesResponse>
getStudentProfile: (profileId: string) => Promise<StudentProfileMutationResponse>
updateStudentProfile: (profile: ChildProfile) => Promise<StudentProfileMutationResponse>
generateStudentComment: (payload: {
profileId: string
@@ -281,7 +325,6 @@ type AppAPI = {
semester: TemplateSemester
type: TemplateType
sourceFilePath: string
fieldMappings?: TemplateFieldMapping
}) => Promise<SaveTemplateResponse>
openTemplate: (filePath: string) => Promise<BasicMutationResponse>
deleteTemplate: (id: string) => Promise<BasicMutationResponse>
@@ -292,6 +335,11 @@ type AppAPI = {
studentIds?: string[]
}) => Promise<GenerateReportsResponse>
openReport: (filePath: string) => Promise<BasicMutationResponse>
downloadReport: (id: string) => Promise<DownloadReportResponse>
downloadClassReports: (payload: { classId: string }) => Promise<DownloadClassReportsResponse>
onReportGenerationProgress: (
callback: (progress: ReportGenerationProgress) => void
) => () => void
deleteReport: (id: string) => Promise<BasicMutationResponse>
}
+43 -2
View File
@@ -8,13 +8,15 @@ const api = {
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'),
loadStudentData: (payload?: { includeProfiles?: boolean; includeImages?: boolean }) =>
ipcRenderer.invoke('student:load', payload),
listStudentProfiles: (payload: {
page: number
pageSize: number
query?: string
classId?: string
}) => ipcRenderer.invoke('student:list', payload),
getStudentProfile: (profileId: string) => ipcRenderer.invoke('student:get-profile', profileId),
updateStudentProfile: (profile: {
id: string
classId: string
@@ -31,6 +33,10 @@ const api = {
traits: string
comment: string
commentGeneratedAt: string
reportGenerated: boolean
meImage: string
workImage1: string
workImage2: string
importedAt: string
}) => ipcRenderer.invoke('student:update-profile', profile),
generateStudentComment: (payload: { profileId: string }) =>
@@ -51,6 +57,7 @@ const api = {
id: string
name: string
type: 'cheap' | 'noble'
teacherNames: string[]
familyPhoto?: string
}
profiles: Array<{
@@ -67,6 +74,12 @@ const api = {
favoriteGames: string[]
favoriteFoods: string[]
traits: string
comment?: string
commentGeneratedAt?: string
reportGenerated?: boolean
meImage?: string
workImage1?: string
workImage2?: string
importedAt: string
}>
}) => ipcRenderer.invoke('student:replace-class-profiles', payload),
@@ -75,6 +88,7 @@ const api = {
id: string
name: string
type: 'cheap' | 'noble'
teacherNames: string[]
familyPhoto?: string
}>
profiles: Array<{
@@ -91,6 +105,12 @@ const api = {
favoriteGames: string[]
favoriteFoods: string[]
traits: string
comment?: string
commentGeneratedAt?: string
reportGenerated?: boolean
meImage?: string
workImage1?: string
workImage2?: string
importedAt: string
}>
}) => ipcRenderer.invoke('student:migrate-local', payload),
@@ -99,6 +119,7 @@ const api = {
id: string
name: string
type: 'cheap' | 'noble'
teacherNames: string[]
familyPhoto?: string
}>
) => ipcRenderer.invoke('student:save-classes', classes),
@@ -124,7 +145,6 @@ const api = {
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),
@@ -132,6 +152,27 @@ const api = {
generateReports: (payload: { templateId: string; classId: string; studentIds?: string[] }) =>
ipcRenderer.invoke('reports:generate', payload),
openReport: (filePath: string) => ipcRenderer.invoke('reports:open', filePath),
downloadReport: (id: string) => ipcRenderer.invoke('reports:download', id),
downloadClassReports: (payload: { classId: string }) =>
ipcRenderer.invoke('reports:download-class', payload),
onReportGenerationProgress: (callback: (progress: {
batchId: string
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
classId: string
className: string
studentId?: string
studentName?: string
current: number
total: number
report?: unknown
error?: string
}) => void) => {
const listener = (_: Electron.IpcRendererEvent, progress: Parameters<typeof callback>[0]) =>
callback(progress)
ipcRenderer.on('reports:generation-progress', listener)
return () => ipcRenderer.removeListener('reports:generation-progress', listener)
},
deleteReport: (id: string) => ipcRenderer.invoke('reports:delete', id)
}
+299
View File
@@ -0,0 +1,299 @@
/* eslint-disable react-refresh/only-export-components, react/prop-types */
import * as React from 'react'
import { Check, X } from 'lucide-react'
import { cn } from '@renderer/lib/utils'
type ComboboxContextValue = {
items: readonly string[]
open: boolean
multiple: boolean
values: string[]
inputValue: string
setInputValue: (value: string) => void
setOpen: (open: boolean) => void
selectValue: (value: string) => void
removeValue: (value: string) => void
}
const ComboboxContext = React.createContext<ComboboxContextValue | null>(null)
function useComboboxContext(): ComboboxContextValue {
const context = React.useContext(ComboboxContext)
if (!context) {
throw new Error('Combobox components must be used inside Combobox')
}
return context
}
export function useComboboxAnchor(): React.RefObject<HTMLDivElement | null> {
return React.useRef<HTMLDivElement>(null)
}
type ComboboxProps = {
children: React.ReactNode
items: readonly string[]
multiple?: boolean
autoHighlight?: boolean
value?: string[]
defaultValue?: string[]
onValueChange?: (values: string[]) => void
}
function Combobox({
children,
items,
multiple = false,
value,
defaultValue = [],
onValueChange
}: ComboboxProps): React.JSX.Element {
const [open, setOpen] = React.useState(false)
const [inputValue, setInputValue] = React.useState('')
const [internalValues, setInternalValues] = React.useState(defaultValue)
const values = value ?? internalValues
function updateValues(nextValues: string[]): void {
if (!value) {
setInternalValues(nextValues)
}
onValueChange?.(nextValues)
}
function selectValue(rawValue: string): void {
const nextValue = rawValue.trim()
if (!nextValue) {
return
}
if (multiple) {
if (!values.includes(nextValue)) {
updateValues([...values, nextValue])
}
} else {
updateValues([nextValue])
setOpen(false)
}
setInputValue('')
}
function removeValue(valueToRemove: string): void {
updateValues(values.filter((item) => item !== valueToRemove))
}
return (
<ComboboxContext.Provider
value={{
items,
open,
multiple,
values,
inputValue,
setInputValue,
setOpen,
selectValue,
removeValue
}}
>
<div className="relative">{children}</div>
</ComboboxContext.Provider>
)
}
const ComboboxChips = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, onClick, ...props }, ref) => {
const { setOpen } = useComboboxContext()
return (
<div
ref={ref}
className={cn(
'flex min-h-9 w-full flex-wrap items-center gap-1 rounded-md border border-input bg-transparent px-2 py-1 text-sm shadow-sm transition-colors focus-within:ring-1 focus-within:ring-ring',
className
)}
onClick={(event) => {
setOpen(true)
onClick?.(event)
}}
{...props}
/>
)
})
ComboboxChips.displayName = 'ComboboxChips'
function ComboboxValue({
children
}: {
children: (values: string[]) => React.ReactNode
}): React.JSX.Element {
const { values } = useComboboxContext()
return <>{children(values)}</>
}
function ComboboxChip({
children,
className
}: {
children: string
className?: string
}): React.JSX.Element {
const { removeValue } = useComboboxContext()
return (
<span
className={cn(
'inline-flex h-7 max-w-full items-center gap-1 rounded-md bg-secondary px-2 text-xs font-medium text-secondary-foreground',
className
)}
>
<span className="truncate">{children}</span>
<button
type="button"
className="grid size-4 shrink-0 place-items-center rounded-sm hover:bg-background/80"
aria-label={`Remove ${children}`}
onClick={(event) => {
event.stopPropagation()
removeValue(children)
}}
>
<X className="size-3" />
</button>
</span>
)
}
function ComboboxChipsInput(
props: React.InputHTMLAttributes<HTMLInputElement>
): React.JSX.Element {
const { inputValue, setInputValue, setOpen, selectValue } = useComboboxContext()
return (
<input
className="h-7 min-w-24 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground"
value={inputValue}
onChange={(event) => {
setInputValue(event.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ',' || event.key === '\uff0c') {
event.preventDefault()
selectValue(inputValue)
}
if (event.key === 'Escape') {
setOpen(false)
}
}}
{...props}
/>
)
}
function ComboboxContent({
children,
className
}: React.HTMLAttributes<HTMLDivElement> & {
anchor?: React.RefObject<HTMLDivElement | null>
}): React.JSX.Element | null {
const { open } = useComboboxContext()
if (!open) {
return null
}
return (
<div
className={cn(
'absolute z-50 mt-1 w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md',
className
)}
onMouseDown={(event) => event.preventDefault()}
>
{children}
</div>
)
}
function ComboboxEmpty({ children }: { children: React.ReactNode }): React.JSX.Element {
const { items, values, inputValue } = useComboboxContext()
const keyword = inputValue.trim().toLowerCase()
const visibleItems = items.filter(
(item) => !values.includes(item) && (!keyword || item.toLowerCase().includes(keyword))
)
if (visibleItems.length > 0 || inputValue.trim()) {
return <></>
}
return <div className="px-3 py-2 text-sm text-muted-foreground">{children}</div>
}
function ComboboxList({
children
}: {
children: (item: string) => React.ReactNode
}): React.JSX.Element {
const { items, values, inputValue } = useComboboxContext()
const keyword = inputValue.trim().toLowerCase()
const visibleItems = items.filter(
(item) => !values.includes(item) && (!keyword || item.toLowerCase().includes(keyword))
)
const shouldShowCustomValue =
inputValue.trim() && !items.includes(inputValue.trim()) && !values.includes(inputValue.trim())
return (
<div className="max-h-56 overflow-y-auto p-1">
{visibleItems.map((item) => children(item))}
{shouldShowCustomValue ? children(inputValue.trim()) : null}
</div>
)
}
function ComboboxItem({
value,
children,
className
}: {
value: string
children: React.ReactNode
className?: string
}): React.JSX.Element {
const { values, selectValue } = useComboboxContext()
const selected = values.includes(value)
return (
<button
type="button"
className={cn(
'flex w-full items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-accent hover:text-accent-foreground',
className
)}
onClick={() => selectValue(value)}
>
<span className="truncate">{children}</span>
{selected && <Check className="size-4" />}
</button>
)
}
export {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue
}
+345 -7
View File
@@ -4,6 +4,7 @@ import {
Camera,
Construction,
FolderPlus,
Images,
MoreHorizontal,
Pencil,
Plus,
@@ -12,6 +13,7 @@ import {
Upload,
X
} from 'lucide-react'
import JSZip from 'jszip'
import { DashboardFooter } from '@renderer/components/dashboard/DashboardFooter'
import {
@@ -34,6 +36,18 @@ import {
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor
} from '@renderer/components/ui/combobox'
import {
Drawer,
DrawerContent,
@@ -67,29 +81,127 @@ type EditingClass = {
id: string
name: string
type: ClassType
teacherNames: string[]
familyPhoto?: string
}
const MAX_CLASS_TEACHERS = 3
const PHOTO_FILE_NAME_MAP: Record<string, 'meImage' | 'workImage1' | 'workImage2'> = {
me: 'meImage',
me_image: 'meImage',
'1': 'workImage1',
work_1: 'workImage1',
work_image_1: 'workImage1',
'2': 'workImage2',
work_2: 'workImage2',
work_image_2: 'workImage2'
}
const IMAGE_EXTENSION_MIME_TYPES: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp'
}
function normalizeTeacherNames(teacherNames: string[]): string[] {
return Array.from(
new Set(teacherNames.map((teacherName) => teacherName.trim()).filter(Boolean))
).slice(0, MAX_CLASS_TEACHERS)
}
function formatTeacherNames(teacherNames: string[]): string {
return normalizeTeacherNames(teacherNames).join(' ')
}
function normalizeMatchName(value: string): string {
return value
.trim()
.replace(/\.[^.]+$/, '')
.replace(/-\d+$/, '')
.replace(/\s+/g, '')
.toLowerCase()
}
function getImageMimeType(fileName: string): string | null {
const extension = fileName.split('.').pop()?.toLowerCase() ?? ''
return IMAGE_EXTENSION_MIME_TYPES[extension] ?? null
}
function getPhotoFieldFromFileName(fileName: string): 'meImage' | 'workImage1' | 'workImage2' | null {
const baseName = fileName
.replace(/\.[^.]+$/, '')
.trim()
.toLowerCase()
.replace(/[\s-]+/g, '_')
if (PHOTO_FILE_NAME_MAP[baseName]) {
return PHOTO_FILE_NAME_MAP[baseName]
}
if (/(^|_)me(_image)?$/.test(baseName)) {
return 'meImage'
}
if (/(^|_)(1|work_?1|work_image_1)$/.test(baseName)) {
return 'workImage1'
}
if (/(^|_)(2|work_?2|work_image_2)$/.test(baseName)) {
return 'workImage2'
}
return null
}
function getZipPathParts(path: string): string[] {
return path.split('/').filter((part) => part && part !== '__MACOSX' && !part.startsWith('.'))
}
function findProfileForZipPath(
pathParts: string[],
targetClass: ClassProfile,
profileByName: Map<string, ChildProfile>
): ChildProfile | null {
const classRootIndex = pathParts.findIndex(
(part) => normalizeMatchName(part) === normalizeMatchName(targetClass.id)
)
const relativeParts = classRootIndex >= 0 ? pathParts.slice(classRootIndex + 1) : pathParts
const folderParts = relativeParts.slice(0, -1).filter((part) => normalizeMatchName(part) !== 'images')
for (let index = folderParts.length - 1; index >= 0; index -= 1) {
const matchedProfile = profileByName.get(normalizeMatchName(folderParts[index]))
if (matchedProfile) {
return matchedProfile
}
}
return null
}
export function ClassPage(): React.JSX.Element {
const fileInputRef = useRef<HTMLInputElement>(null)
const photoZipInputRef = 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 [classTeacherNames, setClassTeacherNames] = useState<string[]>([])
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 [photoZipClass, setPhotoZipClass] = 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()
const response = await window.api.loadStudentData({ includeImages: false })
if (!response.ok) {
showError('读取数据库失败', response.message)
@@ -121,7 +233,12 @@ export function ClassPage(): React.JSX.Element {
}
return classes.filter((classItem) =>
[classItem.name, classItem.id, getClassTypeLabel(classItem.type)]
[
classItem.name,
classItem.teacherNames.join(' '),
classItem.id,
getClassTypeLabel(classItem.type)
]
.join(' ')
.toLowerCase()
.includes(keyword)
@@ -132,6 +249,11 @@ export function ClassPage(): React.JSX.Element {
return profiles.filter((profile) => profile.classId === classId)
}
const teacherNameOptions = useMemo(
() => Array.from(new Set(classes.flatMap((classItem) => classItem.teacherNames))).sort(),
[classes]
)
function showSuccess(message: string, description?: string): void {
toast.success(message, description ? { description } : undefined)
}
@@ -201,6 +323,7 @@ export function ClassPage(): React.JSX.Element {
id: createUuid(),
name: nextName,
type: classType,
teacherNames: normalizeTeacherNames(classTeacherNames),
familyPhoto: classFamilyPhoto || undefined
}
]
@@ -214,6 +337,7 @@ export function ClassPage(): React.JSX.Element {
setClasses(nextClasses)
setClassName('')
setClassType('cheap')
setClassTeacherNames([])
setClassFamilyPhoto('')
if (classPhotoInputRef.current) {
classPhotoInputRef.current.value = ''
@@ -227,6 +351,7 @@ export function ClassPage(): React.JSX.Element {
id: classItem.id,
name: classItem.name,
type: classItem.type,
teacherNames: classItem.teacherNames,
familyPhoto: classItem.familyPhoto
})
}
@@ -258,6 +383,7 @@ export function ClassPage(): React.JSX.Element {
...classItem,
name: nextName,
type: editingClass.type,
teacherNames: normalizeTeacherNames(editingClass.teacherNames),
familyPhoto: editingClass.familyPhoto
}
: classItem
@@ -395,6 +521,121 @@ export function ClassPage(): React.JSX.Element {
}
}
function openPhotoZipPicker(classItem: ClassProfile): void {
setPhotoZipClass(classItem)
if (photoZipInputRef.current) {
photoZipInputRef.current.value = ''
photoZipInputRef.current.click()
}
}
async function handlePhotoZipChange(event: React.ChangeEvent<HTMLInputElement>): Promise<void> {
const file = event.target.files?.[0]
const targetClass = photoZipClass
if (!file || !targetClass) {
return
}
if (!/\.zip$/i.test(file.name)) {
showError('请选择 ZIP 压缩包')
return
}
try {
const latestStudentData = await window.api.loadStudentData()
if (!latestStudentData.ok) {
showError('读取学生数据失败', latestStudentData.message)
return
}
const zip = await JSZip.loadAsync(await file.arrayBuffer())
const latestProfiles = latestStudentData.profiles
const classProfiles = latestProfiles.filter((profile) => profile.classId === targetClass.id)
const profileByName = new Map<string, ChildProfile>()
for (const profile of classProfiles) {
for (const name of [profile.name, profile.englishName, profile.id]) {
const normalizedName = normalizeMatchName(name)
if (normalizedName) {
profileByName.set(normalizedName, profile)
}
}
}
const updatesByProfileId = new Map<string, Partial<ChildProfile>>()
let matchedImageCount = 0
let skippedImageCount = 0
for (const [zipPath, entry] of Object.entries(zip.files)) {
if (entry.dir) {
continue
}
const pathParts = getZipPathParts(zipPath)
const fileName = pathParts.at(-1) ?? ''
const mimeType = getImageMimeType(fileName)
const photoField = getPhotoFieldFromFileName(fileName)
if (!mimeType || !photoField) {
continue
}
const matchedProfile = findProfileForZipPath(pathParts, targetClass, profileByName)
if (!matchedProfile) {
skippedImageCount += 1
continue
}
const base64 = await entry.async('base64')
const currentUpdate = updatesByProfileId.get(matchedProfile.id) ?? {}
currentUpdate[photoField] = `data:${mimeType};base64,${base64}`
updatesByProfileId.set(matchedProfile.id, currentUpdate)
matchedImageCount += 1
}
if (updatesByProfileId.size === 0) {
showError('没有匹配到可导入照片', '请确认 ZIP 内是 班级UUID/images/学生姓名/me.jpg、1.jpg、2.jpg')
return
}
const nextProfiles = latestProfiles.map((profile) => ({
...profile,
...(updatesByProfileId.get(profile.id) ?? {})
}))
for (const profileId of updatesByProfileId.keys()) {
const nextProfile = nextProfiles.find((profile) => profile.id === profileId)
if (nextProfile) {
const response = await window.api.updateStudentProfile(nextProfile)
if (!response.ok) {
throw new Error(response.message)
}
}
}
setClasses(latestStudentData.classes)
setProfiles(nextProfiles)
showSuccess(
'已导入幼儿照片',
`更新 ${updatesByProfileId.size} 名幼儿,写入 ${matchedImageCount} 张照片${
skippedImageCount > 0 ? `,跳过 ${skippedImageCount} 张未匹配照片` : ''
}`
)
} catch (error) {
showError('导入照片 ZIP 失败', error instanceof Error ? error.message : '请检查压缩包结构')
} finally {
setPhotoZipClass(null)
event.target.value = ''
}
}
return (
<>
<section className="space-y-4 bg-white px-4 py-4 ">
@@ -414,7 +655,7 @@ export function ClassPage(): React.JSX.Element {
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索班级名称、类型或 UUID"
placeholder="搜索班级名称、教师、类型或 UUID"
/>
</div>
<div className="flex justify-end">
@@ -466,8 +707,10 @@ export function ClassPage(): React.JSX.Element {
<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>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 truncate font-medium">
{formatTeacherNames(classItem.teacherNames) || '未填写'}
</p>
</div>
</div>
@@ -486,6 +729,10 @@ export function ClassPage(): React.JSX.Element {
<Upload />
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openPhotoZipPicker(classItem)}>
<Images />
ZIP
</DropdownMenuItem>
<DropdownMenuItem
disabled={exportingId === classItem.id}
onSelect={() => handleExportClass(classItem)}
@@ -538,6 +785,14 @@ export function ClassPage(): React.JSX.Element {
</ScrollArea>
</section>
<input
ref={photoZipInputRef}
className="hidden"
type="file"
accept=".zip,application/zip"
onChange={handlePhotoZipChange}
/>
<Drawer direction="right" open={createDrawerOpen} onOpenChange={setCreateDrawerOpen}>
<DrawerContent>
<DrawerHeader>
@@ -545,7 +800,7 @@ export function ClassPage(): React.JSX.Element {
<div>
<DrawerTitle></DrawerTitle>
<DrawerDescription className="mt-1">
</DrawerDescription>
</div>
<Button
@@ -593,6 +848,18 @@ export function ClassPage(): React.JSX.Element {
</Select>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="new-class-teacher">
</label>
<ClassTeacherCombobox
inputId="new-class-teacher"
value={classTeacherNames}
options={teacherNameOptions}
onChange={setClassTeacherNames}
/>
</div>
<div className="grid gap-2">
<p className="text-sm font-medium"></p>
<button
@@ -655,7 +922,7 @@ export function ClassPage(): React.JSX.Element {
<div>
<DrawerTitle></DrawerTitle>
<DrawerDescription className="mt-1">
</DrawerDescription>
</div>
<Button
@@ -705,6 +972,22 @@ export function ClassPage(): React.JSX.Element {
))}
</Select>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="edit-class-teacher">
</label>
<ClassTeacherCombobox
inputId="edit-class-teacher"
value={editingClass.teacherNames}
options={teacherNameOptions}
onChange={(teacherNames) =>
setEditingClass({
...editingClass,
teacherNames
})
}
/>
</div>
<div className="grid gap-2">
<p className="text-sm font-medium"></p>
<button
@@ -843,3 +1126,58 @@ export function ClassPage(): React.JSX.Element {
</>
)
}
function ClassTeacherCombobox({
inputId,
value,
options,
onChange
}: {
inputId: string
value: string[]
options: string[]
onChange: (value: string[]) => void
}): React.JSX.Element {
const anchor = useComboboxAnchor()
return (
<Combobox
multiple
autoHighlight
items={options}
value={value}
onValueChange={(nextValue) => onChange(normalizeTeacherNames(nextValue))}
>
<ComboboxChips ref={anchor} className="w-full">
<ComboboxValue>
{(values) => (
<>
{values.map((teacherName) => (
<ComboboxChip key={teacherName}>{teacherName}</ComboboxChip>
))}
{values.length < MAX_CLASS_TEACHERS && (
<ComboboxChipsInput
id={inputId}
placeholder={values.length === 0 ? '输入老师名称,例如:李老师' : ''}
/>
)}
</>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty></ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
{value.length >= MAX_CLASS_TEACHERS && (
<p className="text-xs text-muted-foreground"> {MAX_CLASS_TEACHERS} </p>
)}
</Combobox>
)
}
+194 -6
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import {
Download,
ExternalLink,
File,
FileSpreadsheet,
@@ -10,6 +11,7 @@ import {
Trash2,
Wand2
} from 'lucide-react'
import { useLocation } from 'react-router-dom'
import { toast } from 'sonner'
import { DashboardFooter } from '@renderer/components/dashboard/DashboardFooter'
@@ -25,7 +27,14 @@ import {
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'
import type {
ClassProfile,
ChildProfile,
ReportFormat,
ReportGenerationProgress,
ReportItem,
TemplateItem
} from '@renderer/types/app'
const formatOptions: Array<{ value: ReportFormat | 'all'; label: string }> = [
{ value: 'all', label: '全部格式' },
@@ -74,21 +83,27 @@ function formatDateTime(value: string): string {
}
export function ReportPage(): React.JSX.Element {
const location = useLocation()
const [classes, setClasses] = useState<ClassProfile[]>([])
const [profiles, setProfiles] = useState<ChildProfile[]>([])
const [templates, setTemplates] = useState<TemplateItem[]>([])
const [reports, setReports] = useState<ReportItem[]>([])
const [progressCards, setProgressCards] = useState<ReportGenerationProgress[]>([])
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 [downloadingId, setDownloadingId] = useState('')
const [downloadingClass, setDownloadingClass] = useState(false)
const [deletingId, setDeletingId] = useState('')
const [pendingStudentId, setPendingStudentId] = useState<string | null>(null)
useEffect(() => {
async function loadPageData(): Promise<void> {
const [studentResponse, templateResponse, reportResponse] = await Promise.all([
window.api.loadStudentData(),
window.api.loadStudentData({ includeImages: false }),
window.api.loadTemplates(),
window.api.loadReports()
])
@@ -97,6 +112,7 @@ export function ReportPage(): React.JSX.Element {
toast.error('读取班级失败', { description: studentResponse.message })
} else {
setClasses(studentResponse.classes)
setProfiles(studentResponse.profiles)
}
if (!templateResponse.ok) {
@@ -119,6 +135,70 @@ export function ReportPage(): React.JSX.Element {
loadPageData()
}, [])
useEffect(() => {
return window.api.onReportGenerationProgress((progress) => {
setProgressCards((currentCards) => {
if (progress.status === 'finished') {
return currentCards.filter((card) => card.batchId !== progress.batchId)
}
if (!progress.studentId) {
return currentCards
}
const nextCard = progress
const existingIndex = currentCards.findIndex(
(card) => card.batchId === progress.batchId && card.studentId === progress.studentId
)
if (existingIndex < 0) {
return [nextCard, ...currentCards]
}
return currentCards.map((card, index) => (index === existingIndex ? nextCard : card))
})
if (progress.status === 'student-finished' && progress.report) {
setReports((currentReports) => [progress.report!, ...currentReports])
}
})
}, [])
useEffect(() => {
const state = location.state as { profileId?: string; mode?: 'view' } | null
if (state?.profileId) {
setPendingStudentId(state.profileId)
}
}, [location.state])
useEffect(() => {
if (!pendingStudentId || profiles.length === 0) {
return
}
const profile = profiles.find((item) => item.id === pendingStudentId)
if (!profile) {
return
}
setSelectedClassId(profile.classId)
setQuery(profile.name || profile.englishName)
const state = location.state as { profileId?: string; mode?: 'view' } | null
if (state?.mode === 'view') {
setPendingStudentId(null)
return
}
if (selectedTemplateId) {
void handleGenerateReports([profile.id])
setPendingStudentId(null)
}
}, [location.state, pendingStudentId, profiles, selectedTemplateId])
const pptxTemplates = templates.filter((template) => template.fileExtension === '.pptx')
const filteredReports = useMemo(() => {
@@ -144,7 +224,7 @@ export function ReportPage(): React.JSX.Element {
})
}, [query, reports, selectedClassId, selectedFormat])
async function handleGenerateReports(): Promise<void> {
async function handleGenerateReports(studentIds?: string[]): Promise<void> {
if (!selectedTemplateId) {
toast.error('请先选择 PPTX 报告模板')
return
@@ -160,7 +240,8 @@ export function ReportPage(): React.JSX.Element {
try {
const response = await window.api.generateReports({
templateId: selectedTemplateId,
classId: selectedClassId
classId: selectedClassId,
studentIds
})
if (!response.ok) {
@@ -168,7 +249,10 @@ export function ReportPage(): React.JSX.Element {
return
}
setReports((currentReports) => [...response.reports, ...currentReports])
setReports((currentReports) => {
const existingIds = new Set(currentReports.map((report) => report.id))
return [...response.reports.filter((report) => !existingIds.has(report.id)), ...currentReports]
})
toast.success('报告生成完成', {
description:
response.skipped.length > 0
@@ -180,6 +264,49 @@ export function ReportPage(): React.JSX.Element {
}
}
async function handleDownloadReport(report: ReportItem): Promise<void> {
setDownloadingId(report.id)
try {
const response = await window.api.downloadReport(report.id)
if (!response.ok) {
if (!response.canceled) {
toast.error('下载报告失败', { description: response.message })
}
return
}
toast.success('报告已下载', { description: response.filePath })
} finally {
setDownloadingId('')
}
}
async function handleDownloadClassReports(): Promise<void> {
if (selectedClassId === 'all') {
toast.error('请先选择要下载的班级')
return
}
setDownloadingClass(true)
try {
const response = await window.api.downloadClassReports({ classId: selectedClassId })
if (!response.ok) {
if (!response.canceled) {
toast.error('下载班级报告失败', { description: response.message })
}
return
}
toast.success(`已下载 ${response.reportCount} 份报告`, { description: response.filePath })
} finally {
setDownloadingClass(false)
}
}
async function handleOpenReport(report: ReportItem): Promise<void> {
setOpeningId(report.id)
@@ -281,16 +408,68 @@ export function ReportPage(): React.JSX.Element {
</Select>
<Button
disabled={generating || selectedClassId === 'all' || !selectedTemplateId}
onClick={handleGenerateReports}
onClick={() => handleGenerateReports()}
>
<Wand2 />
{generating ? '生成中' : '生成当前班级报告'}
</Button>
</div>
<div className="flex justify-end">
<Button
variant="outline"
disabled={downloadingClass || selectedClassId === 'all'}
onClick={handleDownloadClassReports}
>
<Download />
{downloadingClass ? '打包中' : '下载当前班级报告'}
</Button>
</div>
</div>
<ScrollArea className="h-140">
<div className="grid gap-3 pr-3 md:grid-cols-2 xl:grid-cols-3">
{progressCards.map((progress) => (
<Card
key={`${progress.batchId}-${progress.studentId}`}
className="border-primary/40 bg-primary/5 shadow-none"
>
<CardHeader className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary/10 text-primary">
<Wand2 className="size-5" />
</div>
<Badge variant={progress.status === 'student-failed' ? 'destructive' : 'warning'}>
{progress.status === 'student-failed' ? '生成失败' : '生成中'}
</Badge>
</div>
<div className="min-w-0">
<CardTitle className="truncate text-base">
{progress.studentName || '学生'}
</CardTitle>
<CardDescription className="mt-2 truncate">
{progress.className} · {progress.current}/{progress.total}
</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{
width: `${Math.min(
Math.max((progress.current / Math.max(progress.total, 1)) * 100, 8),
100
)}%`
}}
/>
</div>
{progress.error ? (
<p className="mt-3 text-sm text-destructive">{progress.error}</p>
) : null}
</CardContent>
</Card>
))}
{filteredReports.map((report) => {
const ReportIcon = getReportIcon(report.format)
@@ -327,6 +506,15 @@ export function ReportPage(): React.JSX.Element {
</div>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
disabled={downloadingId === report.id}
onClick={() => handleDownloadReport(report)}
>
<Download />
{downloadingId === report.id ? '下载中' : '下载'}
</Button>
<Button
size="sm"
disabled={openingId === report.id}
+162 -22
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import {
Bot,
Camera,
Construction,
FileSearch,
MoreHorizontal,
@@ -127,7 +128,7 @@ export function StudentPage(): React.JSX.Element {
useEffect(() => {
async function loadStudentData(): Promise<void> {
const response = await window.api.loadStudentData()
const response = await window.api.loadStudentData({ includeProfiles: false })
if (!response.ok) {
toast.error('读取数据库失败', { description: response.message })
@@ -178,7 +179,8 @@ export function StudentPage(): React.JSX.Element {
classMap.set(profile.classId, {
id: profile.classId,
name: profile.className,
type: 'cheap'
type: 'cheap',
teacherNames: []
})
}
}
@@ -240,6 +242,24 @@ export function StudentPage(): React.JSX.Element {
})
}
async function openProfilePanel(profile: ChildProfile, mode: 'view' | 'edit'): Promise<void> {
setSelectedId(profile.id)
setEditingProfile(mode === 'edit' ? profile : null)
const response = await window.api.getStudentProfile(profile.id)
if (!response.ok) {
toast.error('读取学生详情失败', { description: response.message })
return
}
updateProfileInPage(response.profile)
if (mode === 'edit') {
setEditingProfile(response.profile)
}
}
async function handleGenerateComment(profile: ChildProfile): Promise<boolean> {
setGeneratingIds((currentIds) => [...currentIds, profile.id])
@@ -288,17 +308,7 @@ export function StudentPage(): React.JSX.Element {
}
function hasGeneratedReport(profile: ChildProfile): boolean {
const profileWithReport = profile as ChildProfile & {
reportPath?: string
reportGeneratedAt?: string
reportFilePath?: string
}
return Boolean(
profileWithReport.reportPath ||
profileWithReport.reportGeneratedAt ||
profileWithReport.reportFilePath
)
return profile.reportGenerated
}
function handleGenerateReport(profile: ChildProfile): void {
@@ -448,6 +458,7 @@ export function StudentPage(): React.JSX.Element {
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-36 pr-6 text-right"></TableHead>
</TableRow>
</TableHeader>
@@ -482,15 +493,17 @@ export function StudentPage(): React.JSX.Element {
<TableCell className="max-w-[260px] truncate">
{profile.comment || '未生成'}
</TableCell>
<TableCell>
<Badge variant={reportReady ? 'success' : 'outline'}>
{reportReady ? '已生成' : '未生成'}
</Badge>
</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)
}}
onClick={() => void openProfilePanel(profile, 'view')}
>
</Button>
@@ -505,10 +518,7 @@ export function StudentPage(): React.JSX.Element {
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
setSelectedId(profile.id)
setEditingProfile(profile)
}}
onSelect={() => void openProfilePanel(profile, 'edit')}
>
<Pencil />
@@ -570,7 +580,7 @@ export function StudentPage(): React.JSX.Element {
})}
{profiles.length === 0 && (
<TableRow>
<TableCell className="h-28 text-center text-muted-foreground" colSpan={10}>
<TableCell className="h-28 text-center text-muted-foreground" colSpan={11}>
xlsx
</TableCell>
</TableRow>
@@ -682,6 +692,10 @@ export function StudentPage(): React.JSX.Element {
) : (
<dl className="grid gap-3 text-sm">
<ProfileField label="班级" value={activeProfile.className || '未分班'} />
<ProfileField
label="班级教师"
value={selectedProfileClass?.teacherNames.join(' ') || '未记录'}
/>
<ProfileField
label="班级类型"
value={
@@ -691,6 +705,10 @@ export function StudentPage(): React.JSX.Element {
}
/>
<ProfileField label="班级 UUID" value={activeProfile.classId || '未记录'} />
<ProfileField
label="成长报告"
value={activeProfile.reportGenerated ? '已生成' : '未生成'}
/>
<ProfileField label="属相" value={activeProfile.zodiac || '未填写'} />
<ProfileField label="我的好朋友" value={formatList(activeProfile.friends)} />
<ProfileField label="我的爱好" value={formatList(activeProfile.hobbies)} />
@@ -709,6 +727,7 @@ export function StudentPage(): React.JSX.Element {
value={activeProfile.commentGeneratedAt || '未生成'}
/>
<ProfileField label="导入时间" value={activeProfile.importedAt} />
<ProfileImageGallery profile={activeProfile} />
</dl>
)}
</div>
@@ -732,6 +751,34 @@ function ProfileField({ label, value }: { label: string; value: string }): React
)
}
function ProfileImageGallery({ profile }: { profile: ChildProfile }): React.JSX.Element {
return (
<div className="grid gap-3 rounded-md border bg-background p-3">
<dt className="text-xs font-medium text-muted-foreground"></dt>
<dd className="grid gap-3 sm:grid-cols-3">
<ProfileImagePreview label="本人照片" src={profile.meImage} />
<ProfileImagePreview label="作品照片 1" src={profile.workImage1} />
<ProfileImagePreview label="作品照片 2" src={profile.workImage2} />
</dd>
</div>
)
}
function ProfileImagePreview({ label, src }: { label: string; src: string }): React.JSX.Element {
return (
<div className="grid gap-2">
<div className="grid aspect-[4/3] place-items-center overflow-hidden rounded-md border bg-muted">
{src ? (
<img className="h-full w-full object-cover" src={src} alt={label} />
) : (
<Camera className="size-5 text-muted-foreground" />
)}
</div>
<p className="text-center text-xs text-muted-foreground">{label}</p>
</div>
)
}
function StudentProfileForm({
profile,
onChange
@@ -756,6 +803,37 @@ function StudentProfileForm({
})
}
function updateImageField(
field: 'meImage' | 'workImage1' | 'workImage2',
file?: File
): void {
if (!file) {
return
}
if (!file.type.startsWith('image/')) {
return
}
const reader = new FileReader()
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') {
onChange({
...profile,
[field]: reader.result
})
}
})
reader.readAsDataURL(file)
}
function clearImageField(field: 'meImage' | 'workImage1' | 'workImage2'): void {
onChange({
...profile,
[field]: ''
})
}
return (
<div className="grid gap-4">
<div className="grid gap-3 sm:grid-cols-2">
@@ -823,6 +901,29 @@ function StudentProfileForm({
multiline
onChange={(value) => updateTextField('comment', value)}
/>
<div className="grid gap-3">
<p className="text-sm font-medium"></p>
<div className="grid gap-3 sm:grid-cols-3">
<EditableImageField
label="本人照片"
value={profile.meImage}
onChange={(file) => updateImageField('meImage', file)}
onClear={() => clearImageField('meImage')}
/>
<EditableImageField
label="作品照片 1"
value={profile.workImage1}
onChange={(file) => updateImageField('workImage1', file)}
onClear={() => clearImageField('workImage1')}
/>
<EditableImageField
label="作品照片 2"
value={profile.workImage2}
onChange={(file) => updateImageField('workImage2', file)}
onClear={() => clearImageField('workImage2')}
/>
</div>
</div>
</div>
)
}
@@ -853,3 +954,42 @@ function EditableField({
</label>
)
}
function EditableImageField({
label,
value,
onChange,
onClear
}: {
label: string
value: string
onChange: (file?: File) => void
onClear: () => void
}): React.JSX.Element {
return (
<label className="grid gap-2 text-sm">
<span className="font-medium">{label}</span>
<span className="grid aspect-[4/3] cursor-pointer place-items-center overflow-hidden rounded-md border border-dashed bg-muted transition-colors hover:border-primary">
{value ? (
<img className="h-full w-full object-cover" src={value} alt={label} />
) : (
<span className="grid place-items-center gap-2 text-center text-muted-foreground">
<Camera className="size-5" />
</span>
)}
</span>
<Input
className="hidden"
type="file"
accept="image/*"
onChange={(event) => onChange(event.target.files?.[0])}
/>
{value ? (
<Button type="button" size="sm" variant="outline" onClick={onClear}>
</Button>
) : null}
</label>
)
}
+9 -311
View File
@@ -5,7 +5,6 @@ import {
FileSpreadsheet,
FileText,
LayoutPanelTop,
ListTree,
MoreHorizontal,
Pencil,
Plus,
@@ -36,14 +35,6 @@ import {
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@renderer/components/ui/dialog'
import {
Drawer,
DrawerContent,
@@ -63,7 +54,6 @@ 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,
@@ -78,7 +68,6 @@ type TemplateForm = {
type: TemplateType
sourceFilePath: string
fileName: string
fieldMappings: TemplateFieldMapping
}
const emptyTemplateForm: TemplateForm = {
@@ -87,22 +76,7 @@ const emptyTemplateForm: TemplateForm = {
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: {}
fileName: ''
}
const templateTypeOptions: Array<{ value: TemplateType; label: string }> = [
@@ -115,22 +89,6 @@ const templateSemesterOptions: Array<{ value: TemplateSemester; label: string }>
{ 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' ? '横版' : '竖版'
}
@@ -171,14 +129,6 @@ function getFileIcon(extension: string): typeof Presentation {
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)
@@ -200,9 +150,7 @@ export function TemplatePage(): React.JSX.Element {
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)
@@ -265,35 +213,11 @@ export function TemplatePage(): React.JSX.Element {
semester: template.semester,
type: template.type,
sourceFilePath: template.filePath,
fileName: template.originalFileName,
fieldMappings: template.fieldMappings
fileName: template.originalFileName
})
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)
@@ -305,19 +229,6 @@ export function TemplatePage(): React.JSX.Element {
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()
@@ -328,13 +239,12 @@ export function TemplatePage(): React.JSX.Element {
return
}
const placeholders = await parsePlaceholdersForFile(response.filePath)
await parsePlaceholdersForFile(response.filePath)
setTemplateForm((currentForm) => ({
...currentForm,
sourceFilePath: response.filePath,
fileName: response.fileName,
fieldMappings: mergeFieldMappings(placeholders, currentForm.fieldMappings)
fileName: response.fileName
}))
}
@@ -355,13 +265,12 @@ export function TemplatePage(): React.JSX.Element {
return
}
const placeholders = await parsePlaceholdersForFile(filePath)
await parsePlaceholdersForFile(filePath)
setTemplateForm((currentForm) => ({
...currentForm,
sourceFilePath: filePath,
fileName: file.name,
fieldMappings: mergeFieldMappings(placeholders, currentForm.fieldMappings)
fileName: file.name
}))
}
@@ -371,85 +280,6 @@ export function TemplatePage(): React.JSX.Element {
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()
@@ -478,8 +308,7 @@ export function TemplatePage(): React.JSX.Element {
grade,
semester: templateForm.semester,
type: templateForm.type,
sourceFilePath: templateForm.sourceFilePath,
fieldMappings: templateForm.fieldMappings
sourceFilePath: templateForm.sourceFilePath
})
if (!response.ok) {
@@ -618,11 +447,8 @@ export function TemplatePage(): React.JSX.Element {
</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>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 truncate text-sm">{template.placeholders.length} </p>
</div>
</div>
@@ -649,13 +475,6 @@ export function TemplatePage(): React.JSX.Element {
<Pencil />
</DropdownMenuItem>
<DropdownMenuItem
disabled={template.fileExtension !== '.pptx'}
onSelect={() => void openMappingDialog(template)}
>
<ListTree />
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onSelect={() => setDeletingTemplate(template)}
@@ -827,127 +646,6 @@ export function TemplatePage(): React.JSX.Element {
</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>
+8
View File
@@ -53,6 +53,10 @@ function mapRowToChild(
traits: asText(row['表现特征']),
comment: '',
commentGeneratedAt: '',
reportGenerated: false,
meImage: '',
workImage1: '',
workImage2: '',
importedAt
}
@@ -108,6 +112,10 @@ function normalizeProfile(profile: Partial<ChildProfile>): ChildProfile | null {
traits: profile.traits ?? '',
comment: profile.comment ?? '',
commentGeneratedAt: profile.commentGeneratedAt ?? '',
reportGenerated: profile.reportGenerated ?? false,
meImage: profile.meImage ?? '',
workImage1: profile.workImage1 ?? '',
workImage2: profile.workImage2 ?? '',
importedAt: profile.importedAt ?? ''
}
}
+11 -1
View File
@@ -17,7 +17,8 @@ export function createDefaultClasses(): ClassProfile[] {
return defaultClassNames.map((name) => ({
id: createUuid(),
name,
type: 'cheap'
type: 'cheap',
teacherNames: []
}))
}
@@ -30,10 +31,19 @@ export function normalizeClass(classItem: Partial<ClassProfile>): ClassProfile |
id: classItem.id,
name: classItem.name,
type: classItem.type === 'noble' ? 'noble' : 'cheap',
teacherNames: normalizeTeacherNames(classItem),
familyPhoto: classItem.familyPhoto
}
}
function normalizeTeacherNames(classItem: Partial<ClassProfile> & { teacherName?: string }): string[] {
if (Array.isArray(classItem.teacherNames)) {
return classItem.teacherNames.map((teacherName) => teacherName.trim()).filter(Boolean)
}
return classItem.teacherName?.trim() ? [classItem.teacherName.trim()] : []
}
export function getClassTypeLabel(type: ClassType): string {
return classTypeOptions.find((item) => item.value === type)?.label ?? '便宜班'
}
+18 -3
View File
@@ -43,6 +43,10 @@ export type ChildProfile = {
traits: string
comment: string
commentGeneratedAt: string
reportGenerated: boolean
meImage: string
workImage1: string
workImage2: string
importedAt: string
}
@@ -52,6 +56,7 @@ export type ClassProfile = {
id: string
name: string
type: ClassType
teacherNames: string[]
familyPhoto?: string
}
@@ -63,8 +68,6 @@ export type TemplatePlaceholder = {
kind: 'text' | 'image'
}
export type TemplateFieldMapping = Record<string, string>
export type TemplateItem = {
id: string
name: string
@@ -75,7 +78,6 @@ export type TemplateItem = {
filePath: string
fileExtension: string
placeholders: TemplatePlaceholder[]
fieldMappings: TemplateFieldMapping
createdAt: string
updatedAt: string
}
@@ -98,6 +100,19 @@ export type ReportItem = {
updatedAt: string
}
export type ReportGenerationProgress = {
batchId: string
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
classId: string
className: string
studentId?: string
studentName?: string
current: number
total: number
report?: ReportItem
error?: string
}
export type LogEntry = {
time: string
level: LogLevel