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