diff --git a/next.config.ts b/next.config.ts index 68a6c64..5c5c27c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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); diff --git a/package-lock.json b/package-lock.json index ea679f2..d962465 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 716a135..838fbda 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(onboarding)/onboarding/page.tsx b/src/app/(onboarding)/onboarding/page.tsx new file mode 100644 index 0000000..d8a034b --- /dev/null +++ b/src/app/(onboarding)/onboarding/page.tsx @@ -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 { + 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 ( +
+
+ {/* useSearchParams 需要 Suspense 边界(P1-1 URL query 持久化步骤) */} + }> + + +
+
+ ) +} + +function OnboardingLoading() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dc7adc8..4917e1b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - + - + - - - {children} - - - - - + + {children} + + + + ); diff --git a/src/i18n/actions.ts b/src/i18n/actions.ts new file mode 100644 index 0000000..481d386 --- /dev/null +++ b/src/i18n/actions.ts @@ -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 } +} diff --git a/src/i18n/request.ts b/src/i18n/request.ts new file mode 100644 index 0000000..98ddb5e --- /dev/null +++ b/src/i18n/request.ts @@ -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, + }, + }; +}); diff --git a/src/modules/classes/actions.ts b/src/modules/classes/actions.ts index d758286..7d74ce5 100644 --- a/src/modules/classes/actions.ts +++ b/src/modules/classes/actions.ts @@ -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> { + 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, + formData: FormData +): Promise> { + 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> }>> { + 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, diff --git a/src/modules/classes/components/class-invitation-manager.tsx b/src/modules/classes/components/class-invitation-manager.tsx new file mode 100644 index 0000000..33ade19 --- /dev/null +++ b/src/modules/classes/components/class-invitation-manager.tsx @@ -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(initialCodes) + const [isGenerateOpen, setIsGenerateOpen] = React.useState(false) + const [revokeTarget, setRevokeTarget] = React.useState(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 ( +
+
+

{t("title")}

+ + + + + setIsGenerateOpen(false)} + onCreated={(record) => { + setCodes((prev) => [record, ...prev]) + }} + /> + +
+ + {codes.length === 0 ? ( +

{t("empty")}

+ ) : ( + + + + {t("code")} + {t("status")} + {t("usedCount")} + {t("expiresAt")} + {t("note")} + {t("copy")} + + + + {codes.map((record) => ( + + + {record.code} + + + + + + + {record.usedCount} + {record.maxUses !== null ? ` / ${record.maxUses}` : ""} + + + + {record.expiresAt + ? new Date(record.expiresAt).toLocaleString() + : t("neverExpires")} + + + {record.note ?? "—"} + + +
+ + {record.status === "active" ? ( + + ) : null} +
+
+
+ ))} +
+
+ )} + + {/* 撤销确认对话框 */} + !open && setRevokeTarget(null)}> + + + {t("revoke")} + {t("revokeConfirm")} + + + + + + + +
+ ) +} + +function StatusBadge({ status }: { status: InvitationCodeRecord["status"] }) { + const t = useTranslations("classes.invitation") + const variant = status === "active" ? "default" : "secondary" + return {t(status)} +} + +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 ( + + + {t("generateWithCustom")} + + {t("defaultDuration")} · {t("defaultMaxUses")} + + +
+
+ + setExpiresInHours(e.target.value)} + placeholder={t("defaultDuration")} + /> +
+
+ + setMaxUses(e.target.value)} + placeholder={t("defaultMaxUses")} + /> +
+
+ + setNote(e.target.value)} + placeholder={t("customNotePlaceholder")} + maxLength={255} + /> +
+ + + + +
+
+ ) +} diff --git a/src/modules/classes/data-access-invitations.ts b/src/modules/classes/data-access-invitations.ts new file mode 100644 index 0000000..51d5ff4 --- /dev/null +++ b/src/modules/classes/data-access-invitations.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await db + .update(classInvitationCodes) + .set({ + status: "disabled", + revokedAt: new Date(), + revokedBy, + }) + .where(eq(classInvitationCodes.id, codeId)) +} + +/** + * 清理已过期/已用尽的邀请码(定时任务用)。 + * 当前采用懒清理,此函数供未来 cron 调用。 + */ +export async function purgeExpiredCodes(): Promise { + 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, + } +} diff --git a/src/modules/classes/data-access.ts b/src/modules/classes/data-access.ts index dd032e4..079a628 100644 --- a/src/modules/classes/data-access.ts +++ b/src/modules/classes/data-access.ts @@ -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 } diff --git a/src/modules/onboarding/components/onboarding-stepper.tsx b/src/modules/onboarding/components/onboarding-stepper.tsx new file mode 100644 index 0000000..5b081e8 --- /dev/null +++ b/src/modules/onboarding/components/onboarding-stepper.tsx @@ -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([]) + const [children, setChildren] = React.useState([{ ...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) => { + 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 ( +
+
+

{title}

+

{description}

+
+ +
+ {stepKeys.map((_, idx) => ( +
= idx ? "bg-primary" : "bg-muted" + )} + /> + ))} +
+ + {/* Step 0: 角色确认(只读) */} + {step === 0 ? ( +
+ +
+ {t(`role.${primaryRole}`)} + {initialStatus.roles.all.length > 1 ? ( + + ({t("role.allRoles", { roles: initialStatus.roles.all.join("、") })}) + + ) : null} +
+

{t("role.adminAssigned")}

+
+ ) : null} + + {/* Step 1: 基础信息 */} + {step === 1 ? ( +
+
+ + setName(e.target.value)} + maxLength={50} + required + /> +
+
+ + setPhone(e.target.value)} + placeholder="11 位手机号" + inputMode="tel" + required + /> +
+
+ + setAddress(e.target.value)} + maxLength={200} + /> +
+
+ ) : null} + + {/* Step 2: 角色信息 */} + {step === 2 ? ( +
+ {isTeacher ? ( + <> +
+ +