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