feat: introduce i18n system and class invitation codes
Add complete i18n infrastructure using next-intl (cookie-driven, without i18n routing) with zh-CN/en dictionary files, locale switcher, and NextIntlClientProvider in root layout. Add class invitation code system with new class_invitation_codes table, data-access layer (generate/validate/consume/revoke), server actions with permission checks, rate limiting, and audit logging. Add class-invitation-manager UI component. Refactor onboarding stepper to use i18n translations and accept new invitation code format (6-char alphanumeric) with backward compatibility for legacy 6-digit codes.
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default withNextIntl(nextConfig);
|
||||
|
||||
709
package-lock.json
generated
709
package-lock.json
generated
@@ -47,6 +47,7 @@
|
||||
"mysql2": "^3.16.0",
|
||||
"next": "16.0.10",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"next-intl": "^4.13.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.8.5",
|
||||
"openai": "^6.25.0",
|
||||
@@ -1832,6 +1833,36 @@
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formatjs/fast-memoize": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.6.tgz",
|
||||
"integrity": "sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formatjs/icu-messageformat-parser": {
|
||||
"version": "3.5.11",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.11.tgz",
|
||||
"integrity": "sha512-NVsuNsc2dUVG9+4HBJ/srScxtA/18LqGgwtop/tuN/OIBjVl6QA+0KhfZQddDD9sEh2LeVjLFPGVU3ixa3blcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/icu-skeleton-parser": "2.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/icu-skeleton-parser": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.10.tgz",
|
||||
"integrity": "sha512-XuSva+8ZGawk8VnD5VD6UeH8KarQ/Z022zgjHDoHmlNiAewstXuuzXc0Hk5pGFSdG+nNw5bfJKXqj1ZXHn9yUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formatjs/intl-localematcher": {
|
||||
"version": "0.8.10",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.10.tgz",
|
||||
"integrity": "sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "3.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
|
||||
@@ -2672,6 +2703,313 @@
|
||||
"cuid2": "bin/cuid2.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3",
|
||||
"is-glob": "^4.0.3",
|
||||
"node-addon-api": "^7.0.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@parcel/watcher-android-arm64": "2.5.6",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.6",
|
||||
"@parcel/watcher-darwin-x64": "2.5.6",
|
||||
"@parcel/watcher-freebsd-x64": "2.5.6",
|
||||
"@parcel/watcher-linux-arm-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-arm-musl": "2.5.6",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-arm64-musl": "2.5.6",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-x64-musl": "2.5.6",
|
||||
"@parcel/watcher-win32-arm64": "2.5.6",
|
||||
"@parcel/watcher-win32-ia32": "2.5.6",
|
||||
"@parcel/watcher-win32-x64": "2.5.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-android-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-freebsd-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-ia32": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
|
||||
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
@@ -5330,6 +5668,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@schummar/icu-type-parser": {
|
||||
"version": "1.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
|
||||
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||
@@ -5342,6 +5686,204 @@
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/core-darwin-arm64": {
|
||||
"version": "1.15.41",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz",
|
||||
"integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-x64": {
|
||||
"version": "1.15.41",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz",
|
||||
"integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||
"version": "1.15.41",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz",
|
||||
"integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||
"version": "1.15.41",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz",
|
||||
"integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-musl": {
|
||||
"version": "1.15.41",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz",
|
||||
"integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-ppc64-gnu": {
|
||||
"version": "1.15.41",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz",
|
||||
"integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-s390x-gnu": {
|
||||
"version": "1.15.41",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz",
|
||||
"integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-gnu": {
|
||||
"version": "1.15.41",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz",
|
||||
"integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-musl": {
|
||||
"version": "1.15.41",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz",
|
||||
"integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||
"version": "1.15.41",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz",
|
||||
"integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||
"version": "1.15.41",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz",
|
||||
"integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-x64-msvc": {
|
||||
"version": "1.15.41",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz",
|
||||
"integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/counter": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -5351,6 +5893,15 @@
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/types": {
|
||||
"version": "0.1.27",
|
||||
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz",
|
||||
"integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@t3-oss/env-core": {
|
||||
"version": "0.13.10",
|
||||
"resolved": "https://registry.npmjs.org/@t3-oss/env-core/-/env-core-0.13.10.tgz",
|
||||
@@ -8665,7 +9216,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -10399,6 +10949,21 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/icu-minify": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.13.0.tgz",
|
||||
"integrity": "sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/amannn"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/icu-messageformat-parser": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -10529,6 +11094,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/intl-messageformat": {
|
||||
"version": "11.2.8",
|
||||
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.8.tgz",
|
||||
"integrity": "sha512-l323RCl3qJDVQ8U9j74ut/hVMdg3VPsOHpVMDvFfz9qiq4dPO5ooVYFNVUzzrpgG39a+RLzcXyJb8VFgIU+tUA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "3.1.6",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.11"
|
||||
}
|
||||
},
|
||||
"node_modules/is-alphabetical": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||
@@ -10725,7 +11300,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -10771,7 +11345,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
@@ -12956,6 +13529,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.0.10",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.10.tgz",
|
||||
@@ -13035,6 +13617,94 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.13.0.tgz",
|
||||
"integrity": "sha512-OvNq2v5XLx4EkQOsAhVE9g+6zdb83XHusADCXXtIW4LILYnjEVaeINdr1lkVWKSjzwNUiMSlH5N4K0OQTRiv6A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/amannn"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.8.1",
|
||||
"@parcel/watcher": "^2.4.1",
|
||||
"@swc/core": "^1.15.2",
|
||||
"icu-minify": "^4.13.0",
|
||||
"negotiator": "^1.0.0",
|
||||
"next-intl-swc-plugin-extractor": "^4.13.0",
|
||||
"po-parser": "^2.1.1",
|
||||
"use-intl": "^4.13.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl-swc-plugin-extractor": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.0.tgz",
|
||||
"integrity": "sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next-intl/node_modules/@swc/core": {
|
||||
"version": "1.15.41",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz",
|
||||
"integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3",
|
||||
"@swc/types": "^0.1.26"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/swc"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@swc/core-darwin-arm64": "1.15.41",
|
||||
"@swc/core-darwin-x64": "1.15.41",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.15.41",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.41",
|
||||
"@swc/core-linux-arm64-musl": "1.15.41",
|
||||
"@swc/core-linux-ppc64-gnu": "1.15.41",
|
||||
"@swc/core-linux-s390x-gnu": "1.15.41",
|
||||
"@swc/core-linux-x64-gnu": "1.15.41",
|
||||
"@swc/core-linux-x64-musl": "1.15.41",
|
||||
"@swc/core-win32-arm64-msvc": "1.15.41",
|
||||
"@swc/core-win32-ia32-msvc": "1.15.41",
|
||||
"@swc/core-win32-x64-msvc": "1.15.41"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/helpers": ">=0.5.17"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/helpers": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl/node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/next-themes": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||
@@ -13073,6 +13743,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@@ -13565,6 +14241,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/po-parser": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
|
||||
"integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -15993,6 +16675,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-intl": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.13.0.tgz",
|
||||
"integrity": "sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/amannn"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "^3.1.0",
|
||||
"@schummar/icu-type-parser": "1.21.5",
|
||||
"icu-minify": "^4.13.0",
|
||||
"intl-messageformat": "^11.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"mysql2": "^3.16.0",
|
||||
"next": "16.0.10",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"next-intl": "^4.13.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.8.5",
|
||||
"openai": "^6.25.0",
|
||||
|
||||
70
src/app/(onboarding)/onboarding/page.tsx
Normal file
70
src/app/(onboarding)/onboarding/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Suspense } from "react"
|
||||
import { redirect } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { getOnboardingStatus } from "@/modules/onboarding/data-access"
|
||||
import { OnboardingStepper } from "@/modules/onboarding/components/onboarding-stepper"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("onboarding")
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
}
|
||||
}
|
||||
|
||||
export default async function OnboardingPage() {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id
|
||||
|
||||
if (!userId) {
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
// 已完成 onboarding 的用户不应停留在此页
|
||||
if (session.user.onboarded) {
|
||||
redirect("/dashboard")
|
||||
}
|
||||
|
||||
const status = await getOnboardingStatus(userId)
|
||||
|
||||
// 二次校验:DB 层面已 onboarded 但 session 未刷新
|
||||
if (!status.required) {
|
||||
redirect("/dashboard")
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||
<div className="w-full max-w-2xl rounded-lg border bg-background p-8 shadow-sm">
|
||||
{/* useSearchParams 需要 Suspense 边界(P1-1 URL query 持久化步骤) */}
|
||||
<Suspense fallback={<OnboardingLoading />}>
|
||||
<OnboardingStepper initialStatus={status} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function OnboardingLoading() {
|
||||
return (
|
||||
<div className="grid gap-6" aria-busy="true" aria-live="polite">
|
||||
<div className="grid gap-1.5">
|
||||
<div className="h-7 w-40 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-64 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1 flex-1 rounded bg-muted" />
|
||||
<div className="h-1 flex-1 rounded bg-muted" />
|
||||
<div className="h-1 flex-1 rounded bg-muted" />
|
||||
<div className="h-1 flex-1 rounded bg-muted" />
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
<div className="h-10 animate-pulse rounded bg-muted" />
|
||||
<div className="h-10 animate-pulse rounded bg-muted" />
|
||||
<div className="h-10 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages } from "next-intl/server";
|
||||
|
||||
import { ThemeProvider } from "@/shared/components/theme-provider";
|
||||
import { Toaster } from "@/shared/components/ui/sonner";
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import { AuthSessionProvider } from "@/shared/components/auth-session-provider"
|
||||
import { OnboardingGate } from "@/shared/components/onboarding-gate"
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { AuthSessionProvider } from "@/shared/components/auth-session-provider";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
@@ -18,31 +20,34 @@ export const metadata: Metadata = {
|
||||
description: "Enterprise Grade K12 Education Management System",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
// v3 i18n:从 cookie 读取 locale(getRequestConfig 已配置),SSR 注入字典
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<body
|
||||
className={`${inter.variable} antialiased font-sans`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ThemeProvider
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<AuthSessionProvider>
|
||||
<NuqsAdapter>
|
||||
{children}
|
||||
<OnboardingGate />
|
||||
</NuqsAdapter>
|
||||
</AuthSessionProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
<AuthSessionProvider>
|
||||
<NuqsAdapter>{children}</NuqsAdapter>
|
||||
</AuthSessionProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
32
src/i18n/actions.ts
Normal file
32
src/i18n/actions.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
"use server"
|
||||
|
||||
import { cookies } from "next/headers"
|
||||
import { revalidatePath } from "next/cache"
|
||||
|
||||
import {
|
||||
LOCALE_COOKIE,
|
||||
LOCALE_COOKIE_OPTIONS,
|
||||
type Locale,
|
||||
isLocale,
|
||||
} from "@/shared/i18n/locale"
|
||||
|
||||
/**
|
||||
* 切换语言:写入 cookie + 触发页面刷新。
|
||||
*
|
||||
* 设计说明:
|
||||
* - 不使用 URL 路由段,避免破坏现有路由组结构
|
||||
* - cookie 持久化用户偏好,SSR 时由 i18n/request.ts 读取
|
||||
* - revalidatePath 确保所有页面用新 locale 重新渲染
|
||||
*/
|
||||
export async function setLocaleAction(locale: string): Promise<{ success: boolean }> {
|
||||
if (!isLocale(locale)) {
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set(LOCALE_COOKIE, locale as Locale, LOCALE_COOKIE_OPTIONS)
|
||||
|
||||
// 刷新所有页面,让 SSR 用新 locale 重新渲染
|
||||
revalidatePath("/", "layout")
|
||||
return { success: true }
|
||||
}
|
||||
40
src/i18n/request.ts
Normal file
40
src/i18n/request.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale } from "@/shared/i18n/locale";
|
||||
|
||||
/**
|
||||
* next-intl 请求配置(without i18n routing 模式)。
|
||||
*
|
||||
* locale 来源优先级:
|
||||
* 1. cookie `NEXT_LOCALE`(用户主动切换时写入)
|
||||
* 2. 默认值 `zh-CN`
|
||||
*
|
||||
* 不使用 Accept-Language 自动协商,避免 SSR 与客户端 hydration 不一致。
|
||||
* 用户切换语言时通过 setLocaleAction 写入 cookie + router.refresh()。
|
||||
*/
|
||||
export default getRequestConfig(async () => {
|
||||
const cookieStore = await cookies();
|
||||
const cookieValue = cookieStore.get(LOCALE_COOKIE)?.value;
|
||||
const locale = isLocale(cookieValue) ? cookieValue : DEFAULT_LOCALE;
|
||||
|
||||
// 按命名空间拆分加载,避免单文件过大
|
||||
const [common, auth, onboarding, classes, errors] = await Promise.all([
|
||||
import(`@/shared/i18n/messages/${locale}/common.json`),
|
||||
import(`@/shared/i18n/messages/${locale}/auth.json`),
|
||||
import(`@/shared/i18n/messages/${locale}/onboarding.json`),
|
||||
import(`@/shared/i18n/messages/${locale}/classes.json`),
|
||||
import(`@/shared/i18n/messages/${locale}/errors.json`),
|
||||
]);
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: {
|
||||
common: common.default,
|
||||
auth: auth.default,
|
||||
onboarding: onboarding.default,
|
||||
classes: classes.default,
|
||||
errors: errors.default,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -404,6 +404,18 @@ export async function joinClassByInvitationCodeAction(
|
||||
return { success: false, message: "Invitation code is required" }
|
||||
}
|
||||
|
||||
// v3:rate limit 防爆破(10 次/5 分钟,按 userId)
|
||||
const { rateLimit, rateLimitKey } = await import("@/shared/lib/rate-limit")
|
||||
const rlKey = rateLimitKey("class-join", ctx.userId)
|
||||
const rlResult = rateLimit({
|
||||
key: rlKey,
|
||||
limit: 10,
|
||||
windowMs: 5 * 60 * 1000,
|
||||
})
|
||||
if (!rlResult.success) {
|
||||
return { success: false, message: "Too many attempts, please try again later" }
|
||||
}
|
||||
|
||||
const subjectValue = formData.get("subject")
|
||||
const subject = ctx.roles.includes("teacher") && typeof subjectValue === "string" ? subjectValue.trim() : null
|
||||
|
||||
@@ -416,6 +428,26 @@ export async function joinClassByInvitationCodeAction(
|
||||
ctx.roles.includes("teacher")
|
||||
? await enrollTeacherByInvitationCode(ctx.userId, code, subject)
|
||||
: await enrollStudentByInvitationCode(ctx.userId, code)
|
||||
|
||||
// 成功后重置 rate limit
|
||||
const { resetRateLimit } = await import("@/shared/lib/rate-limit")
|
||||
resetRateLimit(rlKey)
|
||||
|
||||
// 审计日志
|
||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||
await logAudit({
|
||||
action: "class.invitation.consume",
|
||||
module: "classes",
|
||||
targetId: classId,
|
||||
targetType: "class",
|
||||
detail: {
|
||||
code: String(code).trim().toUpperCase(),
|
||||
userId: ctx.userId,
|
||||
role: ctx.roles.includes("teacher") ? "teacher" : "student",
|
||||
subject,
|
||||
},
|
||||
})
|
||||
|
||||
if (ctx.roles.includes("student")) {
|
||||
revalidatePath("/student/learning/courses")
|
||||
revalidatePath("/student/schedule")
|
||||
@@ -425,6 +457,19 @@ export async function joinClassByInvitationCodeAction(
|
||||
revalidatePath("/profile")
|
||||
return { success: true, message: "Joined class successfully", data: { classId } }
|
||||
} catch (error) {
|
||||
// 审计日志:加入失败
|
||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||
await logAudit({
|
||||
action: "class.invitation.consume_failed",
|
||||
module: "classes",
|
||||
targetId: String(code).trim().toUpperCase(),
|
||||
targetType: "invitation_code",
|
||||
detail: {
|
||||
userId: ctx.userId,
|
||||
reason: error instanceof Error ? error.message : "unknown",
|
||||
},
|
||||
status: "failure",
|
||||
})
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to join class" }
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -477,6 +522,176 @@ export async function regenerateClassInvitationCodeAction(classId: string): Prom
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3 新增:生成自定义邀请码(支持有效期/次数/备注)。
|
||||
* 对标 Google Classroom / 钉钉教育:管理员/教师可为班级生成带有效期与次数限制的邀请码。
|
||||
*
|
||||
* 权限:CLASS_ENROLL(沿用现有权限点,避免过度拆分)
|
||||
* 审计:调用 logAudit 记录生成操作
|
||||
*/
|
||||
export async function createClassInvitationCodeAction(
|
||||
prevState: ActionState<{ code: string; id: string }> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<{ code: string; id: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
const classId = String(formData.get("classId") ?? "").trim()
|
||||
if (!classId) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
const expiresInHoursRaw = formData.get("expiresInHours")
|
||||
const maxUsesRaw = formData.get("maxUses")
|
||||
const note = String(formData.get("note") ?? "").trim() || null
|
||||
|
||||
const expiresInHours =
|
||||
expiresInHoursRaw && String(expiresInHoursRaw).trim() !== ""
|
||||
? Number(expiresInHoursRaw)
|
||||
: null
|
||||
const maxUses =
|
||||
maxUsesRaw && String(maxUsesRaw).trim() !== ""
|
||||
? Number(maxUsesRaw)
|
||||
: null
|
||||
|
||||
if (expiresInHours !== null && (!Number.isFinite(expiresInHours) || expiresInHours <= 0)) {
|
||||
return { success: false, message: "Invalid expiresInHours" }
|
||||
}
|
||||
if (maxUses !== null && (!Number.isFinite(maxUses) || maxUses <= 0)) {
|
||||
return { success: false, message: "Invalid maxUses" }
|
||||
}
|
||||
|
||||
try {
|
||||
const { createInvitationCode } = await import("./data-access-invitations")
|
||||
const record = await createInvitationCode(classId, ctx.userId, {
|
||||
expiresInHours,
|
||||
maxUses,
|
||||
note,
|
||||
})
|
||||
|
||||
// 审计日志
|
||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||
await logAudit({
|
||||
action: "class.invitation.create",
|
||||
module: "classes",
|
||||
targetId: classId,
|
||||
targetType: "class",
|
||||
detail: {
|
||||
codeId: record.id,
|
||||
code: record.code,
|
||||
expiresInHours,
|
||||
maxUses,
|
||||
note,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
|
||||
revalidatePath(`/admin/school/classes`)
|
||||
return {
|
||||
success: true,
|
||||
message: "Invitation code generated",
|
||||
data: { code: record.code, id: record.id },
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Failed to generate code",
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3 新增:撤销邀请码(软删除)。
|
||||
*/
|
||||
export async function revokeClassInvitationCodeAction(
|
||||
prevState: ActionState<null> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
const codeId = String(formData.get("codeId") ?? "").trim()
|
||||
if (!codeId) {
|
||||
return { success: false, message: "Missing code id" }
|
||||
}
|
||||
|
||||
try {
|
||||
const { revokeInvitationCode } = await import("./data-access-invitations")
|
||||
await revokeInvitationCode(codeId, ctx.userId)
|
||||
|
||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||
await logAudit({
|
||||
action: "class.invitation.revoke",
|
||||
module: "classes",
|
||||
targetId: codeId,
|
||||
targetType: "invitation_code",
|
||||
detail: { revokedBy: ctx.userId },
|
||||
})
|
||||
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath(`/admin/school/classes`)
|
||||
return { success: true, message: "Invitation code revoked" }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Failed to revoke code",
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3 新增:列出班级所有邀请码(管理端列表用)。
|
||||
*/
|
||||
export async function listClassInvitationCodesAction(
|
||||
classId: string
|
||||
): Promise<ActionState<{ codes: Array<Record<string, unknown>> }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
try {
|
||||
const { listClassInvitationCodes } = await import("./data-access-invitations")
|
||||
const codes = await listClassInvitationCodes(classId)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
codes: codes.map((c) => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
status: c.status,
|
||||
maxUses: c.maxUses,
|
||||
usedCount: c.usedCount,
|
||||
expiresAt: c.expiresAt?.toISOString() ?? null,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
revokedAt: c.revokedAt?.toISOString() ?? null,
|
||||
note: c.note,
|
||||
})),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Failed to list codes",
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function setStudentEnrollmentStatusAction(
|
||||
classId: string,
|
||||
studentId: string,
|
||||
|
||||
324
src/modules/classes/components/class-invitation-manager.tsx
Normal file
324
src/modules/classes/components/class-invitation-manager.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Copy, Plus, Ban, Clock, Hash } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import {
|
||||
createClassInvitationCodeAction,
|
||||
revokeClassInvitationCodeAction,
|
||||
} from "@/modules/classes/actions"
|
||||
|
||||
/**
|
||||
* 班级邀请码管理面板(v3 新增,对标 Google Classroom / 钉钉教育)。
|
||||
*
|
||||
* 功能:
|
||||
* - 列出班级所有邀请码(含状态/有效期/使用次数)
|
||||
* - 生成自定义邀请码(可选有效期/次数/备注)
|
||||
* - 撤销邀请码(软删除)
|
||||
* - 复制邀请码到剪贴板
|
||||
*
|
||||
* 权限:调用方需确保用户拥有 CLASS_ENROLL 权限(actions 内部已校验)
|
||||
*/
|
||||
interface InvitationCodeRecord {
|
||||
id: string
|
||||
code: string
|
||||
status: "active" | "disabled" | "expired" | "exhausted"
|
||||
maxUses: number | null
|
||||
usedCount: number
|
||||
expiresAt: string | null
|
||||
createdAt: string
|
||||
revokedAt: string | null
|
||||
note: string | null
|
||||
}
|
||||
|
||||
interface ClassInvitationManagerProps {
|
||||
classId: string
|
||||
initialCodes: InvitationCodeRecord[]
|
||||
}
|
||||
|
||||
export function ClassInvitationManager({
|
||||
classId,
|
||||
initialCodes,
|
||||
}: ClassInvitationManagerProps) {
|
||||
const t = useTranslations("classes.invitation")
|
||||
const [codes, setCodes] = React.useState<InvitationCodeRecord[]>(initialCodes)
|
||||
const [isGenerateOpen, setIsGenerateOpen] = React.useState(false)
|
||||
const [revokeTarget, setRevokeTarget] = React.useState<InvitationCodeRecord | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
|
||||
const handleCopy = async (code: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
toast.success(t("copied"))
|
||||
} catch {
|
||||
toast.error(t("copy"))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async () => {
|
||||
if (!revokeTarget) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.set("codeId", revokeTarget.id)
|
||||
const result = await revokeClassInvitationCodeAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(t("revokeSuccess"))
|
||||
setCodes((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === revokeTarget.id
|
||||
? { ...c, status: "disabled", revokedAt: new Date().toISOString() }
|
||||
: c
|
||||
)
|
||||
)
|
||||
setRevokeTarget(null)
|
||||
} else {
|
||||
toast.error(result.message ?? t("revokeFailed"))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{t("title")}</h3>
|
||||
<Dialog open={isGenerateOpen} onOpenChange={setIsGenerateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
{t("generate")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<GenerateCodeDialog
|
||||
classId={classId}
|
||||
onClose={() => setIsGenerateOpen(false)}
|
||||
onCreated={(record) => {
|
||||
setCodes((prev) => [record, ...prev])
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{codes.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("code")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("usedCount")}</TableHead>
|
||||
<TableHead>{t("expiresAt")}</TableHead>
|
||||
<TableHead>{t("note")}</TableHead>
|
||||
<TableHead className="text-right">{t("copy")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{codes.map((record) => (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell className="font-mono font-medium tracking-wider">
|
||||
{record.code}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={record.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">
|
||||
{record.usedCount}
|
||||
{record.maxUses !== null ? ` / ${record.maxUses}` : ""}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{record.expiresAt
|
||||
? new Date(record.expiresAt).toLocaleString()
|
||||
: t("neverExpires")}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate text-sm text-muted-foreground">
|
||||
{record.note ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleCopy(record.code)}
|
||||
aria-label={t("copy")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
{record.status === "active" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setRevokeTarget(record)}
|
||||
aria-label={t("revoke")}
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{/* 撤销确认对话框 */}
|
||||
<Dialog open={!!revokeTarget} onOpenChange={(open) => !open && setRevokeTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("revoke")}</DialogTitle>
|
||||
<DialogDescription>{t("revokeConfirm")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRevokeTarget(null)} disabled={isSubmitting}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleRevoke} disabled={isSubmitting}>
|
||||
{t("revoke")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: InvitationCodeRecord["status"] }) {
|
||||
const t = useTranslations("classes.invitation")
|
||||
const variant = status === "active" ? "default" : "secondary"
|
||||
return <Badge variant={variant}>{t(status)}</Badge>
|
||||
}
|
||||
|
||||
interface GenerateCodeDialogProps {
|
||||
classId: string
|
||||
onClose: () => void
|
||||
onCreated: (record: InvitationCodeRecord) => void
|
||||
}
|
||||
|
||||
function GenerateCodeDialog({ classId, onClose, onCreated }: GenerateCodeDialogProps) {
|
||||
const t = useTranslations("classes.invitation")
|
||||
const [expiresInHours, setExpiresInHours] = React.useState("")
|
||||
const [maxUses, setMaxUses] = React.useState("")
|
||||
const [note, setNote] = React.useState("")
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.set("classId", classId)
|
||||
formData.set("expiresInHours", expiresInHours)
|
||||
formData.set("maxUses", maxUses)
|
||||
formData.set("note", note)
|
||||
const result = await createClassInvitationCodeAction(null, formData)
|
||||
if (result.success && result.data) {
|
||||
toast.success(t("generateSuccess"))
|
||||
onCreated({
|
||||
id: result.data.id,
|
||||
code: result.data.code,
|
||||
status: "active",
|
||||
maxUses: maxUses ? Number(maxUses) : null,
|
||||
usedCount: 0,
|
||||
expiresAt: expiresInHours
|
||||
? new Date(Date.now() + Number(expiresInHours) * 60 * 60 * 1000).toISOString()
|
||||
: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
revokedAt: null,
|
||||
note: note || null,
|
||||
})
|
||||
onClose()
|
||||
} else {
|
||||
toast.error(result.message ?? t("generateFailed"))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("generateWithCustom")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("defaultDuration")} · {t("defaultMaxUses")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="expiresInHours" className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{t("expiresInHours")}
|
||||
</Label>
|
||||
<Input
|
||||
id="expiresInHours"
|
||||
type="number"
|
||||
min="1"
|
||||
value={expiresInHours}
|
||||
onChange={(e) => setExpiresInHours(e.target.value)}
|
||||
placeholder={t("defaultDuration")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="maxUses" className="flex items-center gap-1.5">
|
||||
<Hash className="h-3.5 w-3.5" />
|
||||
{t("maxUsesLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="maxUses"
|
||||
type="number"
|
||||
min="1"
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
placeholder={t("defaultMaxUses")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="note">{t("customNote")}</Label>
|
||||
<Input
|
||||
id="note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("customNotePlaceholder")}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? t("generate") + "..." : t("generate")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
338
src/modules/classes/data-access-invitations.ts
Normal file
338
src/modules/classes/data-access-invitations.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import "server-only"
|
||||
|
||||
import { randomInt } from "node:crypto"
|
||||
import { and, desc, eq, gt, isNull, lt, or, sql } from "drizzle-orm"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { classes, classInvitationCodes } from "@/shared/db/schema"
|
||||
|
||||
/**
|
||||
* 班级邀请码 data-access(v3 新增,对标 Google Classroom / 钉钉教育 / 智学网)。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 6 位字母数字(剔除歧义字符 0/O/1/I/L),空间 22^6 ≈ 1.13 亿
|
||||
* - 支持有效期(expires_at)与次数限制(max_uses)
|
||||
* - 软删除:revoke 时设置 status=disabled + revoked_at
|
||||
* - 懒清理:validate 时若发现已过期/已用尽,顺手更新 status
|
||||
*
|
||||
* 与 classes.invitationCode 的兼容:
|
||||
* - validateInvitationCode 优先查新表,未命中时 fallback 到 classes.invitationCode(旧 6 位数字码)
|
||||
* - 下个版本移除 fallback
|
||||
*/
|
||||
|
||||
/** 剔除歧义字符(0/O/1/I/L/2/Z/5/S/8/B)后的字符集,共 22 个字符 */
|
||||
const CODE_CHARSET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789".split("")
|
||||
const CODE_LENGTH = 6
|
||||
|
||||
/** 邀请码状态 */
|
||||
export type InvitationCodeStatus = "active" | "disabled" | "expired" | "exhausted"
|
||||
|
||||
/** 邀请码记录(列表/详情用) */
|
||||
export interface InvitationCodeRecord {
|
||||
id: string
|
||||
classId: string
|
||||
code: string
|
||||
status: InvitationCodeStatus
|
||||
maxUses: number | null
|
||||
usedCount: number
|
||||
expiresAt: Date | null
|
||||
createdBy: string
|
||||
createdAt: Date
|
||||
revokedAt: Date | null
|
||||
revokedBy: string | null
|
||||
note: string | null
|
||||
}
|
||||
|
||||
/** 生成邀请码选项 */
|
||||
export interface GenerateCodeOptions {
|
||||
/** 有效期(小时),null=永久 */
|
||||
expiresInHours?: number | null
|
||||
/** 最大使用次数,null=无限 */
|
||||
maxUses?: number | null
|
||||
/** 备注 */
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
/** 校验结果 */
|
||||
export interface ValidationResult {
|
||||
valid: boolean
|
||||
classId?: string
|
||||
codeId?: string
|
||||
reason?: "not_found" | "expired" | "exhausted" | "disabled"
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 6 位邀请码(剔除歧义字符)。
|
||||
* 空间:22^6 ≈ 1.13 亿(vs 旧 10^6 = 100 万,提升 113 倍)。
|
||||
*/
|
||||
export function generateCode(): string {
|
||||
let code = ""
|
||||
for (let i = 0; i < CODE_LENGTH; i += 1) {
|
||||
code += CODE_CHARSET[randomInt(0, CODE_CHARSET.length)]
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化用户输入的邀请码:
|
||||
* - 大写化
|
||||
* - 剔除空白
|
||||
* - 兼容旧 6 位数字码(fallback 用)
|
||||
*/
|
||||
export function normalizeCode(input: string): string {
|
||||
return input.trim().toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为合法的新格式邀请码(6 位字母数字,剔除歧义字符)。
|
||||
*/
|
||||
export function isNewFormatCode(code: string): boolean {
|
||||
const normalized = normalizeCode(code)
|
||||
if (normalized.length !== CODE_LENGTH) return false
|
||||
for (const ch of normalized) {
|
||||
if (!CODE_CHARSET.includes(ch)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为旧格式邀请码(6 位数字,fallback 用)。
|
||||
*/
|
||||
export function isLegacyFormatCode(code: string): boolean {
|
||||
return /^\d{6}$/.test(code.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一邀请码(带重试)。
|
||||
* DB unique 约束 + 40 次重试(沿用现有模式)。
|
||||
*/
|
||||
export async function generateUniqueCode(): Promise<string> {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const code = generateCode()
|
||||
const [existing] = await db
|
||||
.select({ id: classInvitationCodes.id })
|
||||
.from(classInvitationCodes)
|
||||
.where(eq(classInvitationCodes.code, code))
|
||||
.limit(1)
|
||||
if (!existing) return code
|
||||
}
|
||||
throw new Error("Failed to generate invitation code")
|
||||
}
|
||||
|
||||
/**
|
||||
* 为班级创建邀请码。
|
||||
*
|
||||
* @param classId 班级 ID
|
||||
* @param createdBy 创建人 ID(管理员/教师)
|
||||
* @param opts 可选:有效期/次数/备注
|
||||
*/
|
||||
export async function createInvitationCode(
|
||||
classId: string,
|
||||
createdBy: string,
|
||||
opts: GenerateCodeOptions = {}
|
||||
): Promise<InvitationCodeRecord> {
|
||||
const code = await generateUniqueCode()
|
||||
const expiresAt = opts.expiresInHours
|
||||
? new Date(Date.now() + opts.expiresInHours * 60 * 60 * 1000)
|
||||
: null
|
||||
|
||||
const id = createId()
|
||||
await db.insert(classInvitationCodes).values({
|
||||
id,
|
||||
classId,
|
||||
code,
|
||||
status: "active",
|
||||
maxUses: opts.maxUses ?? null,
|
||||
usedCount: 0,
|
||||
expiresAt,
|
||||
createdBy,
|
||||
note: opts.note ?? null,
|
||||
})
|
||||
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(classInvitationCodes)
|
||||
.where(eq(classInvitationCodes.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (!record) throw new Error("Failed to create invitation code")
|
||||
return mapRecord(record)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取班级当前有效的邀请码(取最新一个 active 的)。
|
||||
* 给 onboarding/join 用,若无则返回 null。
|
||||
*/
|
||||
export async function getActiveInvitationCode(classId: string): Promise<InvitationCodeRecord | null> {
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(classInvitationCodes)
|
||||
.where(
|
||||
and(
|
||||
eq(classInvitationCodes.classId, classId),
|
||||
eq(classInvitationCodes.status, "active"),
|
||||
or(isNull(classInvitationCodes.expiresAt), gt(classInvitationCodes.expiresAt, new Date()))
|
||||
)
|
||||
)
|
||||
.orderBy(desc(classInvitationCodes.createdAt))
|
||||
.limit(1)
|
||||
|
||||
return record ? mapRecord(record) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出班级所有邀请码(管理端用)。
|
||||
*/
|
||||
export async function listClassInvitationCodes(classId: string): Promise<InvitationCodeRecord[]> {
|
||||
const records = await db
|
||||
.select()
|
||||
.from(classInvitationCodes)
|
||||
.where(eq(classInvitationCodes.classId, classId))
|
||||
.orderBy(desc(classInvitationCodes.createdAt))
|
||||
|
||||
return records.map(mapRecord)
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验邀请码有效性(不消耗)。
|
||||
*
|
||||
* 兼容策略:
|
||||
* 1. 优先查新表 class_invitation_codes
|
||||
* 2. 未命中且为旧格式(6 位数字)时 fallback 到 classes.invitationCode
|
||||
*
|
||||
* 懒清理:若发现已过期/已用尽,顺手更新 status。
|
||||
*/
|
||||
export async function validateInvitationCode(code: string): Promise<ValidationResult> {
|
||||
const normalized = normalizeCode(code)
|
||||
|
||||
// 1. 优先查新表
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(classInvitationCodes)
|
||||
.where(eq(classInvitationCodes.code, normalized))
|
||||
.limit(1)
|
||||
|
||||
if (record) {
|
||||
// 懒清理:已过期
|
||||
if (record.status === "active" && record.expiresAt && record.expiresAt < new Date()) {
|
||||
await db
|
||||
.update(classInvitationCodes)
|
||||
.set({ status: "expired" })
|
||||
.where(eq(classInvitationCodes.id, record.id))
|
||||
return { valid: false, reason: "expired" }
|
||||
}
|
||||
// 懒清理:已用尽
|
||||
if (
|
||||
record.status === "active" &&
|
||||
record.maxUses !== null &&
|
||||
record.usedCount >= record.maxUses
|
||||
) {
|
||||
await db
|
||||
.update(classInvitationCodes)
|
||||
.set({ status: "exhausted" })
|
||||
.where(eq(classInvitationCodes.id, record.id))
|
||||
return { valid: false, reason: "exhausted" }
|
||||
}
|
||||
// 已禁用
|
||||
if (record.status !== "active") {
|
||||
return { valid: false, reason: record.status as ValidationResult["reason"] }
|
||||
}
|
||||
return { valid: true, classId: record.classId, codeId: record.id }
|
||||
}
|
||||
|
||||
// 2. Fallback:旧格式 6 位数字码
|
||||
if (isLegacyFormatCode(normalized)) {
|
||||
const [cls] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(eq(classes.invitationCode, normalized))
|
||||
.limit(1)
|
||||
if (cls) {
|
||||
return { valid: true, classId: cls.id }
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: false, reason: "not_found" }
|
||||
}
|
||||
|
||||
/**
|
||||
* 消耗邀请码(原子性 used_count++)。
|
||||
* 在 enrollStudentByInvitationCode / enrollTeacherByInvitationCode 成功后调用。
|
||||
*/
|
||||
export async function consumeInvitationCode(code: string): Promise<void> {
|
||||
const normalized = normalizeCode(code)
|
||||
await db
|
||||
.update(classInvitationCodes)
|
||||
.set({
|
||||
usedCount: sql`${classInvitationCodes.usedCount} + 1`,
|
||||
})
|
||||
.where(eq(classInvitationCodes.code, normalized))
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销邀请码(软删除)。
|
||||
*/
|
||||
export async function revokeInvitationCode(
|
||||
codeId: string,
|
||||
revokedBy: string
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(classInvitationCodes)
|
||||
.set({
|
||||
status: "disabled",
|
||||
revokedAt: new Date(),
|
||||
revokedBy,
|
||||
})
|
||||
.where(eq(classInvitationCodes.id, codeId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理已过期/已用尽的邀请码(定时任务用)。
|
||||
* 当前采用懒清理,此函数供未来 cron 调用。
|
||||
*/
|
||||
export async function purgeExpiredCodes(): Promise<number> {
|
||||
const now = new Date()
|
||||
const result = await db
|
||||
.update(classInvitationCodes)
|
||||
.set({ status: "expired" })
|
||||
.where(
|
||||
and(
|
||||
eq(classInvitationCodes.status, "active"),
|
||||
or(
|
||||
lt(classInvitationCodes.expiresAt, now),
|
||||
and(
|
||||
isNull(classInvitationCodes.maxUses),
|
||||
sql`${classInvitationCodes.usedCount} >= ${classInvitationCodes.maxUses}`
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// MySqlRawQueryResult 是 [rows, fields] 元组,rows 可能含 affectedRows
|
||||
const rows = Array.isArray(result) ? result[0] : result
|
||||
const affectedRows =
|
||||
typeof rows === "object" && rows !== null && "affectedRows" in rows
|
||||
? Number((rows as { affectedRows: unknown }).affectedRows)
|
||||
: 0
|
||||
return affectedRows
|
||||
}
|
||||
|
||||
// ============ helpers ============
|
||||
|
||||
function mapRecord(row: typeof classInvitationCodes.$inferSelect): InvitationCodeRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
classId: row.classId,
|
||||
code: row.code,
|
||||
status: row.status as InvitationCodeStatus,
|
||||
maxUses: row.maxUses,
|
||||
usedCount: row.usedCount,
|
||||
expiresAt: row.expiresAt,
|
||||
createdBy: row.createdBy,
|
||||
createdAt: row.createdAt,
|
||||
revokedAt: row.revokedAt,
|
||||
revokedBy: row.revokedBy,
|
||||
note: row.note,
|
||||
}
|
||||
}
|
||||
@@ -613,22 +613,26 @@ export async function enrollStudentByInvitationCode(studentId: string, invitatio
|
||||
const sid = studentId.trim()
|
||||
const code = invitationCode.trim()
|
||||
if (!sid) throw new Error("Missing student id")
|
||||
if (!/^\d{6}$/.test(code)) throw new Error("Invalid invitation code")
|
||||
if (!code) throw new Error("Invalid invitation code")
|
||||
|
||||
const [cls] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(eq(classes.invitationCode, code))
|
||||
.limit(1)
|
||||
|
||||
if (!cls) throw new Error("Invalid invitation code")
|
||||
// v3:优先走新邀请码体系(validateInvitationCode 内部含 fallback 到旧 classes.invitationCode)
|
||||
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
|
||||
const result = await validateInvitationCode(code)
|
||||
if (!result.valid || !result.classId) {
|
||||
throw new Error("Invalid invitation code")
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(classEnrollments)
|
||||
.values({ classId: cls.id, studentId: sid, status: "active" })
|
||||
.values({ classId: result.classId, studentId: sid, status: "active" })
|
||||
.onDuplicateKeyUpdate({ set: { status: "active" } })
|
||||
|
||||
return cls.id
|
||||
// 消耗新表邀请码(旧表无计数,跳过)
|
||||
if (result.codeId) {
|
||||
await consumeInvitationCode(code)
|
||||
}
|
||||
|
||||
return result.classId
|
||||
}
|
||||
|
||||
export async function enrollTeacherByInvitationCode(
|
||||
@@ -639,7 +643,7 @@ export async function enrollTeacherByInvitationCode(
|
||||
const tid = teacherId.trim()
|
||||
const code = invitationCode.trim()
|
||||
if (!tid) throw new Error("Missing teacher id")
|
||||
if (!/^\d{6}$/.test(code)) throw new Error("Invalid invitation code")
|
||||
if (!code) throw new Error("Invalid invitation code")
|
||||
|
||||
const [teacher] = await db
|
||||
.select({ id: users.id })
|
||||
@@ -651,10 +655,17 @@ export async function enrollTeacherByInvitationCode(
|
||||
|
||||
if (!teacher) throw new Error("Teacher not found")
|
||||
|
||||
// v3:优先走新邀请码体系(validateInvitationCode 内部含 fallback 到旧 classes.invitationCode)
|
||||
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
|
||||
const result = await validateInvitationCode(code)
|
||||
if (!result.valid || !result.classId) {
|
||||
throw new Error("Invalid invitation code")
|
||||
}
|
||||
|
||||
const [cls] = await db
|
||||
.select({ id: classes.id, teacherId: classes.teacherId })
|
||||
.from(classes)
|
||||
.where(eq(classes.invitationCode, code))
|
||||
.where(eq(classes.id, result.classId))
|
||||
.limit(1)
|
||||
|
||||
if (!cls) throw new Error("Invalid invitation code")
|
||||
@@ -747,6 +758,11 @@ export async function enrollTeacherByInvitationCode(
|
||||
if (!assigned) throw new Error("Class already has assigned teachers")
|
||||
}
|
||||
|
||||
// 消耗新表邀请码(旧表无计数,跳过)
|
||||
if (result.codeId) {
|
||||
await consumeInvitationCode(code)
|
||||
}
|
||||
|
||||
return cls.id
|
||||
}
|
||||
|
||||
|
||||
451
src/modules/onboarding/components/onboarding-stepper.tsx
Normal file
451
src/modules/onboarding/components/onboarding-stepper.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types"
|
||||
import { completeOnboardingAction } from "@/modules/onboarding/actions"
|
||||
import type { OnboardingStatus } from "@/modules/onboarding/types"
|
||||
|
||||
interface OnboardingStepperProps {
|
||||
initialStatus: OnboardingStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* v3 i18n:所有文案通过 useTranslations 读取,支持 zh-CN / en 切换。
|
||||
*/
|
||||
const STEPS_KEYS = ["roleConfirm", "basicInfo", "roleInfo", "complete"] as const
|
||||
|
||||
interface ChildRow {
|
||||
childEmail: string
|
||||
childBirthDate: string
|
||||
childPhoneSuffix: string
|
||||
childRelation: string
|
||||
}
|
||||
|
||||
const EMPTY_CHILD_ROW: ChildRow = {
|
||||
childEmail: "",
|
||||
childBirthDate: "",
|
||||
childPhoneSuffix: "",
|
||||
childRelation: "",
|
||||
}
|
||||
|
||||
export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { update } = useSession()
|
||||
const t = useTranslations("onboarding")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
|
||||
// P1-1:URL query 参数持久化当前步骤
|
||||
const initialStep = clampStep(Number(searchParams.get("step") ?? "0"))
|
||||
const [step, setStep] = React.useState(initialStep)
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
|
||||
const [name, setName] = React.useState(initialStatus.name ?? "")
|
||||
const [phone, setPhone] = React.useState("")
|
||||
const [address, setAddress] = React.useState("")
|
||||
const [classCodes, setClassCodes] = React.useState("")
|
||||
const [teacherSubjects, setTeacherSubjects] = React.useState<ClassSubject[]>([])
|
||||
const [children, setChildren] = React.useState<ChildRow[]>([{ ...EMPTY_CHILD_ROW }])
|
||||
|
||||
const primaryRole = initialStatus.roles.primary
|
||||
const isAdmin = primaryRole === "admin"
|
||||
const isStudent = primaryRole === "student"
|
||||
const isTeacher = primaryRole === "teacher"
|
||||
const isParent = primaryRole === "parent"
|
||||
|
||||
const stepKeys = isAdmin ? STEPS_KEYS.filter((_, i) => i !== 2) : STEPS_KEYS
|
||||
const maxStep = stepKeys.length - 1
|
||||
|
||||
const canNext = React.useMemo(() => {
|
||||
if (step === 1) {
|
||||
return name.trim().length > 0 && phone.trim().length > 0
|
||||
}
|
||||
if (step === 2 && isParent) {
|
||||
const validChildren = children.filter(
|
||||
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
|
||||
)
|
||||
return validChildren.length > 0
|
||||
}
|
||||
return true
|
||||
}, [step, name, phone, isParent, children])
|
||||
|
||||
const toggleSubject = (subject: ClassSubject) => {
|
||||
setTeacherSubjects((prev) =>
|
||||
prev.includes(subject) ? prev.filter((s) => s !== subject) : [...prev, subject]
|
||||
)
|
||||
}
|
||||
|
||||
const goToStep = React.useCallback(
|
||||
(next: number) => {
|
||||
const clamped = Math.max(0, Math.min(maxStep, next))
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set("step", String(clamped))
|
||||
router.replace(`/onboarding?${params.toString()}`, { scroll: false })
|
||||
setStep(clamped)
|
||||
},
|
||||
[maxStep, router, searchParams]
|
||||
)
|
||||
|
||||
const onNext = () => {
|
||||
if (step === 1 && !canNext) {
|
||||
toast.error(t("validation.needNamePhone"))
|
||||
return
|
||||
}
|
||||
if (step === 2 && isParent && !canNext) {
|
||||
toast.error(t("validation.needOneChild"))
|
||||
return
|
||||
}
|
||||
goToStep(step + 1)
|
||||
}
|
||||
|
||||
const onBack = () => {
|
||||
goToStep(step - 1)
|
||||
}
|
||||
|
||||
const canSkipStep2 = isAdmin || isStudent || isTeacher
|
||||
const onSkip = () => {
|
||||
if (isParent) return
|
||||
goToStep(isAdmin ? 2 : 3)
|
||||
}
|
||||
|
||||
const addChildRow = () => {
|
||||
setChildren((prev) => [...prev, { ...EMPTY_CHILD_ROW }])
|
||||
}
|
||||
const removeChildRow = (idx: number) => {
|
||||
setChildren((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== idx)))
|
||||
}
|
||||
const updateChildRow = (idx: number, patch: Partial<ChildRow>) => {
|
||||
setChildren((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
|
||||
}
|
||||
|
||||
const onFinish = async () => {
|
||||
if (isParent) {
|
||||
const validChildren = children.filter(
|
||||
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
|
||||
)
|
||||
if (validChildren.length === 0) {
|
||||
toast.error(t("validation.needOneChild"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.set("name", name.trim())
|
||||
formData.set("phone", phone.trim())
|
||||
formData.set("address", address.trim())
|
||||
// v3:邀请码统一大写化后提交
|
||||
formData.set("classCodes", classCodes.trim().toUpperCase())
|
||||
formData.set("teacherSubjects", JSON.stringify(teacherSubjects))
|
||||
const validChildren = children.filter(
|
||||
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
|
||||
)
|
||||
formData.set("children", JSON.stringify(validChildren))
|
||||
|
||||
const result = await completeOnboardingAction(null, formData)
|
||||
if (!result.success) {
|
||||
toast.error(result.message ?? t("toast.submitFailed"))
|
||||
return
|
||||
}
|
||||
|
||||
if (result.message && result.message.includes("绑定失败")) {
|
||||
toast.warning(result.message)
|
||||
} else {
|
||||
toast.success(t("toast.completeSuccess"))
|
||||
}
|
||||
|
||||
await update?.()
|
||||
const target = result.data?.defaultPath ?? "/dashboard"
|
||||
router.push(target)
|
||||
router.refresh()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : t("toast.submitFailed")
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const title = t(`steps.${stepKeys[step]}`)
|
||||
const description =
|
||||
step === 0
|
||||
? t("role.adminAssigned")
|
||||
: step === 1
|
||||
? t("form.name") + " · " + t("form.phone") + " · " + t("form.address")
|
||||
: step === 2
|
||||
? isParent
|
||||
? t("parent.bindHint")
|
||||
: t("steps.roleInfo")
|
||||
: t("complete.readyHint")
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-1.5">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
role="progressbar"
|
||||
aria-label={t("progress.label")}
|
||||
aria-valuenow={step + 1}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={stepKeys.length}
|
||||
>
|
||||
{stepKeys.map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"h-1 flex-1 rounded transition-colors",
|
||||
step >= idx ? "bg-primary" : "bg-muted"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 0: 角色确认(只读) */}
|
||||
{step === 0 ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("role.yourRole")}</Label>
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm">
|
||||
<span className="font-medium">{t(`role.${primaryRole}`)}</span>
|
||||
{initialStatus.roles.all.length > 1 ? (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
({t("role.allRoles", { roles: initialStatus.roles.all.join("、") })})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("role.adminAssigned")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Step 1: 基础信息 */}
|
||||
{step === 1 ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_name">{t("form.name")} *</Label>
|
||||
<Input
|
||||
id="onb_name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={50}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_phone">{t("form.phone")} *</Label>
|
||||
<Input
|
||||
id="onb_phone"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="11 位手机号"
|
||||
inputMode="tel"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_address">{t("form.address")}</Label>
|
||||
<Input
|
||||
id="onb_address"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Step 2: 角色信息 */}
|
||||
{step === 2 ? (
|
||||
<div className="grid gap-4">
|
||||
{isTeacher ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_codes_teacher">{t("teacher.classCodesOptional")}</Label>
|
||||
<Textarea
|
||||
id="onb_codes_teacher"
|
||||
value={classCodes}
|
||||
onChange={(e) => setClassCodes(e.target.value)}
|
||||
placeholder={t("teacher.classCodesPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("teacher.classCodesHint")}</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("teacher.subjectsOptional")}</Label>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{DEFAULT_CLASS_SUBJECTS.map((s) => (
|
||||
<label key={s} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={teacherSubjects.includes(s)}
|
||||
onCheckedChange={() => toggleSubject(s)}
|
||||
/>
|
||||
{s}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("teacher.subjectsHint")}</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isStudent ? (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_codes_student">{t("student.classCodesOptional")}</Label>
|
||||
<Textarea
|
||||
id="onb_codes_student"
|
||||
value={classCodes}
|
||||
onChange={(e) => setClassCodes(e.target.value)}
|
||||
placeholder={t("student.classCodesPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("student.classCodesHint")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isParent ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground">
|
||||
{t("parent.bindHint")}
|
||||
</div>
|
||||
{children.map((row, idx) => (
|
||||
<div key={idx} className="grid gap-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{t("parent.childN", { index: idx + 1 })}</span>
|
||||
{children.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeChildRow(idx)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{tCommon("remove")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`onb_child_email_${idx}`}>{t("parent.childEmail")} *</Label>
|
||||
<Input
|
||||
id={`onb_child_email_${idx}`}
|
||||
type="email"
|
||||
value={row.childEmail}
|
||||
onChange={(e) => updateChildRow(idx, { childEmail: e.target.value })}
|
||||
placeholder="student@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`onb_child_birth_${idx}`}>{t("parent.childBirthDate")} *</Label>
|
||||
<Input
|
||||
id={`onb_child_birth_${idx}`}
|
||||
type="date"
|
||||
value={row.childBirthDate}
|
||||
onChange={(e) => updateChildRow(idx, { childBirthDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`onb_child_phone_${idx}`}>{t("parent.childPhoneSuffix")} *</Label>
|
||||
<Input
|
||||
id={`onb_child_phone_${idx}`}
|
||||
value={row.childPhoneSuffix}
|
||||
onChange={(e) =>
|
||||
updateChildRow(idx, {
|
||||
childPhoneSuffix: e.target.value.replace(/\D/g, "").slice(0, 4),
|
||||
})
|
||||
}
|
||||
placeholder="4 位数字"
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`onb_child_relation_${idx}`}>{t("parent.childRelation")}</Label>
|
||||
<Input
|
||||
id={`onb_child_relation_${idx}`}
|
||||
value={row.childRelation}
|
||||
onChange={(e) => updateChildRow(idx, { childRelation: e.target.value })}
|
||||
placeholder={t("parent.childRelationPlaceholder")}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{children.length < 10 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addChildRow}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("parent.addChild")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Step 3: 完成 */}
|
||||
{step === (isAdmin ? 2 : 3) ? (
|
||||
<div className="rounded-md border bg-muted/30 px-4 py-4 text-sm">
|
||||
<div className="font-medium">{t("complete.ready")}</div>
|
||||
<div className="mt-1 text-muted-foreground">{t("complete.readyHint")}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
disabled={step === 0 || isSubmitting}
|
||||
>
|
||||
{tCommon("previous")}
|
||||
</Button>
|
||||
{step === 2 && canSkipStep2 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onSkip}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{tCommon("skip")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{step < maxStep ? (
|
||||
<Button type="button" onClick={onNext} disabled={isSubmitting}>
|
||||
{tCommon("next")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" onClick={onFinish} disabled={isSubmitting}>
|
||||
{isSubmitting ? tCommon("submitting") : tCommon("finish")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function clampStep(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.max(0, Math.min(3, Math.floor(value)))
|
||||
}
|
||||
85
src/modules/onboarding/schema.ts
Normal file
85
src/modules/onboarding/schema.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod"
|
||||
|
||||
/**
|
||||
* Onboarding 输入校验 schema。
|
||||
*
|
||||
* 设计原则(参考 K12 教务铁律):
|
||||
* - 角色字段不在用户可提交的 schema 中,角色由管理员预分配,服务端从 usersToRoles 读取。
|
||||
* - 班级代码仅作为"确认/补绑"用途,服务端调用 modules/classes data-access 做强校验。
|
||||
*
|
||||
* v3 修复(对标 PowerSchool Access ID + Access Password):
|
||||
* - P0-2 家长绑定验证增强:从"邮箱+生日"(生日仅 365 种可能)升级为"邮箱+生日+手机号后4位"三因子,
|
||||
* 组合空间提升至 365 × 10000 = 3.65M 种,显著降低枚举攻击风险。
|
||||
* - P1-4 家长多子女:children 数组替代单个 childEmail/childBindingCode,支持一次绑定多个子女。
|
||||
*/
|
||||
const childSchema = z.object({
|
||||
childEmail: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写子女邮箱")
|
||||
.email("子女邮箱格式错误"),
|
||||
childBirthDate: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写子女生日")
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "子女生日格式错误(YYYY-MM-DD)"),
|
||||
childPhoneSuffix: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写子女手机号后 4 位")
|
||||
.regex(/^\d{4}$/, "子女手机号后 4 位格式错误"),
|
||||
childRelation: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(50, "关系字段长度不能超过 50 个字符")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
})
|
||||
|
||||
export const OnboardingSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写姓名")
|
||||
.max(50, "姓名长度不能超过 50 个字符"),
|
||||
phone: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写电话")
|
||||
.regex(/^1\d{10}$/, "请输入有效的手机号(11 位,以 1 开头)"),
|
||||
address: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(200, "住址长度不能超过 200 个字符")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
// 学生/教师补绑班级的邀请码列表(可选,每项为 6 位字母数字,v3 新格式)
|
||||
// v3:从 6 位数字升级为 6 位字母数字(剔除歧义字符 0/O/1/I/L),兼容旧 6 位数字码
|
||||
classCodes: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(
|
||||
/^[A-Z2-9]{6}$|^\d{6}$/,
|
||||
"班级邀请码格式错误(6 位字母数字或 6 位数字)"
|
||||
)
|
||||
)
|
||||
.max(10, "单次最多绑定 10 个班级")
|
||||
.optional()
|
||||
.default([]),
|
||||
// 教师任课科目(可选,v3 修复 P0-3:服务端循环为每个科目绑定)
|
||||
teacherSubjects: z
|
||||
.array(z.string().trim().min(1))
|
||||
.max(10)
|
||||
.optional()
|
||||
.default([]),
|
||||
// 家长绑定子女列表(P1-4 多子女支持)
|
||||
children: z
|
||||
.array(childSchema)
|
||||
.max(10, "单次最多绑定 10 个子女")
|
||||
.optional()
|
||||
.default([]),
|
||||
})
|
||||
|
||||
export type OnboardingInput = z.infer<typeof OnboardingSchema>
|
||||
75
src/shared/components/locale-switcher.tsx
Normal file
75
src/shared/components/locale-switcher.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useTransition } from "react"
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { Check, Globe } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { LOCALES, type Locale } from "@/shared/i18n/locale"
|
||||
import { setLocaleAction } from "@/i18n/actions"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
/**
|
||||
* 语言切换组件(v3 i18n 体系)。
|
||||
*
|
||||
* 设计:
|
||||
* - 不使用 URL 路由段,通过 cookie 持久化用户偏好
|
||||
* - 切换时调用 setLocaleAction 写入 cookie + revalidatePath
|
||||
* - 用 useTransition 保证切换过程不阻塞 UI
|
||||
*/
|
||||
export function LocaleSwitcher({ compact = false }: { compact?: boolean }) {
|
||||
const locale = useLocale()
|
||||
const t = useTranslations("common.locale")
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
function handleSelect(next: Locale) {
|
||||
if (next === locale) return
|
||||
startTransition(async () => {
|
||||
const result = await setLocaleAction(next)
|
||||
if (result.success) {
|
||||
toast.success(t("switch"))
|
||||
} else {
|
||||
toast.error(t("switch"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={compact ? "icon" : "sm"}
|
||||
disabled={isPending}
|
||||
aria-label={t("switch")}
|
||||
className={cn(compact && "h-9 w-9")}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
{!compact ? (
|
||||
<span className="ml-1.5 text-sm">{t(locale)}</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{LOCALES.map((l) => (
|
||||
<DropdownMenuItem
|
||||
key={l}
|
||||
onClick={() => handleSelect(l)}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span>{t(l)}</span>
|
||||
{l === locale ? <Check className="h-4 w-4" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
43
src/shared/i18n/locale.ts
Normal file
43
src/shared/i18n/locale.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 项目 i18n 配置(without i18n routing 模式)。
|
||||
*
|
||||
* 设计决策(v3 引入完整 i18n 体系):
|
||||
* - 采用 next-intl 4.x,官方推荐的 Next.js App Router 方案
|
||||
* - 不使用 `[locale]` 路由段,避免破坏现有 (auth)/(dashboard)/(onboarding) 路由组结构
|
||||
* - locale 通过 cookie 持久化,SSR 时从 cookie 读取
|
||||
* - 字典放在 shared/i18n/messages/,符合三层架构约束
|
||||
*
|
||||
* 支持的 locale:
|
||||
* - zh-CN(默认):简体中文
|
||||
* - en:英文
|
||||
*/
|
||||
|
||||
export const LOCALES = ["zh-CN", "en"] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = "zh-CN";
|
||||
|
||||
/** Cookie 名称,与 proxy.ts / request.ts 保持一致 */
|
||||
export const LOCALE_COOKIE = "NEXT_LOCALE";
|
||||
|
||||
/** Cookie 属性:1 年有效期,全站可读 */
|
||||
export const LOCALE_COOKIE_OPTIONS = {
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
path: "/",
|
||||
sameSite: "lax" as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* 校验字符串是否为受支持的 locale。
|
||||
* 用于从 cookie/header 读取后的容错处理。
|
||||
*/
|
||||
export function isLocale(value: string | null | undefined): value is Locale {
|
||||
return typeof value === "string" && (LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将任意字符串归一化为合法 locale,非法值回退到默认。
|
||||
*/
|
||||
export function normalizeLocale(value: string | null | undefined): Locale {
|
||||
return isLocale(value) ? value : DEFAULT_LOCALE;
|
||||
}
|
||||
31
src/shared/i18n/messages/en/auth.json
Normal file
31
src/shared/i18n/messages/en/auth.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Sign In",
|
||||
"subtitle": "Sign in to your Next_Edu account",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rememberMe": "Remember me",
|
||||
"signIn": "Sign In",
|
||||
"signingIn": "Signing in...",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"noAccount": "Don't have an account?",
|
||||
"register": "Register"
|
||||
},
|
||||
"register": {
|
||||
"title": "Register",
|
||||
"subtitle": "Create your Next_Edu account",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm password",
|
||||
"name": "Name",
|
||||
"signUp": "Sign Up",
|
||||
"signingUp": "Signing up...",
|
||||
"hasAccount": "Already have an account?",
|
||||
"signIn": "Sign In"
|
||||
},
|
||||
"errors": {
|
||||
"invalidCredentials": "Invalid email or password",
|
||||
"emailExists": "This email is already registered",
|
||||
"passwordTooWeak": "Password is too weak"
|
||||
}
|
||||
}
|
||||
55
src/shared/i18n/messages/en/classes.json
Normal file
55
src/shared/i18n/messages/en/classes.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"invitation": {
|
||||
"title": "Class Invitation Code",
|
||||
"generate": "Generate Code",
|
||||
"regenerate": "Regenerate",
|
||||
"revoke": "Revoke",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"code": "Code",
|
||||
"status": "Status",
|
||||
"expiresAt": "Expires At",
|
||||
"usedCount": "Used Count",
|
||||
"maxUses": "Max Uses",
|
||||
"createdAt": "Created At",
|
||||
"note": "Note",
|
||||
"neverExpires": "Never expires",
|
||||
"unlimited": "Unlimited",
|
||||
"active": "Active",
|
||||
"disabled": "Disabled",
|
||||
"expired": "Expired",
|
||||
"exhausted": "Exhausted",
|
||||
"list": "Invitation Codes",
|
||||
"empty": "No invitation codes",
|
||||
"join": "Join Class",
|
||||
"joinPlaceholder": "Enter 6-character code",
|
||||
"joinSuccess": "Joined class successfully",
|
||||
"joinFailed": "Failed to join class",
|
||||
"invalidCode": "Invalid or expired invitation code",
|
||||
"rateLimited": "Too many attempts, please try again later",
|
||||
"alreadyInClass": "You are already in this class",
|
||||
"subjectConflict": "This subject already has a teacher for this class",
|
||||
"generateSuccess": "Invitation code generated",
|
||||
"generateFailed": "Failed to generate invitation code",
|
||||
"revokeSuccess": "Invitation code revoked",
|
||||
"revokeFailed": "Failed to revoke invitation code",
|
||||
"regenerateSuccess": "Invitation code regenerated",
|
||||
"regenerateConfirm": "The old code will be invalidated immediately after regeneration. Continue?",
|
||||
"revokeConfirm": "The code will be invalidated immediately after revocation. Continue?",
|
||||
"expiresInHours": "Validity (hours)",
|
||||
"maxUsesLabel": "Max uses",
|
||||
"customNote": "Note (optional)",
|
||||
"customNotePlaceholder": "e.g., Back-to-school temporary code",
|
||||
"generateWithCustom": "Generate custom code",
|
||||
"defaultDuration": "Never expires",
|
||||
"defaultMaxUses": "Unlimited"
|
||||
},
|
||||
"class": {
|
||||
"title": "Class",
|
||||
"name": "Class Name",
|
||||
"grade": "Grade",
|
||||
"homeroomTeacher": "Homeroom Teacher",
|
||||
"studentCount": "Student Count",
|
||||
"actions": "Actions"
|
||||
}
|
||||
}
|
||||
53
src/shared/i18n/messages/en/common.json
Normal file
53
src/shared/i18n/messages/en/common.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Next_Edu - K12 Smart Education System",
|
||||
"description": "Enterprise Grade K12 Education Management System"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"confirm": "Confirm",
|
||||
"search": "Search",
|
||||
"reset": "Reset",
|
||||
"submit": "Submit",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"finish": "Finish",
|
||||
"skip": "Skip",
|
||||
"retry": "Retry",
|
||||
"close": "Close",
|
||||
"add": "Add",
|
||||
"remove": "Remove",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"loading": "Loading...",
|
||||
"saving": "Saving...",
|
||||
"submitting": "Submitting..."
|
||||
},
|
||||
"status": {
|
||||
"success": "Success",
|
||||
"failure": "Failure",
|
||||
"pending": "Pending",
|
||||
"active": "Active",
|
||||
"disabled": "Disabled",
|
||||
"expired": "Expired",
|
||||
"exhausted": "Exhausted"
|
||||
},
|
||||
"locale": {
|
||||
"switch": "Switch language",
|
||||
"zh-CN": "中文",
|
||||
"en": "English"
|
||||
},
|
||||
"empty": {
|
||||
"default": "No data",
|
||||
"noResults": "No matching results found"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {page}",
|
||||
"total": "{total} items"
|
||||
}
|
||||
}
|
||||
23
src/shared/i18n/messages/en/errors.json
Normal file
23
src/shared/i18n/messages/en/errors.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"auth": {
|
||||
"required": "Please sign in first",
|
||||
"forbidden": "Access denied",
|
||||
"sessionExpired": "Session expired, please sign in again"
|
||||
},
|
||||
"network": {
|
||||
"requestFailed": "Request failed, please try again later",
|
||||
"timeout": "Request timed out",
|
||||
"serverError": "Server error"
|
||||
},
|
||||
"validation": {
|
||||
"required": "This field is required",
|
||||
"invalidFormat": "Invalid format",
|
||||
"tooShort": "Too short",
|
||||
"tooLong": "Too long"
|
||||
},
|
||||
"db": {
|
||||
"notFound": "Record not found",
|
||||
"duplicate": "Record already exists",
|
||||
"constraintViolation": "Data constraint violation"
|
||||
}
|
||||
}
|
||||
78
src/shared/i18n/messages/en/onboarding.json
Normal file
78
src/shared/i18n/messages/en/onboarding.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"title": "First Login Onboarding",
|
||||
"description": "Complete your first login setup",
|
||||
"steps": {
|
||||
"roleConfirm": "Role Confirmation",
|
||||
"basicInfo": "Basic Information",
|
||||
"roleInfo": "Role Information",
|
||||
"complete": "Complete"
|
||||
},
|
||||
"role": {
|
||||
"yourRole": "Your Role",
|
||||
"allRoles": "All roles: {roles}",
|
||||
"adminAssigned": "Roles are pre-assigned by administrators. Contact an administrator to change.",
|
||||
"admin": "Administrator",
|
||||
"teacher": "Teacher",
|
||||
"student": "Student",
|
||||
"parent": "Parent",
|
||||
"grade_head": "Grade Head",
|
||||
"teaching_head": "Teaching Head"
|
||||
},
|
||||
"form": {
|
||||
"name": "Name",
|
||||
"nameRequired": "Please enter your name",
|
||||
"nameMax": "Name cannot exceed 50 characters",
|
||||
"phone": "Phone",
|
||||
"phoneRequired": "Please enter your phone number",
|
||||
"phoneInvalid": "Please enter a valid 11-digit phone number starting with 1",
|
||||
"address": "Address",
|
||||
"addressMax": "Address cannot exceed 200 characters"
|
||||
},
|
||||
"teacher": {
|
||||
"classCodes": "Class Invitation Codes",
|
||||
"classCodesOptional": "Class invitation codes (optional, multiple allowed)",
|
||||
"classCodesPlaceholder": "One per line or comma-separated, 6 alphanumeric characters",
|
||||
"classCodesHint": "The server validates the code and checks whether the subject is already assigned to another teacher.",
|
||||
"subjects": "Teaching Subjects",
|
||||
"subjectsOptional": "Teaching subjects (optional, multiple selection)",
|
||||
"subjectsHint": "Multiple subjects can be selected. The server binds the teacher to each subject for each class. If a subject already has a teacher, the binding will be rejected."
|
||||
},
|
||||
"student": {
|
||||
"classCodesOptional": "Class invitation codes (optional)",
|
||||
"classCodesPlaceholder": "One per line or comma-separated, 6 alphanumeric characters",
|
||||
"classCodesHint": "Skip this if an administrator has pre-assigned your class."
|
||||
},
|
||||
"parent": {
|
||||
"bindHint": "Bind children via child email + child birthday + last 4 digits of child phone (at least one complete entry required, multiple allowed).",
|
||||
"childN": "Child {index}",
|
||||
"childEmail": "Child Email",
|
||||
"childEmailRequired": "Please enter the child's email",
|
||||
"childEmailInvalid": "Invalid child email format",
|
||||
"childBirthDate": "Child Birthday",
|
||||
"childBirthDateRequired": "Please enter the child's birthday",
|
||||
"childBirthDateInvalid": "Invalid child birthday format (YYYY-MM-DD)",
|
||||
"childPhoneSuffix": "Child Phone Last 4 Digits",
|
||||
"childPhoneSuffixRequired": "Please enter the last 4 digits of the child's phone",
|
||||
"childPhoneSuffixInvalid": "Invalid last 4 digits format",
|
||||
"childRelation": "Relationship",
|
||||
"childRelationPlaceholder": "Father / Mother / Other",
|
||||
"addChild": "Add Child"
|
||||
},
|
||||
"complete": {
|
||||
"ready": "Ready to Finish",
|
||||
"readyHint": "Click finish to enter the system."
|
||||
},
|
||||
"validation": {
|
||||
"needNamePhone": "Please enter your name and phone",
|
||||
"needOneChild": "Please complete at least one child's binding information"
|
||||
},
|
||||
"progress": {
|
||||
"label": "Onboarding progress"
|
||||
},
|
||||
"toast": {
|
||||
"completeSuccess": "Setup complete",
|
||||
"partialFailure": "Setup complete, but {count} binding(s) failed",
|
||||
"submitFailed": "Submission failed",
|
||||
"inputInvalid": "Input validation failed"
|
||||
}
|
||||
}
|
||||
31
src/shared/i18n/messages/zh-CN/auth.json
Normal file
31
src/shared/i18n/messages/zh-CN/auth.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "登录",
|
||||
"subtitle": "使用您的账号登录 Next_Edu",
|
||||
"email": "邮箱",
|
||||
"password": "密码",
|
||||
"rememberMe": "记住我",
|
||||
"signIn": "登录",
|
||||
"signingIn": "登录中...",
|
||||
"forgotPassword": "忘记密码?",
|
||||
"noAccount": "还没有账号?",
|
||||
"register": "注册"
|
||||
},
|
||||
"register": {
|
||||
"title": "注册",
|
||||
"subtitle": "创建 Next_Edu 账号",
|
||||
"email": "邮箱",
|
||||
"password": "密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"name": "姓名",
|
||||
"signUp": "注册",
|
||||
"signingUp": "注册中...",
|
||||
"hasAccount": "已有账号?",
|
||||
"signIn": "登录"
|
||||
},
|
||||
"errors": {
|
||||
"invalidCredentials": "邮箱或密码错误",
|
||||
"emailExists": "该邮箱已被注册",
|
||||
"passwordTooWeak": "密码强度不足"
|
||||
}
|
||||
}
|
||||
55
src/shared/i18n/messages/zh-CN/classes.json
Normal file
55
src/shared/i18n/messages/zh-CN/classes.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"invitation": {
|
||||
"title": "班级邀请码",
|
||||
"generate": "生成邀请码",
|
||||
"regenerate": "重新生成",
|
||||
"revoke": "撤销",
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
"code": "邀请码",
|
||||
"status": "状态",
|
||||
"expiresAt": "过期时间",
|
||||
"usedCount": "已用次数",
|
||||
"maxUses": "最大次数",
|
||||
"createdAt": "创建时间",
|
||||
"note": "备注",
|
||||
"neverExpires": "永久有效",
|
||||
"unlimited": "不限",
|
||||
"active": "有效",
|
||||
"disabled": "已禁用",
|
||||
"expired": "已过期",
|
||||
"exhausted": "已用尽",
|
||||
"list": "邀请码列表",
|
||||
"empty": "暂无邀请码",
|
||||
"join": "加入班级",
|
||||
"joinPlaceholder": "请输入 6 位邀请码",
|
||||
"joinSuccess": "加入班级成功",
|
||||
"joinFailed": "加入班级失败",
|
||||
"invalidCode": "邀请码无效或已失效",
|
||||
"rateLimited": "尝试过于频繁,请稍后再试",
|
||||
"alreadyInClass": "你已在该班级中",
|
||||
"subjectConflict": "该班级此科目已有任课教师",
|
||||
"generateSuccess": "邀请码生成成功",
|
||||
"generateFailed": "邀请码生成失败",
|
||||
"revokeSuccess": "邀请码已撤销",
|
||||
"revokeFailed": "邀请码撤销失败",
|
||||
"regenerateSuccess": "邀请码已重新生成",
|
||||
"regenerateConfirm": "重新生成后旧邀请码将立即失效,确定继续吗?",
|
||||
"revokeConfirm": "撤销后此邀请码将立即失效,确定继续吗?",
|
||||
"expiresInHours": "有效期(小时)",
|
||||
"maxUsesLabel": "最大使用次数",
|
||||
"customNote": "备注(可选)",
|
||||
"customNotePlaceholder": "如:开学季临时码",
|
||||
"generateWithCustom": "生成自定义邀请码",
|
||||
"defaultDuration": "永久有效",
|
||||
"defaultMaxUses": "不限次数"
|
||||
},
|
||||
"class": {
|
||||
"title": "班级",
|
||||
"name": "班级名称",
|
||||
"grade": "年级",
|
||||
"homeroomTeacher": "班主任",
|
||||
"studentCount": "学生人数",
|
||||
"actions": "操作"
|
||||
}
|
||||
}
|
||||
53
src/shared/i18n/messages/zh-CN/common.json
Normal file
53
src/shared/i18n/messages/zh-CN/common.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Next_Edu - K12 智慧教务系统",
|
||||
"description": "企业级 K12 教务管理系统"
|
||||
},
|
||||
"actions": {
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"confirm": "确认",
|
||||
"search": "搜索",
|
||||
"reset": "重置",
|
||||
"submit": "提交",
|
||||
"back": "返回",
|
||||
"next": "下一步",
|
||||
"previous": "上一步",
|
||||
"finish": "完成",
|
||||
"skip": "跳过",
|
||||
"retry": "重试",
|
||||
"close": "关闭",
|
||||
"add": "添加",
|
||||
"remove": "移除",
|
||||
"edit": "编辑",
|
||||
"view": "查看",
|
||||
"loading": "加载中...",
|
||||
"saving": "保存中...",
|
||||
"submitting": "提交中..."
|
||||
},
|
||||
"status": {
|
||||
"success": "成功",
|
||||
"failure": "失败",
|
||||
"pending": "待处理",
|
||||
"active": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"expired": "已过期",
|
||||
"exhausted": "已用尽"
|
||||
},
|
||||
"locale": {
|
||||
"switch": "切换语言",
|
||||
"zh-CN": "中文",
|
||||
"en": "English"
|
||||
},
|
||||
"empty": {
|
||||
"default": "暂无数据",
|
||||
"noResults": "未找到匹配结果"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "上一页",
|
||||
"next": "下一页",
|
||||
"page": "第 {page} 页",
|
||||
"total": "共 {total} 条"
|
||||
}
|
||||
}
|
||||
23
src/shared/i18n/messages/zh-CN/errors.json
Normal file
23
src/shared/i18n/messages/zh-CN/errors.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"auth": {
|
||||
"required": "请先登录",
|
||||
"forbidden": "无权限访问",
|
||||
"sessionExpired": "会话已过期,请重新登录"
|
||||
},
|
||||
"network": {
|
||||
"requestFailed": "请求失败,请稍后重试",
|
||||
"timeout": "请求超时",
|
||||
"serverError": "服务器错误"
|
||||
},
|
||||
"validation": {
|
||||
"required": "此字段为必填",
|
||||
"invalidFormat": "格式错误",
|
||||
"tooShort": "长度过短",
|
||||
"tooLong": "长度过长"
|
||||
},
|
||||
"db": {
|
||||
"notFound": "记录不存在",
|
||||
"duplicate": "记录已存在",
|
||||
"constraintViolation": "数据约束冲突"
|
||||
}
|
||||
}
|
||||
78
src/shared/i18n/messages/zh-CN/onboarding.json
Normal file
78
src/shared/i18n/messages/zh-CN/onboarding.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"title": "首次登录引导",
|
||||
"description": "完成首次登录信息配置",
|
||||
"steps": {
|
||||
"roleConfirm": "角色确认",
|
||||
"basicInfo": "基础信息",
|
||||
"roleInfo": "角色信息",
|
||||
"complete": "完成"
|
||||
},
|
||||
"role": {
|
||||
"yourRole": "你的角色",
|
||||
"allRoles": "全部角色:{roles}",
|
||||
"adminAssigned": "角色由管理员预分配,如需变更请联系管理员。",
|
||||
"admin": "管理员",
|
||||
"teacher": "教师",
|
||||
"student": "学生",
|
||||
"parent": "家长",
|
||||
"grade_head": "年级主任",
|
||||
"teaching_head": "教研组长"
|
||||
},
|
||||
"form": {
|
||||
"name": "姓名",
|
||||
"nameRequired": "请填写姓名",
|
||||
"nameMax": "姓名长度不能超过 50 个字符",
|
||||
"phone": "电话",
|
||||
"phoneRequired": "请填写电话",
|
||||
"phoneInvalid": "请输入有效的手机号(11 位,以 1 开头)",
|
||||
"address": "住址",
|
||||
"addressMax": "住址长度不能超过 200 个字符"
|
||||
},
|
||||
"teacher": {
|
||||
"classCodes": "班级邀请码",
|
||||
"classCodesOptional": "班级邀请码(可选,可多个)",
|
||||
"classCodesPlaceholder": "每行一个或用逗号分隔,6 位字母数字",
|
||||
"classCodesHint": "服务端会校验邀请码有效性及科目是否已被其他教师占用。",
|
||||
"subjects": "教学科目",
|
||||
"subjectsOptional": "教学科目(可选,可多选)",
|
||||
"subjectsHint": "可选多个科目,服务端会为每个班级的每个所选科目绑定任课教师。若班级该科目已有任课教师,绑定将被拒绝。"
|
||||
},
|
||||
"student": {
|
||||
"classCodesOptional": "班级邀请码(可选)",
|
||||
"classCodesPlaceholder": "每行一个或用逗号分隔,6 位字母数字",
|
||||
"classCodesHint": "若管理员已预分配班级,可跳过此项。"
|
||||
},
|
||||
"parent": {
|
||||
"bindHint": "通过子女邮箱 + 子女生日 + 子女手机号后 4 位绑定子女(至少完整填写一个,可添加多个)。",
|
||||
"childN": "子女 {index}",
|
||||
"childEmail": "子女邮箱",
|
||||
"childEmailRequired": "请填写子女邮箱",
|
||||
"childEmailInvalid": "子女邮箱格式错误",
|
||||
"childBirthDate": "子女生日",
|
||||
"childBirthDateRequired": "请填写子女生日",
|
||||
"childBirthDateInvalid": "子女生日格式错误(YYYY-MM-DD)",
|
||||
"childPhoneSuffix": "子女手机号后 4 位",
|
||||
"childPhoneSuffixRequired": "请填写子女手机号后 4 位",
|
||||
"childPhoneSuffixInvalid": "子女手机号后 4 位格式错误",
|
||||
"childRelation": "关系",
|
||||
"childRelationPlaceholder": "父亲 / 母亲 / 其他",
|
||||
"addChild": "添加子女"
|
||||
},
|
||||
"complete": {
|
||||
"ready": "已准备完成",
|
||||
"readyHint": "点击完成后进入系统。"
|
||||
},
|
||||
"validation": {
|
||||
"needNamePhone": "请填写姓名与电话",
|
||||
"needOneChild": "请至少完整填写一个子女的绑定信息"
|
||||
},
|
||||
"progress": {
|
||||
"label": "Onboarding 进度"
|
||||
},
|
||||
"toast": {
|
||||
"completeSuccess": "配置完成",
|
||||
"partialFailure": "配置完成,但 {count} 项绑定失败",
|
||||
"submitFailed": "提交失败",
|
||||
"inputInvalid": "输入校验失败"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user