feat: improve report generation workflow
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+198
@@ -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: {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,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)
|
||||
})
|
||||
|
||||
+692
-279
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Vendored
+53
-5
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? '便宜班'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user