fix: 完善学习进度同步与拼图收集流程
统一阶段拼图配置和进度上报,增加同步写入协调与失败重试,优化收集确认及同步反馈,并展示菜单探索进度。补充相关回归测试与字体素材。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6491b0c918
commit
9ab67b1bd3
63 files changed
+2141
-360
No files matched your search
@@ -48,6 +48,7 @@
|
|||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.46.1",
|
"typescript-eslint": "^8.46.1",
|
||||||
"vite": "^7.1.7",
|
"vite": "^7.1.7",
|
||||||
|
"vitest": "^5.0.1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -348,6 +349,10 @@
|
|||||||
|
|
||||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||||
|
|
||||||
|
"@types/chai": ["@types/chai@5.2.3", "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||||
|
|
||||||
|
"@types/deep-eql": ["@types/deep-eql@4.0.2", "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||||
|
|
||||||
"@types/doctrine": ["@types/doctrine@0.0.9", "https://registry.npmmirror.com/@types/doctrine/-/doctrine-0.0.9.tgz", {}, "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA=="],
|
"@types/doctrine": ["@types/doctrine@0.0.9", "https://registry.npmmirror.com/@types/doctrine/-/doctrine-0.0.9.tgz", {}, "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
"@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
@@ -390,6 +395,8 @@
|
|||||||
|
|
||||||
"@vitest/expect": ["@vitest/expect@2.0.5", "https://registry.npmmirror.com/@vitest/expect/-/expect-2.0.5.tgz", { "dependencies": { "@vitest/spy": "2.0.5", "@vitest/utils": "2.0.5", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" } }, "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA=="],
|
"@vitest/expect": ["@vitest/expect@2.0.5", "https://registry.npmmirror.com/@vitest/expect/-/expect-2.0.5.tgz", { "dependencies": { "@vitest/spy": "2.0.5", "@vitest/utils": "2.0.5", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" } }, "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA=="],
|
||||||
|
|
||||||
|
"@vitest/mocker": ["@vitest/mocker@5.0.1", "https://registry.npmmirror.com/@vitest/mocker/-/mocker-5.0.1.tgz", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.1", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q=="],
|
||||||
|
|
||||||
"@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
|
"@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
|
||||||
|
|
||||||
"@vitest/spy": ["@vitest/spy@2.0.5", "https://registry.npmmirror.com/@vitest/spy/-/spy-2.0.5.tgz", { "dependencies": { "tinyspy": "^3.0.0" } }, "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA=="],
|
"@vitest/spy": ["@vitest/spy@2.0.5", "https://registry.npmmirror.com/@vitest/spy/-/spy-2.0.5.tgz", { "dependencies": { "tinyspy": "^3.0.0" } }, "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA=="],
|
||||||
@@ -450,7 +457,7 @@
|
|||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001774", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001774", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
|
||||||
|
|
||||||
"chai": ["chai@5.3.3", "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
"chai": ["chai@6.2.2", "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||||
|
|
||||||
"chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
@@ -520,6 +527,8 @@
|
|||||||
|
|
||||||
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
|
"es-module-lexer": ["es-module-lexer@2.3.2", "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.2.tgz", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||||
|
|
||||||
"es-object-atoms": ["es-object-atoms@1.1.1", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
"es-object-atoms": ["es-object-atoms@1.1.1", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||||
|
|
||||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||||
@@ -560,6 +569,8 @@
|
|||||||
|
|
||||||
"esutils": ["esutils@2.0.3", "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
"esutils": ["esutils@2.0.3", "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||||
|
|
||||||
|
"expect-type": ["expect-type@1.4.0", "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
|
||||||
|
|
||||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
"fast-diff": ["fast-diff@1.3.0", "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
"fast-diff": ["fast-diff@1.3.0", "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
||||||
@@ -766,6 +777,8 @@
|
|||||||
|
|
||||||
"node-releases": ["node-releases@2.0.27", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
"node-releases": ["node-releases@2.0.27", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||||
|
|
||||||
|
"obug": ["obug@2.2.1", "https://registry.npmmirror.com/obug/-/obug-2.2.1.tgz", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="],
|
||||||
|
|
||||||
"open": ["open@8.4.2", "https://registry.npmmirror.com/open/-/open-8.4.2.tgz", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
"open": ["open@8.4.2", "https://registry.npmmirror.com/open/-/open-8.4.2.tgz", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||||
|
|
||||||
"option": ["option@0.2.4", "https://registry.npmmirror.com/option/-/option-0.2.4.tgz", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
|
"option": ["option@0.2.4", "https://registry.npmmirror.com/option/-/option-0.2.4.tgz", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
|
||||||
@@ -868,6 +881,8 @@
|
|||||||
|
|
||||||
"shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
"shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
|
"siginfo": ["siginfo@2.0.0", "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||||
|
|
||||||
"signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
"signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
@@ -876,6 +891,10 @@
|
|||||||
|
|
||||||
"sprintf-js": ["sprintf-js@1.0.3", "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
"sprintf-js": ["sprintf-js@1.0.3", "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||||
|
|
||||||
|
"stackback": ["stackback@0.0.2", "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||||
|
|
||||||
|
"std-env": ["std-env@4.2.0", "https://registry.npmmirror.com/std-env/-/std-env-4.2.0.tgz", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
|
||||||
|
|
||||||
"storybook": ["storybook@8.6.17", "https://registry.npmmirror.com/storybook/-/storybook-8.6.17.tgz", { "dependencies": { "@storybook/core": "8.6.17" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": { "getstorybook": "./bin/index.cjs", "sb": "./bin/index.cjs", "storybook": "./bin/index.cjs" } }, "sha512-krR/l680A6qVnkGiK9p8jY0ucX3+kFCs2f4zw+S3w2Cdq8EiM/tFebPcX2V4S3z2UsO0v0dwAJOJNpzbFPdmVg=="],
|
"storybook": ["storybook@8.6.17", "https://registry.npmmirror.com/storybook/-/storybook-8.6.17.tgz", { "dependencies": { "@storybook/core": "8.6.17" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": { "getstorybook": "./bin/index.cjs", "sb": "./bin/index.cjs", "storybook": "./bin/index.cjs" } }, "sha512-krR/l680A6qVnkGiK9p8jY0ucX3+kFCs2f4zw+S3w2Cdq8EiM/tFebPcX2V4S3z2UsO0v0dwAJOJNpzbFPdmVg=="],
|
||||||
|
|
||||||
"string-width": ["string-width@5.1.2", "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
"string-width": ["string-width@5.1.2", "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||||
@@ -908,6 +927,10 @@
|
|||||||
|
|
||||||
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||||
|
|
||||||
|
"tinybench": ["tinybench@6.1.4", "https://registry.npmmirror.com/tinybench/-/tinybench-6.1.4.tgz", {}, "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ=="],
|
||||||
|
|
||||||
|
"tinyexec": ["tinyexec@1.3.0", "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.0.tgz", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
"tinyrainbow": ["tinyrainbow@1.2.0", "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
|
"tinyrainbow": ["tinyrainbow@1.2.0", "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
|
||||||
@@ -948,12 +971,16 @@
|
|||||||
|
|
||||||
"vite": ["vite@7.3.1", "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
"vite": ["vite@7.3.1", "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||||
|
|
||||||
|
"vitest": ["vitest@5.0.1", "https://registry.npmmirror.com/vitest/-/vitest-5.0.1.tgz", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.1", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "6.1.4", "tinyexec": "1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.1", "@vitest/browser-preview": "5.0.1", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.1", "@vitest/coverage-v8": "5.0.1", "@vitest/ui": "5.0.1", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg=="],
|
||||||
|
|
||||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||||
|
|
||||||
"which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
"which-typed-array": ["which-typed-array@1.1.20", "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
|
"which-typed-array": ["which-typed-array@1.1.20", "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
|
||||||
|
|
||||||
|
"why-is-node-running": ["why-is-node-running@2.3.0", "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||||
|
|
||||||
"word-wrap": ["word-wrap@1.2.5", "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
"word-wrap": ["word-wrap@1.2.5", "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||||
|
|
||||||
"wrap-ansi": ["wrap-ansi@8.1.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
"wrap-ansi": ["wrap-ansi@8.1.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||||
@@ -1014,6 +1041,14 @@
|
|||||||
|
|
||||||
"@vitest/expect/@vitest/utils": ["@vitest/utils@2.0.5", "https://registry.npmmirror.com/@vitest/utils/-/utils-2.0.5.tgz", { "dependencies": { "@vitest/pretty-format": "2.0.5", "estree-walker": "^3.0.3", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" } }, "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ=="],
|
"@vitest/expect/@vitest/utils": ["@vitest/utils@2.0.5", "https://registry.npmmirror.com/@vitest/utils/-/utils-2.0.5.tgz", { "dependencies": { "@vitest/pretty-format": "2.0.5", "estree-walker": "^3.0.3", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" } }, "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ=="],
|
||||||
|
|
||||||
|
"@vitest/expect/chai": ["chai@5.3.3", "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||||
|
|
||||||
|
"@vitest/mocker/@vitest/spy": ["@vitest/spy@5.0.1", "https://registry.npmmirror.com/@vitest/spy/-/spy-5.0.1.tgz", {}, "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q=="],
|
||||||
|
|
||||||
|
"@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||||
|
|
||||||
|
"@vitest/mocker/magic-string": ["magic-string@1.4.1", "https://registry.npmmirror.com/magic-string/-/magic-string-1.4.1.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="],
|
||||||
|
|
||||||
"eslint/ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"eslint/ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
"glob/minimatch": ["minimatch@9.0.8", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.8.tgz", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw=="],
|
"glob/minimatch": ["minimatch@9.0.8", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.8.tgz", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw=="],
|
||||||
@@ -1032,6 +1067,12 @@
|
|||||||
|
|
||||||
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
|
"vitest/magic-string": ["magic-string@1.4.1", "https://registry.npmmirror.com/magic-string/-/magic-string-1.4.1.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="],
|
||||||
|
|
||||||
|
"vitest/picomatch": ["picomatch@4.0.7", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||||
|
|
||||||
|
"vitest/tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||||
|
|
||||||
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
@@ -1096,8 +1137,12 @@
|
|||||||
|
|
||||||
"@vitest/expect/@vitest/utils/estree-walker": ["estree-walker@3.0.3", "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
"@vitest/expect/@vitest/utils/estree-walker": ["estree-walker@3.0.3", "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||||
|
|
||||||
|
"@vitest/mocker/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||||
|
|
||||||
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.3", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.3.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="],
|
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.3", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.3.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="],
|
||||||
|
|
||||||
|
"vitest/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||||
|
|
||||||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|||||||
+3
-1
@@ -17,6 +17,7 @@
|
|||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build"
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.46.1",
|
"typescript-eslint": "^8.46.1",
|
||||||
"vite": "^7.1.7"
|
"vite": "^7.1.7",
|
||||||
|
"vitest": "^5.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 751 B |
@@ -1,81 +1,96 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import {
|
import {
|
||||||
SYNC_LOADING_EVENT,
|
getSyncWriteState,
|
||||||
SYNC_LOADING_HIDE_EVENT,
|
retryActiveSyncWrite,
|
||||||
type SyncLoadingOptions,
|
subscribeBackgroundWriteFailure,
|
||||||
} from './syncLoadingApi'
|
subscribeSyncWriteState,
|
||||||
|
type SyncWriteState,
|
||||||
|
} from '@/lib/utils/syncWriteCoordinator'
|
||||||
|
import { showSyncErrorPrompt } from './SyncErrorPrompt'
|
||||||
|
|
||||||
interface SyncLoadingHostProps {
|
interface SyncLoadingHostProps {
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
text?: string
|
text?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SyncLoadingHost = ({
|
export const SyncLoadingHost = ({ visible = false, text }: SyncLoadingHostProps) => {
|
||||||
visible: controlledVisible,
|
const [writeState, setWriteState] = useState<SyncWriteState>(getSyncWriteState())
|
||||||
text: controlledText,
|
|
||||||
}: SyncLoadingHostProps) => {
|
|
||||||
const [text, setText] = useState('正在同步学习进度...')
|
|
||||||
const [visible, setVisible] = useState(false)
|
|
||||||
const actualVisible = controlledVisible ?? visible
|
|
||||||
const actualText = controlledText ?? text
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => subscribeSyncWriteState(setWriteState), [])
|
||||||
const handleShow = (event: Event) => {
|
|
||||||
const customEvent = event as CustomEvent<SyncLoadingOptions>
|
|
||||||
setText(customEvent.detail.text ?? '正在同步学习进度...')
|
|
||||||
setVisible(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleHide = () => {
|
useEffect(
|
||||||
setVisible(false)
|
() =>
|
||||||
}
|
subscribeBackgroundWriteFailure((message) => {
|
||||||
|
showSyncErrorPrompt({ title: '同步失败', message })
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
window.addEventListener(SYNC_LOADING_EVENT, handleShow)
|
const writeVisible = writeState.phase !== 'idle'
|
||||||
window.addEventListener(SYNC_LOADING_HIDE_EVENT, handleHide)
|
const actualVisible = visible || writeVisible
|
||||||
return () => {
|
const showFailure = !visible && writeState.phase === 'failed'
|
||||||
window.removeEventListener(SYNC_LOADING_EVENT, handleShow)
|
const actualText = text ?? '正在同步学习进度...'
|
||||||
window.removeEventListener(SYNC_LOADING_HIDE_EVENT, handleHide)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{actualVisible && (
|
{actualVisible && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="sync-loading"
|
key="sync-loading"
|
||||||
className="fixed inset-0 z-10000 flex items-center justify-center bg-black/60"
|
className="fixed inset-0 z-200000 flex items-center justify-center bg-black/60"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||||
>
|
>
|
||||||
<motion.div
|
{showFailure ? (
|
||||||
className="flex flex-col items-center justify-center gap-8"
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 12, scale: 0.96 }}
|
className="flex w-[760px] flex-col items-center gap-10 rounded-[24px] border-2 border-[#e1bd78] bg-[#2a1608]/95 px-16 py-14 text-center shadow-2xl"
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
initial={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||||
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
transition={{ duration: 0.24, ease: 'easeOut' }}
|
transition={{ duration: 0.24, ease: 'easeOut' }}
|
||||||
>
|
>
|
||||||
<svg className="h-20 w-20 animate-spin" viewBox="0 0 24 24" fill="none">
|
<div className="text-4xl font-bold tracking-[6px] text-[#f4e4bd]">同步失败</div>
|
||||||
<circle
|
<div className="text-2xl font-semibold leading-relaxed tracking-wider text-[#f4e4bd]">
|
||||||
className="opacity-25"
|
{writeState.message ?? '学习进度保存失败,请检查网络后重新尝试。'}
|
||||||
cx="12"
|
</div>
|
||||||
cy="12"
|
<button
|
||||||
r="10"
|
type="button"
|
||||||
stroke="#f4e4bd"
|
className="rounded-full border-2 border-[#f4e4bd] bg-[#a66a35] px-14 py-4 text-2xl font-bold tracking-[4px] text-white transition hover:brightness-110 active:scale-95 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
strokeWidth="3"
|
disabled={writeState.retrying}
|
||||||
/>
|
onClick={retryActiveSyncWrite}
|
||||||
<path
|
>
|
||||||
className="opacity-90"
|
重新尝试
|
||||||
fill="#f4e4bd"
|
</button>
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
</motion.div>
|
||||||
/>
|
) : (
|
||||||
</svg>
|
<motion.div
|
||||||
<div className="text-3xl font-semibold tracking-[6px] text-[#f4e4bd] drop-shadow-[0_2px_8px_rgba(0,0,0,0.7)]">
|
className="flex flex-col items-center justify-center gap-8"
|
||||||
{actualText}
|
initial={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||||
</div>
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
</motion.div>
|
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.24, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
<svg className="h-20 w-20 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="#f4e4bd"
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-90"
|
||||||
|
fill="#f4e4bd"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="text-3xl font-semibold tracking-[6px] text-[#f4e4bd] drop-shadow-[0_2px_8px_rgba(0,0,0,0.7)]">
|
||||||
|
{actualText}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -54,8 +54,11 @@ const ResidenceLayoutContent = () => {
|
|||||||
const [isFlying, setIsFlying] = useState(false) // 标记是否正在飞行
|
const [isFlying, setIsFlying] = useState(false) // 标记是否正在飞行
|
||||||
const [isAwaitingCollect, setIsAwaitingCollect] = useState(false)
|
const [isAwaitingCollect, setIsAwaitingCollect] = useState(false)
|
||||||
const [isCollectConfirming, setIsCollectConfirming] = useState(false)
|
const [isCollectConfirming, setIsCollectConfirming] = useState(false)
|
||||||
|
const [collectError, setCollectError] = useState<string | null>(null)
|
||||||
const puzzlePieceRef = useRef<HTMLDivElement>(null)
|
const puzzlePieceRef = useRef<HTMLDivElement>(null)
|
||||||
const animationInProgressRef = useRef<boolean>(false) // 防止重复执行动画
|
const animationInProgressRef = useRef<boolean>(false) // 防止重复执行动画
|
||||||
|
const collectionLockRef = useRef(false)
|
||||||
|
const collectionViewRef = useRef(0)
|
||||||
const animationCompletionRef = useRef<(() => void) | null>(null)
|
const animationCompletionRef = useRef<(() => void) | null>(null)
|
||||||
const animationCompletionCallbackRef = useRef<(() => void) | null>(null)
|
const animationCompletionCallbackRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
@@ -70,6 +73,14 @@ const ResidenceLayoutContent = () => {
|
|||||||
return ''
|
return ''
|
||||||
}, [location.pathname])
|
}, [location.pathname])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
collectionViewRef.current += 1
|
||||||
|
collectionLockRef.current = false
|
||||||
|
return () => {
|
||||||
|
collectionViewRef.current += 1
|
||||||
|
}
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
// 判断当前路由是否是 place1(讲解视频,首次不可跳过)
|
// 判断当前路由是否是 place1(讲解视频,首次不可跳过)
|
||||||
const isPlace1Route = useMemo(() => /place1/.test(location.pathname), [location.pathname])
|
const isPlace1Route = useMemo(() => /place1/.test(location.pathname), [location.pathname])
|
||||||
|
|
||||||
@@ -120,80 +131,72 @@ const ResidenceLayoutContent = () => {
|
|||||||
setIsFlying(false)
|
setIsFlying(false)
|
||||||
setIsAwaitingCollect(false)
|
setIsAwaitingCollect(false)
|
||||||
setIsCollectConfirming(false)
|
setIsCollectConfirming(false)
|
||||||
|
setCollectError(null)
|
||||||
|
collectionLockRef.current = false
|
||||||
animationInProgressRef.current = false
|
animationInProgressRef.current = false
|
||||||
resolveAnimation()
|
resolveAnimation()
|
||||||
}, [resolveAnimation])
|
}, [resolveAnimation])
|
||||||
|
|
||||||
// 执行飞行动画
|
// 执行飞行动画
|
||||||
const executeFlyAnimation = useCallback(
|
const executeFlyAnimation = useCallback(() => {
|
||||||
(pieceId: number) => {
|
if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) {
|
||||||
if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) {
|
return false
|
||||||
return false
|
}
|
||||||
|
|
||||||
|
// 使用 requestAnimationFrame 确保元素已完全渲染
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!puzzlePieceRef.current || !residence) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 requestAnimationFrame 确保元素已完全渲染
|
flyToAnimation({
|
||||||
requestAnimationFrame(() => {
|
fromRef: puzzlePieceRef,
|
||||||
if (!puzzlePieceRef.current || !residence) {
|
toRef: puzzleBoxRef,
|
||||||
return
|
targetPosition: 'center',
|
||||||
}
|
duration: ANIMATION_CONFIG.FLY_DURATION,
|
||||||
|
scale: ANIMATION_CONFIG.SCALE,
|
||||||
flyToAnimation({
|
opacity: ANIMATION_CONFIG.OPACITY,
|
||||||
fromRef: puzzlePieceRef,
|
ease: [0.22, 0.72, 0.16, 1],
|
||||||
toRef: puzzleBoxRef,
|
onComplete: cleanupAnimation,
|
||||||
targetPosition: 'center',
|
|
||||||
duration: ANIMATION_CONFIG.FLY_DURATION,
|
|
||||||
scale: ANIMATION_CONFIG.SCALE,
|
|
||||||
opacity: ANIMATION_CONFIG.OPACITY,
|
|
||||||
ease: [0.22, 0.72, 0.16, 1],
|
|
||||||
onComplete: () => {
|
|
||||||
if (residence) {
|
|
||||||
unlockPuzzle(residence, pieceId)
|
|
||||||
}
|
|
||||||
cleanupAnimation()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setIsFlying(true)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return true
|
requestAnimationFrame(() => {
|
||||||
},
|
setIsFlying(true)
|
||||||
[
|
})
|
||||||
ANIMATION_CONFIG.FLY_DURATION,
|
})
|
||||||
ANIMATION_CONFIG.OPACITY,
|
|
||||||
ANIMATION_CONFIG.SCALE,
|
return true
|
||||||
cleanupAnimation,
|
}, [
|
||||||
puzzleBoxRef,
|
ANIMATION_CONFIG.FLY_DURATION,
|
||||||
residence,
|
ANIMATION_CONFIG.OPACITY,
|
||||||
unlockPuzzle,
|
ANIMATION_CONFIG.SCALE,
|
||||||
]
|
cleanupAnimation,
|
||||||
)
|
puzzleBoxRef,
|
||||||
|
residence,
|
||||||
|
])
|
||||||
|
|
||||||
// 等待元素准备就绪并执行动画
|
// 等待元素准备就绪并执行动画
|
||||||
const waitForElementsAndAnimate = useCallback(
|
const waitForElementsAndAnimate = useCallback(
|
||||||
async (pieceId: number, retryCount = 0): Promise<void> => {
|
async (retryCount = 0): Promise<void> => {
|
||||||
if (!residence) {
|
if (!residence) {
|
||||||
cleanupAnimation()
|
cleanupAnimation()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查元素是否已准备好
|
// 检查元素是否已准备就绪
|
||||||
if (puzzleBoxRef.current && puzzlePieceRef.current) {
|
if (puzzleBoxRef.current && puzzlePieceRef.current) {
|
||||||
const success = executeFlyAnimation(pieceId)
|
const success = executeFlyAnimation()
|
||||||
if (success) return
|
if (success) return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果元素未准备好且未超过最大重试次数,则重试
|
// 如果元素未准备好且未超过最大重试次数,则重试
|
||||||
if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) {
|
if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY))
|
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY))
|
||||||
return waitForElementsAndAnimate(pieceId, retryCount + 1)
|
return waitForElementsAndAnimate(retryCount + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 超过最大重试次数,直接解锁并清理
|
// 保存已经成功,动画元素缺失时只需结束收集流程
|
||||||
console.warn('Puzzle animation: 元素未准备好,直接解锁')
|
console.warn('Puzzle animation: 元素未准备好,跳过飞行动画')
|
||||||
unlockPuzzle(residence, pieceId)
|
|
||||||
cleanupAnimation()
|
cleanupAnimation()
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -203,7 +206,6 @@ const ResidenceLayoutContent = () => {
|
|||||||
executeFlyAnimation,
|
executeFlyAnimation,
|
||||||
puzzleBoxRef,
|
puzzleBoxRef,
|
||||||
residence,
|
residence,
|
||||||
unlockPuzzle,
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -213,17 +215,48 @@ const ResidenceLayoutContent = () => {
|
|||||||
}, [puzzlePieceId, residence])
|
}, [puzzlePieceId, residence])
|
||||||
|
|
||||||
const handleCollectPuzzlePiece = useCallback(async () => {
|
const handleCollectPuzzlePiece = useCallback(async () => {
|
||||||
if (!puzzlePieceId || isCollectConfirming || isFlying) return
|
if (
|
||||||
|
!puzzlePieceId ||
|
||||||
|
!residence ||
|
||||||
|
isCollectConfirming ||
|
||||||
|
isFlying ||
|
||||||
|
collectionLockRef.current
|
||||||
|
)
|
||||||
|
return
|
||||||
|
collectionLockRef.current = true
|
||||||
|
const view = collectionViewRef.current
|
||||||
|
|
||||||
|
setCollectError(null)
|
||||||
setIsCollectConfirming(true)
|
setIsCollectConfirming(true)
|
||||||
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.COLLECT_CONFIRM_DURATION))
|
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.COLLECT_CONFIRM_DURATION))
|
||||||
|
|
||||||
|
if (view !== collectionViewRef.current) return
|
||||||
|
|
||||||
|
let saved = false
|
||||||
|
try {
|
||||||
|
saved = await unlockPuzzle(residence, puzzlePieceId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to collect puzzle piece', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view !== collectionViewRef.current) return
|
||||||
|
collectionLockRef.current = false
|
||||||
|
if (!saved) {
|
||||||
|
setCollectError('收下失败,请检查网络后重新尝试')
|
||||||
|
setIsCollectConfirming(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsAwaitingCollect(false)
|
setIsAwaitingCollect(false)
|
||||||
await waitForElementsAndAnimate(puzzlePieceId)
|
setIsCollectConfirming(false)
|
||||||
|
await waitForElementsAndAnimate()
|
||||||
}, [
|
}, [
|
||||||
ANIMATION_CONFIG.COLLECT_CONFIRM_DURATION,
|
ANIMATION_CONFIG.COLLECT_CONFIRM_DURATION,
|
||||||
isCollectConfirming,
|
isCollectConfirming,
|
||||||
isFlying,
|
isFlying,
|
||||||
puzzlePieceId,
|
puzzlePieceId,
|
||||||
|
residence,
|
||||||
|
unlockPuzzle,
|
||||||
waitForElementsAndAnimate,
|
waitForElementsAndAnimate,
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -259,6 +292,7 @@ const ResidenceLayoutContent = () => {
|
|||||||
|
|
||||||
// 开始动画流程:禁用所有操作
|
// 开始动画流程:禁用所有操作
|
||||||
setIsAnimationPlaying(true)
|
setIsAnimationPlaying(true)
|
||||||
|
setCollectError(null)
|
||||||
setPuzzlePieceId(pieceId)
|
setPuzzlePieceId(pieceId)
|
||||||
setShowPuzzlePiece(true)
|
setShowPuzzlePiece(true)
|
||||||
|
|
||||||
@@ -444,6 +478,7 @@ const ResidenceLayoutContent = () => {
|
|||||||
hidden={isFlying}
|
hidden={isFlying}
|
||||||
awaitingCollect={isAwaitingCollect}
|
awaitingCollect={isAwaitingCollect}
|
||||||
confirming={isCollectConfirming}
|
confirming={isCollectConfirming}
|
||||||
|
collectError={collectError}
|
||||||
puzzlePieceTitle={currentPuzzlePieceData?.title ?? null}
|
puzzlePieceTitle={currentPuzzlePieceData?.title ?? null}
|
||||||
onCollect={handleCollectPuzzlePiece}
|
onCollect={handleCollectPuzzlePiece}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ interface PuzzlePiecePortalProps {
|
|||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
awaitingCollect?: boolean
|
awaitingCollect?: boolean
|
||||||
confirming?: boolean
|
confirming?: boolean
|
||||||
|
collectError?: string | null
|
||||||
puzzlePieceTitle?: string | null
|
puzzlePieceTitle?: string | null
|
||||||
onCollect?: () => void
|
onCollect?: () => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
|
const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
|
||||||
@@ -23,6 +24,7 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
|
|||||||
hidden = false,
|
hidden = false,
|
||||||
awaitingCollect = false,
|
awaitingCollect = false,
|
||||||
confirming = false,
|
confirming = false,
|
||||||
|
collectError,
|
||||||
puzzlePieceTitle,
|
puzzlePieceTitle,
|
||||||
onCollect,
|
onCollect,
|
||||||
},
|
},
|
||||||
@@ -153,8 +155,8 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
|
|||||||
<motion.div
|
<motion.div
|
||||||
className="absolute left-0 right-0 top-[clamp(218px,25vw,276px)] flex flex-col items-center gap-3 pointer-events-none"
|
className="absolute left-0 right-0 top-[clamp(218px,25vw,276px)] flex flex-col items-center gap-3 pointer-events-none"
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={confirming ? { opacity: 0, y: 6 } : { opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: confirming ? 0.16 : 0.36, ease: [0.22, 1, 0.36, 1] }}
|
transition={{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }}
|
||||||
style={{ willChange: 'transform, opacity' }}
|
style={{ willChange: 'transform, opacity' }}
|
||||||
>
|
>
|
||||||
{puzzlePieceTitle && (
|
{puzzlePieceTitle && (
|
||||||
@@ -165,12 +167,23 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{collectError && (
|
||||||
|
<div className="rounded-full bg-[#6f211c]/90 px-5 py-2 text-base font-semibold text-[#ffe7c2] shadow-lg">
|
||||||
|
{collectError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<FlipActionButton
|
<FlipActionButton
|
||||||
className="pointer-events-auto border border-[#ffe6a6]/55 bg-linear-to-b from-[#bd8b50] to-[#84512b] px-4 py-1.5 text-base shadow-[0_3px_10px_rgba(0,0,0,0.2)] hover:brightness-105 active:scale-[0.97]"
|
className="pointer-events-auto border border-[#ffe6a6]/55 bg-linear-to-b from-[#bd8b50] to-[#84512b] px-4 py-1.5 text-base shadow-[0_3px_10px_rgba(0,0,0,0.2)] hover:brightness-105 active:scale-[0.97] disabled:cursor-wait disabled:opacity-85"
|
||||||
disabled={confirming}
|
disabled={confirming}
|
||||||
onClick={onCollect}
|
onClick={onCollect}
|
||||||
>
|
>
|
||||||
收下
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
{confirming && (
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-[#fff4d0]/40 border-t-[#fff4d0]" />
|
||||||
|
)}
|
||||||
|
{confirming ? '正在收下...' : collectError ? '重新收下' : '收下'}
|
||||||
|
</span>
|
||||||
</FlipActionButton>
|
</FlipActionButton>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { puzzleDataConfig } from './puzzleData'
|
||||||
|
import { PUZZLE_RESIDENCES } from './puzzleResidences'
|
||||||
|
import {
|
||||||
|
getMenuModuleProgress,
|
||||||
|
isMenuModuleComplete,
|
||||||
|
isMenuModuleStarted,
|
||||||
|
type PuzzleProgress,
|
||||||
|
} from './progression'
|
||||||
|
|
||||||
|
const moduleName = 'revolutionary'
|
||||||
|
const moduleResidences = PUZZLE_RESIDENCES.filter((item) => item.moduleName === moduleName)
|
||||||
|
const totalPieces = moduleResidences.reduce(
|
||||||
|
(total, item) => total + Object.keys(puzzleDataConfig[item.residence] ?? {}).length,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('menu module progress', () => {
|
||||||
|
it('distinguishes unexplored, in-progress, and complete modules', () => {
|
||||||
|
const unexplored: PuzzleProgress = {}
|
||||||
|
|
||||||
|
expect(isMenuModuleStarted(moduleName, unexplored)).toBe(false)
|
||||||
|
expect(isMenuModuleComplete(moduleName, unexplored)).toBe(false)
|
||||||
|
expect(getMenuModuleProgress(moduleName, unexplored)).toBe(0)
|
||||||
|
|
||||||
|
const firstResidence = moduleResidences[0]!
|
||||||
|
const firstPieceId = Number(Object.keys(puzzleDataConfig[firstResidence.residence] ?? {})[0])
|
||||||
|
const inProgress: PuzzleProgress = {
|
||||||
|
[firstResidence.residence]: new Set([firstPieceId]),
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(isMenuModuleStarted(moduleName, inProgress)).toBe(true)
|
||||||
|
expect(isMenuModuleComplete(moduleName, inProgress)).toBe(false)
|
||||||
|
expect(getMenuModuleProgress(moduleName, inProgress)).toBe(Math.round(100 / totalPieces))
|
||||||
|
|
||||||
|
const complete = Object.fromEntries(
|
||||||
|
moduleResidences.map(({ residence }) => [
|
||||||
|
residence,
|
||||||
|
new Set(Object.keys(puzzleDataConfig[residence] ?? {}).map(Number)),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(isMenuModuleStarted(moduleName, complete)).toBe(true)
|
||||||
|
expect(isMenuModuleComplete(moduleName, complete)).toBe(true)
|
||||||
|
expect(getMenuModuleProgress(moduleName, complete)).toBe(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
+20
-11
@@ -1,3 +1,4 @@
|
|||||||
|
import { getStagePieceIds } from './puzzleStages'
|
||||||
import { puzzleDataConfig } from './puzzleData'
|
import { puzzleDataConfig } from './puzzleData'
|
||||||
import { PUZZLE_RESIDENCES } from './puzzleResidences'
|
import { PUZZLE_RESIDENCES } from './puzzleResidences'
|
||||||
|
|
||||||
@@ -55,13 +56,21 @@ export const isMenuModuleComplete = (moduleName: string, puzzles: PuzzleProgress
|
|||||||
return residences.every((item) => isResidenceComplete(item.residence, puzzles))
|
return residences.every((item) => isResidenceComplete(item.residence, puzzles))
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPieceIdsByRoute = (residence: string, routePrefix: string) => {
|
export const getMenuModuleProgress = (moduleName: string, puzzles: PuzzleProgress) => {
|
||||||
const pieces = puzzleDataConfig[residence]
|
const residences = PUZZLE_RESIDENCES.filter((item) => item.moduleName === moduleName)
|
||||||
if (!pieces) return []
|
let totalPieces = 0
|
||||||
|
let unlockedPieces = 0
|
||||||
|
|
||||||
return Object.entries(pieces)
|
residences.forEach(({ residence }) => {
|
||||||
.filter(([, piece]) => piece.route.startsWith(routePrefix))
|
const configuredPieceIds = getConfiguredPieceIds(residence)
|
||||||
.map(([pieceId]) => Number(pieceId))
|
const residencePuzzles = puzzles[residence]
|
||||||
|
|
||||||
|
totalPieces += configuredPieceIds.length
|
||||||
|
unlockedPieces += configuredPieceIds.filter((pieceId) => residencePuzzles?.has(pieceId)).length
|
||||||
|
})
|
||||||
|
|
||||||
|
if (totalPieces === 0) return 0
|
||||||
|
return Math.round((unlockedPieces / totalPieces) * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getResidenceStageUnlocks = (residence: string, puzzles: PuzzleProgress) => {
|
export const getResidenceStageUnlocks = (residence: string, puzzles: PuzzleProgress) => {
|
||||||
@@ -71,10 +80,10 @@ export const getResidenceStageUnlocks = (residence: string, puzzles: PuzzleProgr
|
|||||||
dialogueUnlocked: residenceUnlocked,
|
dialogueUnlocked: residenceUnlocked,
|
||||||
reflectionUnlocked:
|
reflectionUnlocked:
|
||||||
residenceUnlocked &&
|
residenceUnlocked &&
|
||||||
arePiecesUnlocked(residence, puzzles, getPieceIdsByRoute(residence, 'place')),
|
arePiecesUnlocked(residence, puzzles, getStagePieceIds(residence, 'dialogue')),
|
||||||
internalizationUnlocked:
|
internalizationUnlocked:
|
||||||
residenceUnlocked &&
|
residenceUnlocked &&
|
||||||
arePiecesUnlocked(residence, puzzles, getPieceIdsByRoute(residence, 'reflection')),
|
arePiecesUnlocked(residence, puzzles, getStagePieceIds(residence, 'reflection')),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,17 +106,17 @@ export const getResidenceStageCompletion = (
|
|||||||
const dialogueComplete = arePiecesUnlocked(
|
const dialogueComplete = arePiecesUnlocked(
|
||||||
residence,
|
residence,
|
||||||
puzzles,
|
puzzles,
|
||||||
getPieceIdsByRoute(residence, 'place')
|
getStagePieceIds(residence, 'dialogue')
|
||||||
)
|
)
|
||||||
const reflectionComplete = arePiecesUnlocked(
|
const reflectionComplete = arePiecesUnlocked(
|
||||||
residence,
|
residence,
|
||||||
puzzles,
|
puzzles,
|
||||||
getPieceIdsByRoute(residence, 'reflection')
|
getStagePieceIds(residence, 'reflection')
|
||||||
)
|
)
|
||||||
const internalizationComplete = arePiecesUnlocked(
|
const internalizationComplete = arePiecesUnlocked(
|
||||||
residence,
|
residence,
|
||||||
puzzles,
|
puzzles,
|
||||||
getPieceIdsByRoute(residence, 'internalization')
|
getStagePieceIds(residence, 'internalization')
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const sourceFiles = (directory: string): string[] =>
|
||||||
|
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const path = join(directory, entry.name)
|
||||||
|
return entry.isDirectory() ? sourceFiles(path) : /\.tsx?$/.test(path) ? [path] : []
|
||||||
|
})
|
||||||
|
describe('single collection reporting entry', () => {
|
||||||
|
it('keeps every page free of independent course completion reports', () => {
|
||||||
|
for (const path of sourceFiles('src/pages')) {
|
||||||
|
expect(readFileSync(path, 'utf8'), path).not.toMatch(
|
||||||
|
/commitCourseCheckpoint|setCourseTimeProgress|finishCourse/
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('waits for successful collection before starting the fly animation', () => {
|
||||||
|
const source = readFileSync('src/components/layout/ResidenceLayout.tsx', 'utf8')
|
||||||
|
const handler = source.slice(
|
||||||
|
source.indexOf('const handleCollectPuzzlePiece'),
|
||||||
|
source.indexOf('// 处理碎片动画触发')
|
||||||
|
)
|
||||||
|
expect(handler.indexOf('await unlockPuzzle')).toBeLessThan(handler.indexOf('if (!saved)'))
|
||||||
|
expect(handler.indexOf('if (!saved)')).toBeLessThan(
|
||||||
|
handler.indexOf('await waitForElementsAndAnimate')
|
||||||
|
)
|
||||||
|
expect(handler).toContain('collectionLockRef.current = true')
|
||||||
|
expect(handler).toContain('view !== collectionViewRef.current')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { getPuzzleCheckpoint, getStagePieceIds, type PuzzleStage } from './puzzleStages'
|
||||||
|
|
||||||
|
const cases: [string, number[][]][] = [
|
||||||
|
['maozedong', [Array.from({ length: 12 }, (_, i) => i + 1), [13, 14], [15, 16]]],
|
||||||
|
['sunzhongshan', [Array.from({ length: 11 }, (_, i) => i + 1), [12, 13, 14], [15, 16]]],
|
||||||
|
['luxun', [Array.from({ length: 13 }, (_, i) => i + 1), [14, 15], [16]]],
|
||||||
|
['huangyanpei', [Array.from({ length: 13 }, (_, i) => i + 1), [14, 15], [16]]],
|
||||||
|
['nieer', [Array.from({ length: 13 }, (_, i) => i + 1), [14], [15, 16]]],
|
||||||
|
['zhangleping', [Array.from({ length: 13 }, (_, i) => i + 1), [14], [15, 16]]],
|
||||||
|
['rongdesheng', [Array.from({ length: 13 }, (_, i) => i + 1), [14], [15, 16]]],
|
||||||
|
['chenyun', [Array.from({ length: 12 }, (_, i) => i + 1), [13, 14], [15, 16]]],
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('puzzle stage checkpoints', () => {
|
||||||
|
it.each(cases)(
|
||||||
|
'matches all configured stage IDs and course orders for %s',
|
||||||
|
(residence, stages) => {
|
||||||
|
const index = cases.findIndex(([id]) => id === residence)
|
||||||
|
;(['dialogue', 'reflection', 'internalization'] as PuzzleStage[]).forEach((stage, offset) => {
|
||||||
|
expect(getStagePieceIds(residence, stage)).toEqual(stages[offset])
|
||||||
|
for (const piece of stages[offset]!) {
|
||||||
|
expect(getPuzzleCheckpoint(residence, piece)).toEqual({
|
||||||
|
stage,
|
||||||
|
order: index * 3 + offset + 1,
|
||||||
|
pieceIds: stages[offset],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
it('rejects unknown residences and invalid pieces', () => {
|
||||||
|
for (const piece of [0, 17, 1.5, NaN]) expect(getPuzzleCheckpoint('nieer', piece)).toBeNull()
|
||||||
|
expect(getPuzzleCheckpoint('unknown', 14)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { puzzleDataConfig } from './puzzleData'
|
||||||
|
|
||||||
|
export const PUZZLE_STAGE_ROUTES = {
|
||||||
|
dialogue: 'place',
|
||||||
|
reflection: 'reflection',
|
||||||
|
internalization: 'internalization',
|
||||||
|
} as const
|
||||||
|
export type PuzzleStage = keyof typeof PUZZLE_STAGE_ROUTES
|
||||||
|
|
||||||
|
const CHECKPOINT_ORDERS: Record<string, Record<PuzzleStage, number>> = {
|
||||||
|
maozedong: { dialogue: 1, reflection: 2, internalization: 3 },
|
||||||
|
sunzhongshan: { dialogue: 4, reflection: 5, internalization: 6 },
|
||||||
|
luxun: { dialogue: 7, reflection: 8, internalization: 9 },
|
||||||
|
huangyanpei: { dialogue: 10, reflection: 11, internalization: 12 },
|
||||||
|
nieer: { dialogue: 13, reflection: 14, internalization: 15 },
|
||||||
|
zhangleping: { dialogue: 16, reflection: 17, internalization: 18 },
|
||||||
|
rongdesheng: { dialogue: 19, reflection: 20, internalization: 21 },
|
||||||
|
chenyun: { dialogue: 22, reflection: 23, internalization: 24 },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getStagePieceIds = (residence: string, stage: PuzzleStage): number[] =>
|
||||||
|
Object.entries(puzzleDataConfig[residence] ?? {})
|
||||||
|
.filter(([, piece]) => piece.route.startsWith(PUZZLE_STAGE_ROUTES[stage]))
|
||||||
|
.map(([id]) => Number(id))
|
||||||
|
|
||||||
|
export const getPuzzleCheckpoint = (residence: string, pieceId: number) => {
|
||||||
|
const orders = CHECKPOINT_ORDERS[residence]
|
||||||
|
if (!orders || !Number.isInteger(pieceId)) return null
|
||||||
|
for (const stage of Object.keys(PUZZLE_STAGE_ROUTES) as PuzzleStage[]) {
|
||||||
|
const pieceIds = getStagePieceIds(residence, stage)
|
||||||
|
if (pieceIds.includes(pieceId)) return { stage, order: orders[stage], pieceIds }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -10,6 +10,14 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'DingTalk-JinBuTi';
|
||||||
|
src: url('/fonts/DingTalk-JinBuTi.ttf') format('truetype');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
/* *{ */
|
/* *{ */
|
||||||
/* margin: 0;
|
/* margin: 0;
|
||||||
padding: 0; */
|
padding: 0; */
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const state = { yibanConnectionStatus: 'unknown' }
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
state.yibanConnectionStatus = 'unknown'
|
||||||
|
vi.stubGlobal('videoAjax', () => ({ setHost: vi.fn() }))
|
||||||
|
vi.doMock('@/store/userStore', () => ({ useUserStore: { getState: () => state } }))
|
||||||
|
})
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
describe('explicit progress backend parsing', () => {
|
||||||
|
it('does not unwrap a Yiban payload based on an unrelated state field during bootstrap', async () => {
|
||||||
|
const { parseUserDefineContent } = await import('./api-common')
|
||||||
|
const raw = { puzzles: { nieer: [14] }, state: { puzzles: {} } }
|
||||||
|
expect(parseUserDefineContent(JSON.stringify(raw), { backend: 'yiban' })).toEqual(raw)
|
||||||
|
})
|
||||||
|
it('unwraps session persist payloads even while the connection is connected', async () => {
|
||||||
|
const { parseUserDefineContent } = await import('./api-common')
|
||||||
|
state.yibanConnectionStatus = 'connected'
|
||||||
|
expect(
|
||||||
|
parseUserDefineContent('{"state":{"puzzles":{"nieer":[14]}}}', { backend: 'session' })
|
||||||
|
).toEqual({ puzzles: { nieer: [14] } })
|
||||||
|
})
|
||||||
|
it('keeps automatic parsing compatible with the connection backend', async () => {
|
||||||
|
const { parseUserDefineContent } = await import('./api-common')
|
||||||
|
expect(parseUserDefineContent('{"state":{"puzzles":{}}}')).toEqual({ puzzles: {} })
|
||||||
|
state.yibanConnectionStatus = 'connected'
|
||||||
|
expect(parseUserDefineContent('{"puzzles":{}}')).toEqual({ puzzles: {} })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const storage = new Map<string, string>()
|
||||||
|
const sessionStorageMock: Storage = {
|
||||||
|
get length() {
|
||||||
|
return storage.size
|
||||||
|
},
|
||||||
|
clear: () => storage.clear(),
|
||||||
|
getItem: (key) => storage.get(key) ?? null,
|
||||||
|
key: (index) => [...storage.keys()][index] ?? null,
|
||||||
|
removeItem: (key) => storage.delete(key),
|
||||||
|
setItem: (key, value) => storage.set(key, value),
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
storage.clear()
|
||||||
|
vi.stubGlobal('sessionStorage', sessionStorageMock)
|
||||||
|
vi.stubGlobal('videoAjax', () => ({
|
||||||
|
setHost: vi.fn(),
|
||||||
|
addUserDefine: vi.fn(),
|
||||||
|
getUserDefineInfoByUser: vi.fn(),
|
||||||
|
getUserDefineInfo: vi.fn(),
|
||||||
|
finishCourse: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.doMock('@/store/userStore', () => ({
|
||||||
|
useUserStore: {
|
||||||
|
getState: () => ({
|
||||||
|
yibanConnectionStatus: 'disconnected',
|
||||||
|
courseId: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('non-Yiban writes', () => {
|
||||||
|
it('stores user data in sessionStorage without entering the blocking coordinator', async () => {
|
||||||
|
const { setUserDefine } = await import('./api-common')
|
||||||
|
const { getSyncWriteState } = await import('./syncWriteCoordinator')
|
||||||
|
|
||||||
|
await expect(setUserDefine('lesson', 1, 'value', { critical: true })).resolves.toMatchObject({
|
||||||
|
success: true,
|
||||||
|
})
|
||||||
|
expect(sessionStorage.getItem('hsgrl_lesson_1')).toBe('value')
|
||||||
|
expect(getSyncWriteState().phase).toBe('idle')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips course progress without entering the blocking coordinator', async () => {
|
||||||
|
const { setCourseTimeProgress } = await import('./api-common')
|
||||||
|
const { getSyncWriteState } = await import('./syncWriteCoordinator')
|
||||||
|
|
||||||
|
await expect(setCourseTimeProgress(1)).resolves.toMatchObject({
|
||||||
|
success: true,
|
||||||
|
skipped: true,
|
||||||
|
})
|
||||||
|
expect(getSyncWriteState().phase).toBe('idle')
|
||||||
|
})
|
||||||
|
})
|
||||||
+106
-22
@@ -1,4 +1,12 @@
|
|||||||
import { useUserStore } from '@/store/userStore'
|
import { useUserStore } from '@/store/userStore'
|
||||||
|
import {
|
||||||
|
runCallbackWriteRound,
|
||||||
|
type WriteResponse,
|
||||||
|
type WriteRoundResult,
|
||||||
|
} from './callbackWriteRetry'
|
||||||
|
import { enqueueSyncWrite, type WritePriority, type WriteResult } from './syncWriteCoordinator'
|
||||||
|
|
||||||
|
export type { WriteResult } from './syncWriteCoordinator'
|
||||||
|
|
||||||
//超时时间
|
//超时时间
|
||||||
const TIMEOUT_MS = 1000 * 6
|
const TIMEOUT_MS = 1000 * 6
|
||||||
@@ -34,9 +42,17 @@ export interface UserDefineFetchResult {
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VideoAjaxWriteCallback<T = unknown> = (
|
||||||
|
successOrResponse: boolean | WriteResponse<T>,
|
||||||
|
response?: WriteResponse<T>
|
||||||
|
) => void
|
||||||
|
|
||||||
interface VideoAjax {
|
interface VideoAjax {
|
||||||
setHost(host: string): void
|
setHost(host: string): void
|
||||||
addUserDefine(param: UserDefineParam): void
|
addUserDefine(
|
||||||
|
param: UserDefineParam,
|
||||||
|
callback: VideoAjaxWriteCallback<Record<string, unknown>>
|
||||||
|
): void
|
||||||
getUserDefineInfoByUser(
|
getUserDefineInfoByUser(
|
||||||
param: UserDefineParam,
|
param: UserDefineParam,
|
||||||
callback: (data: VideoAjaxResponse | undefined) => void
|
callback: (data: VideoAjaxResponse | undefined) => void
|
||||||
@@ -45,7 +61,10 @@ interface VideoAjax {
|
|||||||
param: UserDefineParam,
|
param: UserDefineParam,
|
||||||
callback: (data: VideoAjaxResponse | undefined) => void
|
callback: (data: VideoAjaxResponse | undefined) => void
|
||||||
): void
|
): void
|
||||||
finishCourse(param: { order: number; co: string; process: number }): void
|
finishCourse(
|
||||||
|
param: { order: number; co: string; process: number },
|
||||||
|
callback: VideoAjaxWriteCallback<number>
|
||||||
|
): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const LESSON_PREFIX = 'hsgrl'
|
const LESSON_PREFIX = 'hsgrl'
|
||||||
@@ -56,6 +75,16 @@ export interface StorageOptions {
|
|||||||
backend?: StorageBackend
|
backend?: StorageBackend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WriteOptions extends StorageOptions {
|
||||||
|
critical?: boolean
|
||||||
|
/** 内部关键事务使用:由外层协调器持有 loading 与重试状态。 */
|
||||||
|
bypassQueue?: boolean
|
||||||
|
/** 内部关键事务共享的剩余截止时间。 */
|
||||||
|
deadlineMs?: number
|
||||||
|
/** 由调用组件提供行内失败反馈时,关闭全局 Toast。 */
|
||||||
|
silent?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const getStorageKey = (lessonId: string | number, key: string | number) =>
|
const getStorageKey = (lessonId: string | number, key: string | number) =>
|
||||||
`${LESSON_PREFIX}_${lessonId}_${key}`
|
`${LESSON_PREFIX}_${lessonId}_${key}`
|
||||||
|
|
||||||
@@ -67,18 +96,44 @@ const resolveStorageBackend = (backend: StorageBackend = 'auto'): 'yiban' | 'ses
|
|||||||
const videoajax: VideoAjax = videoAjax()
|
const videoajax: VideoAjax = videoAjax()
|
||||||
videoajax.setHost('/')
|
videoajax.setHost('/')
|
||||||
|
|
||||||
|
const getWriteResponse = <T>(
|
||||||
|
successOrResponse: boolean | WriteResponse<T>,
|
||||||
|
response?: WriteResponse<T>
|
||||||
|
): WriteResponse<T> | undefined =>
|
||||||
|
typeof successOrResponse === 'boolean' ? response : successOrResponse
|
||||||
|
|
||||||
|
const toWriteResult = <T>(result: WriteRoundResult<T>): WriteResult =>
|
||||||
|
result.success
|
||||||
|
? {
|
||||||
|
success: true,
|
||||||
|
code: result.code,
|
||||||
|
attempts: result.attempts,
|
||||||
|
message: result.response.msg,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
success: false,
|
||||||
|
code: result.code,
|
||||||
|
attempts: result.attempts,
|
||||||
|
message: result.message || '学习进度保存失败',
|
||||||
|
}
|
||||||
|
|
||||||
// lessonId(课时唯一ID)
|
// lessonId(课时唯一ID)
|
||||||
export function setUserDefine(
|
export async function setUserDefine(
|
||||||
lessonId: string | number,
|
lessonId: string | number,
|
||||||
key: string | number,
|
key: string | number,
|
||||||
value: string,
|
value: string,
|
||||||
options: StorageOptions = {}
|
options: WriteOptions = {}
|
||||||
): void {
|
): Promise<WriteResult> {
|
||||||
const backend = resolveStorageBackend(options.backend)
|
const backend = resolveStorageBackend(options.backend)
|
||||||
|
|
||||||
if (backend === 'session') {
|
if (backend === 'session') {
|
||||||
sessionStorage.setItem(getStorageKey(lessonId, key), value)
|
try {
|
||||||
return
|
sessionStorage.setItem(getStorageKey(lessonId, key), value)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save user define to sessionStorage', error)
|
||||||
|
return { success: false, message: '本地学习进度保存失败' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const param: UserDefineParam = {
|
const param: UserDefineParam = {
|
||||||
@@ -86,7 +141,25 @@ export function setUserDefine(
|
|||||||
key,
|
key,
|
||||||
content: value,
|
content: value,
|
||||||
}
|
}
|
||||||
videoajax.addUserDefine(param)
|
const priority: WritePriority = options.critical ? 'critical' : 'background'
|
||||||
|
const runRound = () =>
|
||||||
|
runCallbackWriteRound<Record<string, unknown>>({
|
||||||
|
deadlineMs: options.deadlineMs,
|
||||||
|
invoke: (callback) => {
|
||||||
|
videoajax.addUserDefine(param, (successOrResponse, response) => {
|
||||||
|
callback(getWriteResponse(successOrResponse, response))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (options.bypassQueue) return toWriteResult(await runRound())
|
||||||
|
return enqueueSyncWrite(
|
||||||
|
`user-define:${param.lessonId}:${key}`,
|
||||||
|
priority,
|
||||||
|
runRound,
|
||||||
|
undefined,
|
||||||
|
!options.silent
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 内部:将回调形式封装为 Promise
|
// 内部:将回调形式封装为 Promise
|
||||||
@@ -184,9 +257,9 @@ export async function getUserDefineResult(
|
|||||||
return getUserDefineResultAsyncInternal(lessonId, key, options)
|
return getUserDefineResultAsyncInternal(lessonId, key, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseUserDefineContent<T>(content: string): T {
|
export function parseUserDefineContent<T>(content: string, options: StorageOptions = {}): T {
|
||||||
const raw = JSON.parse(content)
|
const raw = JSON.parse(content)
|
||||||
if (useUserStore.getState().yibanConnectionStatus === 'connected') {
|
if (resolveStorageBackend(options.backend) === 'yiban') {
|
||||||
return raw as T
|
return raw as T
|
||||||
}
|
}
|
||||||
if (raw && typeof raw === 'object' && 'state' in raw) {
|
if (raw && typeof raw === 'object' && 'state' in raw) {
|
||||||
@@ -196,28 +269,39 @@ export function parseUserDefineContent<T>(content: string): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 根据课时顺序号(所处课程的第几课时)更新对应易班平台上的每个环节进度(异步)
|
// 根据课时顺序号(所处课程的第几课时)更新对应易班平台上的每个环节进度(异步)
|
||||||
export async function setCourseTimeProgress(order: number): Promise<boolean> {
|
export async function setCourseTimeProgress(
|
||||||
|
order: number,
|
||||||
|
options: Pick<WriteOptions, 'bypassQueue' | 'deadlineMs'> = {}
|
||||||
|
): Promise<WriteResult> {
|
||||||
if (useUserStore.getState().yibanConnectionStatus !== 'connected') {
|
if (useUserStore.getState().yibanConnectionStatus !== 'connected') {
|
||||||
console.info(`[yiban] skip finishCourse: disconnected, order=${order}`)
|
console.info(`[yiban] skip finishCourse: disconnected, order=${order}`)
|
||||||
return false
|
return { success: true, skipped: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const courseid = useUserStore.getState().courseId
|
const courseid = useUserStore.getState().courseId
|
||||||
// var courseid = getCookie(lesson+"_course_id");//获取url链接的课程ID
|
|
||||||
// const courseid = await getLtDefine('10000')
|
|
||||||
|
|
||||||
if (courseid === undefined || courseid === null || courseid === '') {
|
if (courseid === undefined || courseid === null || courseid === '') {
|
||||||
console.info(`[yiban] skip finishCourse: missing courseId, order=${order}`)
|
console.info(`[yiban] skip finishCourse: missing courseId, order=${order}`)
|
||||||
return false
|
return { success: true, skipped: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (order <= 0) {
|
if (!Number.isInteger(order) || order <= 0) {
|
||||||
console.info(`[yiban] skip finishCourse: invalid order=${order}`)
|
console.error(`[yiban] skip finishCourse: invalid order=${order}`)
|
||||||
return false
|
return { success: false, message: '无效的课时顺序号' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const param2 = { order, co: courseid, process: 100 }
|
const param = { order, co: courseid, process: 100 }
|
||||||
console.info('[yiban] finishCourse', param2)
|
console.info('[yiban] finishCourse', param)
|
||||||
videoajax.finishCourse(param2)
|
const runRound = () =>
|
||||||
return true
|
runCallbackWriteRound<number>({
|
||||||
|
deadlineMs: options.deadlineMs,
|
||||||
|
invoke: (callback) => {
|
||||||
|
videoajax.finishCourse(param, (successOrResponse, response) => {
|
||||||
|
callback(getWriteResponse(successOrResponse, response))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (options.bypassQueue) return toWriteResult(await runRound())
|
||||||
|
return enqueueSyncWrite(`course-progress:${courseid}:${order}`, 'critical', runRound)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { runCallbackWriteRound } from './callbackWriteRetry'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('runCallbackWriteRound', () => {
|
||||||
|
it('resolves on code 200 without depending on data.value', async () => {
|
||||||
|
const invoke = vi.fn((callback) => callback({ code: 200, data: {} }))
|
||||||
|
|
||||||
|
const result = await runCallbackWriteRound({ invoke })
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(invoke).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries an explicit non-200 response after 500ms', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(0)
|
||||||
|
let attempts = 0
|
||||||
|
const promise = runCallbackWriteRound({
|
||||||
|
now: () => Date.now(),
|
||||||
|
invoke: (callback) => {
|
||||||
|
attempts += 1
|
||||||
|
callback(attempts === 1 ? { code: 500, msg: '失败' } : { code: 200 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(attempts).toBe(1)
|
||||||
|
await vi.advanceTimersByTimeAsync(499)
|
||||||
|
expect(attempts).toBe(1)
|
||||||
|
await vi.advanceTimersByTimeAsync(1)
|
||||||
|
|
||||||
|
await expect(promise).resolves.toMatchObject({ success: true, attempts: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not issue another request when no callback arrives', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(0)
|
||||||
|
const invoke = vi.fn()
|
||||||
|
const promise = runCallbackWriteRound({ invoke, now: () => Date.now() })
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000)
|
||||||
|
|
||||||
|
await expect(promise).resolves.toMatchObject({
|
||||||
|
success: false,
|
||||||
|
reason: 'deadline',
|
||||||
|
attempts: 1,
|
||||||
|
})
|
||||||
|
expect(invoke).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('waits for the deadline when the callback has no code', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(0)
|
||||||
|
const invoke = vi.fn((callback) => callback({ msg: 'invalid' }))
|
||||||
|
const promise = runCallbackWriteRound({ invoke, now: () => Date.now() })
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000)
|
||||||
|
|
||||||
|
await expect(promise).resolves.toMatchObject({
|
||||||
|
success: false,
|
||||||
|
reason: 'invalid-response',
|
||||||
|
attempts: 1,
|
||||||
|
})
|
||||||
|
expect(invoke).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
export interface WriteResponse<T = unknown> {
|
||||||
|
code?: number
|
||||||
|
msg?: string
|
||||||
|
data?: {
|
||||||
|
value?: T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WriteFailureReason = 'deadline' | 'non-200' | 'invalid-response' | 'invoke-error'
|
||||||
|
|
||||||
|
export type WriteRoundResult<T = unknown> =
|
||||||
|
| {
|
||||||
|
success: true
|
||||||
|
code: 200
|
||||||
|
attempts: number
|
||||||
|
elapsedMs: number
|
||||||
|
response: WriteResponse<T>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
success: false
|
||||||
|
reason: WriteFailureReason
|
||||||
|
attempts: number
|
||||||
|
elapsedMs: number
|
||||||
|
code?: number
|
||||||
|
message?: string
|
||||||
|
response?: WriteResponse<T>
|
||||||
|
error?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallbackWriteRetryOptions<T> {
|
||||||
|
invoke: (callback: (response: WriteResponse<T> | undefined) => void) => void
|
||||||
|
deadlineMs?: number
|
||||||
|
retryDelayMs?: number
|
||||||
|
now?: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DEADLINE_MS = 10_000
|
||||||
|
const DEFAULT_RETRY_DELAY_MS = 500
|
||||||
|
|
||||||
|
export function runCallbackWriteRound<T>({
|
||||||
|
invoke,
|
||||||
|
deadlineMs = DEFAULT_DEADLINE_MS,
|
||||||
|
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
||||||
|
now = () => performance.now(),
|
||||||
|
}: CallbackWriteRetryOptions<T>): Promise<WriteRoundResult<T>> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const startedAt = now()
|
||||||
|
const deadline = startedAt + deadlineMs
|
||||||
|
let attempts = 0
|
||||||
|
let settled = false
|
||||||
|
let attemptId = 0
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let lastFailure: Omit<
|
||||||
|
Extract<WriteRoundResult<T>, { success: false }>,
|
||||||
|
'elapsedMs' | 'attempts'
|
||||||
|
> = {
|
||||||
|
success: false,
|
||||||
|
reason: 'deadline',
|
||||||
|
}
|
||||||
|
|
||||||
|
const finish = (result: WriteRoundResult<T>) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(deadlineTimer)
|
||||||
|
if (retryTimer !== undefined) clearTimeout(retryTimer)
|
||||||
|
resolve(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
const failAtDeadline = () => {
|
||||||
|
finish({
|
||||||
|
...lastFailure,
|
||||||
|
success: false,
|
||||||
|
attempts,
|
||||||
|
elapsedMs: Math.max(0, now() - startedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadlineTimer = setTimeout(failAtDeadline, deadlineMs)
|
||||||
|
|
||||||
|
const runAttempt = () => {
|
||||||
|
if (settled) return
|
||||||
|
if (now() >= deadline) {
|
||||||
|
failAtDeadline()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts += 1
|
||||||
|
const currentAttemptId = ++attemptId
|
||||||
|
let callbackHandled = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
invoke((response) => {
|
||||||
|
if (settled || callbackHandled || currentAttemptId !== attemptId) return
|
||||||
|
callbackHandled = true
|
||||||
|
|
||||||
|
if (now() >= deadline) {
|
||||||
|
failAtDeadline()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response?.code === 200) {
|
||||||
|
finish({
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
attempts,
|
||||||
|
elapsedMs: Math.max(0, now() - startedAt),
|
||||||
|
response,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof response?.code !== 'number') {
|
||||||
|
lastFailure = {
|
||||||
|
success: false,
|
||||||
|
reason: 'invalid-response',
|
||||||
|
response,
|
||||||
|
message: response?.msg,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastFailure = {
|
||||||
|
success: false,
|
||||||
|
reason: 'non-200',
|
||||||
|
code: response.code,
|
||||||
|
message: response.msg,
|
||||||
|
response,
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingMs = deadline - now()
|
||||||
|
if (remainingMs < retryDelayMs) return
|
||||||
|
retryTimer = setTimeout(runAttempt, retryDelayMs)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
lastFailure = {
|
||||||
|
success: false,
|
||||||
|
reason: 'invoke-error',
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runAttempt()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { WriteOptions, WriteResult } from './api-common'
|
||||||
|
import { MAOZEDONG_KEY, MAOZEDONG_LESSON_ID, saveMaozedongStore } from '@/store/maozedongStore'
|
||||||
|
import {
|
||||||
|
SUNZHONGSHAN_KEY,
|
||||||
|
SUNZHONGSHAN_LESSON_ID,
|
||||||
|
saveSunzhongshanStore,
|
||||||
|
} from '@/store/sunzhongshanStore'
|
||||||
|
import { LUXUN_KEY, LUXUN_LESSON_ID, saveLuxunStore } from '@/store/luxunStore'
|
||||||
|
import {
|
||||||
|
HUANGYANPEI_KEY,
|
||||||
|
HUANGYANPEI_LESSON_ID,
|
||||||
|
saveHuangyanpeiStore,
|
||||||
|
} from '@/store/huangyanpeiStore'
|
||||||
|
import { NIEER_KEY, NIEER_LESSON_ID, saveNieerStore } from '@/store/nieerStore'
|
||||||
|
import {
|
||||||
|
ZHANGLEPING_KEY,
|
||||||
|
ZHANGLEPING_LESSON_ID,
|
||||||
|
saveZhanglepingStore,
|
||||||
|
} from '@/store/zhanglepingStore'
|
||||||
|
import {
|
||||||
|
RONGDESHENG_KEY,
|
||||||
|
RONGDESHENG_LESSON_ID,
|
||||||
|
saveRongdeshengStore,
|
||||||
|
} from '@/store/rongdeshengStore'
|
||||||
|
import { CHENYUN_KEY, CHENYUN_LESSON_ID, saveChenyunStore } from '@/store/chenyunStore'
|
||||||
|
|
||||||
|
type StoreSaver = (options?: WriteOptions) => Promise<WriteResult>
|
||||||
|
|
||||||
|
interface CheckpointStore {
|
||||||
|
save: StoreSaver
|
||||||
|
writeKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWriteKey = (lessonId: string, key: string | number) =>
|
||||||
|
`user-define:hsgrl_${lessonId}:${key}`
|
||||||
|
|
||||||
|
const getCheckpointStore = (order: number): CheckpointStore | undefined => {
|
||||||
|
if (order <= 3) {
|
||||||
|
return {
|
||||||
|
save: saveMaozedongStore,
|
||||||
|
writeKey: getWriteKey(MAOZEDONG_LESSON_ID, MAOZEDONG_KEY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (order <= 6) {
|
||||||
|
return {
|
||||||
|
save: saveSunzhongshanStore,
|
||||||
|
writeKey: getWriteKey(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (order <= 9) {
|
||||||
|
return { save: saveLuxunStore, writeKey: getWriteKey(LUXUN_LESSON_ID, LUXUN_KEY) }
|
||||||
|
}
|
||||||
|
if (order <= 12) {
|
||||||
|
return {
|
||||||
|
save: saveHuangyanpeiStore,
|
||||||
|
writeKey: getWriteKey(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (order <= 15) {
|
||||||
|
return { save: saveNieerStore, writeKey: getWriteKey(NIEER_LESSON_ID, NIEER_KEY) }
|
||||||
|
}
|
||||||
|
if (order <= 18) {
|
||||||
|
return {
|
||||||
|
save: saveZhanglepingStore,
|
||||||
|
writeKey: getWriteKey(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (order <= 21) {
|
||||||
|
return {
|
||||||
|
save: saveRongdeshengStore,
|
||||||
|
writeKey: getWriteKey(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (order <= 24) {
|
||||||
|
return { save: saveChenyunStore, writeKey: getWriteKey(CHENYUN_LESSON_ID, CHENYUN_KEY) }
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveCourseCheckpointState = async (order: number): Promise<WriteResult> => {
|
||||||
|
const checkpointStore =
|
||||||
|
Number.isInteger(order) && order > 0 ? getCheckpointStore(order) : undefined
|
||||||
|
if (!checkpointStore) throw new Error(`Unknown course checkpoint order: ${order}`)
|
||||||
|
return checkpointStore.save()
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { PuzzleProgress } from '@/config/progression'
|
||||||
|
import type { CollectionIdentity } from './puzzleCollection'
|
||||||
|
import type { WriteResult } from './syncWriteCoordinator'
|
||||||
|
|
||||||
|
let storage: Map<string, string>
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
storage = new Map()
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => storage.set(key, value),
|
||||||
|
removeItem: (key: string) => storage.delete(key),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
const deferred = <T>() => {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>((done) => {
|
||||||
|
resolve = done
|
||||||
|
})
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
const fixture = async (initial: PuzzleProgress = {}) => {
|
||||||
|
const { createPuzzleCollector } = await import('./puzzleCollection')
|
||||||
|
const coordinator = await import('./syncWriteCoordinator')
|
||||||
|
const identity: CollectionIdentity = {
|
||||||
|
uid: 'student',
|
||||||
|
courseId: 'course',
|
||||||
|
backend: 'yiban',
|
||||||
|
revision: 0,
|
||||||
|
loaded: true,
|
||||||
|
}
|
||||||
|
let puzzles = initial
|
||||||
|
const report = vi
|
||||||
|
.fn<(order: number) => Promise<WriteResult>>()
|
||||||
|
.mockResolvedValue({ success: true })
|
||||||
|
const save = vi.fn<() => Promise<WriteResult>>().mockResolvedValue({ success: true })
|
||||||
|
const commit = vi.fn((next: PuzzleProgress) => {
|
||||||
|
puzzles = next
|
||||||
|
})
|
||||||
|
const collector = createPuzzleCollector({
|
||||||
|
getIdentity: () => ({ ...identity }),
|
||||||
|
getPuzzles: () => puzzles,
|
||||||
|
report,
|
||||||
|
save,
|
||||||
|
commit,
|
||||||
|
})
|
||||||
|
return { ...collector, coordinator, identity, report, save, commit, getPuzzles: () => puzzles }
|
||||||
|
}
|
||||||
|
const waitForFailure = async (f: Awaited<ReturnType<typeof fixture>>) => {
|
||||||
|
await vi.waitFor(() => expect(f.coordinator.getSyncWriteState().phase).toBe('failed'))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('puzzle collection transaction', () => {
|
||||||
|
it('waits for report, then save, then local commit', async () => {
|
||||||
|
const f = await fixture()
|
||||||
|
const report = deferred<WriteResult>()
|
||||||
|
const save = deferred<WriteResult>()
|
||||||
|
f.report.mockReturnValue(report.promise)
|
||||||
|
f.save.mockReturnValue(save.promise)
|
||||||
|
const claim = f.collect('nieer', 14)
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(f.report).toHaveBeenCalledWith(14, expect.objectContaining({ bypassQueue: true }))
|
||||||
|
)
|
||||||
|
expect(f.save).not.toHaveBeenCalled()
|
||||||
|
expect(f.commit).not.toHaveBeenCalled()
|
||||||
|
report.resolve({ success: true })
|
||||||
|
await vi.waitFor(() => expect(f.save).toHaveBeenCalledTimes(1))
|
||||||
|
expect(f.commit).not.toHaveBeenCalled()
|
||||||
|
save.resolve({ success: true })
|
||||||
|
await expect(claim).resolves.toBe(true)
|
||||||
|
expect(f.getPuzzles().nieer?.has(14)).toBe(true)
|
||||||
|
expect(storage.size).toBe(0)
|
||||||
|
})
|
||||||
|
it('does not save on report failure, and retries report before save', async () => {
|
||||||
|
const f = await fixture()
|
||||||
|
f.report.mockResolvedValueOnce({ success: false })
|
||||||
|
const claim = f.collect('nieer', 14)
|
||||||
|
await waitForFailure(f)
|
||||||
|
expect(f.save).not.toHaveBeenCalled()
|
||||||
|
expect(f.commit).not.toHaveBeenCalled()
|
||||||
|
f.coordinator.retryActiveSyncWrite()
|
||||||
|
await expect(claim).resolves.toBe(true)
|
||||||
|
expect(f.report).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
it('retries only save after an acknowledged report', async () => {
|
||||||
|
const f = await fixture()
|
||||||
|
f.save.mockResolvedValueOnce({ success: false })
|
||||||
|
const claim = f.collect('nieer', 14)
|
||||||
|
await waitForFailure(f)
|
||||||
|
expect(f.commit).not.toHaveBeenCalled()
|
||||||
|
expect(storage.size).toBe(1)
|
||||||
|
f.coordinator.retryActiveSyncWrite()
|
||||||
|
await expect(claim).resolves.toBe(true)
|
||||||
|
expect(f.report).toHaveBeenCalledTimes(1)
|
||||||
|
expect(f.save).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
it('recovers a report acknowledgement after a reload without reporting again', async () => {
|
||||||
|
const first = await fixture()
|
||||||
|
first.save.mockResolvedValue({ success: false })
|
||||||
|
const abandoned = first.collect('nieer', 14)
|
||||||
|
await waitForFailure(first)
|
||||||
|
first.identity.revision++
|
||||||
|
first.coordinator.retryActiveSyncWrite()
|
||||||
|
await expect(abandoned).resolves.toBe(false)
|
||||||
|
vi.resetModules()
|
||||||
|
const next = await fixture()
|
||||||
|
await expect(next.collect('nieer', 14)).resolves.toBe(true)
|
||||||
|
expect(next.report).not.toHaveBeenCalled()
|
||||||
|
expect(storage.size).toBe(0)
|
||||||
|
})
|
||||||
|
it('does not reuse another identity’s acknowledgement', async () => {
|
||||||
|
const first = await fixture()
|
||||||
|
first.save.mockResolvedValue({ success: false })
|
||||||
|
const claim = first.collect('nieer', 14)
|
||||||
|
await waitForFailure(first)
|
||||||
|
first.identity.uid = 'another'
|
||||||
|
first.coordinator.retryActiveSyncWrite()
|
||||||
|
await claim
|
||||||
|
const next = await fixture()
|
||||||
|
next.identity.uid = 'another'
|
||||||
|
await next.collect('nieer', 14)
|
||||||
|
expect(next.report).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
it('deduplicates same-frame clicks', async () => {
|
||||||
|
const f = await fixture()
|
||||||
|
const first = f.collect('nieer', 14)
|
||||||
|
const second = f.collect('nieer', 14)
|
||||||
|
expect(first).toBe(second)
|
||||||
|
await first
|
||||||
|
expect(f.report).toHaveBeenCalledTimes(1)
|
||||||
|
expect(f.save).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
it('serializes missing pieces and reports only when the actual last piece completes the set', async () => {
|
||||||
|
const f = await fixture({ maozedong: new Set([14]) })
|
||||||
|
await expect(f.collect('maozedong', 13)).resolves.toBe(true)
|
||||||
|
expect(f.report).toHaveBeenCalledWith(2, expect.anything())
|
||||||
|
const g = await fixture()
|
||||||
|
await Promise.all([
|
||||||
|
g.collect('maozedong', 14),
|
||||||
|
g.collect('maozedong', 13),
|
||||||
|
g.collect('nieer', 1),
|
||||||
|
])
|
||||||
|
expect(g.getPuzzles().maozedong).toEqual(new Set([14, 13]))
|
||||||
|
expect(g.getPuzzles().nieer).toEqual(new Set([1]))
|
||||||
|
expect(g.report).toHaveBeenCalledTimes(1)
|
||||||
|
expect(g.report).toHaveBeenCalledWith(2, expect.anything())
|
||||||
|
})
|
||||||
|
it('does not backfill or report already-collected historical pieces', async () => {
|
||||||
|
const f = await fixture({ nieer: new Set([14]) })
|
||||||
|
await expect(f.collect('nieer', 14)).resolves.toBe(true)
|
||||||
|
expect(f.report).not.toHaveBeenCalled()
|
||||||
|
expect(f.save).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('supports local-only collection without course reporting', async () => {
|
||||||
|
const f = await fixture()
|
||||||
|
f.identity.backend = 'session'
|
||||||
|
f.identity.courseId = null
|
||||||
|
await expect(f.collect('nieer', 14)).resolves.toBe(true)
|
||||||
|
expect(f.report).not.toHaveBeenCalled()
|
||||||
|
expect(f.save).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ backend: 'session' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('keeps ordinary and local claims uncollected on save failure', async () => {
|
||||||
|
const f = await fixture()
|
||||||
|
f.save.mockResolvedValue({ success: false })
|
||||||
|
await expect(f.collect('nieer', 1)).resolves.toBe(false)
|
||||||
|
f.identity.backend = 'session'
|
||||||
|
await expect(f.collect('nieer', 14)).resolves.toBe(false)
|
||||||
|
expect(f.commit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it.each([
|
||||||
|
{ success: true, skipped: true },
|
||||||
|
{ success: true, superseded: true },
|
||||||
|
])('rejects an unconfirmed report result %j', async (result) => {
|
||||||
|
const f = await fixture()
|
||||||
|
f.report.mockResolvedValueOnce(result)
|
||||||
|
const claim = f.collect('nieer', 14)
|
||||||
|
await waitForFailure(f)
|
||||||
|
expect(f.save).not.toHaveBeenCalled()
|
||||||
|
f.coordinator.retryActiveSyncWrite()
|
||||||
|
await claim
|
||||||
|
})
|
||||||
|
it('rejects a superseded save rather than publishing the piece', async () => {
|
||||||
|
const f = await fixture()
|
||||||
|
f.save.mockResolvedValueOnce({ success: true, superseded: true })
|
||||||
|
await expect(f.collect('nieer', 1)).resolves.toBe(false)
|
||||||
|
expect(f.commit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('does not commit a stale identity after an outstanding report', async () => {
|
||||||
|
const f = await fixture()
|
||||||
|
const report = deferred<WriteResult>()
|
||||||
|
f.report.mockReturnValue(report.promise)
|
||||||
|
const claim = f.collect('nieer', 14)
|
||||||
|
await vi.waitFor(() => expect(f.report).toHaveBeenCalledTimes(1))
|
||||||
|
f.identity.uid = 'another'
|
||||||
|
report.resolve({ success: true })
|
||||||
|
await expect(claim).resolves.toBe(false)
|
||||||
|
expect(f.save).not.toHaveBeenCalled()
|
||||||
|
expect(f.commit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('cancels after a reset/load while a save is outstanding', async () => {
|
||||||
|
const f = await fixture()
|
||||||
|
const save = deferred<WriteResult>()
|
||||||
|
f.save.mockReturnValue(save.promise)
|
||||||
|
const claim = f.collect('nieer', 1)
|
||||||
|
await vi.waitFor(() => expect(f.save).toHaveBeenCalledTimes(1))
|
||||||
|
f.identity.revision++
|
||||||
|
save.resolve({ success: true })
|
||||||
|
await expect(claim).resolves.toBe(false)
|
||||||
|
expect(f.commit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('handles exceptions and blocked storage without trapping the retry queue', async () => {
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error('blocked')
|
||||||
|
},
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error('blocked')
|
||||||
|
},
|
||||||
|
removeItem: () => {
|
||||||
|
throw new Error('blocked')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const f = await fixture()
|
||||||
|
f.save.mockRejectedValueOnce(new Error('network exception'))
|
||||||
|
const claim = f.collect('nieer', 14)
|
||||||
|
await waitForFailure(f)
|
||||||
|
f.coordinator.retryActiveSyncWrite()
|
||||||
|
await expect(claim).resolves.toBe(true)
|
||||||
|
expect(f.report).toHaveBeenCalledTimes(1)
|
||||||
|
await expect(f.collect('nieer', 1)).resolves.toBe(true)
|
||||||
|
})
|
||||||
|
it('rejects invalid or not-yet-loaded claims without any writes', async () => {
|
||||||
|
const f = await fixture()
|
||||||
|
await expect(f.collect('unknown', 1)).resolves.toBe(false)
|
||||||
|
await expect(f.collect('nieer', 17)).resolves.toBe(false)
|
||||||
|
f.identity.loaded = false
|
||||||
|
await expect(f.collect('nieer', 14)).resolves.toBe(false)
|
||||||
|
expect(f.save).not.toHaveBeenCalled()
|
||||||
|
expect(f.report).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { getPuzzleCheckpoint } from '@/config/puzzleStages'
|
||||||
|
import type { PuzzleProgress } from '@/config/progression'
|
||||||
|
import { enqueueSyncWrite, type WriteResult } from './syncWriteCoordinator'
|
||||||
|
import type { WriteOptions } from './api-common'
|
||||||
|
import type { WriteRoundResult } from './callbackWriteRetry'
|
||||||
|
|
||||||
|
export interface CollectionIdentity {
|
||||||
|
uid: string | null
|
||||||
|
courseId: string | null
|
||||||
|
backend: 'yiban' | 'session'
|
||||||
|
revision: number
|
||||||
|
loaded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollectionDependencies {
|
||||||
|
getIdentity: () => CollectionIdentity
|
||||||
|
getPuzzles: () => PuzzleProgress
|
||||||
|
save: (puzzles: PuzzleProgress, options: WriteOptions) => Promise<WriteResult>
|
||||||
|
report: (order: number, options: WriteOptions) => Promise<WriteResult>
|
||||||
|
commit: (puzzles: PuzzleProgress) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACK_PREFIX = 'puzzle-collection-report:v1:'
|
||||||
|
const scopeKey = (identity: CollectionIdentity) =>
|
||||||
|
JSON.stringify([identity.uid, identity.courseId, identity.backend])
|
||||||
|
const identityKey = (identity: CollectionIdentity) =>
|
||||||
|
JSON.stringify([scopeKey(identity), identity.revision, identity.loaded])
|
||||||
|
const toRound = (result: WriteResult): WriteRoundResult =>
|
||||||
|
result.success && !result.skipped && !result.superseded
|
||||||
|
? {
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
attempts: result.attempts ?? 1,
|
||||||
|
elapsedMs: 0,
|
||||||
|
response: { code: 200 },
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
success: false,
|
||||||
|
reason: 'non-200',
|
||||||
|
attempts: result.attempts ?? 1,
|
||||||
|
elapsedMs: 0,
|
||||||
|
message: result.message || '上报或碎片保存未成功,请重新尝试。',
|
||||||
|
}
|
||||||
|
|
||||||
|
// All puzzle sets share one remote record, so serialize across residences as well as pieces.
|
||||||
|
export const createPuzzleCollector = (dependencies: CollectionDependencies) => {
|
||||||
|
let tail: Promise<unknown> = Promise.resolve()
|
||||||
|
const pending = new Map<string, Promise<boolean>>()
|
||||||
|
const acknowledged = new Set<string>()
|
||||||
|
const ackKey = (
|
||||||
|
identity: CollectionIdentity,
|
||||||
|
residence: string,
|
||||||
|
order: number,
|
||||||
|
pieceId: number
|
||||||
|
) => `${ACK_PREFIX}${encodeURIComponent(scopeKey(identity))}:${residence}:${order}:${pieceId}`
|
||||||
|
const hasAck = (key: string) => {
|
||||||
|
if (acknowledged.has(key)) return true
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem(key) === '1') {
|
||||||
|
acknowledged.add(key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// In-memory acknowledgement still supports retry when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const recordAck = (key: string) => {
|
||||||
|
acknowledged.add(key)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, '1')
|
||||||
|
} catch {
|
||||||
|
/* Retry remains available in memory. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const clearAck = (key: string) => {
|
||||||
|
acknowledged.delete(key)
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
} catch {
|
||||||
|
/* A saved piece never triggers another report. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collect = (residence: string, pieceId: number): Promise<boolean> => {
|
||||||
|
const checkpoint = getPuzzleCheckpoint(residence, pieceId)
|
||||||
|
const identity = dependencies.getIdentity()
|
||||||
|
if (!checkpoint || !identity.loaded) return Promise.resolve(false)
|
||||||
|
const capturedIdentity = identityKey(identity)
|
||||||
|
const isCurrent = () => identityKey(dependencies.getIdentity()) === capturedIdentity
|
||||||
|
const key = `${capturedIdentity}:${residence}:${pieceId}`
|
||||||
|
const existing = pending.get(key)
|
||||||
|
if (existing) return existing
|
||||||
|
const acknowledgementKey = ackKey(identity, residence, checkpoint.order, pieceId)
|
||||||
|
const run = async () => {
|
||||||
|
if (!isCurrent()) return false
|
||||||
|
const puzzles = dependencies.getPuzzles()
|
||||||
|
const pieces = puzzles[residence] ?? new Set<number>()
|
||||||
|
if (pieces.has(pieceId)) {
|
||||||
|
clearAck(acknowledgementKey)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const candidate = { ...puzzles, [residence]: new Set([...pieces, pieceId]) }
|
||||||
|
const isLastPiece = checkpoint.pieceIds.every((id) => candidate[residence]!.has(id))
|
||||||
|
let committed = false
|
||||||
|
const runRound = async (): Promise<WriteRoundResult> => {
|
||||||
|
// Identity/load changes cancel the stale claim and release the coordinator, without writes.
|
||||||
|
if (!isCurrent()) return toRound({ success: true })
|
||||||
|
try {
|
||||||
|
if (isLastPiece && identity.backend === 'yiban' && !hasAck(acknowledgementKey)) {
|
||||||
|
const report = await dependencies.report(checkpoint.order, {
|
||||||
|
bypassQueue: true,
|
||||||
|
deadlineMs: 10_000,
|
||||||
|
})
|
||||||
|
if (!toRound(report).success) return toRound(report)
|
||||||
|
recordAck(acknowledgementKey)
|
||||||
|
}
|
||||||
|
if (!isCurrent()) return toRound({ success: true })
|
||||||
|
const saved = await dependencies.save(candidate, {
|
||||||
|
backend: identity.backend,
|
||||||
|
bypassQueue: true,
|
||||||
|
silent: true,
|
||||||
|
deadlineMs: 10_000,
|
||||||
|
})
|
||||||
|
if (!toRound(saved).success) return toRound(saved)
|
||||||
|
if (!isCurrent()) return toRound({ success: true })
|
||||||
|
dependencies.commit(candidate)
|
||||||
|
committed = true
|
||||||
|
clearAck(acknowledgementKey)
|
||||||
|
return toRound(saved)
|
||||||
|
} catch (error) {
|
||||||
|
return toRound({
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '碎片收下失败',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isLastPiece && identity.backend === 'yiban') {
|
||||||
|
await enqueueSyncWrite(`puzzle-collection:${key}`, 'critical', runRound, [])
|
||||||
|
} else {
|
||||||
|
await runRound()
|
||||||
|
}
|
||||||
|
return committed
|
||||||
|
}
|
||||||
|
const request = tail
|
||||||
|
.then(run)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to collect puzzle piece', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pending.delete(key)
|
||||||
|
})
|
||||||
|
tail = request
|
||||||
|
pending.set(key, request)
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSavedAcknowledgements = () => {
|
||||||
|
const identity = dependencies.getIdentity()
|
||||||
|
for (const [residence, pieces] of Object.entries(dependencies.getPuzzles())) {
|
||||||
|
for (const pieceId of pieces) {
|
||||||
|
const checkpoint = getPuzzleCheckpoint(residence, pieceId)
|
||||||
|
if (checkpoint) clearAck(ackKey(identity, residence, checkpoint.order, pieceId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { collect, clearSavedAcknowledgements }
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { WriteRoundResult } from './callbackWriteRetry'
|
||||||
|
|
||||||
|
beforeEach(() => vi.resetModules())
|
||||||
|
const success = (): WriteRoundResult => ({
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
attempts: 1,
|
||||||
|
elapsedMs: 0,
|
||||||
|
response: { code: 200 },
|
||||||
|
})
|
||||||
|
describe('unexpected write exceptions', () => {
|
||||||
|
it('releases the queue after a background runner throws', async () => {
|
||||||
|
const { enqueueSyncWrite } = await import('./syncWriteCoordinator')
|
||||||
|
await expect(
|
||||||
|
enqueueSyncWrite('bad', 'background', async () => {
|
||||||
|
throw new Error('broken')
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ success: false, message: 'broken' })
|
||||||
|
await expect(
|
||||||
|
enqueueSyncWrite('next', 'background', async () => success())
|
||||||
|
).resolves.toMatchObject({ success: true })
|
||||||
|
})
|
||||||
|
it('allows manual retry after a critical runner throws', async () => {
|
||||||
|
const { enqueueSyncWrite, getSyncWriteState, retryActiveSyncWrite } =
|
||||||
|
await import('./syncWriteCoordinator')
|
||||||
|
const runner = vi.fn().mockRejectedValueOnce(new Error('broken')).mockResolvedValue(success())
|
||||||
|
const request = enqueueSyncWrite('critical', 'critical', runner)
|
||||||
|
await vi.waitFor(() => expect(getSyncWriteState().phase).toBe('failed'))
|
||||||
|
retryActiveSyncWrite()
|
||||||
|
await expect(request).resolves.toMatchObject({ success: true })
|
||||||
|
expect(getSyncWriteState().phase).toBe('idle')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { WriteRoundResult } from './callbackWriteRetry'
|
||||||
|
|
||||||
|
const success = (): WriteRoundResult => ({
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
attempts: 1,
|
||||||
|
elapsedMs: 1,
|
||||||
|
response: { code: 200 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const failure = (): WriteRoundResult => ({
|
||||||
|
success: false,
|
||||||
|
reason: 'deadline',
|
||||||
|
attempts: 1,
|
||||||
|
elapsedMs: 10_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('syncWriteCoordinator', () => {
|
||||||
|
it('runs a queued critical job before background jobs that have not started', async () => {
|
||||||
|
const { enqueueSyncWrite } = await import('./syncWriteCoordinator')
|
||||||
|
const order: string[] = []
|
||||||
|
let releaseActive!: () => void
|
||||||
|
const activeRound = new Promise<WriteRoundResult>((resolve) => {
|
||||||
|
releaseActive = () => resolve(success())
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = enqueueSyncWrite('background-active', 'background', async () => {
|
||||||
|
order.push('background-active')
|
||||||
|
return activeRound
|
||||||
|
})
|
||||||
|
const second = enqueueSyncWrite('background-waiting', 'background', async () => {
|
||||||
|
order.push('background-waiting')
|
||||||
|
return success()
|
||||||
|
})
|
||||||
|
const critical = enqueueSyncWrite('critical', 'critical', async () => {
|
||||||
|
order.push('critical')
|
||||||
|
return success()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(order).toEqual(['background-active'])
|
||||||
|
releaseActive()
|
||||||
|
await Promise.all([first, second, critical])
|
||||||
|
|
||||||
|
expect(order).toEqual(['background-active', 'critical', 'background-waiting'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a critical promise pending until the user retries successfully', async () => {
|
||||||
|
const { enqueueSyncWrite, getSyncWriteState, retryActiveSyncWrite } =
|
||||||
|
await import('./syncWriteCoordinator')
|
||||||
|
let rounds = 0
|
||||||
|
const promise = enqueueSyncWrite('critical', 'critical', async () => {
|
||||||
|
rounds += 1
|
||||||
|
return rounds === 1 ? failure() : success()
|
||||||
|
})
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(getSyncWriteState().phase).toBe('failed'))
|
||||||
|
let resolved = false
|
||||||
|
void promise.then(() => {
|
||||||
|
resolved = true
|
||||||
|
})
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(resolved).toBe(false)
|
||||||
|
|
||||||
|
retryActiveSyncWrite()
|
||||||
|
await expect(promise).resolves.toMatchObject({ success: true })
|
||||||
|
expect(rounds).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supersedes an older queued background write with the same key', async () => {
|
||||||
|
const { enqueueSyncWrite } = await import('./syncWriteCoordinator')
|
||||||
|
let releaseActive!: () => void
|
||||||
|
const activeRound = new Promise<WriteRoundResult>((resolve) => {
|
||||||
|
releaseActive = () => resolve(success())
|
||||||
|
})
|
||||||
|
const staleRunner = vi.fn(async () => success())
|
||||||
|
|
||||||
|
const active = enqueueSyncWrite('other', 'background', () => activeRound)
|
||||||
|
const stale = enqueueSyncWrite('same-key', 'background', staleRunner)
|
||||||
|
const critical = enqueueSyncWrite('compound-checkpoint', 'critical', async () => success(), [
|
||||||
|
'same-key',
|
||||||
|
])
|
||||||
|
|
||||||
|
await expect(stale).resolves.toMatchObject({ success: true, superseded: true })
|
||||||
|
expect(staleRunner).not.toHaveBeenCalled()
|
||||||
|
releaseActive()
|
||||||
|
await Promise.all([active, critical])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import type { WriteRoundResult } from './callbackWriteRetry'
|
||||||
|
|
||||||
|
export type WritePriority = 'background' | 'critical'
|
||||||
|
|
||||||
|
export interface WriteResult {
|
||||||
|
success: boolean
|
||||||
|
skipped?: boolean
|
||||||
|
superseded?: boolean
|
||||||
|
code?: number
|
||||||
|
message?: string
|
||||||
|
attempts?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncWriteState {
|
||||||
|
phase: 'idle' | 'loading' | 'failed'
|
||||||
|
message?: string
|
||||||
|
retrying: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoundRunner = () => Promise<WriteRoundResult<unknown>>
|
||||||
|
|
||||||
|
interface WriteJob {
|
||||||
|
id: number
|
||||||
|
key: string
|
||||||
|
supersedeKeys: string[]
|
||||||
|
priority: WritePriority
|
||||||
|
notifyOnFailure: boolean
|
||||||
|
runRound: RoundRunner
|
||||||
|
resolve: (result: WriteResult) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateListeners = new Set<(state: SyncWriteState) => void>()
|
||||||
|
const backgroundFailureListeners = new Set<(message: string) => void>()
|
||||||
|
const queue: WriteJob[] = []
|
||||||
|
let state: SyncWriteState = { phase: 'idle', retrying: false }
|
||||||
|
let activeJob: WriteJob | null = null
|
||||||
|
let nextJobId = 1
|
||||||
|
let retryResolver: (() => void) | null = null
|
||||||
|
|
||||||
|
const emitState = (next: SyncWriteState) => {
|
||||||
|
state = next
|
||||||
|
stateListeners.forEach((listener) => listener(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCriticalWork = () =>
|
||||||
|
activeJob?.priority === 'critical' || queue.some((job) => job.priority === 'critical')
|
||||||
|
|
||||||
|
const refreshLoadingState = () => {
|
||||||
|
if (hasCriticalWork() && state.phase !== 'failed') {
|
||||||
|
emitState({ phase: 'loading', retrying: false })
|
||||||
|
} else if (!hasCriticalWork() && state.phase !== 'failed') {
|
||||||
|
emitState({ phase: 'idle', retrying: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitForRetry = () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
retryResolver = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
const runWriteRound = async (job: WriteJob): Promise<WriteRoundResult> => {
|
||||||
|
try {
|
||||||
|
return await job.runRound()
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reason: 'invoke-error',
|
||||||
|
attempts: 1,
|
||||||
|
elapsedMs: 0,
|
||||||
|
message: error instanceof Error ? error.message : '学习进度保存失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runCriticalJob = async (job: WriteJob): Promise<WriteResult> => {
|
||||||
|
while (true) {
|
||||||
|
emitState({ phase: 'loading', retrying: false })
|
||||||
|
const result = await runWriteRound(job)
|
||||||
|
if (result.success) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
code: result.code,
|
||||||
|
attempts: result.attempts,
|
||||||
|
message: result.response.msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitState({
|
||||||
|
phase: 'failed',
|
||||||
|
retrying: false,
|
||||||
|
message: result.message || '学习进度保存失败,请检查网络后重新尝试。',
|
||||||
|
})
|
||||||
|
await waitForRetry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runBackgroundJob = async (job: WriteJob): Promise<WriteResult> => {
|
||||||
|
const result = await runWriteRound(job)
|
||||||
|
if (result.success) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
code: result.code,
|
||||||
|
attempts: result.attempts,
|
||||||
|
message: result.response.msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = result.message || '学习进度保存失败,请检查网络后稍后重试。'
|
||||||
|
const supersededByCritical = queue.some(
|
||||||
|
(queued) => queued.priority === 'critical' && queued.supersedeKeys.includes(job.key)
|
||||||
|
)
|
||||||
|
if (!supersededByCritical && job.notifyOnFailure) {
|
||||||
|
backgroundFailureListeners.forEach((listener) => listener(message))
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: result.code,
|
||||||
|
attempts: result.attempts,
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processQueue = async () => {
|
||||||
|
if (activeJob || queue.length === 0) {
|
||||||
|
refreshLoadingState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeJob = queue.shift() ?? null
|
||||||
|
if (!activeJob) return
|
||||||
|
refreshLoadingState()
|
||||||
|
|
||||||
|
const job = activeJob
|
||||||
|
const result =
|
||||||
|
job.priority === 'critical' ? await runCriticalJob(job) : await runBackgroundJob(job)
|
||||||
|
job.resolve(result)
|
||||||
|
activeJob = null
|
||||||
|
retryResolver = null
|
||||||
|
refreshLoadingState()
|
||||||
|
queueMicrotask(() => void processQueue())
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enqueueSyncWrite = (
|
||||||
|
key: string,
|
||||||
|
priority: WritePriority,
|
||||||
|
runRound: RoundRunner,
|
||||||
|
supersedeKeys: string[] = [key],
|
||||||
|
notifyOnFailure = true
|
||||||
|
): Promise<WriteResult> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const job: WriteJob = {
|
||||||
|
id: nextJobId++,
|
||||||
|
key,
|
||||||
|
supersedeKeys,
|
||||||
|
priority,
|
||||||
|
notifyOnFailure,
|
||||||
|
runRound,
|
||||||
|
resolve,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priority === 'critical') {
|
||||||
|
for (let index = queue.length - 1; index >= 0; index -= 1) {
|
||||||
|
const queued = queue[index]
|
||||||
|
if (queued?.priority === 'background' && supersedeKeys.includes(queued.key)) {
|
||||||
|
queue.splice(index, 1)
|
||||||
|
queued.resolve({ success: true, superseded: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstBackgroundIndex = queue.findIndex((queued) => queued.priority === 'background')
|
||||||
|
if (firstBackgroundIndex === -1) queue.push(job)
|
||||||
|
else queue.splice(firstBackgroundIndex, 0, job)
|
||||||
|
emitState({ phase: 'loading', retrying: false })
|
||||||
|
} else {
|
||||||
|
for (let index = queue.length - 1; index >= 0; index -= 1) {
|
||||||
|
const queued = queue[index]
|
||||||
|
if (queued?.priority === 'background' && queued.key === key) {
|
||||||
|
queue.splice(index, 1)
|
||||||
|
queued.resolve({ success: true, superseded: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queue.push(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
void processQueue()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const retryActiveSyncWrite = () => {
|
||||||
|
if (state.phase !== 'failed' || !retryResolver) return
|
||||||
|
const resolve = retryResolver
|
||||||
|
retryResolver = null
|
||||||
|
emitState({ phase: 'loading', retrying: true })
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const subscribeSyncWriteState = (listener: (next: SyncWriteState) => void) => {
|
||||||
|
stateListeners.add(listener)
|
||||||
|
listener(state)
|
||||||
|
return () => {
|
||||||
|
stateListeners.delete(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const subscribeBackgroundWriteFailure = (listener: (message: string) => void) => {
|
||||||
|
backgroundFailureListeners.add(listener)
|
||||||
|
return () => {
|
||||||
|
backgroundFailureListeners.delete(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSyncWriteState = () => state
|
||||||
+33
-2
@@ -5,7 +5,11 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { DEFAULT_PUZZLE_RESIDENCE, PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
|
import { DEFAULT_PUZZLE_RESIDENCE, PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
|
||||||
import { puzzleDataConfig } from '@/config/puzzleData'
|
import { puzzleDataConfig } from '@/config/puzzleData'
|
||||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||||
import { isMenuModuleComplete } from '@/config/progression'
|
import {
|
||||||
|
getMenuModuleProgress,
|
||||||
|
isMenuModuleComplete,
|
||||||
|
isMenuModuleStarted,
|
||||||
|
} from '@/config/progression'
|
||||||
|
|
||||||
const Menu = () => {
|
const Menu = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -93,6 +97,8 @@ const Menu = () => {
|
|||||||
<div className="absolute left-40 top-70 inline-grid grid-cols-2 gap-x-20 gap-y-10">
|
<div className="absolute left-40 top-70 inline-grid grid-cols-2 gap-x-20 gap-y-10">
|
||||||
{Category.map((item) => {
|
{Category.map((item) => {
|
||||||
const isComplete = isLoaded && isMenuModuleComplete(item.moduleName, puzzles)
|
const isComplete = isLoaded && isMenuModuleComplete(item.moduleName, puzzles)
|
||||||
|
const isStarted = isLoaded && isMenuModuleStarted(item.moduleName, puzzles)
|
||||||
|
const moduleProgress = isStarted ? getMenuModuleProgress(item.moduleName, puzzles) : 0
|
||||||
const completeImageMap: Record<string, string> = {
|
const completeImageMap: Record<string, string> = {
|
||||||
revolutionary: 'menu/革命完成',
|
revolutionary: 'menu/革命完成',
|
||||||
cultural: 'menu/文化完成',
|
cultural: 'menu/文化完成',
|
||||||
@@ -118,7 +124,32 @@ const Menu = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="mt-23">
|
<div className="mt-23">
|
||||||
<Image className="absolute bottom-5" src={getStaticImage('menu/未看完')} />
|
<Image className="absolute bottom-5" src={getStaticImage('menu/未看完')} />
|
||||||
<Image src={getStaticImage('menu/未探索')} />
|
{isStarted ? (
|
||||||
|
<div
|
||||||
|
className="relative h-[43px] w-[95px] bg-center bg-no-repeat"
|
||||||
|
style={{
|
||||||
|
backgroundImage: getBackgroundImage('menu/进度标签底'),
|
||||||
|
backgroundSize: '100% 100%',
|
||||||
|
}}
|
||||||
|
role="img"
|
||||||
|
aria-label={`探索进度 ${moduleProgress}%`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 flex items-center justify-center text-[30px] leading-none text-white"
|
||||||
|
style={{
|
||||||
|
fontFamily: "'DingTalk-JinBuTi', sans-serif",
|
||||||
|
fontWeight: 400,
|
||||||
|
letterSpacing: '2px',
|
||||||
|
transform: 'translateX(1px) scaleY(0.9)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{moduleProgress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Image src={getStaticImage('menu/未探索')} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { saveChenyunStore } from '@/store/chenyunStore'
|
||||||
import { useMemo, useState, useRef, useEffect } from 'react'
|
import { useMemo, useState, useRef, useEffect } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +16,7 @@ import { SuccessAlert, DragQuizPanel } from '@/components/layout'
|
|||||||
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||||
import { GuideSteps, GuideStepsItem } from '@/components'
|
import { GuideSteps, GuideStepsItem } from '@/components'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||||
type QuizOption = {
|
type QuizOption = {
|
||||||
value: string
|
value: string
|
||||||
label: string
|
label: string
|
||||||
@@ -222,7 +223,14 @@ const ChenyunInternalization = () => {
|
|||||||
content=""
|
content=""
|
||||||
onContinue={() => {
|
onContinue={() => {
|
||||||
setActiveSuccess(null)
|
setActiveSuccess(null)
|
||||||
triggerPuzzleAnimation(16)
|
void triggerPuzzleAnimation(16)
|
||||||
|
.then(() => {
|
||||||
|
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||||
|
return saveChenyunStore()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to commit internalization checkpoint', error)
|
||||||
|
})
|
||||||
setDragQuizValues((prev) => {
|
setDragQuizValues((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
delete next[item.id]
|
delete next[item.id]
|
||||||
@@ -496,7 +504,6 @@ const ChenyunInternalization = () => {
|
|||||||
animationSpeed={200}
|
animationSpeed={200}
|
||||||
onContinue={() => {
|
onContinue={() => {
|
||||||
setShowStudyGuide(false)
|
setShowStudyGuide(false)
|
||||||
setCourseTimeProgress(24)
|
|
||||||
navigate('/learning-map', { state: { completedResidence: 'chenyun' } })
|
navigate('/learning-map', { state: { completedResidence: 'chenyun' } })
|
||||||
}}
|
}}
|
||||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_40.mp3')}
|
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_40.mp3')}
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import ScaledPortal from '@/components/layout/ScaledPortal'
|
|||||||
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
|
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import type { VideoRef } from '@/components/video'
|
import type { VideoRef } from '@/components/video'
|
||||||
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
|
import { saveChenyunStore, useChenyunStore } from '@/store/chenyunStore'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
|
|
||||||
// starName 固定为 chenyun
|
// starName 固定为 chenyun
|
||||||
const starName = 'chenyun'
|
const starName = 'chenyun'
|
||||||
@@ -109,9 +108,8 @@ const ChenyunPlace9 = () => {
|
|||||||
className="absolute bottom-0 right-0"
|
className="absolute bottom-0 right-0"
|
||||||
src={getStaticImage('chenyun/place9/离开场景')}
|
src={getStaticImage('chenyun/place9/离开场景')}
|
||||||
button
|
button
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
setCourseTimeProgress(22)
|
await saveChenyunStore()
|
||||||
saveChenyunStore()
|
|
||||||
navigate(`/${moduleName}/${starName}/home`)
|
navigate(`/${moduleName}/${starName}/home`)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -178,10 +178,10 @@ export const MimianBattle = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理单选题5提交
|
// 处理单选题5提交
|
||||||
const handleQuiz5Submit = (value: string, isCorrect: boolean) => {
|
const handleQuiz5Submit = async (value: string, isCorrect: boolean) => {
|
||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
// 标记米棉之战为已解决,并收起文件
|
// 标记米棉之战为已解决,并收起文件
|
||||||
state.setIsMimianSolved(true)
|
await state.setIsMimianSolved(true)
|
||||||
onCollapseFile()
|
onCollapseFile()
|
||||||
|
|
||||||
state.setShowMimianQuiz5(false)
|
state.setShowMimianQuiz5(false)
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
* 管理米棉之战相关的所有状态
|
* 管理米棉之战相关的所有状态
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
|
import { saveChenyunStore, useChenyunStore } from '@/store/chenyunStore'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 米棉之战状态管理 Hook
|
* 米棉之战状态管理 Hook
|
||||||
@@ -15,10 +14,9 @@ export const useMimianState = () => {
|
|||||||
const { isMimianSolved, setMimianSolved } = useChenyunStore()
|
const { isMimianSolved, setMimianSolved } = useChenyunStore()
|
||||||
|
|
||||||
// 更新解决状态时,直接更新 store
|
// 更新解决状态时,直接更新 store
|
||||||
const handleSetIsMimianSolved = (solved: boolean) => {
|
const handleSetIsMimianSolved = async (solved: boolean) => {
|
||||||
setMimianSolved(solved)
|
setMimianSolved(solved)
|
||||||
saveChenyunStore()
|
await saveChenyunStore()
|
||||||
setCourseTimeProgress(23)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单选题1相关状态
|
// 单选题1相关状态
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
||||||
import { useEffect, useMemo, useState, useRef } from 'react'
|
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +17,7 @@ import { SuccessAlert, DragQuizPanel } from '@/components/layout'
|
|||||||
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
|
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||||
import { GuideSteps, GuideStepsItem } from '@/components'
|
import { GuideSteps, GuideStepsItem } from '@/components'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||||
|
|
||||||
type QuizOption = {
|
type QuizOption = {
|
||||||
value: string
|
value: string
|
||||||
@@ -298,7 +299,14 @@ const HuangyanpeiInternalization = () => {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
if (item.id === 'quiz-3') {
|
if (item.id === 'quiz-3') {
|
||||||
triggerPuzzleAnimation(16)
|
void triggerPuzzleAnimation(16)
|
||||||
|
.then(() => {
|
||||||
|
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||||
|
return saveHuangyanpeiStore()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to commit internalization checkpoint', error)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const cb = resumeCallback
|
const cb = resumeCallback
|
||||||
setResumeCallback(null)
|
setResumeCallback(null)
|
||||||
@@ -538,7 +546,6 @@ const HuangyanpeiInternalization = () => {
|
|||||||
animationSpeed={200}
|
animationSpeed={200}
|
||||||
onContinue={() => {
|
onContinue={() => {
|
||||||
setShowStudyGuide(false)
|
setShowStudyGuide(false)
|
||||||
setCourseTimeProgress(12)
|
|
||||||
navigate('/learning-map', { state: { completedResidence: 'huangyanpei' } })
|
navigate('/learning-map', { state: { completedResidence: 'huangyanpei' } })
|
||||||
}}
|
}}
|
||||||
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_31.mp3')}
|
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_31.mp3')}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useNavigate, useParams } from 'react-router-dom'
|
|||||||
import { EndingOverlay } from '@/components/overlay/EndingOverlay'
|
import { EndingOverlay } from '@/components/overlay/EndingOverlay'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
|
|
||||||
const starName = 'huangyanpei'
|
const starName = 'huangyanpei'
|
||||||
|
|
||||||
@@ -105,9 +104,8 @@ const HuangyanpeiPlace6 = () => {
|
|||||||
className="absolute bottom-0 right-4"
|
className="absolute bottom-0 right-4"
|
||||||
src={getStaticImage('components/place/离开场景')}
|
src={getStaticImage('components/place/离开场景')}
|
||||||
button
|
button
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
saveHuangyanpeiStore()
|
await saveHuangyanpeiStore()
|
||||||
setCourseTimeProgress(10)
|
|
||||||
navigate(`/${moduleName}/${starName}/home`)
|
navigate(`/${moduleName}/${starName}/home`)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import { useNavigate, useParams } from 'react-router-dom'
|
|||||||
import ViseAssemblyDialog from './scene3/ViseAssemblyDialog'
|
import ViseAssemblyDialog from './scene3/ViseAssemblyDialog'
|
||||||
import type { VideoRef } from '@/components/video'
|
import type { VideoRef } from '@/components/video'
|
||||||
import { QuizDialog } from './components'
|
import { QuizDialog } from './components'
|
||||||
import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
import { saveHuangyanpeiStore, useHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
import { useCursorController } from '@/hooks/useCursorController'
|
import { useCursorController } from '@/hooks/useCursorController'
|
||||||
import { saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
|
|
||||||
const starName = 'huangyanpei'
|
const starName = 'huangyanpei'
|
||||||
@@ -242,10 +240,9 @@ const HuangyanpeiReflectionScene3 = () => {
|
|||||||
showSkipButton
|
showSkipButton
|
||||||
skipKey="huangyanpei_reflection_scene3_video"
|
skipKey="huangyanpei_reflection_scene3_video"
|
||||||
showReadonlyProgress
|
showReadonlyProgress
|
||||||
onEnded={() => {
|
onEnded={async () => {
|
||||||
|
await saveHuangyanpeiStore()
|
||||||
setShowVideo(false)
|
setShowVideo(false)
|
||||||
saveHuangyanpeiStore()
|
|
||||||
setCourseTimeProgress(11)
|
|
||||||
navigate(`/${moduleName}/${starName}/home`)
|
navigate(`/${moduleName}/${starName}/home`)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import TowelSalesDialog from './scene4/TowelSalesDialog'
|
|||||||
import type { VideoRef } from '@/components/video'
|
import type { VideoRef } from '@/components/video'
|
||||||
import { QuizDialog } from './components'
|
import { QuizDialog } from './components'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
import { saveHuangyanpeiStore, useHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
import { useCursorController } from '@/hooks/useCursorController'
|
import { useCursorController } from '@/hooks/useCursorController'
|
||||||
|
|
||||||
const starName = 'huangyanpei'
|
const starName = 'huangyanpei'
|
||||||
@@ -223,10 +222,9 @@ const HuangyanpeiReflectionScene4 = () => {
|
|||||||
skipKey="huangyanpei_reflection_scene4_video"
|
skipKey="huangyanpei_reflection_scene4_video"
|
||||||
showReadonlyProgress
|
showReadonlyProgress
|
||||||
playsInline
|
playsInline
|
||||||
onEnded={() => {
|
onEnded={async () => {
|
||||||
|
await saveHuangyanpeiStore()
|
||||||
setShowVideo(false)
|
setShowVideo(false)
|
||||||
saveHuangyanpeiStore()
|
|
||||||
setCourseTimeProgress(11)
|
|
||||||
navigate(`/${moduleName}/${starName}/home`)
|
navigate(`/${moduleName}/${starName}/home`)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { saveLuxunStore } from '@/store/luxunStore'
|
||||||
import { useEffect, useMemo, useState, useRef } from 'react'
|
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +16,7 @@ import { SuccessAlert, DragQuizPanel } from '@/components/layout'
|
|||||||
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||||
import { GuideSteps, GuideStepsItem } from '@/components'
|
import { GuideSteps, GuideStepsItem } from '@/components'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||||
|
|
||||||
type QuizOption = {
|
type QuizOption = {
|
||||||
value: string
|
value: string
|
||||||
@@ -393,7 +394,14 @@ const LuxunInternalization = () => {
|
|||||||
setSuccessContent('')
|
setSuccessContent('')
|
||||||
const cb = resumeCallback
|
const cb = resumeCallback
|
||||||
setResumeCallback(null)
|
setResumeCallback(null)
|
||||||
triggerPuzzleAnimation(16)
|
void triggerPuzzleAnimation(16)
|
||||||
|
.then(() => {
|
||||||
|
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||||
|
return saveLuxunStore()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to commit internalization checkpoint', error)
|
||||||
|
})
|
||||||
cb?.()
|
cb?.()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -538,7 +546,6 @@ const LuxunInternalization = () => {
|
|||||||
animationSpeed={200}
|
animationSpeed={200}
|
||||||
onContinue={() => {
|
onContinue={() => {
|
||||||
setShowStudyGuide(false)
|
setShowStudyGuide(false)
|
||||||
setCourseTimeProgress(9)
|
|
||||||
navigate('/learning-map', { state: { completedResidence: 'luxun' } })
|
navigate('/learning-map', { state: { completedResidence: 'luxun' } })
|
||||||
}}
|
}}
|
||||||
audioSrc={getPublicPath('audio/luxun/mp3_luxun_33_02_new.mp3')}
|
audioSrc={getPublicPath('audio/luxun/mp3_luxun_33_02_new.mp3')}
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ import { useState, useRef, useMemo } from 'react'
|
|||||||
import SuccessAlert from '@/components/layout/alert/success-alert'
|
import SuccessAlert from '@/components/layout/alert/success-alert'
|
||||||
import { useCursorController } from '@/hooks/useCursorController'
|
import { useCursorController } from '@/hooks/useCursorController'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore'
|
import { saveLuxunStore, useLuxunStore } from '@/store/luxunStore'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
|
|
||||||
// starName 固定为 luxun
|
// starName 固定为 luxun
|
||||||
const starName = 'luxun'
|
const starName = 'luxun'
|
||||||
@@ -431,13 +430,12 @@ const LuxunPlace5 = () => {
|
|||||||
{/* 视频结束后的结束语对话 */}
|
{/* 视频结束后的结束语对话 */}
|
||||||
<GuideSteps
|
<GuideSteps
|
||||||
visible={endingGuideVisible}
|
visible={endingGuideVisible}
|
||||||
onVisibleChange={(visible) => {
|
onVisibleChange={async (visible) => {
|
||||||
setShowEndingVideo(false)
|
setShowEndingVideo(false)
|
||||||
setEndingGuideVisible(visible)
|
setEndingGuideVisible(visible)
|
||||||
if (!visible && moduleName) {
|
if (!visible && moduleName) {
|
||||||
// 对话结束后返回鲁迅首页
|
// 对话结束后返回鲁迅首页
|
||||||
saveLuxunStore()
|
await saveLuxunStore()
|
||||||
setCourseTimeProgress(7)
|
|
||||||
navigate(`/${moduleName}/${starName}/home`)
|
navigate(`/${moduleName}/${starName}/home`)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { saveLuxunStore } from '@/store/luxunStore'
|
||||||
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
@@ -5,8 +6,6 @@ import { Image, GuideSteps, GuideStepsItem, StudyGuideModal, EndingOverlay } fro
|
|||||||
import { SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
import { SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||||
import SuccessAlert from '@/components/layout/alert/success-alert'
|
import SuccessAlert from '@/components/layout/alert/success-alert'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
import { saveLuxunStore } from '@/store/luxunStore'
|
|
||||||
|
|
||||||
const starName = 'luxun'
|
const starName = 'luxun'
|
||||||
|
|
||||||
@@ -344,9 +343,8 @@ const LuxunReflectionScene3 = () => {
|
|||||||
className="absolute bottom-45 right-0"
|
className="absolute bottom-45 right-0"
|
||||||
src={getStaticImage('components/place/离开场景')}
|
src={getStaticImage('components/place/离开场景')}
|
||||||
button
|
button
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
saveLuxunStore()
|
await saveLuxunStore()
|
||||||
setCourseTimeProgress(8)
|
|
||||||
navigate(`/${moduleName}/${starName}/home`)
|
navigate(`/${moduleName}/${starName}/home`)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { saveMaozedongStore } from '@/store/maozedongStore'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +16,7 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
|||||||
import { GuideSteps, GuideStepsItem } from '@/components'
|
import { GuideSteps, GuideStepsItem } from '@/components'
|
||||||
import { BasketQuizPanel, DragQuizPanel, SuccessAlert } from '@/components/layout'
|
import { BasketQuizPanel, DragQuizPanel, SuccessAlert } from '@/components/layout'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||||
|
|
||||||
const starName = 'maozedong'
|
const starName = 'maozedong'
|
||||||
|
|
||||||
@@ -291,7 +292,16 @@ const MaozedongInternalization = () => {
|
|||||||
|
|
||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
setActiveSuccess(q.id)
|
setActiveSuccess(q.id)
|
||||||
if (q.id === 'quiz-5') triggerPuzzleAnimation(16)
|
if (q.id === 'quiz-5') {
|
||||||
|
void triggerPuzzleAnimation(16)
|
||||||
|
.then(() => {
|
||||||
|
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||||
|
return saveMaozedongStore()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to commit internalization checkpoint', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
if (q.id === 'quiz-2') triggerPuzzleAnimation(15)
|
if (q.id === 'quiz-2') triggerPuzzleAnimation(15)
|
||||||
setResumeCallback(() => resume)
|
setResumeCallback(() => resume)
|
||||||
return
|
return
|
||||||
@@ -527,7 +537,6 @@ const MaozedongInternalization = () => {
|
|||||||
animationSpeed={200}
|
animationSpeed={200}
|
||||||
onContinue={() => {
|
onContinue={() => {
|
||||||
setShowCompleted(false)
|
setShowCompleted(false)
|
||||||
setCourseTimeProgress(3)
|
|
||||||
navigate('/learning-map', { state: { completedResidence: 'maozedong' } })
|
navigate('/learning-map', { state: { completedResidence: 'maozedong' } })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import SuccessAlert from '@/components/layout/alert/success-alert'
|
|||||||
import ScaledPortal from '@/components/layout/ScaledPortal'
|
import ScaledPortal from '@/components/layout/ScaledPortal'
|
||||||
import { useMaozedongStore, saveMaozedongStore } from '@/store/maozedongStore'
|
import { useMaozedongStore, saveMaozedongStore } from '@/store/maozedongStore'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
|
|
||||||
// starName 固定为 maozedong
|
// starName 固定为 maozedong
|
||||||
const starName = 'maozedong'
|
const starName = 'maozedong'
|
||||||
@@ -152,10 +151,9 @@ const MaozedongPlace4 = () => {
|
|||||||
className="absolute right-0 bottom-0"
|
className="absolute right-0 bottom-0"
|
||||||
src={getStaticImage('components/place/离开场景')}
|
src={getStaticImage('components/place/离开场景')}
|
||||||
button
|
button
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (moduleName && starName) {
|
if (moduleName && starName) {
|
||||||
saveMaozedongStore()
|
await saveMaozedongStore()
|
||||||
setCourseTimeProgress(1)
|
|
||||||
navigate(`/${moduleName}/${starName}/home`)
|
navigate(`/${moduleName}/${starName}/home`)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { saveMaozedongStore } from '@/store/maozedongStore'
|
||||||
import {
|
import {
|
||||||
SingleChoiceQuizPanel,
|
SingleChoiceQuizPanel,
|
||||||
Video,
|
Video,
|
||||||
@@ -10,10 +11,8 @@ import { SuccessAlert } from '@/components/layout'
|
|||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { cn, getStaticImage } from '@/lib/utils'
|
import { cn, getStaticImage } from '@/lib/utils'
|
||||||
import { getPublicPath } from '@/lib/utils'
|
import { getPublicPath } from '@/lib/utils'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { saveMaozedongStore } from '@/store/maozedongStore'
|
|
||||||
|
|
||||||
// 光标组件 - 带旋转动画
|
// 光标组件 - 带旋转动画
|
||||||
// Safari 兼容:使用 onTouchEnd 和 -webkit-tap-highlight-color
|
// Safari 兼容:使用 onTouchEnd 和 -webkit-tap-highlight-color
|
||||||
@@ -488,10 +487,9 @@ const Home = () => {
|
|||||||
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_23.mp3')}
|
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_23.mp3')}
|
||||||
animationSpeed={200}
|
animationSpeed={200}
|
||||||
text="毛泽东在中共四大筹备期间的三次重要抉择,均锚定真实实践、扎根基层,为四大首次明确无产阶级领导权、工农联盟筑牢基础。虽因身体健康问题未参会,但其筹备工作与后续调研,如纽带般连接理论与实践,让党的革命理念在历史浪潮中,朝着正确方向坚定前行,成为早期共产党人探索革命道路的生动注脚,影响深远,照耀后世奋进征程。"
|
text="毛泽东在中共四大筹备期间的三次重要抉择,均锚定真实实践、扎根基层,为四大首次明确无产阶级领导权、工农联盟筑牢基础。虽因身体健康问题未参会,但其筹备工作与后续调研,如纽带般连接理论与实践,让党的革命理念在历史浪潮中,朝着正确方向坚定前行,成为早期共产党人探索革命道路的生动注脚,影响深远,照耀后世奋进征程。"
|
||||||
onContinue={() => {
|
onContinue={async () => {
|
||||||
|
await saveMaozedongStore()
|
||||||
setEndingVisible(false)
|
setEndingVisible(false)
|
||||||
saveMaozedongStore()
|
|
||||||
setCourseTimeProgress(2)
|
|
||||||
navigate(`/${moduleName}/maozedong/home`)
|
navigate(`/${moduleName}/maozedong/home`)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { saveNieerStore } from '@/store/nieerStore'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +15,7 @@ import { GuideSteps, GuideStepsItem } from '@/components'
|
|||||||
import { SuccessAlert } from '@/components/layout'
|
import { SuccessAlert } from '@/components/layout'
|
||||||
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||||
|
|
||||||
const starName = 'nieer'
|
const starName = 'nieer'
|
||||||
|
|
||||||
@@ -241,7 +242,14 @@ const NieerInternalization = () => {
|
|||||||
triggerPuzzleAnimation(15)
|
triggerPuzzleAnimation(15)
|
||||||
}
|
}
|
||||||
if (item.id === 'quiz-5') {
|
if (item.id === 'quiz-5') {
|
||||||
triggerPuzzleAnimation(16)
|
void triggerPuzzleAnimation(16)
|
||||||
|
.then(() => {
|
||||||
|
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||||
|
return saveNieerStore()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to commit internalization checkpoint', error)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
setSuccessContent('')
|
setSuccessContent('')
|
||||||
setResumeCallback(() => resume)
|
setResumeCallback(() => resume)
|
||||||
@@ -380,7 +388,6 @@ const NieerInternalization = () => {
|
|||||||
animationSpeed={200}
|
animationSpeed={200}
|
||||||
onContinue={() => {
|
onContinue={() => {
|
||||||
setShowStudyGuide(false)
|
setShowStudyGuide(false)
|
||||||
setCourseTimeProgress(15)
|
|
||||||
navigate('/learning-map', { state: { completedResidence: 'nieer' } })
|
navigate('/learning-map', { state: { completedResidence: 'nieer' } })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { useNavigate, useParams } from 'react-router-dom'
|
|||||||
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
|
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
|
||||||
import { useCursorController } from '@/hooks/useCursorController'
|
import { useCursorController } from '@/hooks/useCursorController'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
|
|
||||||
const NieerPlace6 = () => {
|
const NieerPlace6 = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -109,9 +108,8 @@ const NieerPlace6 = () => {
|
|||||||
className="absolute bottom-0 right-0"
|
className="absolute bottom-0 right-0"
|
||||||
src={getStaticImage('components/place/离开场景')}
|
src={getStaticImage('components/place/离开场景')}
|
||||||
button
|
button
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
saveNieerStore()
|
await saveNieerStore()
|
||||||
setCourseTimeProgress(13)
|
|
||||||
navigate(`/${moduleName}/${starName}/home`)
|
navigate(`/${moduleName}/${starName}/home`)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
getUnlockVideoSrcForScene,
|
getUnlockVideoSrcForScene,
|
||||||
getUnlockedByIdMap,
|
getUnlockedByIdMap,
|
||||||
} from './reflectionProgress'
|
} from './reflectionProgress'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
|
|
||||||
const starName = 'nieer'
|
const starName = 'nieer'
|
||||||
|
|
||||||
@@ -343,10 +342,9 @@ const NieerReflection = () => {
|
|||||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_35.mp3')}
|
audioSrc={getPublicPath('audio/nieer/mp3_nieer_35.mp3')}
|
||||||
animationSpeed={200}
|
animationSpeed={200}
|
||||||
text={`恭喜你完成所有时空旅行!成功绘制“国歌精神图谱”,解锁6个跨越时空的国歌故事~国歌的故事还在继续,它的精神藏在每一次唱响、每一次坚守中。愿你带着这份精神力量,做国歌的传承者、爱国的践行者!`}
|
text={`恭喜你完成所有时空旅行!成功绘制“国歌精神图谱”,解锁6个跨越时空的国歌故事~国歌的故事还在继续,它的精神藏在每一次唱响、每一次坚守中。愿你带着这份精神力量,做国歌的传承者、爱国的践行者!`}
|
||||||
onContinue={() => {
|
onContinue={async () => {
|
||||||
|
await saveNieerStore()
|
||||||
setEndingVisible(false)
|
setEndingVisible(false)
|
||||||
// saveNieerStore()
|
|
||||||
setCourseTimeProgress(14)
|
|
||||||
navigate(`/${moduleName}/${starName}/home`)
|
navigate(`/${moduleName}/${starName}/home`)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { saveRongdeshengStore } from '@/store/rongdeshengStore'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +18,7 @@ import { SuccessAlert } from '@/components/layout'
|
|||||||
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||||
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||||
|
|
||||||
const starName = 'rongdesheng'
|
const starName = 'rongdesheng'
|
||||||
|
|
||||||
@@ -312,7 +313,16 @@ const RongdeshengInternalization = () => {
|
|||||||
// 答题成功后触发碎片获取动画
|
// 答题成功后触发碎片获取动画
|
||||||
// quiz-2 -> 碎片15(坚韧不拔),quiz-5 -> 碎片16(社会责任担当)
|
// quiz-2 -> 碎片15(坚韧不拔),quiz-5 -> 碎片16(社会责任担当)
|
||||||
if (item.id === 'quiz-2') triggerPuzzleAnimation(15)
|
if (item.id === 'quiz-2') triggerPuzzleAnimation(15)
|
||||||
if (item.id === 'quiz-5') triggerPuzzleAnimation(16)
|
if (item.id === 'quiz-5') {
|
||||||
|
void triggerPuzzleAnimation(16)
|
||||||
|
.then(() => {
|
||||||
|
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||||
|
return saveRongdeshengStore()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to commit internalization checkpoint', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -508,7 +518,6 @@ const RongdeshengInternalization = () => {
|
|||||||
animationSpeed={200}
|
animationSpeed={200}
|
||||||
onContinue={() => {
|
onContinue={() => {
|
||||||
setShowEnding(false)
|
setShowEnding(false)
|
||||||
setCourseTimeProgress(21)
|
|
||||||
navigate('/learning-map', { state: { completedResidence: 'rongdesheng' } })
|
navigate('/learning-map', { state: { completedResidence: 'rongdesheng' } })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { useState } from 'react'
|
|||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { saveRongdeshengStore, useRongdeshengStore } from '@/store/rongdeshengStore'
|
import { saveRongdeshengStore, useRongdeshengStore } from '@/store/rongdeshengStore'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
|
|
||||||
const starName = 'rongdesheng'
|
const starName = 'rongdesheng'
|
||||||
const place = 'place4'
|
const place = 'place4'
|
||||||
@@ -165,10 +164,9 @@ const RongdeshengPlace4 = () => {
|
|||||||
className="absolute bottom-0 right-0"
|
className="absolute bottom-0 right-0"
|
||||||
src={getStaticImage('components/place/离开场景')}
|
src={getStaticImage('components/place/离开场景')}
|
||||||
button
|
button
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
setLastVisitedPlace('place4')
|
setLastVisitedPlace('place4')
|
||||||
saveRongdeshengStore()
|
await saveRongdeshengStore()
|
||||||
setCourseTimeProgress(19)
|
|
||||||
navigate(`/${moduleName}/${starName}/home`)
|
navigate(`/${moduleName}/${starName}/home`)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { VideoBubbles } from './components/VideoBubbles'
|
|||||||
import { HINT_AUDIOS, HINT_TEXTS } from './constants'
|
import { HINT_AUDIOS, HINT_TEXTS } from './constants'
|
||||||
import { MODULES, getSceneTitle } from './modules.data'
|
import { MODULES, getSceneTitle } from './modules.data'
|
||||||
import type { ModuleId, ModuleState, VideoPlayingState } from './types'
|
import type { ModuleId, ModuleState, VideoPlayingState } from './types'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
|
|
||||||
const RongdeshengReflection = () => {
|
const RongdeshengReflection = () => {
|
||||||
@@ -382,11 +381,10 @@ const RongdeshengReflection = () => {
|
|||||||
visible={showFinalScene}
|
visible={showFinalScene}
|
||||||
finalVideoStage={finalVideoStage}
|
finalVideoStage={finalVideoStage}
|
||||||
onStart={() => setFinalVideoStage('playing')}
|
onStart={() => setFinalVideoStage('playing')}
|
||||||
onVideoEnded={() => {
|
onVideoEnded={async () => {
|
||||||
setCourseTimeProgress(20)
|
|
||||||
setFinalVideoStage('ended')
|
|
||||||
markReflectionFinalCompleted()
|
markReflectionFinalCompleted()
|
||||||
saveRongdeshengStore()
|
await saveRongdeshengStore()
|
||||||
|
setFinalVideoStage('ended')
|
||||||
}}
|
}}
|
||||||
onExit={() => {
|
onExit={() => {
|
||||||
navigate(`/${moduleName}/${starName}/home`)
|
navigate(`/${moduleName}/${starName}/home`)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { saveSunzhongshanStore } from '@/store/sunzhongshanStore'
|
||||||
import { useEffect, useMemo, useState, useRef } from 'react'
|
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +16,7 @@ import { SuccessAlert, DragQuizPanel } from '@/components/layout'
|
|||||||
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
|
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||||
import { GuideSteps, GuideStepsItem } from '@/components'
|
import { GuideSteps, GuideStepsItem } from '@/components'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||||
|
|
||||||
type QuizOption = {
|
type QuizOption = {
|
||||||
value: string
|
value: string
|
||||||
@@ -407,7 +408,14 @@ const SunzhongshanInternalization = () => {
|
|||||||
if (item.id === 'quiz-2') {
|
if (item.id === 'quiz-2') {
|
||||||
triggerPuzzleAnimation(15)
|
triggerPuzzleAnimation(15)
|
||||||
} else if (item.id === 'quiz-5') {
|
} else if (item.id === 'quiz-5') {
|
||||||
triggerPuzzleAnimation(16)
|
void triggerPuzzleAnimation(16)
|
||||||
|
.then(() => {
|
||||||
|
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||||
|
return saveSunzhongshanStore()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to commit internalization checkpoint', error)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
cb?.()
|
cb?.()
|
||||||
}}
|
}}
|
||||||
@@ -545,7 +553,6 @@ const SunzhongshanInternalization = () => {
|
|||||||
animationSpeed={200}
|
animationSpeed={200}
|
||||||
onContinue={() => {
|
onContinue={() => {
|
||||||
setShowStudyGuide(false)
|
setShowStudyGuide(false)
|
||||||
setCourseTimeProgress(6)
|
|
||||||
navigate('/learning-map', { state: { completedResidence: 'sunzhongshan' } })
|
navigate('/learning-map', { state: { completedResidence: 'sunzhongshan' } })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ import { useCursorController } from '@/hooks/useCursorController'
|
|||||||
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
|
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { useSunzhongshanStore, saveSunzhongshanStore } from '@/store/sunzhongshanStore'
|
import { saveSunzhongshanStore, useSunzhongshanStore } from '@/store/sunzhongshanStore'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
|
|
||||||
const starName = 'sunzhongshan'
|
const starName = 'sunzhongshan'
|
||||||
|
|
||||||
@@ -134,9 +133,8 @@ const SunzhongshanPlace4 = () => {
|
|||||||
className="absolute bottom-0 right-0"
|
className="absolute bottom-0 right-0"
|
||||||
src={getStaticImage('components/place/离开场景')}
|
src={getStaticImage('components/place/离开场景')}
|
||||||
button
|
button
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
setCourseTimeProgress(4)
|
await saveSunzhongshanStore()
|
||||||
saveSunzhongshanStore()
|
|
||||||
navigate(`/${moduleName}/${starName}`)
|
navigate(`/${moduleName}/${starName}`)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ import { SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
|||||||
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
|
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
|
||||||
import type { ClueHint } from '@/components/clue'
|
import type { ClueHint } from '@/components/clue'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { useSunzhongshanStore, saveSunzhongshanStore } from '@/store/sunzhongshanStore'
|
import { saveSunzhongshanStore, useSunzhongshanStore } from '@/store/sunzhongshanStore'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
|
|
||||||
const starName = 'sunzhongshan'
|
const starName = 'sunzhongshan'
|
||||||
const sceneId = `${starName}/reflection/scene3`
|
const sceneId = `${starName}/reflection/scene3`
|
||||||
@@ -277,15 +276,14 @@ const SunzhongshanReflectionScene3 = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理完成提示关闭
|
// 处理完成提示关闭
|
||||||
const handleCompleteGuideClose = () => {
|
const handleCompleteGuideClose = async () => {
|
||||||
setShowCompleteGuide(false)
|
setShowCompleteGuide(false)
|
||||||
// 保存场景3完成状态
|
// 保存场景3完成状态
|
||||||
setReflectionFlag(`${starName}-reflection-scene3-completed`, true)
|
setReflectionFlag(`${starName}-reflection-scene3-completed`, true)
|
||||||
if (moduleName && starName) {
|
if (moduleName && starName) {
|
||||||
const { setReflectionRoute } = useSunzhongshanStore.getState()
|
const { setReflectionRoute } = useSunzhongshanStore.getState()
|
||||||
setReflectionRoute('reflection3')
|
setReflectionRoute('reflection3')
|
||||||
saveSunzhongshanStore()
|
await saveSunzhongshanStore()
|
||||||
setCourseTimeProgress(5)
|
|
||||||
navigate(`/${moduleName}/${starName}/home`)
|
navigate(`/${moduleName}/${starName}/home`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { saveZhanglepingStore } from '@/store/zhanglepingStore'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +15,7 @@ import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/lay
|
|||||||
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||||
import { GuideSteps, GuideStepsItem } from '@/components'
|
import { GuideSteps, GuideStepsItem } from '@/components'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||||
|
|
||||||
const starName = 'zhangleping'
|
const starName = 'zhangleping'
|
||||||
|
|
||||||
@@ -299,7 +300,14 @@ const ZhanglepingInternalization = () => {
|
|||||||
'先生善于捕捉核心细节展现人物特质,化繁为简的场景刻画让漫画更具感染力,这正是“以少胜多”艺术境界的体现。'
|
'先生善于捕捉核心细节展现人物特质,化繁为简的场景刻画让漫画更具感染力,这正是“以少胜多”艺术境界的体现。'
|
||||||
)
|
)
|
||||||
} else if (item.id === 'quiz-4') {
|
} else if (item.id === 'quiz-4') {
|
||||||
triggerPuzzleAnimation(16)
|
void triggerPuzzleAnimation(16)
|
||||||
|
.then(() => {
|
||||||
|
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||||
|
return saveZhanglepingStore()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to commit internalization checkpoint', error)
|
||||||
|
})
|
||||||
setSuccessContent(
|
setSuccessContent(
|
||||||
'张乐平先生坚持艺术扎根生活,以简洁笔墨刻画时代,用作品传递温暖与正义,这些都是他的核心创作理念。'
|
'张乐平先生坚持艺术扎根生活,以简洁笔墨刻画时代,用作品传递温暖与正义,这些都是他的核心创作理念。'
|
||||||
)
|
)
|
||||||
@@ -495,7 +503,6 @@ const ZhanglepingInternalization = () => {
|
|||||||
animationSpeed={200}
|
animationSpeed={200}
|
||||||
onContinue={() => {
|
onContinue={() => {
|
||||||
setShowStudyGuide(false)
|
setShowStudyGuide(false)
|
||||||
setCourseTimeProgress(18)
|
|
||||||
navigate('/learning-map', { state: { completedResidence: 'zhangleping' } })
|
navigate('/learning-map', { state: { completedResidence: 'zhangleping' } })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import Cursor from '@/components/cursor'
|
|||||||
import { useCursorController } from '@/hooks/useCursorController'
|
import { useCursorController } from '@/hooks/useCursorController'
|
||||||
import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore'
|
import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore'
|
||||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
|
|
||||||
// starName 固定为 zhangleping
|
// starName 固定为 zhangleping
|
||||||
const starName = 'zhangleping'
|
const starName = 'zhangleping'
|
||||||
@@ -131,10 +130,9 @@ const ZhanglepingPlace5 = () => {
|
|||||||
className="absolute right-0 bottom-0"
|
className="absolute right-0 bottom-0"
|
||||||
src={getStaticImage('components/place/离开场景')}
|
src={getStaticImage('components/place/离开场景')}
|
||||||
button
|
button
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (moduleName && starName) {
|
if (moduleName && starName) {
|
||||||
saveZhanglepingStore()
|
await saveZhanglepingStore()
|
||||||
setCourseTimeProgress(16)
|
|
||||||
navigate(`/${moduleName}/${starName}/home`)
|
navigate(`/${moduleName}/${starName}/home`)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
|||||||
import { Image, StudyGuideModal, Video } from '@/components'
|
import { Image, StudyGuideModal, Video } from '@/components'
|
||||||
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||||
import { useZhanglepingStore, saveZhanglepingStore } from '@/store/zhanglepingStore'
|
import { useZhanglepingStore, saveZhanglepingStore } from '@/store/zhanglepingStore'
|
||||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
|
||||||
|
|
||||||
const starName = 'zhangleping'
|
const starName = 'zhangleping'
|
||||||
|
|
||||||
@@ -93,15 +92,6 @@ const ZhanglepingReflection = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allFragmentsCompleted = sceneConfig.every((scene) => isSceneCompleted(scene.id))
|
const allFragmentsCompleted = sceneConfig.every((scene) => isSceneCompleted(scene.id))
|
||||||
|
|
||||||
// 监听allFragmentsCompleted
|
|
||||||
useEffect(() => {
|
|
||||||
if (allFragmentsCompleted) {
|
|
||||||
saveZhanglepingStore()
|
|
||||||
setCourseTimeProgress(17)
|
|
||||||
}
|
|
||||||
}, [allFragmentsCompleted])
|
|
||||||
|
|
||||||
// 场景逐步解锁:1 默认解锁;N 需要 N-1 完成
|
// 场景逐步解锁:1 默认解锁;N 需要 N-1 完成
|
||||||
const isSceneUnlocked = (sceneId: SceneId) => {
|
const isSceneUnlocked = (sceneId: SceneId) => {
|
||||||
if (sceneId === 1) return true
|
if (sceneId === 1) return true
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
getUserDefineResult,
|
getUserDefineResult,
|
||||||
parseUserDefineContent,
|
parseUserDefineContent,
|
||||||
type UserDefineFetchStatus,
|
type UserDefineFetchStatus,
|
||||||
|
type WriteOptions,
|
||||||
|
type WriteResult,
|
||||||
} from '@/lib/utils/api-common'
|
} from '@/lib/utils/api-common'
|
||||||
|
|
||||||
interface ChenyunStore {
|
interface ChenyunStore {
|
||||||
@@ -191,7 +193,7 @@ export const useChenyunStore = create<ChenyunStore>()(
|
|||||||
export const CHENYUN_LESSON_ID = 'Cheny_duihua'
|
export const CHENYUN_LESSON_ID = 'Cheny_duihua'
|
||||||
export const CHENYUN_KEY = 22
|
export const CHENYUN_KEY = 22
|
||||||
|
|
||||||
export const saveChenyunStore = (): void => {
|
export const saveChenyunStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||||
const state = useChenyunStore.getState()
|
const state = useChenyunStore.getState()
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -202,11 +204,7 @@ export const saveChenyunStore = (): void => {
|
|||||||
completedVideos: state.completedVideos,
|
completedVideos: state.completedVideos,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return setUserDefine(CHENYUN_LESSON_ID, CHENYUN_KEY, JSON.stringify(payload), options)
|
||||||
setUserDefine(CHENYUN_LESSON_ID, CHENYUN_KEY, JSON.stringify(payload))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save Chenyun store', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const applyChenyunPersistedState = (payload: ChenyunPersistedState): void => {
|
export const applyChenyunPersistedState = (payload: ChenyunPersistedState): void => {
|
||||||
@@ -219,7 +217,6 @@ export const applyChenyunPersistedState = (payload: ChenyunPersistedState): void
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const loadChenyunStore = async (): Promise<UserDefineFetchStatus> => {
|
export const loadChenyunStore = async (): Promise<UserDefineFetchStatus> => {
|
||||||
try {
|
try {
|
||||||
const result = await getUserDefineResult(CHENYUN_LESSON_ID, CHENYUN_KEY)
|
const result = await getUserDefineResult(CHENYUN_LESSON_ID, CHENYUN_KEY)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
getUserDefineResult,
|
getUserDefineResult,
|
||||||
parseUserDefineContent,
|
parseUserDefineContent,
|
||||||
type UserDefineFetchStatus,
|
type UserDefineFetchStatus,
|
||||||
|
type WriteOptions,
|
||||||
|
type WriteResult,
|
||||||
} from '@/lib/utils/api-common'
|
} from '@/lib/utils/api-common'
|
||||||
|
|
||||||
interface HuangyanpeiStore {
|
interface HuangyanpeiStore {
|
||||||
@@ -275,7 +277,7 @@ export const useHuangyanpeiStore = create<HuangyanpeiStore>()(
|
|||||||
export const HUANGYANPEI_LESSON_ID = 'Huangyp_duihua'
|
export const HUANGYANPEI_LESSON_ID = 'Huangyp_duihua'
|
||||||
export const HUANGYANPEI_KEY = 10
|
export const HUANGYANPEI_KEY = 10
|
||||||
|
|
||||||
export const saveHuangyanpeiStore = (): void => {
|
export const saveHuangyanpeiStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||||
const state = useHuangyanpeiStore.getState()
|
const state = useHuangyanpeiStore.getState()
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -285,13 +287,10 @@ export const saveHuangyanpeiStore = (): void => {
|
|||||||
reflectionRoute: state.reflectionRoute,
|
reflectionRoute: state.reflectionRoute,
|
||||||
shownReflectionCompletionGuides: state.shownReflectionCompletionGuides,
|
shownReflectionCompletionGuides: state.shownReflectionCompletionGuides,
|
||||||
reflectionScene2Choice: state.reflectionScene2Choice,
|
reflectionScene2Choice: state.reflectionScene2Choice,
|
||||||
|
completedVideos: state.completedVideos,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return setUserDefine(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY, JSON.stringify(payload), options)
|
||||||
setUserDefine(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY, JSON.stringify(payload))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save Huangyanpei store', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const applyHuangyanpeiPersistedState = (payload: HuangyanpeiPersistedState): void => {
|
export const applyHuangyanpeiPersistedState = (payload: HuangyanpeiPersistedState): void => {
|
||||||
@@ -306,7 +305,6 @@ export const applyHuangyanpeiPersistedState = (payload: HuangyanpeiPersistedStat
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const loadHuangyanpeiStore = async (): Promise<UserDefineFetchStatus> => {
|
export const loadHuangyanpeiStore = async (): Promise<UserDefineFetchStatus> => {
|
||||||
try {
|
try {
|
||||||
const result = await getUserDefineResult(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY)
|
const result = await getUserDefineResult(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const { setUserDefine } = vi.hoisted(() => ({
|
||||||
|
setUserDefine: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/utils/api-common', () => ({ setUserDefine }))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
setUserDefine.mockClear()
|
||||||
|
const storage = new Map<string, string>()
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => storage.set(key, value),
|
||||||
|
removeItem: (key: string) => storage.delete(key),
|
||||||
|
})
|
||||||
|
vi.stubGlobal('window', { localStorage })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
const image = 'data:image/jpeg;base64,YWJj'
|
||||||
|
|
||||||
|
describe('local-only artwork', () => {
|
||||||
|
it('keeps Zhang Leping artwork locally and excludes even short data URLs from sync', async () => {
|
||||||
|
const { useZhanglepingStore, saveZhanglepingStore, applyZhanglepingPersistedState } =
|
||||||
|
await import('./zhanglepingStore')
|
||||||
|
useZhanglepingStore.getState().setDrawArtwork(image, 'caption')
|
||||||
|
await saveZhanglepingStore()
|
||||||
|
const payload = JSON.parse(setUserDefine.mock.calls[0]![2])
|
||||||
|
expect(payload).not.toHaveProperty('drawArtworkDataUrl')
|
||||||
|
expect(payload.drawArtworkCaption).toBe('caption')
|
||||||
|
expect(payload.hasDrawArtwork).toBe(true)
|
||||||
|
applyZhanglepingPersistedState(payload)
|
||||||
|
expect(useZhanglepingStore.getState().drawArtworkDataUrl).toBe(image)
|
||||||
|
expect(JSON.parse(localStorage.getItem('zhangleping-storage')!).state.drawArtworkDataUrl).toBe(
|
||||||
|
image
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps Nie Er previews locally while syncing other work fields', async () => {
|
||||||
|
const { useNieerStore, saveNieerStore, applyNieerPersistedState } = await import('./nieerStore')
|
||||||
|
useNieerStore.getState().setReflectionWork(6, { previewImageDataUrl: image, paradeId: '1' })
|
||||||
|
await saveNieerStore()
|
||||||
|
const payload = JSON.parse(setUserDefine.mock.calls[0]![2])
|
||||||
|
expect(payload.reflectionWorks[6]).not.toHaveProperty('previewImageDataUrl')
|
||||||
|
expect(payload.reflectionWorks[6].paradeId).toBe('1')
|
||||||
|
expect(useNieerStore.getState().reflectionWorks[6]?.previewImageDataUrl).toBe(image)
|
||||||
|
applyNieerPersistedState(payload)
|
||||||
|
expect(useNieerStore.getState().reflectionWorks[6]?.previewImageDataUrl).toBe(image)
|
||||||
|
expect(
|
||||||
|
JSON.parse(localStorage.getItem('nieer-storage')!).state.reflectionWorks[6]
|
||||||
|
.previewImageDataUrl
|
||||||
|
).toBe(image)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not restore legacy remote images over local images', async () => {
|
||||||
|
const { useZhanglepingStore, applyZhanglepingPersistedState } =
|
||||||
|
await import('./zhanglepingStore')
|
||||||
|
const { useNieerStore, applyNieerPersistedState } = await import('./nieerStore')
|
||||||
|
useZhanglepingStore.getState().setDrawArtwork(image, 'caption')
|
||||||
|
useNieerStore.getState().setReflectionWork(6, { previewImageDataUrl: image })
|
||||||
|
applyZhanglepingPersistedState({ drawArtworkDataUrl: 'remote' })
|
||||||
|
applyNieerPersistedState({ reflectionWorks: { 6: { previewImageDataUrl: 'remote' } } })
|
||||||
|
expect(useZhanglepingStore.getState().drawArtworkDataUrl).toBe(image)
|
||||||
|
expect(useNieerStore.getState().reflectionWorks[6]?.previewImageDataUrl).toBe(image)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
getUserDefineResult,
|
getUserDefineResult,
|
||||||
parseUserDefineContent,
|
parseUserDefineContent,
|
||||||
type UserDefineFetchStatus,
|
type UserDefineFetchStatus,
|
||||||
|
type WriteOptions,
|
||||||
|
type WriteResult,
|
||||||
} from '@/lib/utils/api-common'
|
} from '@/lib/utils/api-common'
|
||||||
|
|
||||||
interface LuxunStore {
|
interface LuxunStore {
|
||||||
@@ -169,7 +171,7 @@ export const useLuxunStore = create<LuxunStore>()(
|
|||||||
export const LUXUN_LESSON_ID = 'Lux_duihua'
|
export const LUXUN_LESSON_ID = 'Lux_duihua'
|
||||||
export const LUXUN_KEY = 7
|
export const LUXUN_KEY = 7
|
||||||
|
|
||||||
export const saveLuxunStore = (): void => {
|
export const saveLuxunStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||||
const state = useLuxunStore.getState()
|
const state = useLuxunStore.getState()
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -179,11 +181,7 @@ export const saveLuxunStore = (): void => {
|
|||||||
completedVideos: state.completedVideos,
|
completedVideos: state.completedVideos,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return setUserDefine(LUXUN_LESSON_ID, LUXUN_KEY, JSON.stringify(payload), options)
|
||||||
setUserDefine(LUXUN_LESSON_ID, LUXUN_KEY, JSON.stringify(payload))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save Luxun store', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const applyLuxunPersistedState = (payload: LuxunPersistedState): void => {
|
export const applyLuxunPersistedState = (payload: LuxunPersistedState): void => {
|
||||||
@@ -195,7 +193,6 @@ export const applyLuxunPersistedState = (payload: LuxunPersistedState): void =>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const loadLuxunStore = async (): Promise<UserDefineFetchStatus> => {
|
export const loadLuxunStore = async (): Promise<UserDefineFetchStatus> => {
|
||||||
try {
|
try {
|
||||||
const result = await getUserDefineResult(LUXUN_LESSON_ID, LUXUN_KEY)
|
const result = await getUserDefineResult(LUXUN_LESSON_ID, LUXUN_KEY)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
getUserDefineResult,
|
getUserDefineResult,
|
||||||
parseUserDefineContent,
|
parseUserDefineContent,
|
||||||
type UserDefineFetchStatus,
|
type UserDefineFetchStatus,
|
||||||
|
type WriteOptions,
|
||||||
|
type WriteResult,
|
||||||
} from '@/lib/utils/api-common'
|
} from '@/lib/utils/api-common'
|
||||||
|
|
||||||
interface MaozedongStore {
|
interface MaozedongStore {
|
||||||
@@ -123,18 +125,14 @@ export const useMaozedongStore = create<MaozedongStore>()(
|
|||||||
export const MAOZEDONG_LESSON_ID = 'Maozd_duihua'
|
export const MAOZEDONG_LESSON_ID = 'Maozd_duihua'
|
||||||
export const MAOZEDONG_KEY = 1
|
export const MAOZEDONG_KEY = 1
|
||||||
|
|
||||||
export const saveMaozedongStore = (): void => {
|
export const saveMaozedongStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||||
const state = useMaozedongStore.getState()
|
const state = useMaozedongStore.getState()
|
||||||
const payload = {
|
const payload = {
|
||||||
visitedExhibits: serializeVisited(state.visitedExhibits),
|
visitedExhibits: serializeVisited(state.visitedExhibits),
|
||||||
lastVisitedPlace: state.lastVisitedPlace,
|
lastVisitedPlace: state.lastVisitedPlace,
|
||||||
completedVideos: state.completedVideos,
|
completedVideos: state.completedVideos,
|
||||||
}
|
}
|
||||||
try {
|
return setUserDefine(MAOZEDONG_LESSON_ID, MAOZEDONG_KEY, JSON.stringify(payload), options)
|
||||||
setUserDefine(MAOZEDONG_LESSON_ID, MAOZEDONG_KEY, JSON.stringify(payload))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save Maozedong store', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyMaozedongPersistedState = (payload: MaozedongPersistedState): void => {
|
const applyMaozedongPersistedState = (payload: MaozedongPersistedState): void => {
|
||||||
|
|||||||
+26
-9
@@ -6,6 +6,8 @@ import {
|
|||||||
getUserDefineResult,
|
getUserDefineResult,
|
||||||
parseUserDefineContent,
|
parseUserDefineContent,
|
||||||
type UserDefineFetchStatus,
|
type UserDefineFetchStatus,
|
||||||
|
type WriteOptions,
|
||||||
|
type WriteResult,
|
||||||
} from '@/lib/utils/api-common'
|
} from '@/lib/utils/api-common'
|
||||||
|
|
||||||
interface NieerStore {
|
interface NieerStore {
|
||||||
@@ -276,37 +278,52 @@ export const useNieerStore = create<NieerStore>()(
|
|||||||
export const NIEER_LESSON_ID = 'Nieer_duihua'
|
export const NIEER_LESSON_ID = 'Nieer_duihua'
|
||||||
export const NIEER_KEY = 5
|
export const NIEER_KEY = 5
|
||||||
|
|
||||||
export const saveNieerStore = (): void => {
|
export const saveNieerStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||||
const state = useNieerStore.getState()
|
const state = useNieerStore.getState()
|
||||||
|
// 易班 GET 接口不支持 Base64,作品截图仅保存在本地 persist 中。
|
||||||
|
const reflectionWorks = Object.fromEntries(
|
||||||
|
Object.entries(state.reflectionWorks).map(([sceneId, work]) => {
|
||||||
|
const remoteWork = { ...work }
|
||||||
|
delete remoteWork.previewImageDataUrl
|
||||||
|
return [sceneId, remoteWork]
|
||||||
|
})
|
||||||
|
)
|
||||||
const payload = {
|
const payload = {
|
||||||
visitedExhibits: serializeVisitedExhibits(state.visitedExhibits),
|
visitedExhibits: serializeVisitedExhibits(state.visitedExhibits),
|
||||||
visitedReflectionCursors: serializeVisitedExhibits(state.visitedReflectionCursors),
|
visitedReflectionCursors: serializeVisitedExhibits(state.visitedReflectionCursors),
|
||||||
lastVisitedPlace: state.lastVisitedPlace,
|
lastVisitedPlace: state.lastVisitedPlace,
|
||||||
reflectionWorks: state.reflectionWorks,
|
reflectionWorks,
|
||||||
hasSeenReflectionGuide: state.hasSeenReflectionGuide,
|
hasSeenReflectionGuide: state.hasSeenReflectionGuide,
|
||||||
hasSeenBoxHint: state.hasSeenBoxHint,
|
hasSeenBoxHint: state.hasSeenBoxHint,
|
||||||
completedVideos: state.completedVideos,
|
completedVideos: state.completedVideos,
|
||||||
}
|
}
|
||||||
try {
|
return setUserDefine(NIEER_LESSON_ID, NIEER_KEY, JSON.stringify(payload), options)
|
||||||
setUserDefine(NIEER_LESSON_ID, NIEER_KEY, JSON.stringify(payload))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save Nieer store', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const applyNieerPersistedState = (payload: NieerPersistedState): void => {
|
export const applyNieerPersistedState = (payload: NieerPersistedState): void => {
|
||||||
|
const localWorks = useNieerStore.getState().reflectionWorks
|
||||||
|
const reflectionWorks = Object.fromEntries(
|
||||||
|
Object.entries(payload.reflectionWorks ?? {}).map(([sceneId, work]) => {
|
||||||
|
const restoredWork = { ...work }
|
||||||
|
delete restoredWork.previewImageDataUrl
|
||||||
|
const localPreview = localWorks[Number(sceneId)]?.previewImageDataUrl
|
||||||
|
if (typeof localPreview === 'string') {
|
||||||
|
restoredWork.previewImageDataUrl = localPreview
|
||||||
|
}
|
||||||
|
return [sceneId, restoredWork]
|
||||||
|
})
|
||||||
|
)
|
||||||
useNieerStore.setState({
|
useNieerStore.setState({
|
||||||
visitedExhibits: deserializeVisitedExhibits(payload.visitedExhibits),
|
visitedExhibits: deserializeVisitedExhibits(payload.visitedExhibits),
|
||||||
visitedReflectionCursors: deserializeVisitedExhibits(payload.visitedReflectionCursors),
|
visitedReflectionCursors: deserializeVisitedExhibits(payload.visitedReflectionCursors),
|
||||||
lastVisitedPlace: payload.lastVisitedPlace ?? null,
|
lastVisitedPlace: payload.lastVisitedPlace ?? null,
|
||||||
reflectionWorks: payload.reflectionWorks ?? {},
|
reflectionWorks,
|
||||||
hasSeenReflectionGuide: payload.hasSeenReflectionGuide ?? false,
|
hasSeenReflectionGuide: payload.hasSeenReflectionGuide ?? false,
|
||||||
hasSeenBoxHint: payload.hasSeenBoxHint ?? false,
|
hasSeenBoxHint: payload.hasSeenBoxHint ?? false,
|
||||||
completedVideos: payload.completedVideos ?? {},
|
completedVideos: payload.completedVideos ?? {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const loadNieerStore = async (): Promise<UserDefineFetchStatus> => {
|
export const loadNieerStore = async (): Promise<UserDefineFetchStatus> => {
|
||||||
try {
|
try {
|
||||||
const result = await getUserDefineResult(NIEER_LESSON_ID, NIEER_KEY)
|
const result = await getUserDefineResult(NIEER_LESSON_ID, NIEER_KEY)
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { getResidenceStageCompletion } from '@/config/progression'
|
||||||
|
import { getStagePieceIds } from '@/config/puzzleStages'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
save: vi.fn(),
|
||||||
|
report: vi.fn(),
|
||||||
|
fetch: vi.fn(),
|
||||||
|
parse: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/utils/api-common', () => ({
|
||||||
|
setUserDefine: mocks.save,
|
||||||
|
setCourseTimeProgress: mocks.report,
|
||||||
|
getUserDefineResult: mocks.fetch,
|
||||||
|
parseUserDefineContent: mocks.parse,
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
mocks.save.mockReset().mockResolvedValue({ success: true })
|
||||||
|
mocks.report.mockReset().mockResolvedValue({ success: true })
|
||||||
|
mocks.fetch
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ status: 'success', content: '{"puzzles":{"nieer":[14]}}' })
|
||||||
|
mocks.parse.mockReset().mockImplementation((content) => JSON.parse(content))
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
const storage = {
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => values.set(key, value),
|
||||||
|
removeItem: (key: string) => values.delete(key),
|
||||||
|
}
|
||||||
|
vi.stubGlobal('localStorage', storage)
|
||||||
|
vi.stubGlobal('window', { localStorage: storage })
|
||||||
|
})
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
const fixture = async () => {
|
||||||
|
const { useUserStore } = await import('./userStore')
|
||||||
|
useUserStore.getState().setCourseInfo({ uid: 'student', courseId: 'course' })
|
||||||
|
useUserStore.getState().setYibanConnectionStatus('connected')
|
||||||
|
const module = await import('./puzzleStore')
|
||||||
|
module.usePuzzleStore.getState().setLoaded(true)
|
||||||
|
return { ...module, useUserStore }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('puzzle store collection integration', () => {
|
||||||
|
const residences = [
|
||||||
|
'maozedong',
|
||||||
|
'sunzhongshan',
|
||||||
|
'luxun',
|
||||||
|
'huangyanpei',
|
||||||
|
'nieer',
|
||||||
|
'zhangleping',
|
||||||
|
'rongdesheng',
|
||||||
|
'chenyun',
|
||||||
|
]
|
||||||
|
const scenarios = residences.flatMap((residence, index) =>
|
||||||
|
(['dialogue', 'reflection', 'internalization'] as const).map((stage, offset) => ({
|
||||||
|
residence,
|
||||||
|
stage,
|
||||||
|
order: index * 3 + offset + 1,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
it.each(scenarios)(
|
||||||
|
'reports $residence/$stage as order $order at the collection entry',
|
||||||
|
async ({ residence, stage, order }) => {
|
||||||
|
const f = await fixture()
|
||||||
|
const required = getStagePieceIds(residence, stage)
|
||||||
|
const missing = required[0]!
|
||||||
|
f.usePuzzleStore.setState({ puzzles: { [residence]: new Set(required.slice(1)) } })
|
||||||
|
await expect(f.usePuzzleStore.getState().unlockPuzzle(residence, missing)).resolves.toBe(true)
|
||||||
|
expect(mocks.report).toHaveBeenCalledWith(order, expect.anything())
|
||||||
|
expect(mocks.report.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
mocks.save.mock.invocationCallOrder[0]!
|
||||||
|
)
|
||||||
|
const payload = JSON.parse(mocks.save.mock.calls[0]![2])
|
||||||
|
expect(new Set(payload.puzzles[residence])).toEqual(new Set(required))
|
||||||
|
expect(
|
||||||
|
getResidenceStageCompletion(residence, f.usePuzzleStore.getState().puzzles)[
|
||||||
|
`${stage}Complete`
|
||||||
|
]
|
||||||
|
).toBe(true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
it('does not publish progress until the remote save has succeeded', async () => {
|
||||||
|
const f = await fixture()
|
||||||
|
let resolveSave!: (result: { success: boolean }) => void
|
||||||
|
mocks.save.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveSave = resolve
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const claim = f.usePuzzleStore.getState().unlockPuzzle('nieer', 14)
|
||||||
|
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalledTimes(1))
|
||||||
|
expect(
|
||||||
|
getResidenceStageCompletion('nieer', f.usePuzzleStore.getState().puzzles).reflectionComplete
|
||||||
|
).toBe(false)
|
||||||
|
resolveSave({ success: true })
|
||||||
|
await claim
|
||||||
|
expect(
|
||||||
|
getResidenceStageCompletion('nieer', f.usePuzzleStore.getState().puzzles).reflectionComplete
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
it('uses session storage without reporting when courseId is missing', async () => {
|
||||||
|
const f = await fixture()
|
||||||
|
f.useUserStore.getState().resetCourseInfo()
|
||||||
|
f.useUserStore.getState().setYibanConnectionStatus('connected')
|
||||||
|
await f.usePuzzleStore.getState().unlockPuzzle('nieer', 14)
|
||||||
|
expect(mocks.report).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.save).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ backend: 'session' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('loads historical pieces with the explicit backend and never backfills reports', async () => {
|
||||||
|
const f = await fixture()
|
||||||
|
await f.loadPuzzleStore({ backend: 'yiban' })
|
||||||
|
expect(mocks.parse).toHaveBeenCalledWith('{"puzzles":{"nieer":[14]}}', { backend: 'yiban' })
|
||||||
|
expect(mocks.report).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.save).not.toHaveBeenCalled()
|
||||||
|
await f.usePuzzleStore.getState().unlockPuzzle('nieer', 14)
|
||||||
|
expect(mocks.report).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.save).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('cancels an outstanding save after reset without restoring the old pieces', async () => {
|
||||||
|
const f = await fixture()
|
||||||
|
let resolveSave!: (result: { success: boolean }) => void
|
||||||
|
mocks.save.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveSave = resolve
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const claim = f.usePuzzleStore.getState().unlockPuzzle('nieer', 1)
|
||||||
|
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalledTimes(1))
|
||||||
|
f.usePuzzleStore.getState().resetAll()
|
||||||
|
resolveSave({ success: true })
|
||||||
|
await expect(claim).resolves.toBe(false)
|
||||||
|
expect(f.usePuzzleStore.getState().puzzles).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
+42
-22
@@ -3,12 +3,20 @@ import { persist } from 'zustand/middleware'
|
|||||||
import { STORE_NAMES } from './storeNames'
|
import { STORE_NAMES } from './storeNames'
|
||||||
import {
|
import {
|
||||||
setUserDefine,
|
setUserDefine,
|
||||||
|
setCourseTimeProgress,
|
||||||
getUserDefineResult,
|
getUserDefineResult,
|
||||||
parseUserDefineContent,
|
parseUserDefineContent,
|
||||||
type StorageOptions,
|
type StorageOptions,
|
||||||
type UserDefineFetchStatus,
|
type UserDefineFetchStatus,
|
||||||
|
type WriteOptions,
|
||||||
|
type WriteResult,
|
||||||
} from '@/lib/utils/api-common'
|
} from '@/lib/utils/api-common'
|
||||||
|
|
||||||
|
import { useUserStore } from './userStore'
|
||||||
|
import { createPuzzleCollector } from '@/lib/utils/puzzleCollection'
|
||||||
|
|
||||||
|
let collectionRevision = 0
|
||||||
|
|
||||||
interface PuzzleStore {
|
interface PuzzleStore {
|
||||||
// 每个故居的已解锁碎片集合,格式:{ residence: Set<pieceId> }
|
// 每个故居的已解锁碎片集合,格式:{ residence: Set<pieceId> }
|
||||||
puzzles: Record<string, Set<number>>
|
puzzles: Record<string, Set<number>>
|
||||||
@@ -17,7 +25,7 @@ interface PuzzleStore {
|
|||||||
setLoaded: (isLoaded: boolean) => void
|
setLoaded: (isLoaded: boolean) => void
|
||||||
|
|
||||||
// 解锁碎片
|
// 解锁碎片
|
||||||
unlockPuzzle: (residence: string, pieceId: number) => void
|
unlockPuzzle: (residence: string, pieceId: number) => Promise<boolean>
|
||||||
|
|
||||||
// 检查碎片是否解锁
|
// 检查碎片是否解锁
|
||||||
isPuzzleUnlocked: (residence: string, pieceId: number) => boolean
|
isPuzzleUnlocked: (residence: string, pieceId: number) => boolean
|
||||||
@@ -92,22 +100,14 @@ export const usePuzzleStore = create<PuzzleStore>()(
|
|||||||
puzzles: {},
|
puzzles: {},
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
|
|
||||||
setLoaded: (isLoaded: boolean) => set({ isLoaded }),
|
setLoaded: (isLoaded: boolean) => {
|
||||||
|
if (get().isLoaded !== isLoaded) collectionRevision += 1
|
||||||
unlockPuzzle: (residence: string, pieceId: number) => {
|
set({ isLoaded })
|
||||||
const { puzzles } = get()
|
if (isLoaded) puzzleCollector.clearSavedAcknowledgements()
|
||||||
// 确保 puzzles 格式正确
|
|
||||||
const fixedPuzzles = ensurePuzzlesAreSets(puzzles)
|
|
||||||
const residencePuzzles = fixedPuzzles[residence] || new Set<number>()
|
|
||||||
|
|
||||||
if (!residencePuzzles.has(pieceId)) {
|
|
||||||
const newPuzzles = { ...fixedPuzzles }
|
|
||||||
newPuzzles[residence] = new Set([...residencePuzzles, pieceId])
|
|
||||||
savePuzzleStore(newPuzzles)
|
|
||||||
set({ puzzles: newPuzzles })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
unlockPuzzle: (residence, pieceId) => puzzleCollector.collect(residence, pieceId),
|
||||||
|
|
||||||
isPuzzleUnlocked: (residence: string, pieceId: number) => {
|
isPuzzleUnlocked: (residence: string, pieceId: number) => {
|
||||||
const { puzzles } = get()
|
const { puzzles } = get()
|
||||||
// 确保 puzzles 格式正确
|
// 确保 puzzles 格式正确
|
||||||
@@ -142,6 +142,7 @@ export const usePuzzleStore = create<PuzzleStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
resetPuzzles: (residence: string) => {
|
resetPuzzles: (residence: string) => {
|
||||||
|
collectionRevision += 1
|
||||||
const { puzzles } = get()
|
const { puzzles } = get()
|
||||||
// 确保 puzzles 格式正确
|
// 确保 puzzles 格式正确
|
||||||
const fixedPuzzles = ensurePuzzlesAreSets(puzzles)
|
const fixedPuzzles = ensurePuzzlesAreSets(puzzles)
|
||||||
@@ -151,10 +152,12 @@ export const usePuzzleStore = create<PuzzleStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
resetAll: () => {
|
resetAll: () => {
|
||||||
|
collectionRevision += 1
|
||||||
set({ puzzles: {} })
|
set({ puzzles: {} })
|
||||||
},
|
},
|
||||||
|
|
||||||
applyPersistedState: (payload: PuzzlePersistedState) => {
|
applyPersistedState: (payload: PuzzlePersistedState) => {
|
||||||
|
collectionRevision += 1
|
||||||
set({ puzzles: deserializePuzzles(payload.puzzles) })
|
set({ puzzles: deserializePuzzles(payload.puzzles) })
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -182,20 +185,36 @@ export const usePuzzleStore = create<PuzzleStore>()(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const puzzleCollector = createPuzzleCollector({
|
||||||
|
getIdentity: () => {
|
||||||
|
const { uid, courseId, yibanConnectionStatus } = useUserStore.getState()
|
||||||
|
return {
|
||||||
|
uid,
|
||||||
|
courseId,
|
||||||
|
backend: yibanConnectionStatus === 'connected' && courseId ? 'yiban' : 'session',
|
||||||
|
revision: collectionRevision,
|
||||||
|
loaded: usePuzzleStore.getState().isLoaded,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getPuzzles: () => ensurePuzzlesAreSets(usePuzzleStore.getState().puzzles),
|
||||||
|
save: (puzzles, options) => savePuzzleStore(puzzles, options),
|
||||||
|
report: (order, options) => setCourseTimeProgress(order, options),
|
||||||
|
commit: (puzzles) => usePuzzleStore.setState({ puzzles }),
|
||||||
|
})
|
||||||
|
|
||||||
// ========== 易班平台数据保存 ==========
|
// ========== 易班平台数据保存 ==========
|
||||||
export const PUZZLE_LESSON_ID = 'Cheny_neihua'
|
export const PUZZLE_LESSON_ID = 'Cheny_neihua'
|
||||||
export const PUZZLE_KEY = 24
|
export const PUZZLE_KEY = 24
|
||||||
|
|
||||||
export const savePuzzleStore = (puzzles: Record<string, Set<number>>): void => {
|
export const savePuzzleStore = (
|
||||||
|
puzzles: Record<string, Set<number>>,
|
||||||
|
options: WriteOptions = {}
|
||||||
|
): Promise<WriteResult> => {
|
||||||
const payload = {
|
const payload = {
|
||||||
puzzles: serializePuzzles(puzzles),
|
puzzles: serializePuzzles(puzzles),
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return setUserDefine(PUZZLE_LESSON_ID, PUZZLE_KEY, JSON.stringify(payload), options)
|
||||||
setUserDefine(PUZZLE_LESSON_ID, PUZZLE_KEY, JSON.stringify(payload))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save Chenyun store', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadPuzzleStore = async (
|
export const loadPuzzleStore = async (
|
||||||
@@ -210,8 +229,9 @@ export const loadPuzzleStore = async (
|
|||||||
return result.status
|
return result.status
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = parseUserDefineContent<PuzzlePersistedState>(result.content)
|
const payload = parseUserDefineContent<PuzzlePersistedState>(result.content, options)
|
||||||
usePuzzleStore.getState().applyPersistedState(payload)
|
usePuzzleStore.getState().applyPersistedState(payload)
|
||||||
|
puzzleCollector.clearSavedAcknowledgements()
|
||||||
return result.status
|
return result.status
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load puzzle store', error)
|
console.error('Failed to load puzzle store', error)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
getUserDefineResult,
|
getUserDefineResult,
|
||||||
parseUserDefineContent,
|
parseUserDefineContent,
|
||||||
type UserDefineFetchStatus,
|
type UserDefineFetchStatus,
|
||||||
|
type WriteOptions,
|
||||||
|
type WriteResult,
|
||||||
} from '@/lib/utils/api-common'
|
} from '@/lib/utils/api-common'
|
||||||
|
|
||||||
interface RongdeshengStore {
|
interface RongdeshengStore {
|
||||||
@@ -222,7 +224,7 @@ export const useRongdeshengStore = create<RongdeshengStore>()(
|
|||||||
export const RONGDESHENG_LESSON_ID = 'Rongds_duihua'
|
export const RONGDESHENG_LESSON_ID = 'Rongds_duihua'
|
||||||
export const RONGDESHENG_KEY = 19
|
export const RONGDESHENG_KEY = 19
|
||||||
|
|
||||||
export const saveRongdeshengStore = (): void => {
|
export const saveRongdeshengStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||||
const state = useRongdeshengStore.getState()
|
const state = useRongdeshengStore.getState()
|
||||||
const payload = {
|
const payload = {
|
||||||
visitedExhibits: serializeVisitedExhibits(state.visitedExhibits),
|
visitedExhibits: serializeVisitedExhibits(state.visitedExhibits),
|
||||||
@@ -233,11 +235,7 @@ export const saveRongdeshengStore = (): void => {
|
|||||||
reflectionGuideShown: state.reflectionGuideShown,
|
reflectionGuideShown: state.reflectionGuideShown,
|
||||||
completedVideos: state.completedVideos,
|
completedVideos: state.completedVideos,
|
||||||
}
|
}
|
||||||
try {
|
return setUserDefine(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY, JSON.stringify(payload), options)
|
||||||
setUserDefine(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY, JSON.stringify(payload))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save Rongdesheng store', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const applyRongdeshengPersistedState = (payload: RongdeshengPersistedState): void => {
|
export const applyRongdeshengPersistedState = (payload: RongdeshengPersistedState): void => {
|
||||||
@@ -252,7 +250,6 @@ export const applyRongdeshengPersistedState = (payload: RongdeshengPersistedStat
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const loadRongdeshengStore = async (): Promise<UserDefineFetchStatus> => {
|
export const loadRongdeshengStore = async (): Promise<UserDefineFetchStatus> => {
|
||||||
try {
|
try {
|
||||||
const result = await getUserDefineResult(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY)
|
const result = await getUserDefineResult(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
getUserDefineResult,
|
getUserDefineResult,
|
||||||
parseUserDefineContent,
|
parseUserDefineContent,
|
||||||
type UserDefineFetchStatus,
|
type UserDefineFetchStatus,
|
||||||
|
type WriteOptions,
|
||||||
|
type WriteResult,
|
||||||
} from '@/lib/utils/api-common'
|
} from '@/lib/utils/api-common'
|
||||||
|
|
||||||
export interface Clue {
|
export interface Clue {
|
||||||
@@ -263,7 +265,7 @@ export const useSunzhongshanStore = create<SunzhongshanStore>()(
|
|||||||
export const SUNZHONGSHAN_LESSON_ID = 'Sunzs_duihua'
|
export const SUNZHONGSHAN_LESSON_ID = 'Sunzs_duihua'
|
||||||
export const SUNZHONGSHAN_KEY = 4
|
export const SUNZHONGSHAN_KEY = 4
|
||||||
|
|
||||||
export const saveSunzhongshanStore = (): void => {
|
export const saveSunzhongshanStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||||
const state = useSunzhongshanStore.getState()
|
const state = useSunzhongshanStore.getState()
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -275,11 +277,7 @@ export const saveSunzhongshanStore = (): void => {
|
|||||||
completedVideos: state.completedVideos,
|
completedVideos: state.completedVideos,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return setUserDefine(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY, JSON.stringify(payload), options)
|
||||||
setUserDefine(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY, JSON.stringify(payload))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save Sunzhongshan store', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const applySunzhongshanPersistedState = (payload: SunzhongshanPersistedState): void => {
|
export const applySunzhongshanPersistedState = (payload: SunzhongshanPersistedState): void => {
|
||||||
@@ -293,7 +291,6 @@ export const applySunzhongshanPersistedState = (payload: SunzhongshanPersistedSt
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const loadSunzhongshanStore = async (): Promise<UserDefineFetchStatus> => {
|
export const loadSunzhongshanStore = async (): Promise<UserDefineFetchStatus> => {
|
||||||
try {
|
try {
|
||||||
const result = await getUserDefineResult(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY)
|
const result = await getUserDefineResult(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
getUserDefineResult,
|
getUserDefineResult,
|
||||||
parseUserDefineContent,
|
parseUserDefineContent,
|
||||||
type UserDefineFetchStatus,
|
type UserDefineFetchStatus,
|
||||||
|
type WriteOptions,
|
||||||
|
type WriteResult,
|
||||||
} from '@/lib/utils/api-common'
|
} from '@/lib/utils/api-common'
|
||||||
|
|
||||||
interface ZhanglepingStore {
|
interface ZhanglepingStore {
|
||||||
@@ -238,33 +240,24 @@ export const useZhanglepingStore = create<ZhanglepingStore>()(
|
|||||||
// ========== 易班平台数据保存(体悟模块) ==========
|
// ========== 易班平台数据保存(体悟模块) ==========
|
||||||
export const ZHANGLEPING_LESSON_ID = 'Zhanglp_tiwu'
|
export const ZHANGLEPING_LESSON_ID = 'Zhanglp_tiwu'
|
||||||
export const ZHANGLEPING_KEY = 16
|
export const ZHANGLEPING_KEY = 16
|
||||||
const REMOTE_MAX_DATA_URL_LENGTH = 1200
|
|
||||||
|
|
||||||
export const saveZhanglepingStore = (): void => {
|
export const saveZhanglepingStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||||
const state = useZhanglepingStore.getState()
|
const state = useZhanglepingStore.getState()
|
||||||
|
|
||||||
// 易班接口在当前环境是 GET 参数传输,完整 base64 图会导致 URL 超长请求失败。
|
// 易班 GET 接口不支持 Base64,画作图片仅保存在本地 persist 中。
|
||||||
// 因此远端只保存轻量摘要;完整画作仍保留在本地 persist(localStorage)中。
|
|
||||||
const remoteArtworkDataUrl =
|
|
||||||
state.drawArtworkDataUrl.length <= REMOTE_MAX_DATA_URL_LENGTH ? state.drawArtworkDataUrl : ''
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
visitedExhibits: serializeVisited(state.visitedExhibits),
|
visitedExhibits: serializeVisited(state.visitedExhibits),
|
||||||
lastVisitedPlace: state.lastVisitedPlace,
|
lastVisitedPlace: state.lastVisitedPlace,
|
||||||
reflectionRoute: state.reflectionRoute,
|
reflectionRoute: state.reflectionRoute,
|
||||||
visitedReflectionCursors: serializeVisited(state.visitedReflectionCursors),
|
visitedReflectionCursors: serializeVisited(state.visitedReflectionCursors),
|
||||||
drawArtworkDataUrl: remoteArtworkDataUrl,
|
|
||||||
drawArtworkCaption: state.drawArtworkCaption,
|
drawArtworkCaption: state.drawArtworkCaption,
|
||||||
hasDrawArtwork: state.hasDrawArtwork,
|
hasDrawArtwork: state.hasDrawArtwork,
|
||||||
hasSeenStudyGuide: state.hasSeenStudyGuide,
|
hasSeenStudyGuide: state.hasSeenStudyGuide,
|
||||||
completedVideos: state.completedVideos,
|
completedVideos: state.completedVideos,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return setUserDefine(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY, JSON.stringify(payload), options)
|
||||||
setUserDefine(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY, JSON.stringify(payload))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save Zhangleping store', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const applyZhanglepingPersistedState = (payload: ZhanglepingPersistedState): void => {
|
export const applyZhanglepingPersistedState = (payload: ZhanglepingPersistedState): void => {
|
||||||
@@ -273,7 +266,6 @@ export const applyZhanglepingPersistedState = (payload: ZhanglepingPersistedStat
|
|||||||
lastVisitedPlace: payload.lastVisitedPlace ?? null,
|
lastVisitedPlace: payload.lastVisitedPlace ?? null,
|
||||||
reflectionRoute: resolveReflectionRoute(payload.reflectionRoute),
|
reflectionRoute: resolveReflectionRoute(payload.reflectionRoute),
|
||||||
visitedReflectionCursors: deserializeVisited(payload.visitedReflectionCursors),
|
visitedReflectionCursors: deserializeVisited(payload.visitedReflectionCursors),
|
||||||
drawArtworkDataUrl: payload.drawArtworkDataUrl ?? '',
|
|
||||||
drawArtworkCaption: payload.drawArtworkCaption ?? '',
|
drawArtworkCaption: payload.drawArtworkCaption ?? '',
|
||||||
hasDrawArtwork: payload.hasDrawArtwork ?? false,
|
hasDrawArtwork: payload.hasDrawArtwork ?? false,
|
||||||
hasSeenStudyGuide: payload.hasSeenStudyGuide ?? false,
|
hasSeenStudyGuide: payload.hasSeenStudyGuide ?? false,
|
||||||
@@ -281,7 +273,6 @@ export const applyZhanglepingPersistedState = (payload: ZhanglepingPersistedStat
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const loadZhanglepingStore = async (): Promise<UserDefineFetchStatus> => {
|
export const loadZhanglepingStore = async (): Promise<UserDefineFetchStatus> => {
|
||||||
try {
|
try {
|
||||||
const result = await getUserDefineResult(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY)
|
const result = await getUserDefineResult(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY)
|
||||||
|
|||||||
Vendored
+23
-15
@@ -6,6 +6,22 @@ declare module '@/lib/utils/api-common' {
|
|||||||
backend?: StorageBackend
|
backend?: StorageBackend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WriteOptions extends StorageOptions {
|
||||||
|
critical?: boolean
|
||||||
|
bypassQueue?: boolean
|
||||||
|
deadlineMs?: number
|
||||||
|
silent?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WriteResult {
|
||||||
|
success: boolean
|
||||||
|
skipped?: boolean
|
||||||
|
superseded?: boolean
|
||||||
|
code?: number
|
||||||
|
message?: string
|
||||||
|
attempts?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserDefineFetchResult {
|
export interface UserDefineFetchResult {
|
||||||
status: UserDefineFetchStatus
|
status: UserDefineFetchStatus
|
||||||
content: string
|
content: string
|
||||||
@@ -17,8 +33,8 @@ declare module '@/lib/utils/api-common' {
|
|||||||
lessonId: string | number,
|
lessonId: string | number,
|
||||||
key: string | number,
|
key: string | number,
|
||||||
value: string,
|
value: string,
|
||||||
options?: StorageOptions
|
options?: WriteOptions
|
||||||
): void
|
): Promise<WriteResult>
|
||||||
|
|
||||||
export function getUserDefineResult(
|
export function getUserDefineResult(
|
||||||
lessonId: string | number,
|
lessonId: string | number,
|
||||||
@@ -26,18 +42,10 @@ declare module '@/lib/utils/api-common' {
|
|||||||
options?: StorageOptions
|
options?: StorageOptions
|
||||||
): Promise<UserDefineFetchResult>
|
): Promise<UserDefineFetchResult>
|
||||||
|
|
||||||
export function parseUserDefineContent<T>(content: string): T
|
export function parseUserDefineContent<T>(content: string, options?: StorageOptions): T
|
||||||
|
|
||||||
export function getUserDefine(
|
export function setCourseTimeProgress(
|
||||||
lessonId: string | number,
|
order: number,
|
||||||
key: string | number,
|
options?: Pick<WriteOptions, 'bypassQueue' | 'deadlineMs'>
|
||||||
options?: StorageOptions
|
): Promise<WriteResult>
|
||||||
): Promise<string>
|
|
||||||
|
|
||||||
export function getUsersDefine(
|
|
||||||
lessonId: string | number,
|
|
||||||
key: string | number
|
|
||||||
): Promise<string | { content?: string }[]>
|
|
||||||
|
|
||||||
export function setCourseTimeProgress(order: number): Promise<boolean>
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user