diff --git a/bun.lock b/bun.lock index bcf9701..f159291 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 432951a..e1b2d35 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/fonts/DingTalk-JinBuTi.ttf b/public/fonts/DingTalk-JinBuTi.ttf new file mode 100644 index 0000000..c4efa55 Binary files /dev/null and b/public/fonts/DingTalk-JinBuTi.ttf differ diff --git a/src/assets/images/menu/进度标签底.png b/src/assets/images/menu/进度标签底.png new file mode 100644 index 0000000..16241f2 Binary files /dev/null and b/src/assets/images/menu/进度标签底.png differ diff --git a/src/components/guide/SyncLoadingOverlay.tsx b/src/components/guide/SyncLoadingOverlay.tsx index 80bda9b..4dd30bf 100644 --- a/src/components/guide/SyncLoadingOverlay.tsx +++ b/src/components/guide/SyncLoadingOverlay.tsx @@ -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(getSyncWriteState()) - useEffect(() => { - const handleShow = (event: Event) => { - const customEvent = event as CustomEvent - 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 ( {actualVisible && ( - - - - - -
- {actualText} -
-
+ {showFailure ? ( + +
同步失败
+
+ {writeState.message ?? '学习进度保存失败,请检查网络后重新尝试。'} +
+ +
+ ) : ( + + + + + +
+ {actualText} +
+
+ )}
)}
diff --git a/src/components/layout/ResidenceLayout.tsx b/src/components/layout/ResidenceLayout.tsx index 5e8b2e7..ccd30b0 100644 --- a/src/components/layout/ResidenceLayout.tsx +++ b/src/components/layout/ResidenceLayout.tsx @@ -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(null) const puzzlePieceRef = useRef(null) const animationInProgressRef = useRef(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 => { + async (retryCount = 0): Promise => { 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} /> diff --git a/src/components/puzzle/PuzzlePiecePortal.tsx b/src/components/puzzle/PuzzlePiecePortal.tsx index fb220d3..e2b2fd4 100644 --- a/src/components/puzzle/PuzzlePiecePortal.tsx +++ b/src/components/puzzle/PuzzlePiecePortal.tsx @@ -11,8 +11,9 @@ interface PuzzlePiecePortalProps { hidden?: boolean awaitingCollect?: boolean confirming?: boolean + collectError?: string | null puzzlePieceTitle?: string | null - onCollect?: () => void + onCollect?: () => void | Promise } const PuzzlePiecePortal = forwardRef( @@ -23,6 +24,7 @@ const PuzzlePiecePortal = forwardRef( hidden = false, awaitingCollect = false, confirming = false, + collectError, puzzlePieceTitle, onCollect, }, @@ -153,8 +155,8 @@ const PuzzlePiecePortal = forwardRef( {puzzlePieceTitle && ( @@ -165,12 +167,23 @@ const PuzzlePiecePortal = forwardRef( )} + {collectError && ( +
+ {collectError} +
+ )} + - 收下 + + {confirming && ( + + )} + {confirming ? '正在收下...' : collectError ? '重新收下' : '收下'} +
)} diff --git a/src/config/progression.test.ts b/src/config/progression.test.ts new file mode 100644 index 0000000..274fd25 --- /dev/null +++ b/src/config/progression.test.ts @@ -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) + }) +}) diff --git a/src/config/progression.ts b/src/config/progression.ts index a3bf01d..767fc1e 100644 --- a/src/config/progression.ts +++ b/src/config/progression.ts @@ -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 { diff --git a/src/config/puzzleReportingSites.test.ts b/src/config/puzzleReportingSites.test.ts new file mode 100644 index 0000000..61e1b87 --- /dev/null +++ b/src/config/puzzleReportingSites.test.ts @@ -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') + }) +}) diff --git a/src/config/puzzleStages.test.ts b/src/config/puzzleStages.test.ts new file mode 100644 index 0000000..e5d49de --- /dev/null +++ b/src/config/puzzleStages.test.ts @@ -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() + }) +}) diff --git a/src/config/puzzleStages.ts b/src/config/puzzleStages.ts new file mode 100644 index 0000000..3b68b6b --- /dev/null +++ b/src/config/puzzleStages.ts @@ -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> = { + 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 +} diff --git a/src/index.css b/src/index.css index 6b8c5c0..5c57a04 100644 --- a/src/index.css +++ b/src/index.css @@ -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; */ diff --git a/src/lib/utils/api-common.backend.test.ts b/src/lib/utils/api-common.backend.test.ts new file mode 100644 index 0000000..cdcdaec --- /dev/null +++ b/src/lib/utils/api-common.backend.test.ts @@ -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: {} }) + }) +}) diff --git a/src/lib/utils/api-common.test.ts b/src/lib/utils/api-common.test.ts new file mode 100644 index 0000000..6521f91 --- /dev/null +++ b/src/lib/utils/api-common.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const storage = new Map() +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') + }) +}) diff --git a/src/lib/utils/api-common.ts b/src/lib/utils/api-common.ts index c056b85..4d938a6 100644 --- a/src/lib/utils/api-common.ts +++ b/src/lib/utils/api-common.ts @@ -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 = ( + successOrResponse: boolean | WriteResponse, + response?: WriteResponse +) => void + interface VideoAjax { setHost(host: string): void - addUserDefine(param: UserDefineParam): void + addUserDefine( + param: UserDefineParam, + callback: VideoAjaxWriteCallback> + ): 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 + ): 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 = ( + successOrResponse: boolean | WriteResponse, + response?: WriteResponse +): WriteResponse | undefined => + typeof successOrResponse === 'boolean' ? response : successOrResponse + +const toWriteResult = (result: WriteRoundResult): 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 { 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>({ + 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(content: string): T { +export function parseUserDefineContent(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(content: string): T { } // 根据课时顺序号(所处课程的第几课时)更新对应易班平台上的每个环节进度(异步) -export async function setCourseTimeProgress(order: number): Promise { +export async function setCourseTimeProgress( + order: number, + options: Pick = {} +): Promise { 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({ + 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) } diff --git a/src/lib/utils/callbackWriteRetry.test.ts b/src/lib/utils/callbackWriteRetry.test.ts new file mode 100644 index 0000000..5a44344 --- /dev/null +++ b/src/lib/utils/callbackWriteRetry.test.ts @@ -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) + }) +}) diff --git a/src/lib/utils/callbackWriteRetry.ts b/src/lib/utils/callbackWriteRetry.ts new file mode 100644 index 0000000..9503151 --- /dev/null +++ b/src/lib/utils/callbackWriteRetry.ts @@ -0,0 +1,145 @@ +export interface WriteResponse { + code?: number + msg?: string + data?: { + value?: T + } +} + +export type WriteFailureReason = 'deadline' | 'non-200' | 'invalid-response' | 'invoke-error' + +export type WriteRoundResult = + | { + success: true + code: 200 + attempts: number + elapsedMs: number + response: WriteResponse + } + | { + success: false + reason: WriteFailureReason + attempts: number + elapsedMs: number + code?: number + message?: string + response?: WriteResponse + error?: unknown + } + +export interface CallbackWriteRetryOptions { + invoke: (callback: (response: WriteResponse | undefined) => void) => void + deadlineMs?: number + retryDelayMs?: number + now?: () => number +} + +const DEFAULT_DEADLINE_MS = 10_000 +const DEFAULT_RETRY_DELAY_MS = 500 + +export function runCallbackWriteRound({ + invoke, + deadlineMs = DEFAULT_DEADLINE_MS, + retryDelayMs = DEFAULT_RETRY_DELAY_MS, + now = () => performance.now(), +}: CallbackWriteRetryOptions): Promise> { + return new Promise((resolve) => { + const startedAt = now() + const deadline = startedAt + deadlineMs + let attempts = 0 + let settled = false + let attemptId = 0 + let retryTimer: ReturnType | undefined + let lastFailure: Omit< + Extract, { success: false }>, + 'elapsedMs' | 'attempts' + > = { + success: false, + reason: 'deadline', + } + + const finish = (result: WriteRoundResult) => { + 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() + }) +} diff --git a/src/lib/utils/courseCheckpoint.ts b/src/lib/utils/courseCheckpoint.ts new file mode 100644 index 0000000..734140a --- /dev/null +++ b/src/lib/utils/courseCheckpoint.ts @@ -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 + +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 => { + const checkpointStore = + Number.isInteger(order) && order > 0 ? getCheckpointStore(order) : undefined + if (!checkpointStore) throw new Error(`Unknown course checkpoint order: ${order}`) + return checkpointStore.save() +} diff --git a/src/lib/utils/puzzleCollection.test.ts b/src/lib/utils/puzzleCollection.test.ts new file mode 100644 index 0000000..6603063 --- /dev/null +++ b/src/lib/utils/puzzleCollection.test.ts @@ -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 +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 = () => { + let resolve!: (value: T) => void + const promise = new Promise((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>() + .mockResolvedValue({ success: true }) + const save = vi.fn<() => Promise>().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>) => { + 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() + const save = deferred() + 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() + 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() + 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() + }) +}) diff --git a/src/lib/utils/puzzleCollection.ts b/src/lib/utils/puzzleCollection.ts new file mode 100644 index 0000000..d5016a6 --- /dev/null +++ b/src/lib/utils/puzzleCollection.ts @@ -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 + report: (order: number, options: WriteOptions) => Promise + 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 = Promise.resolve() + const pending = new Map>() + const acknowledged = new Set() + 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 => { + 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() + 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 => { + // 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 } +} diff --git a/src/lib/utils/syncWriteCoordinator.exceptions.test.ts b/src/lib/utils/syncWriteCoordinator.exceptions.test.ts new file mode 100644 index 0000000..2007b3b --- /dev/null +++ b/src/lib/utils/syncWriteCoordinator.exceptions.test.ts @@ -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') + }) +}) diff --git a/src/lib/utils/syncWriteCoordinator.test.ts b/src/lib/utils/syncWriteCoordinator.test.ts new file mode 100644 index 0000000..39f3f3a --- /dev/null +++ b/src/lib/utils/syncWriteCoordinator.test.ts @@ -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((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((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]) + }) +}) diff --git a/src/lib/utils/syncWriteCoordinator.ts b/src/lib/utils/syncWriteCoordinator.ts new file mode 100644 index 0000000..44bede9 --- /dev/null +++ b/src/lib/utils/syncWriteCoordinator.ts @@ -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> + +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((resolve) => { + retryResolver = resolve + }) + +const runWriteRound = async (job: WriteJob): Promise => { + 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 => { + 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 => { + 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 => + 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 diff --git a/src/pages/Menu.tsx b/src/pages/Menu.tsx index 98e12bf..00f321c 100644 --- a/src/pages/Menu.tsx +++ b/src/pages/Menu.tsx @@ -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 = () => {
{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 = { revolutionary: 'menu/革命完成', cultural: 'menu/文化完成', @@ -118,7 +124,32 @@ const Menu = () => { ) : (
- + {isStarted ? ( +
+ + {moduleProgress}% + +
+ ) : ( + + )}
)}
diff --git a/src/pages/chenyun/internalization.tsx b/src/pages/chenyun/internalization.tsx index 949be46..4b1934a 100644 --- a/src/pages/chenyun/internalization.tsx +++ b/src/pages/chenyun/internalization.tsx @@ -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')} diff --git a/src/pages/chenyun/place/place9.tsx b/src/pages/chenyun/place/place9.tsx index 46534a8..f0b8a44 100644 --- a/src/pages/chenyun/place/place9.tsx +++ b/src/pages/chenyun/place/place9.tsx @@ -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`) }} /> diff --git a/src/pages/chenyun/reflection/mimian/MimianBattle.tsx b/src/pages/chenyun/reflection/mimian/MimianBattle.tsx index 14f31bd..24ec2ca 100644 --- a/src/pages/chenyun/reflection/mimian/MimianBattle.tsx +++ b/src/pages/chenyun/reflection/mimian/MimianBattle.tsx @@ -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) diff --git a/src/pages/chenyun/reflection/mimian/useMimianState.ts b/src/pages/chenyun/reflection/mimian/useMimianState.ts index 9d33184..775fddc 100644 --- a/src/pages/chenyun/reflection/mimian/useMimianState.ts +++ b/src/pages/chenyun/reflection/mimian/useMimianState.ts @@ -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相关状态 diff --git a/src/pages/huangyanpei/internalization.tsx b/src/pages/huangyanpei/internalization.tsx index 77de838..0036ad3 100644 --- a/src/pages/huangyanpei/internalization.tsx +++ b/src/pages/huangyanpei/internalization.tsx @@ -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')} diff --git a/src/pages/huangyanpei/place/place6.tsx b/src/pages/huangyanpei/place/place6.tsx index 3b6be4d..d17c52d 100644 --- a/src/pages/huangyanpei/place/place6.tsx +++ b/src/pages/huangyanpei/place/place6.tsx @@ -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`) }} /> diff --git a/src/pages/huangyanpei/reflection/scene3.tsx b/src/pages/huangyanpei/reflection/scene3.tsx index fa0fae7..4e082a6 100644 --- a/src/pages/huangyanpei/reflection/scene3.tsx +++ b/src/pages/huangyanpei/reflection/scene3.tsx @@ -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`) }} /> diff --git a/src/pages/huangyanpei/reflection/scene4.tsx b/src/pages/huangyanpei/reflection/scene4.tsx index ca63b26..c89050e 100644 --- a/src/pages/huangyanpei/reflection/scene4.tsx +++ b/src/pages/huangyanpei/reflection/scene4.tsx @@ -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`) }} /> diff --git a/src/pages/luxun/internalization.tsx b/src/pages/luxun/internalization.tsx index 84d7017..f76c48c 100644 --- a/src/pages/luxun/internalization.tsx +++ b/src/pages/luxun/internalization.tsx @@ -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')} diff --git a/src/pages/luxun/place/place4.tsx b/src/pages/luxun/place/place4.tsx index 3c03a0f..7c6fc19 100644 --- a/src/pages/luxun/place/place4.tsx +++ b/src/pages/luxun/place/place4.tsx @@ -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 = () => { {/* 视频结束后的结束语对话 */} { + onVisibleChange={async (visible) => { setShowEndingVideo(false) setEndingGuideVisible(visible) if (!visible && moduleName) { // 对话结束后返回鲁迅首页 - saveLuxunStore() - setCourseTimeProgress(7) + await saveLuxunStore() navigate(`/${moduleName}/${starName}/home`) } }} diff --git a/src/pages/luxun/reflection/scene3.tsx b/src/pages/luxun/reflection/scene3.tsx index c582f94..93941b3 100644 --- a/src/pages/luxun/reflection/scene3.tsx +++ b/src/pages/luxun/reflection/scene3.tsx @@ -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`) }} /> diff --git a/src/pages/maozedong/internalization.tsx b/src/pages/maozedong/internalization.tsx index c8e15b4..5eb3037 100644 --- a/src/pages/maozedong/internalization.tsx +++ b/src/pages/maozedong/internalization.tsx @@ -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' } }) }} /> diff --git a/src/pages/maozedong/place/place4.tsx b/src/pages/maozedong/place/place4.tsx index dcff501..d6b36c5 100644 --- a/src/pages/maozedong/place/place4.tsx +++ b/src/pages/maozedong/place/place4.tsx @@ -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`) } }} diff --git a/src/pages/maozedong/reflection.tsx b/src/pages/maozedong/reflection.tsx index 13f1f55..ffba102 100644 --- a/src/pages/maozedong/reflection.tsx +++ b/src/pages/maozedong/reflection.tsx @@ -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`) }} /> diff --git a/src/pages/nieer/internalization.tsx b/src/pages/nieer/internalization.tsx index e7c376e..610af65 100644 --- a/src/pages/nieer/internalization.tsx +++ b/src/pages/nieer/internalization.tsx @@ -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' } }) }} /> diff --git a/src/pages/nieer/place/place6.tsx b/src/pages/nieer/place/place6.tsx index 335585e..c53e4e5 100644 --- a/src/pages/nieer/place/place6.tsx +++ b/src/pages/nieer/place/place6.tsx @@ -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`) }} /> diff --git a/src/pages/nieer/reflection/index.tsx b/src/pages/nieer/reflection/index.tsx index 0d8bc9f..594798c 100644 --- a/src/pages/nieer/reflection/index.tsx +++ b/src/pages/nieer/reflection/index.tsx @@ -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`) }} /> diff --git a/src/pages/rongdesheng/internalization.tsx b/src/pages/rongdesheng/internalization.tsx index 354ca9f..d26a059 100644 --- a/src/pages/rongdesheng/internalization.tsx +++ b/src/pages/rongdesheng/internalization.tsx @@ -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' } }) }} /> diff --git a/src/pages/rongdesheng/place/place4.tsx b/src/pages/rongdesheng/place/place4.tsx index 99295d3..4b13852 100644 --- a/src/pages/rongdesheng/place/place4.tsx +++ b/src/pages/rongdesheng/place/place4.tsx @@ -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`) }} /> diff --git a/src/pages/rongdesheng/reflection/index.tsx b/src/pages/rongdesheng/reflection/index.tsx index 442cc12..905b436 100644 --- a/src/pages/rongdesheng/reflection/index.tsx +++ b/src/pages/rongdesheng/reflection/index.tsx @@ -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`) diff --git a/src/pages/sunzhongshan/internalization.tsx b/src/pages/sunzhongshan/internalization.tsx index df07d6b..5751252 100644 --- a/src/pages/sunzhongshan/internalization.tsx +++ b/src/pages/sunzhongshan/internalization.tsx @@ -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' } }) }} /> diff --git a/src/pages/sunzhongshan/place/place4.tsx b/src/pages/sunzhongshan/place/place4.tsx index 0a9bfc6..69e402b 100644 --- a/src/pages/sunzhongshan/place/place4.tsx +++ b/src/pages/sunzhongshan/place/place4.tsx @@ -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}`) }} /> diff --git a/src/pages/sunzhongshan/reflection/scene3.tsx b/src/pages/sunzhongshan/reflection/scene3.tsx index d8c2e55..9ef3cf9 100644 --- a/src/pages/sunzhongshan/reflection/scene3.tsx +++ b/src/pages/sunzhongshan/reflection/scene3.tsx @@ -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`) } } diff --git a/src/pages/zhangleping/internalization.tsx b/src/pages/zhangleping/internalization.tsx index 75e8154..52112f1 100644 --- a/src/pages/zhangleping/internalization.tsx +++ b/src/pages/zhangleping/internalization.tsx @@ -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' } }) }} /> diff --git a/src/pages/zhangleping/place/place5.tsx b/src/pages/zhangleping/place/place5.tsx index dfc3b83..b8e746f 100644 --- a/src/pages/zhangleping/place/place5.tsx +++ b/src/pages/zhangleping/place/place5.tsx @@ -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`) } }} diff --git a/src/pages/zhangleping/reflection/index.tsx b/src/pages/zhangleping/reflection/index.tsx index 93d97d7..96f12f3 100644 --- a/src/pages/zhangleping/reflection/index.tsx +++ b/src/pages/zhangleping/reflection/index.tsx @@ -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 diff --git a/src/store/chenyunStore.ts b/src/store/chenyunStore.ts index 08d0503..827d208 100644 --- a/src/store/chenyunStore.ts +++ b/src/store/chenyunStore.ts @@ -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()( export const CHENYUN_LESSON_ID = 'Cheny_duihua' export const CHENYUN_KEY = 22 -export const saveChenyunStore = (): void => { +export const saveChenyunStore = (options: WriteOptions = {}): Promise => { 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 => { try { const result = await getUserDefineResult(CHENYUN_LESSON_ID, CHENYUN_KEY) diff --git a/src/store/huangyanpeiStore.ts b/src/store/huangyanpeiStore.ts index be514e6..e278ce1 100644 --- a/src/store/huangyanpeiStore.ts +++ b/src/store/huangyanpeiStore.ts @@ -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()( export const HUANGYANPEI_LESSON_ID = 'Huangyp_duihua' export const HUANGYANPEI_KEY = 10 -export const saveHuangyanpeiStore = (): void => { +export const saveHuangyanpeiStore = (options: WriteOptions = {}): Promise => { 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 => { try { const result = await getUserDefineResult(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY) diff --git a/src/store/localArtwork.test.ts b/src/store/localArtwork.test.ts new file mode 100644 index 0000000..d924b79 --- /dev/null +++ b/src/store/localArtwork.test.ts @@ -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() + 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) + }) +}) diff --git a/src/store/luxunStore.ts b/src/store/luxunStore.ts index f7eb7ac..9d8c8c6 100644 --- a/src/store/luxunStore.ts +++ b/src/store/luxunStore.ts @@ -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()( export const LUXUN_LESSON_ID = 'Lux_duihua' export const LUXUN_KEY = 7 -export const saveLuxunStore = (): void => { +export const saveLuxunStore = (options: WriteOptions = {}): Promise => { 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 => { try { const result = await getUserDefineResult(LUXUN_LESSON_ID, LUXUN_KEY) diff --git a/src/store/maozedongStore.ts b/src/store/maozedongStore.ts index d896d85..ea2d959 100644 --- a/src/store/maozedongStore.ts +++ b/src/store/maozedongStore.ts @@ -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()( export const MAOZEDONG_LESSON_ID = 'Maozd_duihua' export const MAOZEDONG_KEY = 1 -export const saveMaozedongStore = (): void => { +export const saveMaozedongStore = (options: WriteOptions = {}): Promise => { 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 => { diff --git a/src/store/nieerStore.ts b/src/store/nieerStore.ts index 65e10cf..cb04536 100644 --- a/src/store/nieerStore.ts +++ b/src/store/nieerStore.ts @@ -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()( export const NIEER_LESSON_ID = 'Nieer_duihua' export const NIEER_KEY = 5 -export const saveNieerStore = (): void => { +export const saveNieerStore = (options: WriteOptions = {}): Promise => { 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 => { try { const result = await getUserDefineResult(NIEER_LESSON_ID, NIEER_KEY) diff --git a/src/store/puzzleStore.collection.test.ts b/src/store/puzzleStore.collection.test.ts new file mode 100644 index 0000000..c7a43e4 --- /dev/null +++ b/src/store/puzzleStore.collection.test.ts @@ -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() + 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({}) + }) +}) diff --git a/src/store/puzzleStore.ts b/src/store/puzzleStore.ts index 33ecdb9..7d3399e 100644 --- a/src/store/puzzleStore.ts +++ b/src/store/puzzleStore.ts @@ -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 } puzzles: Record> @@ -17,7 +25,7 @@ interface PuzzleStore { setLoaded: (isLoaded: boolean) => void // 解锁碎片 - unlockPuzzle: (residence: string, pieceId: number) => void + unlockPuzzle: (residence: string, pieceId: number) => Promise // 检查碎片是否解锁 isPuzzleUnlocked: (residence: string, pieceId: number) => boolean @@ -92,22 +100,14 @@ export const usePuzzleStore = create()( 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() - - 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()( }, resetPuzzles: (residence: string) => { + collectionRevision += 1 const { puzzles } = get() // 确保 puzzles 格式正确 const fixedPuzzles = ensurePuzzlesAreSets(puzzles) @@ -151,10 +152,12 @@ export const usePuzzleStore = create()( }, resetAll: () => { + collectionRevision += 1 set({ puzzles: {} }) }, applyPersistedState: (payload: PuzzlePersistedState) => { + collectionRevision += 1 set({ puzzles: deserializePuzzles(payload.puzzles) }) }, }), @@ -182,20 +185,36 @@ export const usePuzzleStore = create()( ) ) +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>): void => { +export const savePuzzleStore = ( + puzzles: Record>, + options: WriteOptions = {} +): Promise => { 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(result.content) + const payload = parseUserDefineContent(result.content, options) usePuzzleStore.getState().applyPersistedState(payload) + puzzleCollector.clearSavedAcknowledgements() return result.status } catch (error) { console.error('Failed to load puzzle store', error) diff --git a/src/store/rongdeshengStore.ts b/src/store/rongdeshengStore.ts index dc17fa4..2718a0d 100644 --- a/src/store/rongdeshengStore.ts +++ b/src/store/rongdeshengStore.ts @@ -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()( export const RONGDESHENG_LESSON_ID = 'Rongds_duihua' export const RONGDESHENG_KEY = 19 -export const saveRongdeshengStore = (): void => { +export const saveRongdeshengStore = (options: WriteOptions = {}): Promise => { 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 => { try { const result = await getUserDefineResult(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY) diff --git a/src/store/sunzhongshanStore.ts b/src/store/sunzhongshanStore.ts index d4ced5c..1ee7e05 100644 --- a/src/store/sunzhongshanStore.ts +++ b/src/store/sunzhongshanStore.ts @@ -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()( export const SUNZHONGSHAN_LESSON_ID = 'Sunzs_duihua' export const SUNZHONGSHAN_KEY = 4 -export const saveSunzhongshanStore = (): void => { +export const saveSunzhongshanStore = (options: WriteOptions = {}): Promise => { 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 => { try { const result = await getUserDefineResult(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY) diff --git a/src/store/zhanglepingStore.ts b/src/store/zhanglepingStore.ts index ad2c31b..c01b06b 100644 --- a/src/store/zhanglepingStore.ts +++ b/src/store/zhanglepingStore.ts @@ -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()( // ========== 易班平台数据保存(体悟模块) ========== 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 => { 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 => { try { const result = await getUserDefineResult(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY) diff --git a/types/api-common.d.ts b/types/api-common.d.ts index e278c58..3813d44 100644 --- a/types/api-common.d.ts +++ b/types/api-common.d.ts @@ -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 export function getUserDefineResult( lessonId: string | number, @@ -26,18 +42,10 @@ declare module '@/lib/utils/api-common' { options?: StorageOptions ): Promise - export function parseUserDefineContent(content: string): T + export function parseUserDefineContent(content: string, options?: StorageOptions): T - export function getUserDefine( - lessonId: string | number, - key: string | number, - options?: StorageOptions - ): Promise - - export function getUsersDefine( - lessonId: string | number, - key: string | number - ): Promise - - export function setCourseTimeProgress(order: number): Promise + export function setCourseTimeProgress( + order: number, + options?: Pick + ): Promise }