Compare commits

...
Author SHA1 Message Date
zhongyidianandClaude Opus 5 9ab67b1bd3 fix: 完善学习进度同步与拼图收集流程
统一阶段拼图配置和进度上报,增加同步写入协调与失败重试,优化收集确认及同步反馈,并展示菜单探索进度。补充相关回归测试与字体素材。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-12 09:29:21 +08:00
63 changed files with 2141 additions and 360 deletions

No files matched your search

+46 -1
View File
@@ -48,6 +48,7 @@
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1",
"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/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/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/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/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=="],
"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=="],
@@ -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-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-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=="],
"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-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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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/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=="],
"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=="],
"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-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/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=="],
"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=="],
"@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
View File
@@ -17,6 +17,7 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
@@ -64,6 +65,7 @@
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3",
"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

+70 -55
View File
@@ -1,81 +1,96 @@
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import {
SYNC_LOADING_EVENT,
SYNC_LOADING_HIDE_EVENT,
type SyncLoadingOptions,
} from './syncLoadingApi'
getSyncWriteState,
retryActiveSyncWrite,
subscribeBackgroundWriteFailure,
subscribeSyncWriteState,
type SyncWriteState,
} from '@/lib/utils/syncWriteCoordinator'
import { showSyncErrorPrompt } from './SyncErrorPrompt'
interface SyncLoadingHostProps {
visible?: boolean
text?: string
}
export const SyncLoadingHost = ({
visible: controlledVisible,
text: controlledText,
}: SyncLoadingHostProps) => {
const [text, setText] = useState('正在同步学习进度...')
const [visible, setVisible] = useState(false)
const actualVisible = controlledVisible ?? visible
const actualText = controlledText ?? text
export const SyncLoadingHost = ({ visible = false, text }: SyncLoadingHostProps) => {
const [writeState, setWriteState] = useState<SyncWriteState>(getSyncWriteState())
useEffect(() => {
const handleShow = (event: Event) => {
const customEvent = event as CustomEvent<SyncLoadingOptions>
setText(customEvent.detail.text ?? '正在同步学习进度...')
setVisible(true)
}
useEffect(() => subscribeSyncWriteState(setWriteState), [])
const handleHide = () => {
setVisible(false)
}
useEffect(
() =>
subscribeBackgroundWriteFailure((message) => {
showSyncErrorPrompt({ title: '同步失败', message })
}),
[]
)
window.addEventListener(SYNC_LOADING_EVENT, handleShow)
window.addEventListener(SYNC_LOADING_HIDE_EVENT, handleHide)
return () => {
window.removeEventListener(SYNC_LOADING_EVENT, handleShow)
window.removeEventListener(SYNC_LOADING_HIDE_EVENT, handleHide)
}
}, [])
const writeVisible = writeState.phase !== 'idle'
const actualVisible = visible || writeVisible
const showFailure = !visible && writeState.phase === 'failed'
const actualText = text ?? '正在同步学习进度...'
return (
<AnimatePresence>
{actualVisible && (
<motion.div
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 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<motion.div
className="flex flex-col items-center justify-center gap-8"
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
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>
{showFailure ? (
<motion.div
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"
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.24, ease: 'easeOut' }}
>
<div className="text-4xl font-bold tracking-[6px] text-[#f4e4bd]">同步失败</div>
<div className="text-2xl font-semibold leading-relaxed tracking-wider text-[#f4e4bd]">
{writeState.message ?? '学习进度保存失败,请检查网络后重新尝试。'}
</div>
<button
type="button"
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"
disabled={writeState.retrying}
onClick={retryActiveSyncWrite}
>
重新尝试
</button>
</motion.div>
) : (
<motion.div
className="flex flex-col items-center justify-center gap-8"
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
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>
)}
</AnimatePresence>
+86 -51
View File
@@ -54,8 +54,11 @@ const ResidenceLayoutContent = () => {
const [isFlying, setIsFlying] = useState(false) // 标记是否正在飞行
const [isAwaitingCollect, setIsAwaitingCollect] = useState(false)
const [isCollectConfirming, setIsCollectConfirming] = useState(false)
const [collectError, setCollectError] = useState<string | null>(null)
const puzzlePieceRef = useRef<HTMLDivElement>(null)
const animationInProgressRef = useRef<boolean>(false) // 防止重复执行动画
const collectionLockRef = useRef(false)
const collectionViewRef = useRef(0)
const animationCompletionRef = useRef<(() => void) | null>(null)
const animationCompletionCallbackRef = useRef<(() => void) | null>(null)
@@ -70,6 +73,14 @@ const ResidenceLayoutContent = () => {
return ''
}, [location.pathname])
useEffect(() => {
collectionViewRef.current += 1
collectionLockRef.current = false
return () => {
collectionViewRef.current += 1
}
}, [location.pathname])
// 判断当前路由是否是 place1(讲解视频,首次不可跳过)
const isPlace1Route = useMemo(() => /place1/.test(location.pathname), [location.pathname])
@@ -120,80 +131,72 @@ const ResidenceLayoutContent = () => {
setIsFlying(false)
setIsAwaitingCollect(false)
setIsCollectConfirming(false)
setCollectError(null)
collectionLockRef.current = false
animationInProgressRef.current = false
resolveAnimation()
}, [resolveAnimation])
// 执行飞行动画
const executeFlyAnimation = useCallback(
(pieceId: number) => {
if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) {
return false
const executeFlyAnimation = useCallback(() => {
if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) {
return false
}
// 使用 requestAnimationFrame 确保元素已完全渲染
requestAnimationFrame(() => {
if (!puzzlePieceRef.current || !residence) {
return
}
// 使用 requestAnimationFrame 确保元素已完全渲染
requestAnimationFrame(() => {
if (!puzzlePieceRef.current || !residence) {
return
}
flyToAnimation({
fromRef: puzzlePieceRef,
toRef: puzzleBoxRef,
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)
})
flyToAnimation({
fromRef: puzzlePieceRef,
toRef: puzzleBoxRef,
targetPosition: 'center',
duration: ANIMATION_CONFIG.FLY_DURATION,
scale: ANIMATION_CONFIG.SCALE,
opacity: ANIMATION_CONFIG.OPACITY,
ease: [0.22, 0.72, 0.16, 1],
onComplete: cleanupAnimation,
})
return true
},
[
ANIMATION_CONFIG.FLY_DURATION,
ANIMATION_CONFIG.OPACITY,
ANIMATION_CONFIG.SCALE,
cleanupAnimation,
puzzleBoxRef,
residence,
unlockPuzzle,
]
)
requestAnimationFrame(() => {
setIsFlying(true)
})
})
return true
}, [
ANIMATION_CONFIG.FLY_DURATION,
ANIMATION_CONFIG.OPACITY,
ANIMATION_CONFIG.SCALE,
cleanupAnimation,
puzzleBoxRef,
residence,
])
// 等待元素准备就绪并执行动画
const waitForElementsAndAnimate = useCallback(
async (pieceId: number, retryCount = 0): Promise<void> => {
async (retryCount = 0): Promise<void> => {
if (!residence) {
cleanupAnimation()
return
}
// 检查元素是否已准备好
// 检查元素是否已准备就绪
if (puzzleBoxRef.current && puzzlePieceRef.current) {
const success = executeFlyAnimation(pieceId)
const success = executeFlyAnimation()
if (success) return
}
// 如果元素未准备好且未超过最大重试次数,则重试
if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY))
return waitForElementsAndAnimate(pieceId, retryCount + 1)
return waitForElementsAndAnimate(retryCount + 1)
}
// 超过最大重试次数,直接解锁并清理
console.warn('Puzzle animation: 元素未准备好,直接解锁')
unlockPuzzle(residence, pieceId)
// 保存已经成功,动画元素缺失时只需结束收集流程
console.warn('Puzzle animation: 元素未准备好,跳过飞行动画')
cleanupAnimation()
},
[
@@ -203,7 +206,6 @@ const ResidenceLayoutContent = () => {
executeFlyAnimation,
puzzleBoxRef,
residence,
unlockPuzzle,
]
)
@@ -213,17 +215,48 @@ const ResidenceLayoutContent = () => {
}, [puzzlePieceId, residence])
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)
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)
await waitForElementsAndAnimate(puzzlePieceId)
setIsCollectConfirming(false)
await waitForElementsAndAnimate()
}, [
ANIMATION_CONFIG.COLLECT_CONFIRM_DURATION,
isCollectConfirming,
isFlying,
puzzlePieceId,
residence,
unlockPuzzle,
waitForElementsAndAnimate,
])
@@ -259,6 +292,7 @@ const ResidenceLayoutContent = () => {
// 开始动画流程:禁用所有操作
setIsAnimationPlaying(true)
setCollectError(null)
setPuzzlePieceId(pieceId)
setShowPuzzlePiece(true)
@@ -444,6 +478,7 @@ const ResidenceLayoutContent = () => {
hidden={isFlying}
awaitingCollect={isAwaitingCollect}
confirming={isCollectConfirming}
collectError={collectError}
puzzlePieceTitle={currentPuzzlePieceData?.title ?? null}
onCollect={handleCollectPuzzlePiece}
/>
+18 -5
View File
@@ -11,8 +11,9 @@ interface PuzzlePiecePortalProps {
hidden?: boolean
awaitingCollect?: boolean
confirming?: boolean
collectError?: string | null
puzzlePieceTitle?: string | null
onCollect?: () => void
onCollect?: () => void | Promise<void>
}
const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
@@ -23,6 +24,7 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
hidden = false,
awaitingCollect = false,
confirming = false,
collectError,
puzzlePieceTitle,
onCollect,
},
@@ -153,8 +155,8 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
<motion.div
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 }}
animate={confirming ? { opacity: 0, y: 6 } : { opacity: 1, y: 0 }}
transition={{ duration: confirming ? 0.16 : 0.36, ease: [0.22, 1, 0.36, 1] }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }}
style={{ willChange: 'transform, opacity' }}
>
{puzzlePieceTitle && (
@@ -165,12 +167,23 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
</div>
)}
{collectError && (
<div className="rounded-full bg-[#6f211c]/90 px-5 py-2 text-base font-semibold text-[#ffe7c2] shadow-lg">
{collectError}
</div>
)}
<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}
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>
</motion.div>
)}
+47
View File
@@ -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
View File
@@ -1,3 +1,4 @@
import { getStagePieceIds } from './puzzleStages'
import { puzzleDataConfig } from './puzzleData'
import { PUZZLE_RESIDENCES } from './puzzleResidences'
@@ -55,13 +56,21 @@ export const isMenuModuleComplete = (moduleName: string, puzzles: PuzzleProgress
return residences.every((item) => isResidenceComplete(item.residence, puzzles))
}
const getPieceIdsByRoute = (residence: string, routePrefix: string) => {
const pieces = puzzleDataConfig[residence]
if (!pieces) return []
export const getMenuModuleProgress = (moduleName: string, puzzles: PuzzleProgress) => {
const residences = PUZZLE_RESIDENCES.filter((item) => item.moduleName === moduleName)
let totalPieces = 0
let unlockedPieces = 0
return Object.entries(pieces)
.filter(([, piece]) => piece.route.startsWith(routePrefix))
.map(([pieceId]) => Number(pieceId))
residences.forEach(({ residence }) => {
const configuredPieceIds = getConfiguredPieceIds(residence)
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) => {
@@ -71,10 +80,10 @@ export const getResidenceStageUnlocks = (residence: string, puzzles: PuzzleProgr
dialogueUnlocked: residenceUnlocked,
reflectionUnlocked:
residenceUnlocked &&
arePiecesUnlocked(residence, puzzles, getPieceIdsByRoute(residence, 'place')),
arePiecesUnlocked(residence, puzzles, getStagePieceIds(residence, 'dialogue')),
internalizationUnlocked:
residenceUnlocked &&
arePiecesUnlocked(residence, puzzles, getPieceIdsByRoute(residence, 'reflection')),
arePiecesUnlocked(residence, puzzles, getStagePieceIds(residence, 'reflection')),
}
}
@@ -97,17 +106,17 @@ export const getResidenceStageCompletion = (
const dialogueComplete = arePiecesUnlocked(
residence,
puzzles,
getPieceIdsByRoute(residence, 'place')
getStagePieceIds(residence, 'dialogue')
)
const reflectionComplete = arePiecesUnlocked(
residence,
puzzles,
getPieceIdsByRoute(residence, 'reflection')
getStagePieceIds(residence, 'reflection')
)
const internalizationComplete = arePiecesUnlocked(
residence,
puzzles,
getPieceIdsByRoute(residence, 'internalization')
getStagePieceIds(residence, 'internalization')
)
return {
+31
View File
@@ -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')
})
})
+36
View File
@@ -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()
})
})
+34
View File
@@ -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
}
+8
View File
@@ -10,6 +10,14 @@
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;
padding: 0; */
+31
View File
@@ -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: {} })
})
})
+58
View File
@@ -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
View File
@@ -1,4 +1,12 @@
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
@@ -34,9 +42,17 @@ export interface UserDefineFetchResult {
message?: string
}
type VideoAjaxWriteCallback<T = unknown> = (
successOrResponse: boolean | WriteResponse<T>,
response?: WriteResponse<T>
) => void
interface VideoAjax {
setHost(host: string): void
addUserDefine(param: UserDefineParam): void
addUserDefine(
param: UserDefineParam,
callback: VideoAjaxWriteCallback<Record<string, unknown>>
): void
getUserDefineInfoByUser(
param: UserDefineParam,
callback: (data: VideoAjaxResponse | undefined) => void
@@ -45,7 +61,10 @@ interface VideoAjax {
param: UserDefineParam,
callback: (data: VideoAjaxResponse | undefined) => 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'
@@ -56,6 +75,16 @@ export interface StorageOptions {
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) =>
`${LESSON_PREFIX}_${lessonId}_${key}`
@@ -67,18 +96,44 @@ const resolveStorageBackend = (backend: StorageBackend = 'auto'): 'yiban' | 'ses
const videoajax: VideoAjax = videoAjax()
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)
export function setUserDefine(
export async function setUserDefine(
lessonId: string | number,
key: string | number,
value: string,
options: StorageOptions = {}
): void {
options: WriteOptions = {}
): Promise<WriteResult> {
const backend = resolveStorageBackend(options.backend)
if (backend === 'session') {
sessionStorage.setItem(getStorageKey(lessonId, key), value)
return
try {
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 = {
@@ -86,7 +141,25 @@ export function setUserDefine(
key,
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
@@ -184,9 +257,9 @@ export async function getUserDefineResult(
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)
if (useUserStore.getState().yibanConnectionStatus === 'connected') {
if (resolveStorageBackend(options.backend) === 'yiban') {
return raw as T
}
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') {
console.info(`[yiban] skip finishCourse: disconnected, order=${order}`)
return false
return { success: true, skipped: true }
}
const courseid = useUserStore.getState().courseId
// var courseid = getCookie(lesson+"_course_id");//获取url链接的课程ID
// const courseid = await getLtDefine('10000')
if (courseid === undefined || courseid === null || courseid === '') {
console.info(`[yiban] skip finishCourse: missing courseId, order=${order}`)
return false
return { success: true, skipped: true }
}
if (order <= 0) {
console.info(`[yiban] skip finishCourse: invalid order=${order}`)
return false
if (!Number.isInteger(order) || order <= 0) {
console.error(`[yiban] skip finishCourse: invalid order=${order}`)
return { success: false, message: '无效的课时顺序号' }
}
const param2 = { order, co: courseid, process: 100 }
console.info('[yiban] finishCourse', param2)
videoajax.finishCourse(param2)
return true
const param = { order, co: courseid, process: 100 }
console.info('[yiban] finishCourse', param)
const runRound = () =>
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)
}
+69
View File
@@ -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)
})
})
+145
View File
@@ -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()
})
}
+85
View File
@@ -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()
}
+247
View File
@@ -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()
})
})
+169
View File
@@ -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])
})
})
+211
View File
@@ -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
View File
@@ -5,7 +5,11 @@ import { useNavigate } from 'react-router-dom'
import { DEFAULT_PUZZLE_RESIDENCE, PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
import { puzzleDataConfig } from '@/config/puzzleData'
import { usePuzzleStore } from '@/store/puzzleStore'
import { isMenuModuleComplete } from '@/config/progression'
import {
getMenuModuleProgress,
isMenuModuleComplete,
isMenuModuleStarted,
} from '@/config/progression'
const Menu = () => {
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">
{Category.map((item) => {
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> = {
revolutionary: 'menu/革命完成',
cultural: 'menu/文化完成',
@@ -118,7 +124,32 @@ const Menu = () => {
) : (
<div className="mt-23">
<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>
+10 -3
View File
@@ -1,3 +1,4 @@
import { saveChenyunStore } from '@/store/chenyunStore'
import { useMemo, useState, useRef, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
@@ -15,7 +16,7 @@ import { SuccessAlert, DragQuizPanel } from '@/components/layout'
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
import { GuideSteps, GuideStepsItem } from '@/components'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { usePuzzleStore } from '@/store/puzzleStore'
type QuizOption = {
value: string
label: string
@@ -222,7 +223,14 @@ const ChenyunInternalization = () => {
content=""
onContinue={() => {
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) => {
const next = { ...prev }
delete next[item.id]
@@ -496,7 +504,6 @@ const ChenyunInternalization = () => {
animationSpeed={200}
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(24)
navigate('/learning-map', { state: { completedResidence: 'chenyun' } })
}}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_40.mp3')}
+3 -5
View File
@@ -6,9 +6,8 @@ import ScaledPortal from '@/components/layout/ScaledPortal'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import type { VideoRef } from '@/components/video'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
import { saveChenyunStore, useChenyunStore } from '@/store/chenyunStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
// starName 固定为 chenyun
const starName = 'chenyun'
@@ -109,9 +108,8 @@ const ChenyunPlace9 = () => {
className="absolute bottom-0 right-0"
src={getStaticImage('chenyun/place9/离开场景')}
button
onClick={() => {
setCourseTimeProgress(22)
saveChenyunStore()
onClick={async () => {
await saveChenyunStore()
navigate(`/${moduleName}/${starName}/home`)
}}
/>
@@ -178,10 +178,10 @@ export const MimianBattle = ({
}
// 处理单选题5提交
const handleQuiz5Submit = (value: string, isCorrect: boolean) => {
const handleQuiz5Submit = async (value: string, isCorrect: boolean) => {
if (isCorrect) {
// 标记米棉之战为已解决,并收起文件
state.setIsMimianSolved(true)
await state.setIsMimianSolved(true)
onCollapseFile()
state.setShowMimianQuiz5(false)
@@ -3,8 +3,7 @@
* 管理米棉之战相关的所有状态
*/
import { useState } from 'react'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { saveChenyunStore, useChenyunStore } from '@/store/chenyunStore'
/**
* 米棉之战状态管理 Hook
@@ -15,10 +14,9 @@ export const useMimianState = () => {
const { isMimianSolved, setMimianSolved } = useChenyunStore()
// 更新解决状态时,直接更新 store
const handleSetIsMimianSolved = (solved: boolean) => {
const handleSetIsMimianSolved = async (solved: boolean) => {
setMimianSolved(solved)
saveChenyunStore()
setCourseTimeProgress(23)
await saveChenyunStore()
}
// 单选题1相关状态
+10 -3
View File
@@ -1,3 +1,4 @@
import { saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { useEffect, useMemo, useState, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
@@ -16,7 +17,7 @@ import { SuccessAlert, DragQuizPanel } from '@/components/layout'
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
import { GuideSteps, GuideStepsItem } from '@/components'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { usePuzzleStore } from '@/store/puzzleStore'
type QuizOption = {
value: string
@@ -298,7 +299,14 @@ const HuangyanpeiInternalization = () => {
return next
})
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
setResumeCallback(null)
@@ -538,7 +546,6 @@ const HuangyanpeiInternalization = () => {
animationSpeed={200}
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(12)
navigate('/learning-map', { state: { completedResidence: 'huangyanpei' } })
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_31.mp3')}
+2 -4
View File
@@ -8,7 +8,6 @@ import { useNavigate, useParams } from 'react-router-dom'
import { EndingOverlay } from '@/components/overlay/EndingOverlay'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
const starName = 'huangyanpei'
@@ -105,9 +104,8 @@ const HuangyanpeiPlace6 = () => {
className="absolute bottom-0 right-4"
src={getStaticImage('components/place/离开场景')}
button
onClick={() => {
saveHuangyanpeiStore()
setCourseTimeProgress(10)
onClick={async () => {
await saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/home`)
}}
/>
+3 -6
View File
@@ -7,10 +7,8 @@ import { useNavigate, useParams } from 'react-router-dom'
import ViseAssemblyDialog from './scene3/ViseAssemblyDialog'
import type { VideoRef } from '@/components/video'
import { QuizDialog } from './components'
import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { saveHuangyanpeiStore, useHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { useCursorController } from '@/hooks/useCursorController'
import { saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
const starName = 'huangyanpei'
@@ -242,10 +240,9 @@ const HuangyanpeiReflectionScene3 = () => {
showSkipButton
skipKey="huangyanpei_reflection_scene3_video"
showReadonlyProgress
onEnded={() => {
onEnded={async () => {
await saveHuangyanpeiStore()
setShowVideo(false)
saveHuangyanpeiStore()
setCourseTimeProgress(11)
navigate(`/${moduleName}/${starName}/home`)
}}
/>
+3 -5
View File
@@ -8,8 +8,7 @@ import TowelSalesDialog from './scene4/TowelSalesDialog'
import type { VideoRef } from '@/components/video'
import { QuizDialog } from './components'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { saveHuangyanpeiStore, useHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { useCursorController } from '@/hooks/useCursorController'
const starName = 'huangyanpei'
@@ -223,10 +222,9 @@ const HuangyanpeiReflectionScene4 = () => {
skipKey="huangyanpei_reflection_scene4_video"
showReadonlyProgress
playsInline
onEnded={() => {
onEnded={async () => {
await saveHuangyanpeiStore()
setShowVideo(false)
saveHuangyanpeiStore()
setCourseTimeProgress(11)
navigate(`/${moduleName}/${starName}/home`)
}}
/>
+10 -3
View File
@@ -1,3 +1,4 @@
import { saveLuxunStore } from '@/store/luxunStore'
import { useEffect, useMemo, useState, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
@@ -15,7 +16,7 @@ import { SuccessAlert, DragQuizPanel } from '@/components/layout'
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
import { GuideSteps, GuideStepsItem } from '@/components'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { usePuzzleStore } from '@/store/puzzleStore'
type QuizOption = {
value: string
@@ -393,7 +394,14 @@ const LuxunInternalization = () => {
setSuccessContent('')
const cb = resumeCallback
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?.()
}}
/>
@@ -538,7 +546,6 @@ const LuxunInternalization = () => {
animationSpeed={200}
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(9)
navigate('/learning-map', { state: { completedResidence: 'luxun' } })
}}
audioSrc={getPublicPath('audio/luxun/mp3_luxun_33_02_new.mp3')}
+3 -5
View File
@@ -17,8 +17,7 @@ import { useState, useRef, useMemo } from 'react'
import SuccessAlert from '@/components/layout/alert/success-alert'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { saveLuxunStore, useLuxunStore } from '@/store/luxunStore'
// starName 固定为 luxun
const starName = 'luxun'
@@ -431,13 +430,12 @@ const LuxunPlace5 = () => {
{/* 视频结束后的结束语对话 */}
<GuideSteps
visible={endingGuideVisible}
onVisibleChange={(visible) => {
onVisibleChange={async (visible) => {
setShowEndingVideo(false)
setEndingGuideVisible(visible)
if (!visible && moduleName) {
// 对话结束后返回鲁迅首页
saveLuxunStore()
setCourseTimeProgress(7)
await saveLuxunStore()
navigate(`/${moduleName}/${starName}/home`)
}
}}
+3 -5
View File
@@ -1,3 +1,4 @@
import { saveLuxunStore } from '@/store/luxunStore'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState, useEffect, useRef } from 'react'
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 SuccessAlert from '@/components/layout/alert/success-alert'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { saveLuxunStore } from '@/store/luxunStore'
const starName = 'luxun'
@@ -344,9 +343,8 @@ const LuxunReflectionScene3 = () => {
className="absolute bottom-45 right-0"
src={getStaticImage('components/place/离开场景')}
button
onClick={() => {
saveLuxunStore()
setCourseTimeProgress(8)
onClick={async () => {
await saveLuxunStore()
navigate(`/${moduleName}/${starName}/home`)
}}
/>
+12 -3
View File
@@ -1,3 +1,4 @@
import { saveMaozedongStore } from '@/store/maozedongStore'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
@@ -15,7 +16,7 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { GuideSteps, GuideStepsItem } from '@/components'
import { BasketQuizPanel, DragQuizPanel, SuccessAlert } from '@/components/layout'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { usePuzzleStore } from '@/store/puzzleStore'
const starName = 'maozedong'
@@ -291,7 +292,16 @@ const MaozedongInternalization = () => {
if (isCorrect) {
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)
setResumeCallback(() => resume)
return
@@ -527,7 +537,6 @@ const MaozedongInternalization = () => {
animationSpeed={200}
onContinue={() => {
setShowCompleted(false)
setCourseTimeProgress(3)
navigate('/learning-map', { state: { completedResidence: 'maozedong' } })
}}
/>
+2 -4
View File
@@ -19,7 +19,6 @@ import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
import { useMaozedongStore, saveMaozedongStore } from '@/store/maozedongStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
// starName 固定为 maozedong
const starName = 'maozedong'
@@ -152,10 +151,9 @@ const MaozedongPlace4 = () => {
className="absolute right-0 bottom-0"
src={getStaticImage('components/place/离开场景')}
button
onClick={() => {
onClick={async () => {
if (moduleName && starName) {
saveMaozedongStore()
setCourseTimeProgress(1)
await saveMaozedongStore()
navigate(`/${moduleName}/${starName}/home`)
}
}}
+3 -5
View File
@@ -1,3 +1,4 @@
import { saveMaozedongStore } from '@/store/maozedongStore'
import {
SingleChoiceQuizPanel,
Video,
@@ -10,10 +11,8 @@ import { SuccessAlert } from '@/components/layout'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { cn, getStaticImage } from '@/lib/utils'
import { getPublicPath } from '@/lib/utils'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { saveMaozedongStore } from '@/store/maozedongStore'
// 光标组件 - 带旋转动画
// Safari 兼容:使用 onTouchEnd 和 -webkit-tap-highlight-color
@@ -488,10 +487,9 @@ const Home = () => {
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_23.mp3')}
animationSpeed={200}
text="毛泽东在中共四大筹备期间的三次重要抉择,均锚定真实实践、扎根基层,为四大首次明确无产阶级领导权、工农联盟筑牢基础。虽因身体健康问题未参会,但其筹备工作与后续调研,如纽带般连接理论与实践,让党的革命理念在历史浪潮中,朝着正确方向坚定前行,成为早期共产党人探索革命道路的生动注脚,影响深远,照耀后世奋进征程。"
onContinue={() => {
onContinue={async () => {
await saveMaozedongStore()
setEndingVisible(false)
saveMaozedongStore()
setCourseTimeProgress(2)
navigate(`/${moduleName}/maozedong/home`)
}}
/>
+10 -3
View File
@@ -1,3 +1,4 @@
import { saveNieerStore } from '@/store/nieerStore'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
@@ -14,7 +15,7 @@ import { GuideSteps, GuideStepsItem } from '@/components'
import { SuccessAlert } from '@/components/layout'
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { usePuzzleStore } from '@/store/puzzleStore'
const starName = 'nieer'
@@ -241,7 +242,14 @@ const NieerInternalization = () => {
triggerPuzzleAnimation(15)
}
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('')
setResumeCallback(() => resume)
@@ -380,7 +388,6 @@ const NieerInternalization = () => {
animationSpeed={200}
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(15)
navigate('/learning-map', { state: { completedResidence: 'nieer' } })
}}
/>
+2 -4
View File
@@ -16,7 +16,6 @@ import { useNavigate, useParams } from 'react-router-dom'
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
const NieerPlace6 = () => {
const navigate = useNavigate()
@@ -109,9 +108,8 @@ const NieerPlace6 = () => {
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/离开场景')}
button
onClick={() => {
saveNieerStore()
setCourseTimeProgress(13)
onClick={async () => {
await saveNieerStore()
navigate(`/${moduleName}/${starName}/home`)
}}
/>
+2 -4
View File
@@ -15,7 +15,6 @@ import {
getUnlockVideoSrcForScene,
getUnlockedByIdMap,
} from './reflectionProgress'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
const starName = 'nieer'
@@ -343,10 +342,9 @@ const NieerReflection = () => {
audioSrc={getPublicPath('audio/nieer/mp3_nieer_35.mp3')}
animationSpeed={200}
text={`恭喜你完成所有时空旅行!成功绘制“国歌精神图谱”,解锁6个跨越时空的国歌故事~国歌的故事还在继续,它的精神藏在每一次唱响、每一次坚守中。愿你带着这份精神力量,做国歌的传承者、爱国的践行者!`}
onContinue={() => {
onContinue={async () => {
await saveNieerStore()
setEndingVisible(false)
// saveNieerStore()
setCourseTimeProgress(14)
navigate(`/${moduleName}/${starName}/home`)
}}
/>
+12 -3
View File
@@ -1,3 +1,4 @@
import { saveRongdeshengStore } from '@/store/rongdeshengStore'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
@@ -17,7 +18,7 @@ import { SuccessAlert } from '@/components/layout'
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { usePuzzleStore } from '@/store/puzzleStore'
const starName = 'rongdesheng'
@@ -312,7 +313,16 @@ const RongdeshengInternalization = () => {
// 答题成功后触发碎片获取动画
// quiz-2 -> 碎片15(坚韧不拔),quiz-5 -> 碎片16(社会责任担当)
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}
onContinue={() => {
setShowEnding(false)
setCourseTimeProgress(21)
navigate('/learning-map', { state: { completedResidence: 'rongdesheng' } })
}}
/>
+2 -4
View File
@@ -17,7 +17,6 @@ import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { saveRongdeshengStore, useRongdeshengStore } from '@/store/rongdeshengStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
const starName = 'rongdesheng'
const place = 'place4'
@@ -165,10 +164,9 @@ const RongdeshengPlace4 = () => {
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/离开场景')}
button
onClick={() => {
onClick={async () => {
setLastVisitedPlace('place4')
saveRongdeshengStore()
setCourseTimeProgress(19)
await saveRongdeshengStore()
navigate(`/${moduleName}/${starName}/home`)
}}
/>
+3 -5
View File
@@ -12,7 +12,6 @@ import { VideoBubbles } from './components/VideoBubbles'
import { HINT_AUDIOS, HINT_TEXTS } from './constants'
import { MODULES, getSceneTitle } from './modules.data'
import type { ModuleId, ModuleState, VideoPlayingState } from './types'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
const RongdeshengReflection = () => {
@@ -382,11 +381,10 @@ const RongdeshengReflection = () => {
visible={showFinalScene}
finalVideoStage={finalVideoStage}
onStart={() => setFinalVideoStage('playing')}
onVideoEnded={() => {
setCourseTimeProgress(20)
setFinalVideoStage('ended')
onVideoEnded={async () => {
markReflectionFinalCompleted()
saveRongdeshengStore()
await saveRongdeshengStore()
setFinalVideoStage('ended')
}}
onExit={() => {
navigate(`/${moduleName}/${starName}/home`)
+10 -3
View File
@@ -1,3 +1,4 @@
import { saveSunzhongshanStore } from '@/store/sunzhongshanStore'
import { useEffect, useMemo, useState, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
@@ -15,7 +16,7 @@ import { SuccessAlert, DragQuizPanel } from '@/components/layout'
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
import { GuideSteps, GuideStepsItem } from '@/components'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { usePuzzleStore } from '@/store/puzzleStore'
type QuizOption = {
value: string
@@ -407,7 +408,14 @@ const SunzhongshanInternalization = () => {
if (item.id === 'quiz-2') {
triggerPuzzleAnimation(15)
} 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?.()
}}
@@ -545,7 +553,6 @@ const SunzhongshanInternalization = () => {
animationSpeed={200}
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(6)
navigate('/learning-map', { state: { completedResidence: 'sunzhongshan' } })
}}
/>
+3 -5
View File
@@ -17,8 +17,7 @@ import { useCursorController } from '@/hooks/useCursorController'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useSunzhongshanStore, saveSunzhongshanStore } from '@/store/sunzhongshanStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { saveSunzhongshanStore, useSunzhongshanStore } from '@/store/sunzhongshanStore'
const starName = 'sunzhongshan'
@@ -134,9 +133,8 @@ const SunzhongshanPlace4 = () => {
className="absolute bottom-0 right-0"
src={getStaticImage('components/place/离开场景')}
button
onClick={() => {
setCourseTimeProgress(4)
saveSunzhongshanStore()
onClick={async () => {
await saveSunzhongshanStore()
navigate(`/${moduleName}/${starName}`)
}}
/>
+3 -5
View File
@@ -15,8 +15,7 @@ import { SingleChoiceQuizPanel } from '@/components/layout/quiz'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import type { ClueHint } from '@/components/clue'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useSunzhongshanStore, saveSunzhongshanStore } from '@/store/sunzhongshanStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { saveSunzhongshanStore, useSunzhongshanStore } from '@/store/sunzhongshanStore'
const starName = 'sunzhongshan'
const sceneId = `${starName}/reflection/scene3`
@@ -277,15 +276,14 @@ const SunzhongshanReflectionScene3 = () => {
}
// 处理完成提示关闭
const handleCompleteGuideClose = () => {
const handleCompleteGuideClose = async () => {
setShowCompleteGuide(false)
// 保存场景3完成状态
setReflectionFlag(`${starName}-reflection-scene3-completed`, true)
if (moduleName && starName) {
const { setReflectionRoute } = useSunzhongshanStore.getState()
setReflectionRoute('reflection3')
saveSunzhongshanStore()
setCourseTimeProgress(5)
await saveSunzhongshanStore()
navigate(`/${moduleName}/${starName}/home`)
}
}
+10 -3
View File
@@ -1,3 +1,4 @@
import { saveZhanglepingStore } from '@/store/zhanglepingStore'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
@@ -14,7 +15,7 @@ import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/lay
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { GuideSteps, GuideStepsItem } from '@/components'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { usePuzzleStore } from '@/store/puzzleStore'
const starName = 'zhangleping'
@@ -299,7 +300,14 @@ const ZhanglepingInternalization = () => {
'先生善于捕捉核心细节展现人物特质,化繁为简的场景刻画让漫画更具感染力,这正是“以少胜多”艺术境界的体现。'
)
} 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(
'张乐平先生坚持艺术扎根生活,以简洁笔墨刻画时代,用作品传递温暖与正义,这些都是他的核心创作理念。'
)
@@ -495,7 +503,6 @@ const ZhanglepingInternalization = () => {
animationSpeed={200}
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(18)
navigate('/learning-map', { state: { completedResidence: 'zhangleping' } })
}}
/>
+2 -4
View File
@@ -16,7 +16,6 @@ import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
// starName 固定为 zhangleping
const starName = 'zhangleping'
@@ -131,10 +130,9 @@ const ZhanglepingPlace5 = () => {
className="absolute right-0 bottom-0"
src={getStaticImage('components/place/离开场景')}
button
onClick={() => {
onClick={async () => {
if (moduleName && starName) {
saveZhanglepingStore()
setCourseTimeProgress(16)
await saveZhanglepingStore()
navigate(`/${moduleName}/${starName}/home`)
}
}}
@@ -3,7 +3,6 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { Image, StudyGuideModal, Video } from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useZhanglepingStore, saveZhanglepingStore } from '@/store/zhanglepingStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
const starName = 'zhangleping'
@@ -93,15 +92,6 @@ const ZhanglepingReflection = () => {
}
const allFragmentsCompleted = sceneConfig.every((scene) => isSceneCompleted(scene.id))
// 监听allFragmentsCompleted
useEffect(() => {
if (allFragmentsCompleted) {
saveZhanglepingStore()
setCourseTimeProgress(17)
}
}, [allFragmentsCompleted])
// 场景逐步解锁:1 默认解锁;N 需要 N-1 完成
const isSceneUnlocked = (sceneId: SceneId) => {
if (sceneId === 1) return true
+4 -7
View File
@@ -6,6 +6,8 @@ import {
getUserDefineResult,
parseUserDefineContent,
type UserDefineFetchStatus,
type WriteOptions,
type WriteResult,
} from '@/lib/utils/api-common'
interface ChenyunStore {
@@ -191,7 +193,7 @@ export const useChenyunStore = create<ChenyunStore>()(
export const CHENYUN_LESSON_ID = 'Cheny_duihua'
export const CHENYUN_KEY = 22
export const saveChenyunStore = (): void => {
export const saveChenyunStore = (options: WriteOptions = {}): Promise<WriteResult> => {
const state = useChenyunStore.getState()
const payload = {
@@ -202,11 +204,7 @@ export const saveChenyunStore = (): void => {
completedVideos: state.completedVideos,
}
try {
setUserDefine(CHENYUN_LESSON_ID, CHENYUN_KEY, JSON.stringify(payload))
} catch (error) {
console.error('Failed to save Chenyun store', error)
}
return setUserDefine(CHENYUN_LESSON_ID, CHENYUN_KEY, JSON.stringify(payload), options)
}
export const applyChenyunPersistedState = (payload: ChenyunPersistedState): void => {
@@ -219,7 +217,6 @@ export const applyChenyunPersistedState = (payload: ChenyunPersistedState): void
})
}
export const loadChenyunStore = async (): Promise<UserDefineFetchStatus> => {
try {
const result = await getUserDefineResult(CHENYUN_LESSON_ID, CHENYUN_KEY)
+5 -7
View File
@@ -6,6 +6,8 @@ import {
getUserDefineResult,
parseUserDefineContent,
type UserDefineFetchStatus,
type WriteOptions,
type WriteResult,
} from '@/lib/utils/api-common'
interface HuangyanpeiStore {
@@ -275,7 +277,7 @@ export const useHuangyanpeiStore = create<HuangyanpeiStore>()(
export const HUANGYANPEI_LESSON_ID = 'Huangyp_duihua'
export const HUANGYANPEI_KEY = 10
export const saveHuangyanpeiStore = (): void => {
export const saveHuangyanpeiStore = (options: WriteOptions = {}): Promise<WriteResult> => {
const state = useHuangyanpeiStore.getState()
const payload = {
@@ -285,13 +287,10 @@ export const saveHuangyanpeiStore = (): void => {
reflectionRoute: state.reflectionRoute,
shownReflectionCompletionGuides: state.shownReflectionCompletionGuides,
reflectionScene2Choice: state.reflectionScene2Choice,
completedVideos: state.completedVideos,
}
try {
setUserDefine(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY, JSON.stringify(payload))
} catch (error) {
console.error('Failed to save Huangyanpei store', error)
}
return setUserDefine(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY, JSON.stringify(payload), options)
}
export const applyHuangyanpeiPersistedState = (payload: HuangyanpeiPersistedState): void => {
@@ -306,7 +305,6 @@ export const applyHuangyanpeiPersistedState = (payload: HuangyanpeiPersistedStat
})
}
export const loadHuangyanpeiStore = async (): Promise<UserDefineFetchStatus> => {
try {
const result = await getUserDefineResult(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY)
+68
View File
@@ -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)
})
})
+4 -7
View File
@@ -6,6 +6,8 @@ import {
getUserDefineResult,
parseUserDefineContent,
type UserDefineFetchStatus,
type WriteOptions,
type WriteResult,
} from '@/lib/utils/api-common'
interface LuxunStore {
@@ -169,7 +171,7 @@ export const useLuxunStore = create<LuxunStore>()(
export const LUXUN_LESSON_ID = 'Lux_duihua'
export const LUXUN_KEY = 7
export const saveLuxunStore = (): void => {
export const saveLuxunStore = (options: WriteOptions = {}): Promise<WriteResult> => {
const state = useLuxunStore.getState()
const payload = {
@@ -179,11 +181,7 @@ export const saveLuxunStore = (): void => {
completedVideos: state.completedVideos,
}
try {
setUserDefine(LUXUN_LESSON_ID, LUXUN_KEY, JSON.stringify(payload))
} catch (error) {
console.error('Failed to save Luxun store', error)
}
return setUserDefine(LUXUN_LESSON_ID, LUXUN_KEY, JSON.stringify(payload), options)
}
export const applyLuxunPersistedState = (payload: LuxunPersistedState): void => {
@@ -195,7 +193,6 @@ export const applyLuxunPersistedState = (payload: LuxunPersistedState): void =>
})
}
export const loadLuxunStore = async (): Promise<UserDefineFetchStatus> => {
try {
const result = await getUserDefineResult(LUXUN_LESSON_ID, LUXUN_KEY)
+4 -6
View File
@@ -6,6 +6,8 @@ import {
getUserDefineResult,
parseUserDefineContent,
type UserDefineFetchStatus,
type WriteOptions,
type WriteResult,
} from '@/lib/utils/api-common'
interface MaozedongStore {
@@ -123,18 +125,14 @@ export const useMaozedongStore = create<MaozedongStore>()(
export const MAOZEDONG_LESSON_ID = 'Maozd_duihua'
export const MAOZEDONG_KEY = 1
export const saveMaozedongStore = (): void => {
export const saveMaozedongStore = (options: WriteOptions = {}): Promise<WriteResult> => {
const state = useMaozedongStore.getState()
const payload = {
visitedExhibits: serializeVisited(state.visitedExhibits),
lastVisitedPlace: state.lastVisitedPlace,
completedVideos: state.completedVideos,
}
try {
setUserDefine(MAOZEDONG_LESSON_ID, MAOZEDONG_KEY, JSON.stringify(payload))
} catch (error) {
console.error('Failed to save Maozedong store', error)
}
return setUserDefine(MAOZEDONG_LESSON_ID, MAOZEDONG_KEY, JSON.stringify(payload), options)
}
const applyMaozedongPersistedState = (payload: MaozedongPersistedState): void => {
+26 -9
View File
@@ -6,6 +6,8 @@ import {
getUserDefineResult,
parseUserDefineContent,
type UserDefineFetchStatus,
type WriteOptions,
type WriteResult,
} from '@/lib/utils/api-common'
interface NieerStore {
@@ -276,37 +278,52 @@ export const useNieerStore = create<NieerStore>()(
export const NIEER_LESSON_ID = 'Nieer_duihua'
export const NIEER_KEY = 5
export const saveNieerStore = (): void => {
export const saveNieerStore = (options: WriteOptions = {}): Promise<WriteResult> => {
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 = {
visitedExhibits: serializeVisitedExhibits(state.visitedExhibits),
visitedReflectionCursors: serializeVisitedExhibits(state.visitedReflectionCursors),
lastVisitedPlace: state.lastVisitedPlace,
reflectionWorks: state.reflectionWorks,
reflectionWorks,
hasSeenReflectionGuide: state.hasSeenReflectionGuide,
hasSeenBoxHint: state.hasSeenBoxHint,
completedVideos: state.completedVideos,
}
try {
setUserDefine(NIEER_LESSON_ID, NIEER_KEY, JSON.stringify(payload))
} catch (error) {
console.error('Failed to save Nieer store', error)
}
return setUserDefine(NIEER_LESSON_ID, NIEER_KEY, JSON.stringify(payload), options)
}
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({
visitedExhibits: deserializeVisitedExhibits(payload.visitedExhibits),
visitedReflectionCursors: deserializeVisitedExhibits(payload.visitedReflectionCursors),
lastVisitedPlace: payload.lastVisitedPlace ?? null,
reflectionWorks: payload.reflectionWorks ?? {},
reflectionWorks,
hasSeenReflectionGuide: payload.hasSeenReflectionGuide ?? false,
hasSeenBoxHint: payload.hasSeenBoxHint ?? false,
completedVideos: payload.completedVideos ?? {},
})
}
export const loadNieerStore = async (): Promise<UserDefineFetchStatus> => {
try {
const result = await getUserDefineResult(NIEER_LESSON_ID, NIEER_KEY)
+144
View File
@@ -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
View File
@@ -3,12 +3,20 @@ import { persist } from 'zustand/middleware'
import { STORE_NAMES } from './storeNames'
import {
setUserDefine,
setCourseTimeProgress,
getUserDefineResult,
parseUserDefineContent,
type StorageOptions,
type UserDefineFetchStatus,
type WriteOptions,
type WriteResult,
} from '@/lib/utils/api-common'
import { useUserStore } from './userStore'
import { createPuzzleCollector } from '@/lib/utils/puzzleCollection'
let collectionRevision = 0
interface PuzzleStore {
// 每个故居的已解锁碎片集合,格式:{ residence: Set<pieceId> }
puzzles: Record<string, Set<number>>
@@ -17,7 +25,7 @@ interface PuzzleStore {
setLoaded: (isLoaded: boolean) => void
// 解锁碎片
unlockPuzzle: (residence: string, pieceId: number) => void
unlockPuzzle: (residence: string, pieceId: number) => Promise<boolean>
// 检查碎片是否解锁
isPuzzleUnlocked: (residence: string, pieceId: number) => boolean
@@ -92,22 +100,14 @@ export const usePuzzleStore = create<PuzzleStore>()(
puzzles: {},
isLoaded: false,
setLoaded: (isLoaded: boolean) => set({ isLoaded }),
unlockPuzzle: (residence: string, pieceId: number) => {
const { puzzles } = get()
// 确保 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 })
}
setLoaded: (isLoaded: boolean) => {
if (get().isLoaded !== isLoaded) collectionRevision += 1
set({ isLoaded })
if (isLoaded) puzzleCollector.clearSavedAcknowledgements()
},
unlockPuzzle: (residence, pieceId) => puzzleCollector.collect(residence, pieceId),
isPuzzleUnlocked: (residence: string, pieceId: number) => {
const { puzzles } = get()
// 确保 puzzles 格式正确
@@ -142,6 +142,7 @@ export const usePuzzleStore = create<PuzzleStore>()(
},
resetPuzzles: (residence: string) => {
collectionRevision += 1
const { puzzles } = get()
// 确保 puzzles 格式正确
const fixedPuzzles = ensurePuzzlesAreSets(puzzles)
@@ -151,10 +152,12 @@ export const usePuzzleStore = create<PuzzleStore>()(
},
resetAll: () => {
collectionRevision += 1
set({ puzzles: {} })
},
applyPersistedState: (payload: PuzzlePersistedState) => {
collectionRevision += 1
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_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 = {
puzzles: serializePuzzles(puzzles),
}
try {
setUserDefine(PUZZLE_LESSON_ID, PUZZLE_KEY, JSON.stringify(payload))
} catch (error) {
console.error('Failed to save Chenyun store', error)
}
return setUserDefine(PUZZLE_LESSON_ID, PUZZLE_KEY, JSON.stringify(payload), options)
}
export const loadPuzzleStore = async (
@@ -210,8 +229,9 @@ export const loadPuzzleStore = async (
return result.status
}
const payload = parseUserDefineContent<PuzzlePersistedState>(result.content)
const payload = parseUserDefineContent<PuzzlePersistedState>(result.content, options)
usePuzzleStore.getState().applyPersistedState(payload)
puzzleCollector.clearSavedAcknowledgements()
return result.status
} catch (error) {
console.error('Failed to load puzzle store', error)
+4 -7
View File
@@ -6,6 +6,8 @@ import {
getUserDefineResult,
parseUserDefineContent,
type UserDefineFetchStatus,
type WriteOptions,
type WriteResult,
} from '@/lib/utils/api-common'
interface RongdeshengStore {
@@ -222,7 +224,7 @@ export const useRongdeshengStore = create<RongdeshengStore>()(
export const RONGDESHENG_LESSON_ID = 'Rongds_duihua'
export const RONGDESHENG_KEY = 19
export const saveRongdeshengStore = (): void => {
export const saveRongdeshengStore = (options: WriteOptions = {}): Promise<WriteResult> => {
const state = useRongdeshengStore.getState()
const payload = {
visitedExhibits: serializeVisitedExhibits(state.visitedExhibits),
@@ -233,11 +235,7 @@ export const saveRongdeshengStore = (): void => {
reflectionGuideShown: state.reflectionGuideShown,
completedVideos: state.completedVideos,
}
try {
setUserDefine(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY, JSON.stringify(payload))
} catch (error) {
console.error('Failed to save Rongdesheng store', error)
}
return setUserDefine(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY, JSON.stringify(payload), options)
}
export const applyRongdeshengPersistedState = (payload: RongdeshengPersistedState): void => {
@@ -252,7 +250,6 @@ export const applyRongdeshengPersistedState = (payload: RongdeshengPersistedStat
})
}
export const loadRongdeshengStore = async (): Promise<UserDefineFetchStatus> => {
try {
const result = await getUserDefineResult(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY)
+4 -7
View File
@@ -6,6 +6,8 @@ import {
getUserDefineResult,
parseUserDefineContent,
type UserDefineFetchStatus,
type WriteOptions,
type WriteResult,
} from '@/lib/utils/api-common'
export interface Clue {
@@ -263,7 +265,7 @@ export const useSunzhongshanStore = create<SunzhongshanStore>()(
export const SUNZHONGSHAN_LESSON_ID = 'Sunzs_duihua'
export const SUNZHONGSHAN_KEY = 4
export const saveSunzhongshanStore = (): void => {
export const saveSunzhongshanStore = (options: WriteOptions = {}): Promise<WriteResult> => {
const state = useSunzhongshanStore.getState()
const payload = {
@@ -275,11 +277,7 @@ export const saveSunzhongshanStore = (): void => {
completedVideos: state.completedVideos,
}
try {
setUserDefine(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY, JSON.stringify(payload))
} catch (error) {
console.error('Failed to save Sunzhongshan store', error)
}
return setUserDefine(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY, JSON.stringify(payload), options)
}
export const applySunzhongshanPersistedState = (payload: SunzhongshanPersistedState): void => {
@@ -293,7 +291,6 @@ export const applySunzhongshanPersistedState = (payload: SunzhongshanPersistedSt
})
}
export const loadSunzhongshanStore = async (): Promise<UserDefineFetchStatus> => {
try {
const result = await getUserDefineResult(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY)
+5 -14
View File
@@ -6,6 +6,8 @@ import {
getUserDefineResult,
parseUserDefineContent,
type UserDefineFetchStatus,
type WriteOptions,
type WriteResult,
} from '@/lib/utils/api-common'
interface ZhanglepingStore {
@@ -238,33 +240,24 @@ export const useZhanglepingStore = create<ZhanglepingStore>()(
// ========== 易班平台数据保存(体悟模块) ==========
export const ZHANGLEPING_LESSON_ID = 'Zhanglp_tiwu'
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()
// 易班接口在当前环境是 GET 参数传输,完整 base64 图会导致 URL 超长请求失败。
// 因此远端只保存轻量摘要;完整画作仍保留在本地 persist(localStorage)中。
const remoteArtworkDataUrl =
state.drawArtworkDataUrl.length <= REMOTE_MAX_DATA_URL_LENGTH ? state.drawArtworkDataUrl : ''
// 易班 GET 接口不支持 Base64,画作图片仅保存在本地 persist 中。
const payload = {
visitedExhibits: serializeVisited(state.visitedExhibits),
lastVisitedPlace: state.lastVisitedPlace,
reflectionRoute: state.reflectionRoute,
visitedReflectionCursors: serializeVisited(state.visitedReflectionCursors),
drawArtworkDataUrl: remoteArtworkDataUrl,
drawArtworkCaption: state.drawArtworkCaption,
hasDrawArtwork: state.hasDrawArtwork,
hasSeenStudyGuide: state.hasSeenStudyGuide,
completedVideos: state.completedVideos,
}
try {
setUserDefine(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY, JSON.stringify(payload))
} catch (error) {
console.error('Failed to save Zhangleping store', error)
}
return setUserDefine(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY, JSON.stringify(payload), options)
}
export const applyZhanglepingPersistedState = (payload: ZhanglepingPersistedState): void => {
@@ -273,7 +266,6 @@ export const applyZhanglepingPersistedState = (payload: ZhanglepingPersistedStat
lastVisitedPlace: payload.lastVisitedPlace ?? null,
reflectionRoute: resolveReflectionRoute(payload.reflectionRoute),
visitedReflectionCursors: deserializeVisited(payload.visitedReflectionCursors),
drawArtworkDataUrl: payload.drawArtworkDataUrl ?? '',
drawArtworkCaption: payload.drawArtworkCaption ?? '',
hasDrawArtwork: payload.hasDrawArtwork ?? false,
hasSeenStudyGuide: payload.hasSeenStudyGuide ?? false,
@@ -281,7 +273,6 @@ export const applyZhanglepingPersistedState = (payload: ZhanglepingPersistedStat
})
}
export const loadZhanglepingStore = async (): Promise<UserDefineFetchStatus> => {
try {
const result = await getUserDefineResult(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY)
+23 -15
View File
@@ -6,6 +6,22 @@ declare module '@/lib/utils/api-common' {
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 {
status: UserDefineFetchStatus
content: string
@@ -17,8 +33,8 @@ declare module '@/lib/utils/api-common' {
lessonId: string | number,
key: string | number,
value: string,
options?: StorageOptions
): void
options?: WriteOptions
): Promise<WriteResult>
export function getUserDefineResult(
lessonId: string | number,
@@ -26,18 +42,10 @@ declare module '@/lib/utils/api-common' {
options?: StorageOptions
): Promise<UserDefineFetchResult>
export function parseUserDefineContent<T>(content: string): T
export function parseUserDefineContent<T>(content: string, options?: StorageOptions): T
export function getUserDefine(
lessonId: string | number,
key: string | number,
options?: StorageOptions
): Promise<string>
export function getUsersDefine(
lessonId: string | number,
key: string | number
): Promise<string | { content?: string }[]>
export function setCourseTimeProgress(order: number): Promise<boolean>
export function setCourseTimeProgress(
order: number,
options?: Pick<WriteOptions, 'bypassQueue' | 'deadlineMs'>
): Promise<WriteResult>
}