diff --git a/package.json b/package.json index ef3e3f1..ad4c1c7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82d5499..34d4f45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/main/entities/ClassEntity.ts b/src/main/entities/ClassEntity.ts index 4db5e4f..c00a2b1 100644 --- a/src/main/entities/ClassEntity.ts +++ b/src/main/entities/ClassEntity.ts @@ -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({ 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, diff --git a/src/main/entities/StudentProfileEntity.ts b/src/main/entities/StudentProfileEntity.ts index 5a0301a..6cb9414 100644 --- a/src/main/entities/StudentProfileEntity.ts +++ b/src/main/entities/StudentProfileEntity.ts @@ -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 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, diff --git a/src/main/index.ts b/src/main/index.ts index 1638b8b..69c3ad2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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') diff --git a/src/main/ipc/reportIpc.ts b/src/main/ipc/reportIpc.ts index c1af475..261fdd0 100644 --- a/src/main/ipc/reportIpc.ts +++ b/src/main/ipc/reportIpc.ts @@ -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 => { + 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 => { + 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 => { try { await deleteReport(id) diff --git a/src/main/ipc/studentIpc.ts b/src/main/ipc/studentIpc.ts index 37f2435..366d092 100644 --- a/src/main/ipc/studentIpc.ts +++ b/src/main/ipc/studentIpc.ts @@ -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 => { + void migrateStoredImagesToFiles().catch((error) => { + console.error('[图片迁移] 迁移 SQLite 内图片到文件失败', error) + }) + + ipcMain.handle('student:load', async (_, payload?: LoadStudentDataInput): Promise => { 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 => { + 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 => { diff --git a/src/main/services/databaseService.ts b/src/main/services/databaseService.ts index 30cd88b..b9ab7a5 100644 --- a/src/main/services/databaseService.ts +++ b/src/main/services/databaseService.ts @@ -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 | null = null function getDatabasePath(): string { return join(app.getPath('userData'), DATABASE_FILENAME) @@ -22,7 +25,11 @@ export async function getAppDataSource(): Promise { 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 { } }) - 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 } diff --git a/src/main/services/imageStorageService.ts b/src/main/services/imageStorageService.ts new file mode 100644 index 0000000..43e3108 --- /dev/null +++ b/src/main/services/imageStorageService.ts @@ -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 = { + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/bmp': 'bmp', + 'image/gif': 'gif' +} + +const EXTENSION_MIME_MAP: Record = { + 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 { + 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 { + 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 { + if (!value) { + return null + } + + const parsed = parseDataUrl(value) + + if (parsed) { + return parsed.buffer + } + + return readFile(resolveImagePath(value)) +} diff --git a/src/main/services/loggerService.ts b/src/main/services/loggerService.ts new file mode 100644 index 0000000..567e023 --- /dev/null +++ b/src/main/services/loggerService.ts @@ -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 { + return log.scope(scope) +} diff --git a/src/main/services/modelService.ts b/src/main/services/modelService.ts index da2b86f..a5fd694 100644 --- a/src/main/services/modelService.ts +++ b/src/main/services/modelService.ts @@ -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/g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - function sanitizeFileName(value: string): string { return ( [...value] @@ -117,6 +110,16 @@ function sanitizeFileName(value: string): string { ) } +function getReportDownloadFileName(report: ReportEntity | ReportItem): string { + return `${sanitizeFileName(`${report.className}${report.studentName}`)}${report.fileExtension}` +} + +function sendReportProgress(progress: ReportGenerationProgress): void { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('reports:generation-progress', progress) + } +} + function getFormatFromExtension(extension: string): ReportFormat { if (['.doc', '.docx'].includes(extension)) { return 'word' @@ -133,11 +136,13 @@ function getFormatFromExtension(extension: string): ReportFormat { return 'ppt' } -function getStudentData(student: StudentProfileEntity): ReportStudentData { +function getStudentData(student: StudentProfileEntity, classItem: ClassEntity): ReportStudentData { const friends = parseJsonList(student.friends).join('、') || ' ' const hobbies = parseJsonList(student.hobbies).join('、') || ' ' const favoriteGames = parseJsonList(student.favoriteGames).join('、') || ' ' const favoriteFoods = parseJsonList(student.favoriteFoods).join('、') || ' ' + const teacherName = + parseTeacherNames(classItem.teacherNames || classItem.teacherName || '').join(' ') || ' ' return { id: student.id, @@ -157,104 +162,75 @@ function getStudentData(student: StudentProfileEntity): ReportStudentData { game: favoriteGames, favoriteFoods, food: favoriteFoods, + meImage: student.meImage || ' ', + workImage1: student.workImage1 || ' ', + workImage2: student.workImage2 || ' ', traits: student.traits || ' ', comment: student.comment || '暂无评语', comments: student.comment || '暂无评语', - teacherName: '', - teacher_name: '' + teacherName, + teacher_name: teacherName } } -function parseTemplateFieldMappings(value: string): Record { +function parseTeacherNames(value: string): string[] { try { const parsed = JSON.parse(value) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return {} + if (Array.isArray(parsed)) { + return parsed + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean) } - - return Object.fromEntries( - Object.entries(parsed).filter( - (entry): entry is [string, string] => - typeof entry[0] === 'string' && typeof entry[1] === 'string' - ) - ) } catch { - return {} - } -} - -function normalizeFieldKey(value: string): string { - return value.replace(/[_\s-]+(.)?/g, (_, next: string | undefined) => - next ? next.toUpperCase() : '' - ) -} - -function getMappedValue( - fieldKey: string, - studentData: ReportStudentData, - classItem: ClassEntity -): string { - if (fieldKey === 'class' || fieldKey === 'className') { - return classItem.name + // Older data may contain a single teacher name instead of a JSON array. } - const directValue = studentData[fieldKey as keyof ReportStudentData] - - if (typeof directValue === 'string') { - return directValue - } - - const normalizedValue = studentData[normalizeFieldKey(fieldKey) as keyof ReportStudentData] - - return typeof normalizedValue === 'string' ? normalizedValue : ' ' + return value.trim() ? [value.trim()] : [] } -function buildTextReplacements( - template: TemplateEntity, - studentData: ReportStudentData, - classItem: ClassEntity -): Record { - const defaultReplacements: Record = { +function buildTextReplacements(studentData: ReportStudentData, classItem: ClassEntity): Record { + return { name: studentData.name, class: classItem.name, + className: classItem.name, comments: studentData.comments, + comment: studentData.comment, + teacherName: studentData.teacherName, teacher_name: studentData.teacher_name, + englishName: studentData.englishName, english_name: studentData.englishName, sex: studentData.sex, + gender: studentData.gender, birthday: studentData.birthday, zodiac: studentData.zodiac, + friends: studentData.friends, friend: studentData.friend, + hobbies: studentData.hobbies, hobby: studentData.hobby, + favoriteGames: studentData.favoriteGames, game: studentData.game, - food: studentData.food + favoriteFoods: studentData.favoriteFoods, + food: studentData.food, + me_image: studentData.meImage, + work_image_1: studentData.workImage1, + work_image_2: studentData.workImage2 } - const fieldMappings = parseTemplateFieldMappings(template.fieldMappings) - - for (const [placeholder, fieldKey] of Object.entries(fieldMappings)) { - if (fieldKey === 'classImage' || fieldKey === 'familyPhoto') { - continue - } - - defaultReplacements[placeholder] = getMappedValue(fieldKey, studentData, classItem) - } - - return defaultReplacements } -function buildImageReplacements(template: TemplateEntity): Record { - const imageReplacements: Record = { - class_image: 'familyPhoto' +function buildImageReplacements(): Record { + return { + class_image: 'familyPhoto', + classImage: 'familyPhoto', + familyPhoto: 'familyPhoto', + me_image: 'meImage', + meImage: 'meImage', + work_image_1: 'workImage1', + workImage1: 'workImage1', + work_image_2: 'workImage2', + workImage2: 'workImage2' } - const fieldMappings = parseTemplateFieldMappings(template.fieldMappings) - - for (const [placeholder, fieldKey] of Object.entries(fieldMappings)) { - if (fieldKey === 'classImage' || fieldKey === 'familyPhoto') { - imageReplacements[placeholder] = 'familyPhoto' - } - } - - return imageReplacements } function parseJsonList(value: string): string[] { @@ -266,244 +242,497 @@ function parseJsonList(value: string): string[] { } } -function getSlidePaths(zip: JSZip): string[] { - return Object.keys(zip.files) - .filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path)) - .sort((first, second) => { - const firstNumber = Number(first.match(/slide(\d+)\.xml$/)?.[1] ?? 0) - const secondNumber = Number(second.match(/slide(\d+)\.xml$/)?.[1] ?? 0) +function normalizePptObjectName(value?: string | number | null): string { + return String(value ?? '') + .trim() + .replace(/^field[:_-]/i, '') + .replace(/[_\s-]+(.)?/g, (_, next: string | undefined) => (next ? next.toUpperCase() : '')) + .toLowerCase() +} - return firstNumber - secondNumber +type PptImageInfo = { + id?: string | number + name?: string + relationshipId?: string + targetPath?: string + position?: PptPosition | null +} + +type PptShapeInfo = { + id?: string | number + name?: string + position?: PptPosition | null +} + +type PptPosition = { + x: number + y: number + cx: number + cy: number +} + +type PptImageReplacementTarget = { + slideNumber: number + imageIdentifier: string + imageKey: string + overlapArea: number + centerDistance: number +} + +type PptImageReplacementPlan = { + placeholderName: string + fieldKey: string + imageValue?: string | null + target: PptImageReplacementTarget +} + +function getSlideImages(ppt: PPTXTemplater, slideNumber: number): PptImageInfo[] { + // node-pptx-templater 的 useSlide(n) 会把后续操作限定到第 n 页。 + // getImages() 返回这一页所有真正的图片对象(PowerPoint XML 里的 p:pic), + // 其中 id/name 用来调用 replaceImage,relationshipId/targetPath 用来识别图片资源。 + const slideScopedPpt = ppt.useSlide(slideNumber) as unknown as { + getImages?: () => PptImageInfo[] + } + + return slideScopedPpt.getImages?.() ?? [] +} + +function getSlideShapes(ppt: PPTXTemplater, slideNumber: number): PptShapeInfo[] { + // getShapes() 返回普通形状对象(PowerPoint XML 里的 p:sp)。 + // 很多模板会把占位符命名在“矩形/占位框”上,而真正的图片对象名称仍然是“图片 90”。 + // 所以后面会用这些形状的位置去匹配与它重叠的图片。 + const slideScopedPpt = ppt.useSlide(slideNumber) as unknown as { + getShapes?: () => PptShapeInfo[] + } + + return slideScopedPpt.getShapes?.() ?? [] +} + +function getOverlapArea(first?: PptPosition | null, second?: PptPosition | null): number { + if (!first || !second) { + return 0 + } + + const left = Math.max(first.x, second.x) + const top = Math.max(first.y, second.y) + const right = Math.min(first.x + first.cx, second.x + second.cx) + const bottom = Math.min(first.y + first.cy, second.y + second.cy) + const width = Math.max(right - left, 0) + const height = Math.max(bottom - top, 0) + + return width * height +} + +function getCenterDistance(first?: PptPosition | null, second?: PptPosition | null): number { + if (!first || !second) { + return Number.MAX_SAFE_INTEGER + } + + const firstCenterX = first.x + first.cx / 2 + const firstCenterY = first.y + first.cy / 2 + const secondCenterX = second.x + second.cx / 2 + const secondCenterY = second.y + second.cy / 2 + + return Math.hypot(firstCenterX - secondCenterX, firstCenterY - secondCenterY) +} + +function getImageReplacementKey(slideNumber: number, image: PptImageInfo): string { + return [ + slideNumber, + image.id ?? '', + image.name ?? '', + image.relationshipId ?? '', + image.targetPath ?? '' + ].join('|') +} + +function getImageIdentifier(image: PptImageInfo, fallback: string): string { + return String(image.id ?? image.name ?? image.relationshipId ?? fallback) +} + +function describeImageValue(value?: string | null): Record { + if (!value) { + return { exists: false } + } + + return { + exists: true, + source: value.startsWith('data:image/') ? 'data-url' : 'file-path', + length: value.length, + preview: value.startsWith('data:image/') ? value.slice(0, 40) : value + } +} + +function describePosition(position?: PptPosition | null): Record | null { + if (!position) { + return null + } + + return { + x: position.x, + y: position.y, + cx: position.cx, + cy: position.cy + } +} + +function logSlideObjects(ppt: PPTXTemplater, traceId: string): void { + for (let slideNumber = 1; slideNumber <= ppt.slideCount; slideNumber += 1) { + const images = getSlideImages(ppt, slideNumber).map((image) => ({ + id: image.id, + name: image.name, + relationshipId: image.relationshipId, + targetPath: image.targetPath, + position: describePosition(image.position) + })) + const shapes = getSlideShapes(ppt, slideNumber).map((shape) => ({ + id: shape.id, + name: shape.name, + position: describePosition(shape.position) + })) + + logger.debug(`[${traceId}] PPTX 第 ${slideNumber} 页对象扫描`, { + imageCount: images.length, + images, + shapeCount: shapes.length, + shapes }) + } } -function getRelsPath(slidePath: string): string { - return slidePath.replace('ppt/slides/', 'ppt/slides/_rels/') + '.rels' -} +function findImageReplacementTargets( + ppt: PPTXTemplater, + slideNumber: number, + placeholderName: string, + usedImageKeys: Set, + traceId: string +): PptImageReplacementTarget[] { + const normalizedPlaceholderName = normalizePptObjectName(placeholderName) + const slideImages = getSlideImages(ppt, slideNumber) + const directMatchingTargets = slideImages + .filter((image) => + [image.name, image.id].some( + (identifier) => normalizePptObjectName(identifier) === normalizedPlaceholderName + ) + ) + .map((image) => ({ + slideNumber, + imageIdentifier: getImageIdentifier(image, placeholderName), + imageKey: getImageReplacementKey(slideNumber, image), + overlapArea: Number.MAX_SAFE_INTEGER, + centerDistance: 0 + })) + .filter((target) => !usedImageKeys.has(target.imageKey)) -function replaceShapeText(slideXml: string, shapeName: string, text: string): string { - return slideXml.replace(//g, (shapeXml: string) => { - if (!hasShapeName(shapeXml, shapeName)) { - return shapeXml - } + if (directMatchingTargets.length > 0) { + logger.debug(`[${traceId}] 图片占位符直接命中图片对象`, { + slideNumber, + placeholderName, + targets: directMatchingTargets + }) + return directMatchingTargets + } - const escapedText = escapeXml(text) - const textMatches = [...shapeXml.matchAll(/([\s\S]*?)<\/a:t>/g)] + const placeholderShapes = getSlideShapes(ppt, slideNumber).filter((shape) => + [shape.name, shape.id].some( + (identifier) => normalizePptObjectName(identifier) === normalizedPlaceholderName + ) + ) - if (textMatches.length === 0) { - return shapeXml - } - - let replacedFirstText = false - return shapeXml.replace(/[\s\S]*?<\/a:t>/g, () => { - if (replacedFirstText) { - return '' + // 如果模板里命名的是普通形状,而不是图片对象,就使用几何位置做兜底: + // 1. 先找与占位形状有重叠面积的图片; + // 2. 重叠面积越大越可信; + // 3. 面积相近时,中心点越近越可信; + // 4. usedImageKeys 确保 work_image_1 和 work_image_2 不会抢到同一张图片。 + const fallbackTargets = placeholderShapes + .flatMap((placeholderShape) => + slideImages.map((image) => ({ + slideNumber, + imageIdentifier: getImageIdentifier(image, placeholderName), + imageKey: getImageReplacementKey(slideNumber, image), + overlapArea: getOverlapArea(placeholderShape.position, image.position), + centerDistance: getCenterDistance(placeholderShape.position, image.position) + })) + ) + .filter((candidate) => candidate.overlapArea > 0 && !usedImageKeys.has(candidate.imageKey)) + .sort((first, second) => { + if (second.overlapArea !== first.overlapArea) { + return second.overlapArea - first.overlapArea } - replacedFirstText = true - return `${escapedText}` + return first.centerDistance - second.centerDistance }) + + logger.debug(`[${traceId}] 图片占位符按位置匹配图片对象`, { + slideNumber, + placeholderName, + placeholderShapes: placeholderShapes.map((shape) => ({ + id: shape.id, + name: shape.name, + position: describePosition(shape.position) + })), + candidateCount: fallbackTargets.length, + candidates: fallbackTargets.slice(0, 8) }) + + return fallbackTargets } -function hasShapeName(shapeXml: string, shapeName: string): boolean { - return new RegExp(`]*name="${escapeRegExp(shapeName)}"[^>]*>`).test(shapeXml) +async function updateShapeTextIfExists( + ppt: PPTXTemplater, + shapeName: string, + text: string +): Promise { + for (let slideNumber = 1; slideNumber <= ppt.slideCount; slideNumber += 1) { + try { + await ppt.useSlide(slideNumber).updateShape(shapeName, { text }) + } catch (error) { + const message = error instanceof Error ? error.message : '' + + if (!message.includes('not found')) { + throw error + } + } + } } -function replaceInlineTextPlaceholder(slideXml: string, placeholder: string, text: string): string { - const placeholderPattern = new RegExp(`\\{\\{\\s*${escapeRegExp(placeholder)}\\s*\\}\\}`, 'g') +async function replaceImageIfExists( + ppt: PPTXTemplater, + replacementPlan: PptImageReplacementPlan, + usedImageKeys: Set, + traceId: string +): Promise { + const imageBuffer = await readImageBuffer(replacementPlan.imageValue) - return slideXml.replace(/([\s\S]*?)<\/a:t>/g, (textNode, value: string) => { - placeholderPattern.lastIndex = 0 + if (!imageBuffer) { + logger.warn(`[${traceId}] 图片替换跳过:没有可读取的图片数据`, { + placeholderName: replacementPlan.placeholderName, + fieldKey: replacementPlan.fieldKey, + imageValue: describeImageValue(replacementPlan.imageValue) + }) + return + } - if (!placeholderPattern.test(value)) { - return textNode + if (usedImageKeys.has(replacementPlan.target.imageKey)) { + logger.warn(`[${traceId}] 图片替换跳过:目标图片对象已经替换过`, { + placeholderName: replacementPlan.placeholderName, + fieldKey: replacementPlan.fieldKey, + target: replacementPlan.target + }) + return + } + + try { + logger.debug(`[${traceId}] 开始使用 node-pptx-templater.replaceImage 替换图片`, { + placeholderName: replacementPlan.placeholderName, + fieldKey: replacementPlan.fieldKey, + imageValue: describeImageValue(replacementPlan.imageValue), + bufferBytes: imageBuffer.length, + target: replacementPlan.target + }) + + await ppt + .useSlide(replacementPlan.target.slideNumber) + .replaceImage(replacementPlan.target.imageIdentifier, imageBuffer) + usedImageKeys.add(replacementPlan.target.imageKey) + logger.info(`[${traceId}] node-pptx-templater.replaceImage 图片替换成功`, { + placeholderName: replacementPlan.placeholderName, + fieldKey: replacementPlan.fieldKey, + target: replacementPlan.target + }) + } catch (error) { + const message = error instanceof Error ? error.message : '' + + if (!message.includes('not found')) { + logger.error(`[${traceId}] node-pptx-templater.replaceImage 图片替换失败`, { + placeholderName: replacementPlan.placeholderName, + fieldKey: replacementPlan.fieldKey, + target: replacementPlan.target, + error + }) + throw error } - placeholderPattern.lastIndex = 0 - return `${value.replace(placeholderPattern, escapeXml(text))}` - }) + logger.warn(`[${traceId}] 图片替换跳过:目标在当前页未找到`, { + placeholderName: replacementPlan.placeholderName, + fieldKey: replacementPlan.fieldKey, + target: replacementPlan.target, + message + }) + } } -async function replaceTextPlaceholders( - zip: JSZip, - replacements: Record -): Promise { - for (const slidePath of getSlidePaths(zip)) { - const slideFile = zip.file(slidePath) +function buildImageReplacementPlans( + ppt: PPTXTemplater, + replacements: Record, + getImageValue: (fieldKey: string) => string | null | undefined, + traceId: string +): PptImageReplacementPlan[] { + const plans: PptImageReplacementPlan[] = [] + const usedImageKeys = new Set() + const usedFieldSlideKeys = new Set() - if (!slideFile) { + for (const [placeholderName, fieldKey] of Object.entries(replacements)) { + const imageValue = getImageValue(fieldKey) + + logger.debug(`[${traceId}] 开始规划图片占位符`, { + placeholderName, + fieldKey, + imageValue: describeImageValue(imageValue) + }) + + if (!imageValue) { + logger.warn(`[${traceId}] 图片占位符跳过:字段没有图片`, { + placeholderName, + fieldKey + }) continue } - let slideXml = await slideFile.async('text') + for (let slideNumber = 1; slideNumber <= ppt.slideCount; slideNumber += 1) { + const fieldSlideKey = `${fieldKey}|${slideNumber}` - for (const [shapeName, text] of Object.entries(replacements)) { - slideXml = replaceShapeText(slideXml, shapeName, text) - slideXml = replaceInlineTextPlaceholder(slideXml, shapeName, text) - } + if (usedFieldSlideKeys.has(fieldSlideKey)) { + continue + } - zip.file(slidePath, slideXml) - } -} + const target = findImageReplacementTargets( + ppt, + slideNumber, + placeholderName, + usedImageKeys, + traceId + )[0] -function findPictureShape(slideXml: string, shapeName: string): ShapeLocation['shapeXml'] | null { - const pictureShapes = slideXml.match(//g) ?? [] + if (!target) { + logger.debug(`[${traceId}] 当前页没有找到可替换图片目标`, { + slideNumber, + placeholderName, + fieldKey + }) + continue + } - return pictureShapes.find((shapeXml) => hasShapeName(shapeXml, shapeName)) ?? null -} - -function getEmbedRelId(shapeXml: string): string | undefined { - return shapeXml.match(/r:embed="([^"]+)"/)?.[1] -} - -function findShapeLocation( - shapeName: string, - slideXmlByPath: Map -): ShapeLocation | null { - for (const [slidePath, slideXml] of slideXmlByPath.entries()) { - const shapeXml = findPictureShape(slideXml, shapeName) - - if (!shapeXml) { - continue - } - - return { - relsPath: getRelsPath(slidePath), - shapeXml, - embedRelId: getEmbedRelId(shapeXml) + // 这里先登记,再执行真正替换。 + // 原因是 node-pptx-templater 的 replaceImage 会修改 PPTX 内部 relationship, + // 如果边扫描边替换,后续 getImages() 看到的 targetPath 可能已经变了, + // 容易让第二个占位符匹配到刚被第一个占位符替换过的图片。 + plans.push({ + placeholderName, + fieldKey, + imageValue, + target + }) + usedImageKeys.add(target.imageKey) + usedFieldSlideKeys.add(fieldSlideKey) + logger.info(`[${traceId}] 图片替换计划已生成`, { + placeholderName, + fieldKey, + slideNumber, + target + }) } } - return null -} + logger.info(`[${traceId}] 图片替换计划汇总`, { + planCount: plans.length, + plans: plans.map((plan) => ({ + placeholderName: plan.placeholderName, + fieldKey: plan.fieldKey, + imageValue: describeImageValue(plan.imageValue), + target: plan.target + })) + }) -async function loadSlideXmlByPath(zip: JSZip): Promise> { - const slideXmlByPath = new Map() + const relationshipKeyCounts = new Map() - for (const slidePath of getSlidePaths(zip)) { - const slideFile = zip.file(slidePath) - - if (slideFile) { - slideXmlByPath.set(slidePath, await slideFile.async('text')) - } + for (const plan of plans) { + const relationshipKey = plan.target.imageKey.split('|').slice(3).join('|') + relationshipKeyCounts.set(relationshipKey, (relationshipKeyCounts.get(relationshipKey) ?? 0) + 1) } - return slideXmlByPath -} - -async function replacePicturePlaceholder( - zip: JSZip, - slideXmlByPath: Map, - shapeName: string, - imageDataUrl?: string -): Promise { - if (!imageDataUrl) { - return - } - - const location = findShapeLocation(shapeName, slideXmlByPath) - - if (!location?.embedRelId) { - return - } - - const relsFile = zip.file(location.relsPath) - - if (!relsFile) { - return - } - - const relsXml = await relsFile.async('text') - const relationshipPattern = new RegExp( - `(]*Id="${escapeRegExp(location.embedRelId)}"[^>]*Target=")([^"]+)("[^>]*/>)` + const sharedRelationshipKeys = Array.from(relationshipKeyCounts.entries()).filter( + ([, count]) => count > 1 ) - const target = relsXml.match(relationshipPattern)?.[2] - if (!target) { - return - } - - const mediaPath = `ppt/${target.replace(/^\.\.\//, '')}` - const imageMatch = imageDataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/) - - if (!imageMatch) { - return - } - - const mimeType = imageMatch[1] - const extension = mimeType.includes('png') ? 'png' : mimeType.includes('bmp') ? 'bmp' : 'jpg' - const targetMediaPath = mediaPath.replace(/\.[^.]+$/, `.${extension}`) - - zip.file(targetMediaPath, Buffer.from(imageMatch[2], 'base64')) - - if (targetMediaPath !== mediaPath) { - zip.remove(mediaPath) - zip.file( - location.relsPath, - relsXml.replace(relationshipPattern, `$1${target.replace(/\.[^.]+$/, `.${extension}`)}$3`) + if (sharedRelationshipKeys.length > 0) { + logger.warn( + `[${traceId}] 图片替换计划发现共享 relationship,请确认模板中的占位图片不是复制同一张图片资源`, + { sharedRelationshipKeys } ) - await ensureImageContentType(zip, extension, mimeType) - } -} - -async function ensureImageContentType( - zip: JSZip, - extension: string, - mimeType: string -): Promise { - const contentTypesFile = zip.file(CONTENT_TYPES_PATH) - - if (!contentTypesFile) { - return } - const contentTypesXml = await contentTypesFile.async('text') - - if (contentTypesXml.includes(`Extension="${extension}"`)) { - return - } - - zip.file( - CONTENT_TYPES_PATH, - contentTypesXml.replace( - '', - `` - ) - ) + return plans } async function buildPptxReport( template: TemplateEntity, outputPath: string, student: StudentProfileEntity, - classItem: ClassEntity + classItem: ClassEntity, + traceId: string ): Promise { - const templateBuffer = await readFile(resolveTemplateFilePath(template.filePath)) - const zip = await JSZip.loadAsync(templateBuffer) - const studentData = getStudentData(student) - - await replaceTextPlaceholders(zip, buildTextReplacements(template, studentData, classItem)) - - const slideXmlByPath = await loadSlideXmlByPath(zip) - - for (const [placeholder, fieldKey] of Object.entries(buildImageReplacements(template))) { - if (fieldKey === 'familyPhoto') { - await replacePicturePlaceholder( - zip, - slideXmlByPath, - placeholder, - classItem.familyPhoto ?? undefined - ) - } - } - - const outputBuffer = await zip.generateAsync({ - type: 'nodebuffer', - compression: 'DEFLATE' + const templateFilePath = resolveTemplateFilePath(template.filePath) + logger.info(`[${traceId}] 开始加载 PPTX 模板`, { + templateId: template.id, + templateName: template.name, + templateFilePath, + outputPath, + studentId: student.id, + studentName: student.name, + classId: classItem.id, + className: classItem.name }) - await writeFile(outputPath, outputBuffer) + const ppt = await PPTXTemplater.load(templateFilePath, { + logLevel: 'silent' + }) + const studentData = getStudentData(student, classItem) + const textReplacements = buildTextReplacements(studentData, classItem) + const replacedImageKeys = new Set() + + logger.info(`[${traceId}] PPTX 模板加载完成`, { + slideCount: ppt.slideCount, + textReplacementKeys: Object.keys(textReplacements), + imageReplacementAliases: buildImageReplacements() + }) + logSlideObjects(ppt, traceId) + + // 文本替换分两步: + // 1. replaceText({ '{{name}}': '张三' }) 处理文本框里的 Mustache 风格占位符; + // 2. updateShape('name', { text }) 处理“形状对象名就是字段名”的模板。 + ppt.replaceText( + Object.fromEntries( + Object.entries(textReplacements).map(([placeholder, value]) => [`{{${placeholder}}}`, value]) + ) + ) + + for (const [shapeName, text] of Object.entries(textReplacements)) { + await updateShapeTextIfExists(ppt, shapeName, text) + } + logger.debug(`[${traceId}] 文本替换完成`, { + replacementCount: Object.keys(textReplacements).length + }) + + const imageReplacementPlans = buildImageReplacementPlans( + ppt, + buildImageReplacements(), + (fieldKey) => + fieldKey === 'familyPhoto' + ? classItem.familyPhoto + : student[fieldKey as 'meImage' | 'workImage1' | 'workImage2'], + traceId + ) + + for (const imageReplacementPlan of imageReplacementPlans) { + await replaceImageIfExists(ppt, imageReplacementPlan, replacedImageKeys, traceId) + } + + await ppt.saveToFile(outputPath) + logger.info(`[${traceId}] PPTX 报告保存完成`, { + outputPath, + replacedImageCount: replacedImageKeys.size + }) } async function removeFileIfExists(filePath: string): Promise { @@ -578,16 +807,59 @@ export async function generateReports(input: GenerateReportsInput): Promise<{ const now = new Date().toISOString() const reports: ReportItem[] = [] const skipped: Array<{ studentName: string; reason: string }> = [] + const batchTraceId = randomUUID() - for (const student of targetStudents) { + logger.info(`[${batchTraceId}] 开始批量生成报告`, { + templateId: template.id, + templateName: template.name, + templateFilePath: resolveTemplateFilePath(template.filePath), + classId: classItem.id, + className: classItem.name, + selectedStudentCount: targetStudents.length, + selectedIds: Array.from(selectedIds) + }) + sendReportProgress({ + batchId: batchTraceId, + status: 'started', + classId: classItem.id, + className: classItem.name, + current: 0, + total: targetStudents.length + }) + + for (const [studentIndex, student] of targetStudents.entries()) { const studentName = student.name || student.englishName || '未命名学生' const title = `${classItem.name} ${studentName} 幼儿成长报告` const reportId = randomUUID() const fileName = `${sanitizeFileName(title)}${template.fileExtension}` const outputPath = join(getReportStoragePath(), `${reportId}${template.fileExtension}`) + const traceId = `${batchTraceId}:${student.id}` try { - await buildPptxReport(template, outputPath, student, classItem) + sendReportProgress({ + batchId: batchTraceId, + status: 'student-started', + classId: classItem.id, + className: classItem.name, + studentId: student.id, + studentName, + current: studentIndex + 1, + total: targetStudents.length + }) + logger.info(`[${traceId}] 开始生成单个学生报告`, { + studentId: student.id, + studentName, + reportId, + outputPath, + imageFields: { + meImage: describeImageValue(student.meImage), + workImage1: describeImageValue(student.workImage1), + workImage2: describeImageValue(student.workImage2), + familyPhoto: describeImageValue(classItem.familyPhoto) + } + }) + + await buildPptxReport(template, outputPath, student, classItem, traceId) const entity: ReportEntity = { id: reportId, @@ -606,16 +878,66 @@ export async function generateReports(input: GenerateReportsInput): Promise<{ } await reportRepository.save(entity) - reports.push(mapReportEntity(entity)) + await studentRepository.update({ id: student.id }, { reportGenerated: true }) + const reportItem = mapReportEntity(entity) + reports.push(reportItem) + sendReportProgress({ + batchId: batchTraceId, + status: 'student-finished', + classId: classItem.id, + className: classItem.name, + studentId: student.id, + studentName, + current: studentIndex + 1, + total: targetStudents.length, + report: reportItem + }) + logger.info(`[${traceId}] 单个学生报告生成成功`, { + reportId, + outputPath + }) } catch (error) { + const reason = error instanceof Error ? error.message : '生成失败' + logger.error(`[${traceId}] 单个学生报告生成失败`, { + studentId: student.id, + studentName, + reportId, + outputPath, + error + }) skipped.push({ studentName, - reason: error instanceof Error ? error.message : '生成失败' + reason + }) + sendReportProgress({ + batchId: batchTraceId, + status: 'student-failed', + classId: classItem.id, + className: classItem.name, + studentId: student.id, + studentName, + current: studentIndex + 1, + total: targetStudents.length, + error: reason }) await removeFileIfExists(outputPath) } } + logger.info(`[${batchTraceId}] 批量生成报告完成`, { + successCount: reports.length, + skippedCount: skipped.length, + skipped + }) + sendReportProgress({ + batchId: batchTraceId, + status: 'finished', + classId: classItem.id, + className: classItem.name, + current: targetStudents.length, + total: targetStudents.length + }) + return { reports, skipped } } @@ -629,14 +951,105 @@ export async function openReport(filePath: string): Promise { } } -export async function deleteReport(id: string): Promise { +export async function downloadReport(id: string): Promise<{ filePath: string; canceled?: boolean }> { const repository = await getReportRepository() const report = await repository.findOneBy({ id }) + if (!report) { + throw new Error('报告不存在') + } + + await access(report.filePath) + + const result = await dialog.showSaveDialog({ + title: '下载报告', + defaultPath: getReportDownloadFileName(report), + filters: [ + { + name: '报告文件', + extensions: [report.fileExtension.replace(/^\./, '') || 'pptx'] + } + ] + }) + + if (result.canceled || !result.filePath) { + return { filePath: '', canceled: true } + } + + await copyFile(report.filePath, result.filePath) + return { filePath: result.filePath } +} + +export async function downloadClassReports( + input: DownloadClassReportsInput +): Promise<{ filePath: string; reportCount: number; canceled?: boolean }> { + const repository = await getReportRepository() + const reports = await repository.find({ + where: { + classId: input.classId + }, + order: { + studentName: 'ASC', + createdAt: 'DESC' + } + }) + + if (reports.length === 0) { + throw new Error('当前班级还没有可下载的报告') + } + + const className = reports[0]?.className || '班级报告' + const result = await dialog.showSaveDialog({ + title: '下载班级报告', + defaultPath: `${sanitizeFileName(className)}成长报告.zip`, + filters: [{ name: 'ZIP 压缩包', extensions: ['zip'] }] + }) + + if (result.canceled || !result.filePath) { + return { filePath: '', reportCount: reports.length, canceled: true } + } + + const zip = new JSZip() + const usedFileNames = new Map() + + for (const report of reports) { + await access(report.filePath) + const baseFileName = getReportDownloadFileName(report) + const usedCount = usedFileNames.get(baseFileName) ?? 0 + usedFileNames.set(baseFileName, usedCount + 1) + const fileName = + usedCount === 0 + ? baseFileName + : `${baseFileName.replace(report.fileExtension, '')}-${usedCount + 1}${report.fileExtension}` + + zip.file(fileName, await readFile(report.filePath)) + } + + const zipBuffer = await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE' + }) + await writeFile(result.filePath, zipBuffer) + + return { filePath: result.filePath, reportCount: reports.length } +} + +export async function deleteReport(id: string): Promise { + const source = await getAppDataSource() + const repository = source.getRepository(ReportEntitySchema) + const studentRepository = source.getRepository(StudentProfileEntitySchema) + const report = await repository.findOneBy({ id }) + if (!report) { return } await repository.delete({ id }) await removeFileIfExists(report.filePath) + + const remainingReportCount = await repository.countBy({ studentId: report.studentId }) + + if (remainingReportCount === 0) { + await studentRepository.update({ id: report.studentId }, { reportGenerated: false }) + } } diff --git a/src/main/services/studentService.ts b/src/main/services/studentService.ts index 4450957..d95b5bc 100644 --- a/src/main/services/studentService.ts +++ b/src/main/services/studentService.ts @@ -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 { + if (!isDataUrlImage(entity.familyPhoto)) { + return entity + } + + return { + ...entity, + familyPhoto: await storeImageValue('classes', entity.id, 'familyPhoto', entity.familyPhoto) + } +} + +async function hydrateClassImages(entity: ClassEntity): Promise { + if (!entity.familyPhoto) { + return entity + } + + return { + ...entity, + familyPhoto: await readImageAsDataUrl(entity.familyPhoto) + } +} + +async function normalizeStoredStudentImages( + entity: StudentProfileEntity +): Promise { + 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 { + 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 { 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 { + 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({ - order: { - id: 'ASC' + 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 { + const source = await getAppDataSource() + const repository = source.getRepository(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 { const source = await getAppDataSource() const repository = source.getRepository(StudentProfileEntitySchema) @@ -210,8 +364,9 @@ export async function updateStudentProfile(profile: ChildProfile): Promise { @@ -291,8 +446,9 @@ export async function generateStudentComment(profileId: string): Promise { @@ -312,7 +468,7 @@ export async function saveClasses(classes: ClassProfile[]): Promise { } 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(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 { throw new Error('学生信息不存在') } } + +export async function migrateStoredImagesToFiles(): Promise { + const source = await getAppDataSource() + const classRepository = source.getRepository(ClassEntitySchema) + const childRepository = source.getRepository(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) + } + } +} diff --git a/src/main/services/templateService.ts b/src/main/services/templateService.ts index eb5529a..2b1db8e 100644 --- a/src/main/services/templateService.ts +++ b/src/main/services/templateService.ts @@ -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, name: string, @@ -195,17 +202,24 @@ async function parsePptxPlaceholders(filePath: string): Promise]*name="([^"]+)"/g)) { - const rawName = shapeMatch[1] - const name = decodeXml(rawName) + for (const shapeMatch of slideXml.matchAll(/]*>/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 first.name.localeCompare(second.name, 'zh-CN') ) @@ -309,16 +327,6 @@ export async function saveTemplate(input: TemplateInput): Promise : 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 filePath: nextStoredFilePath, fileExtension: extension, placeholders: JSON.stringify(placeholders), - fieldMappings: JSON.stringify(fieldMappings), + fieldMappings: '{}', createdAt: existingTemplate?.createdAt ?? now, updatedAt: now } diff --git a/src/main/types/report.ts b/src/main/types/report.ts index 396ad3b..1ab6d3b 100644 --- a/src/main/types/report.ts +++ b/src/main/types/report.ts @@ -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 + } diff --git a/src/main/types/student.ts b/src/main/types/student.ts index c631a59..ee4f771 100644 --- a/src/main/types/student.ts +++ b/src/main/types/student.ts @@ -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 } diff --git a/src/main/types/template.ts b/src/main/types/template.ts index 9a9a6a2..0756aba 100644 --- a/src/main/types/template.ts +++ b/src/main/types/template.ts @@ -6,8 +6,6 @@ export type TemplatePlaceholder = { kind: 'text' | 'image' } -export type TemplateFieldMapping = Record - 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 = diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 7b86b6d..348dede 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 - 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 deleteStudentProfile: (profileId: string) => Promise @@ -246,13 +289,14 @@ type AppAPI = { classId: string childNames: string[] }) => Promise - loadStudentData: () => Promise + loadStudentData: (payload?: LoadStudentDataInput) => Promise listStudentProfiles: (payload: { page: number pageSize: number query?: string classId?: string }) => Promise + getStudentProfile: (profileId: string) => Promise updateStudentProfile: (profile: ChildProfile) => Promise generateStudentComment: (payload: { profileId: string @@ -281,7 +325,6 @@ type AppAPI = { semester: TemplateSemester type: TemplateType sourceFilePath: string - fieldMappings?: TemplateFieldMapping }) => Promise openTemplate: (filePath: string) => Promise deleteTemplate: (id: string) => Promise @@ -292,6 +335,11 @@ type AppAPI = { studentIds?: string[] }) => Promise openReport: (filePath: string) => Promise + downloadReport: (id: string) => Promise + downloadClassReports: (payload: { classId: string }) => Promise + onReportGenerationProgress: ( + callback: (progress: ReportGenerationProgress) => void + ) => () => void deleteReport: (id: string) => Promise } diff --git a/src/preload/index.ts b/src/preload/index.ts index b80adc0..1b0ed69 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 }) => 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[0]) => + callback(progress) + + ipcRenderer.on('reports:generation-progress', listener) + return () => ipcRenderer.removeListener('reports:generation-progress', listener) + }, deleteReport: (id: string) => ipcRenderer.invoke('reports:delete', id) } diff --git a/src/renderer/src/components/ui/combobox.tsx b/src/renderer/src/components/ui/combobox.tsx new file mode 100644 index 0000000..ed90a41 --- /dev/null +++ b/src/renderer/src/components/ui/combobox.tsx @@ -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(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 { + return React.useRef(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 ( + +
{children}
+
+ ) +} + +const ComboboxChips = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, onClick, ...props }, ref) => { + const { setOpen } = useComboboxContext() + + return ( +
{ + 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 ( + + {children} + + + ) +} + +function ComboboxChipsInput( + props: React.InputHTMLAttributes +): React.JSX.Element { + const { inputValue, setInputValue, setOpen, selectValue } = useComboboxContext() + + return ( + { + 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 & { + anchor?: React.RefObject +}): React.JSX.Element | null { + const { open } = useComboboxContext() + + if (!open) { + return null + } + + return ( +
event.preventDefault()} + > + {children} +
+ ) +} + +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
{children}
+} + +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 ( +
+ {visibleItems.map((item) => children(item))} + {shouldShowCustomValue ? children(inputValue.trim()) : null} +
+ ) +} + +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 ( + + ) +} + +export { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue +} diff --git a/src/renderer/src/pages/ClassPage.tsx b/src/renderer/src/pages/ClassPage.tsx index 403bea1..b1c54e8 100644 --- a/src/renderer/src/pages/ClassPage.tsx +++ b/src/renderer/src/pages/ClassPage.tsx @@ -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 = { + 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 = { + 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 +): 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(null) + const photoZipInputRef = useRef(null) const classPhotoInputRef = useRef(null) const editPhotoInputRef = useRef(null) const [classes, setClasses] = useState([]) const [profiles, setProfiles] = useState([]) const [className, setClassName] = useState('') const [classType, setClassType] = useState('cheap') + const [classTeacherNames, setClassTeacherNames] = useState([]) const [classFamilyPhoto, setClassFamilyPhoto] = useState('') const [query, setQuery] = useState('') const [createDrawerOpen, setCreateDrawerOpen] = useState(false) const [exportingId, setExportingId] = useState('') const [editingClass, setEditingClass] = useState(null) const [uploadingClass, setUploadingClass] = useState(null) + const [photoZipClass, setPhotoZipClass] = useState(null) const [selectedFile, setSelectedFile] = useState(null) const [isDragActive, setIsDragActive] = useState(false) useEffect(() => { async function loadStudentData(): Promise { - 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): Promise { + 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() + + 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>() + 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 ( <>
@@ -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" />
@@ -466,8 +707,10 @@ export function ClassPage(): React.JSX.Element {

{childCount} 条

-

班级类型

-

{getClassTypeLabel(classItem.type)}

+

班级教师

+

+ {formatTeacherNames(classItem.teacherNames) || '未填写'} +

@@ -486,6 +729,10 @@ export function ClassPage(): React.JSX.Element { 上传幼儿信息 + openPhotoZipPicker(classItem)}> + + 导入照片 ZIP + handleExportClass(classItem)} @@ -538,6 +785,14 @@ export function ClassPage(): React.JSX.Element { + + @@ -545,7 +800,7 @@ export function ClassPage(): React.JSX.Element {
新建班级 - 填写班级名称、类型,并上传班级全家福。 + 填写班级名称、教师、类型,并上传班级全家福。
+
+ +
+ {progressCards.map((progress) => ( + + +
+
+ +
+ + {progress.status === 'student-failed' ? '生成失败' : '生成中'} + +
+
+ + {progress.studentName || '学生'} 正在生成报告 + + + {progress.className} · {progress.current}/{progress.total} + +
+
+ +
+
+
+ {progress.error ? ( +

{progress.error}

+ ) : null} + + + ))} + {filteredReports.map((report) => { const ReportIcon = getReportIcon(report.format) @@ -327,6 +506,15 @@ export function ReportPage(): React.JSX.Element {
+ @@ -505,10 +518,7 @@ export function StudentPage(): React.JSX.Element { 学生操作 { - setSelectedId(profile.id) - setEditingProfile(profile) - }} + onSelect={() => void openProfilePanel(profile, 'edit')} > 编辑学生信息 @@ -570,7 +580,7 @@ export function StudentPage(): React.JSX.Element { })} {profiles.length === 0 && ( - + 暂无匹配数据,请先在班级管理中上传幼儿 xlsx 表格 @@ -682,6 +692,10 @@ export function StudentPage(): React.JSX.Element { ) : (
+ + @@ -709,6 +727,7 @@ export function StudentPage(): React.JSX.Element { value={activeProfile.commentGeneratedAt || '未生成'} /> +
)}
@@ -732,6 +751,34 @@ function ProfileField({ label, value }: { label: string; value: string }): React ) } +function ProfileImageGallery({ profile }: { profile: ChildProfile }): React.JSX.Element { + return ( +
+
幼儿照片
+
+ + + +
+
+ ) +} + +function ProfileImagePreview({ label, src }: { label: string; src: string }): React.JSX.Element { + return ( +
+
+ {src ? ( + {label} + ) : ( + + )} +
+

{label}

+
+ ) +} + 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 (
@@ -823,6 +901,29 @@ function StudentProfileForm({ multiline onChange={(value) => updateTextField('comment', value)} /> +
+

幼儿照片

+
+ updateImageField('meImage', file)} + onClear={() => clearImageField('meImage')} + /> + updateImageField('workImage1', file)} + onClear={() => clearImageField('workImage1')} + /> + updateImageField('workImage2', file)} + onClear={() => clearImageField('workImage2')} + /> +
+
) } @@ -853,3 +954,42 @@ function EditableField({ ) } + +function EditableImageField({ + label, + value, + onChange, + onClear +}: { + label: string + value: string + onChange: (file?: File) => void + onClear: () => void +}): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/pages/TemplatePage.tsx b/src/renderer/src/pages/TemplatePage.tsx index 27fa398..52ee875 100644 --- a/src/renderer/src/pages/TemplatePage.tsx +++ b/src/renderer/src/pages/TemplatePage.tsx @@ -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(emptyTemplateForm) - const [mappingDialog, setMappingDialog] = useState(emptyMappingDialog) const [saving, setSaving] = useState(false) - const [mappingSaving, setMappingSaving] = useState(false) const [openingId, setOpeningId] = useState('') const [deletingTemplate, setDeletingTemplate] = useState(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 { - 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 { 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 { 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 { - 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, fieldKey: string): void { - event.dataTransfer.setData('application/x-student-field', fieldKey) - event.dataTransfer.effectAllowed = 'copy' - } - - function handlePlaceholderDrop( - event: React.DragEvent, - 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 { 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 {

-

字段对照

-

- {Object.keys(template.fieldMappings).length}/ - {template.placeholders.length} -

+

占位符

+

{template.placeholders.length} 个

@@ -649,13 +475,6 @@ export function TemplatePage(): React.JSX.Element { 编辑信息
- void openMappingDialog(template)} - > - - 字段关联 - setDeletingTemplate(template)} @@ -827,127 +646,6 @@ export function TemplatePage(): React.JSX.Element { - { - if (!open) { - setMappingDialog(emptyMappingDialog) - } - }} - > - - - 字段关联 - - {mappingDialog.template - ? `为「${mappingDialog.template.name}」选择模板占位符和学生字段的对应关系。` - : '选择模板占位符和学生字段的对应关系。'} - - - -
-
- 模板占位符 - 绑定学生字段 -
-
- {mappingDialog.placeholders.map((placeholder) => { - const mappedField = mappingDialog.fieldMappings[placeholder.name] ?? '' - - return ( -
event.preventDefault()} - onDrop={(event) => handlePlaceholderDrop(event, placeholder)} - > -
-
- - {placeholder.kind === 'image' ? '图片' : '文本'} - -

{placeholder.name}

-
-

- {mappedField ? `已绑定:${getStudentFieldLabel(mappedField)}` : '未绑定'} -

-
-
- - -
-
- ) - })} - - {mappingDialog.placeholders.length === 0 && ( -
- 当前模板没有解析到可关联的占位符 -
- )} -
-
- -
-
-

可拖拽的学生字段

-

- 已匹配 {Object.keys(mappingDialog.fieldMappings).length}/ - {mappingDialog.placeholders.length} -

-
-
- {studentFieldOptions.map((field) => ( - - ))} -
-
- - - - - -
-
- setDeletingTemplate(null)}> diff --git a/src/renderer/src/student/children.ts b/src/renderer/src/student/children.ts index feebb8d..b7a305b 100644 --- a/src/renderer/src/student/children.ts +++ b/src/renderer/src/student/children.ts @@ -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 | 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 ?? '' } } diff --git a/src/renderer/src/student/classes.ts b/src/renderer/src/student/classes.ts index 7ab5e5e..aa6713b 100644 --- a/src/renderer/src/student/classes.ts +++ b/src/renderer/src/student/classes.ts @@ -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 | id: classItem.id, name: classItem.name, type: classItem.type === 'noble' ? 'noble' : 'cheap', + teacherNames: normalizeTeacherNames(classItem), familyPhoto: classItem.familyPhoto } } +function normalizeTeacherNames(classItem: Partial & { 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 ?? '便宜班' } diff --git a/src/renderer/src/types/app.ts b/src/renderer/src/types/app.ts index efada99..5cc497b 100644 --- a/src/renderer/src/types/app.ts +++ b/src/renderer/src/types/app.ts @@ -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 - 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