diff --git a/.eslintrc b/.eslintrc index 5a8d5e4d..76a00382 100644 --- a/.eslintrc +++ b/.eslintrc @@ -4,7 +4,7 @@ "taro/react", "eslint:recommended", "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking", + // "plugin:@typescript-eslint/recommended-requiring-type-checking", "plugin:react-hooks/recommended", "prettier", "plugin:prettier/recommended" @@ -27,6 +27,7 @@ "react/jsx-curly-brace-presence": ["warn", { "props": "never", "children": "never" }], "@typescript-eslint/no-unused-vars": "off", "unused-imports/no-unused-imports": "warn", + "@typescript-eslint/no-explicit-any": "off", "unused-imports/no-unused-vars": [ "warn", { diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml deleted file mode 100644 index b0afc941..00000000 --- a/.github/workflows/build_test.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Build Test - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - lint-and-format: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - - - name: Install dependencies - run: yarn install - - - name: Run Build - run: yarn build:weapp - - - name: Check for merge conflicts - if: github.event_name == 'pull_request' - run: | - git fetch --no-tags --prune origin +refs/heads/*:refs/remotes/origin/* - git checkout -qf ${{ github.event.pull_request.head.sha }} - git merge-base --is-ancestor origin/main ${{ github.event.pull_request.head.sha }} || git merge --no-commit --no-ff origin/main - if [ $? -ne 0 ]; then - echo "Merge conflicts detected. Please resolve them before merging." - exit 1 - fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..eebe9efa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint & Format + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: yarn + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: yarn install --immutable + + - name: Prettier + run: yarn prettier --check . + + - name: ESLint + run: npx eslint . + + build: + name: Build WeChat Mini Program + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: yarn + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: yarn install --immutable + + - name: Build + run: yarn build:weapp + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + if: success() + with: + name: weapp-dist-${{ github.sha }} + path: dist/ + retention-days: 7 diff --git a/.github/workflows/lint_and_format_check.yml b/.github/workflows/lint_and_format_check.yml deleted file mode 100644 index f57dba41..00000000 --- a/.github/workflows/lint_and_format_check.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Lint and Format Check - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - lint-and-format: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - - - name: Install dependencies - run: yarn install - - - name: Run Prettier - run: npx prettier --check . - - - name: Run ESLint - run: npx eslint . - - - name: Check for merge conflicts - if: github.event_name == 'pull_request' - run: | - git fetch --no-tags --prune origin +refs/heads/*:refs/remotes/origin/* - git checkout -qf ${{ github.event.pull_request.head.sha }} - git merge-base --is-ancestor origin/main ${{ github.event.pull_request.head.sha }} || git merge --no-commit --no-ff origin/main - if [ $? -ne 0 ]; then - echo "Merge conflicts detected. Please resolve them before merging." - exit 1 - fi diff --git a/.gitignore b/.gitignore index c8cf6fbb..19b5cb61 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ dist/ +myrepo.bundle +graphify-out/ deploy_versions/ .temp/ .rn_temp/ diff --git a/README.md b/README.md index 5f354698..5307d097 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
muxiK-StackFrontend2.0 logo

muxiK-StackFrontend2.0

-

muxiK-StackFrontend2.0

+

木犀课栈微信小程序

探索项目文档 »

@@ -18,38 +18,120 @@
-## 🔖 项目导览 +## 项目简介 -### ✨ 提交规范 +木犀课栈前端,基于 **Taro 4 + React 18** 的微信小程序。业务数据通过 **Zustand store** 统一管理,页面不直接调用 REST API。 + +**技术栈:** Taro 4.0.6 · React 18 · Zustand · TypeScript · Sass · Tailwind(weapp-tw)· Yarn 4 + +## 本地开发 + +### 环境要求 + +- Node.js 18+ +- 微信开发者工具(打开编译产物目录 `dist/`) + +### 常用命令 + +```bash +git clone https://github.com/MuxiKeStack/muxiK-StackFrontend2.0.git +cd muxiK-StackFrontend2.0 +yarn +yarn dev:weapp # 监听编译,产物输出到 dist/ +yarn build:weapp # 生产构建 +yarn lint # ESLint +yarn prettier # 格式化 +``` + +### 微信开发者工具 + +1. `project.config.json` 中 `miniprogramRoot` 指向 **`dist/`** +2. 终端先跑 `yarn dev:weapp` 或 `yarn build:weapp`,再在开发者工具中打开项目根目录 +3. 若出现页面路径找不到、组件 `wx://not-found` 等异常,先清缓存再重建: + +```bash +rm -rf dist node_modules/.cache .swc +yarn build:weapp +``` + +开发者工具内:**清缓存 → 全部清除 → 重新编译**。 + +## 目录结构 + +``` +src/ +├── pages/ # 主包页面(Tab、高频链路) +│ ├── main/ # 广场 +│ ├── guide/ # 手册 +│ ├── profile/ # 我的(Tab 壳) +│ ├── myclass/ # 我的课程(Tab「+」入口) +│ ├── classInfo/ # 课程详情 +│ ├── notification/ # 消息 +│ ├── research/ # 搜索 +│ └── login/ +├── subpackages/ # 分包(方案 B:按业务域组织) +│ ├── profile/ # 个人中心低频:收藏、评课历史、编辑资料 +│ ├── feedback/ # 意见反馈 +│ └── course/ # 评课 / 问答 +├── common/ # 组件、请求、工具、常量 +│ └── constants/routes.ts # 分包页面跳转路径 +├── store/ # Zustand 数据层 +└── app.config.ts # 主包 / 分包注册 +``` + +### 分包说明 + +| 分包 | 页面 | 说明 | +| ---------------------- | ----------------------------------------------------- | -------------- | +| **主包** | login、四个 Tab、myclass、classInfo、research | 冷启动必载 | +| `subpackages/profile` | myCollection、evaluationHistory、editUser | 从「我的」进入 | +| `subpackages/feedback` | main、writefeedback、history、detail | 意见反馈全流程 | +| `subpackages/course` | evaluate、evaluateInfo、questionInfo、publishQuestion | 评课与问答 | + +新增分包页面时: + +1. 在 `src/subpackages/<域>/pages/` 下建页面 +2. 在 `src/app.config.ts` 的 `subpackages` 中注册 +3. 在 `src/common/constants/routes.ts` 补充路径,跳转统一使用 `ROUTES` + +## 数据层约定 + +- **API 只在 store 里调用**,页面通过 store 的 `load` / action 读写数据 +- **加载策略**(`src/store/types.ts`): + - `cache-first`:有缓存先用,没有再请求 + - `network-first`:先请求,失败用旧缓存兜底(如 FAQ) + - `network-only`:只请求、不读缓存(登录、提交等写操作) +- 工具函数 `loadData` / `sourceLabel` 见 `src/store/loadUtils.ts` +- store 统一从 `src/store/index.ts` 导出 + +## CI + +GitHub Actions 工作流见 `.github/workflows/ci.yml`: + +| Job | 内容 | +| ----------------- | -------------------------------------------------------- | +| **Lint & Format** | Prettier check、ESLint | +| **Build** | `yarn build:weapp`,成功时上传 `dist/` 产物(保留 7 天) | + +触发:`push` 到 `main`、任意 `pull_request`、手动 `workflow_dispatch`。 + +## 提交规范 - 🎉 init:项目初始化 - ✨ feat:新增功能(feature) -- 🐞 fix:修复bug +- 🐞 fix:修复 bug - 📃 docs:文档修改 - 🌈 style:代码样式修改,不影响原代码逻辑 - ✅ test:测试相关的改动 - 🔨 refactor:代码重构 - 🔧 chore:建制过程或辅助工具的变动 -### 🎯 相容环境 - -- 现代浏览器(Chrome >= 64, Edge >= 79, Firefox >= 78, Safari >= 12) - -### 💻 本地调试 - -```bash -$ git clone https://github.com/MuxiKeStack/muxiK-StackFrontend2.0.git -$ cd muxiK-StackFrontend2.0 -$ yarn -$ yarn dev:weapp -``` - -### 📝 授权 +## 授权 -上述文件皆以 MIT 许可授权 +上述文件皆以 MIT 许可授权。 > 详细之授权请参考 [LICENSE](LICENSE) 文件 -### 📄 内部文档 +## 内部文档 项目进度和未来计划请参考 [飞书文档](https://muxi.feishu.cn/docx/Q1PwdIMH0opWwRxG8WccgeClnck?from=from_copylink) diff --git a/config/index.ts b/config/index.ts index 60421b43..ed4be46d 100644 --- a/config/index.ts +++ b/config/index.ts @@ -35,6 +35,8 @@ const config = { compiler: { type: 'webpack5', prebundle: { + enable: false, + force: true, exclude: ['taro-ui'], }, }, @@ -42,6 +44,11 @@ const config = { enable: true, // Webpack 持久化缓存配置,建议开启。默认配置请参考:https://docs.taro.zone/docs/config-detail#cache }, mini: { + sassLoaderOption: { + sassOptions: { + silenceDeprecations: ['import'], + }, + }, postcss: { pxtransform: { enable: true, @@ -61,6 +68,9 @@ const config = { }, }, }, + miniCssExtractPluginOption: { + ignoreOrder: true, + }, optimizeMainPackage: { enable: true, exclude: [ @@ -105,9 +115,21 @@ const config = { // }); }, }, + sass: { + resource: [ + path.resolve(__dirname, '..', 'src/common/styles/tokens/_index.scss'), + path.resolve(__dirname, '..', 'src/common/styles/functions/_index.scss'), + path.resolve(__dirname, '..', 'src/common/styles/mixins/_index.scss'), + ], + }, h5: { publicPath: '/', staticDirectory: 'static', + sassLoaderOption: { + sassOptions: { + silenceDeprecations: ['import'], + }, + }, postcss: { autoprefixer: { enable: true, diff --git "a/docs/\346\265\213\350\257\225\346\226\207\346\241\243.md" "b/docs/\346\265\213\350\257\225\346\226\207\346\241\243.md" index f6c2b8c1..84d64ba5 100644 --- "a/docs/\346\265\213\350\257\225\346\226\207\346\241\243.md" +++ "b/docs/\346\265\213\350\257\225\346\226\207\346\241\243.md" @@ -1,3 +1,5 @@ +已过期,有没有人想写文档 +

muxiK-StackFrontend2.0

diff --git "a/docs/\350\277\233\345\272\246\346\226\207\346\241\243.md" "b/docs/\350\277\233\345\272\246\346\226\207\346\241\243.md" index a85066a5..94bbf2a0 100644 --- "a/docs/\350\277\233\345\272\246\346\226\207\346\241\243.md" +++ "b/docs/\350\277\233\345\272\246\346\226\207\346\241\243.md" @@ -1,3 +1,5 @@ +已过期,有没有人想写文档 +

muxiK-StackFrontend2.0

diff --git a/myrepo.bundle b/myrepo.bundle deleted file mode 100644 index 5355784c..00000000 Binary files a/myrepo.bundle and /dev/null differ diff --git a/package.json b/package.json index e064b0fb..5a61b979 100644 --- a/package.json +++ b/package.json @@ -44,22 +44,22 @@ "@taro-hooks/plugin-auto-import": "^2.1.0", "@taro-hooks/plugin-react": "^2.1.0", "@taroify/core": "^0.4.0-alpha.0", - "@tarojs/components": "4.1.2", - "@tarojs/components-advanced": "^4.1.2", - "@tarojs/helper": "4.1.2", - "@tarojs/plugin-framework-react": "4.1.2", - "@tarojs/plugin-html": "^4.1.2", - "@tarojs/plugin-platform-alipay": "4.1.2", - "@tarojs/plugin-platform-h5": "4.1.2", - "@tarojs/plugin-platform-jd": "4.1.2", - "@tarojs/plugin-platform-qq": "4.1.2", - "@tarojs/plugin-platform-swan": "4.1.2", - "@tarojs/plugin-platform-tt": "4.1.2", - "@tarojs/plugin-platform-weapp": "4.1.2", - "@tarojs/react": "4.1.2", - "@tarojs/runtime": "4.1.2", - "@tarojs/shared": "4.1.2", - "@tarojs/taro": "4.1.2", + "@tarojs/components": "4.0.6", + "@tarojs/components-advanced": "4.0.6", + "@tarojs/helper": "4.0.6", + "@tarojs/plugin-framework-react": "4.0.6", + "@tarojs/plugin-html": "4.0.6", + "@tarojs/plugin-platform-alipay": "4.0.6", + "@tarojs/plugin-platform-h5": "4.0.6", + "@tarojs/plugin-platform-jd": "4.0.6", + "@tarojs/plugin-platform-qq": "4.0.6", + "@tarojs/plugin-platform-swan": "4.0.6", + "@tarojs/plugin-platform-tt": "4.0.6", + "@tarojs/plugin-platform-weapp": "4.0.6", + "@tarojs/react": "4.0.6", + "@tarojs/runtime": "4.0.6", + "@tarojs/shared": "4.0.6", + "@tarojs/taro": "4.0.6", "echarts": "^5.5.1", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", @@ -77,23 +77,26 @@ }, "devDependencies": { "@babel/core": "^7.25.2", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-proposal-decorators": "^7.24.1", + "@babel/preset-react": "^7.24.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", - "@tarojs/cli": "4.1.2", - "@tarojs/taro-loader": "4.0.5", - "@tarojs/webpack5-runner": "4.0.5", + "@tarojs/cli": "4.0.6", + "@tarojs/taro-loader": "4.0.6", + "@tarojs/webpack5-runner": "4.0.6", "@types/node": "^22.5.5", "@types/react": "^18.3.8", "@types/sass": "1.45.0", "@types/webpack-env": "^1.18.5", - "@typescript-eslint/eslint-plugin": "^7.17.0", - "@typescript-eslint/parser": "^8.6.0", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", "autoprefixer": "^10.4.19", "babel-plugin-import": "^1.13.8", - "babel-preset-taro": "3.6.35", + "babel-preset-taro": "4.0.6", "cache-loader": "^4.1.0", "cssnano": "^7.0.6", "eslint": "^8.57.1", - "eslint-config-taro": "4.0.5", + "eslint-config-taro": "4.0.6", "eslint-plugin-import": "^2.30.0", "eslint-plugin-react": "^7.36.1", "eslint-plugin-react-hooks": "^4.6.2", @@ -117,7 +120,11 @@ "webpack": "^5.94.0" }, "resolutions": { - "@types/sass": "1.45.0" + "@types/sass": "1.45.0", + "@tarojs/taro": "4.0.6", + "@tarojs/runtime": "4.0.6", + "@tarojs/shared": "4.0.6", + "@tarojs/helper": "4.0.6" }, "packageManager": "yarn@4.3.1", "husky": { diff --git a/project.config.json b/project.config.json index 9c582381..7089f5a7 100644 --- a/project.config.json +++ b/project.config.json @@ -8,7 +8,7 @@ "es6": true, "enhance": true, "compileHotReLoad": false, - "postcss": true, + "postcss": false, "preloadBackgroundData": false, "minified": true, "newFeature": true, diff --git a/project.private.config.json b/project.private.config.json index d23a7c59..daa84041 100644 --- a/project.private.config.json +++ b/project.private.config.json @@ -1,24 +1,24 @@ -{ - "description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html", - "projectname": "muxiK-StackFrontend2.0", - "setting": { - "compileHotReLoad": true, - "bigPackageSizeSupport": true, - "urlCheck": false, - "autoAudits": true, - "preloadBackgroundData": false, - "skylineRenderEnable": false, - "coverView": true, - "lazyloadPlaceholderEnable": false, - "useApiHook": true, - "useApiHostProcess": true, - "showShadowRootInWxmlPanel": false, - "useStaticServer": false, - "useLanDebug": false, - "showES6CompileOption": false, - "checkInvalidKey": true, - "ignoreDevUnusedFiles": true - }, - "libVersion": "3.8.9", - "condition": {} +{ + "description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html", + "projectname": "muxiK-StackFrontend2.0", + "setting": { + "compileHotReLoad": true, + "bigPackageSizeSupport": true, + "urlCheck": false, + "autoAudits": true, + "preloadBackgroundData": false, + "skylineRenderEnable": false, + "coverView": true, + "lazyloadPlaceholderEnable": false, + "useApiHook": true, + "useApiHostProcess": true, + "showShadowRootInWxmlPanel": false, + "useStaticServer": false, + "useLanDebug": false, + "showES6CompileOption": false, + "checkInvalidKey": true, + "ignoreDevUnusedFiles": true + }, + "libVersion": "3.8.12", + "condition": {} } \ No newline at end of file diff --git a/scripts/genapi.js b/scripts/genapi.js new file mode 100644 index 00000000..1c270d3c --- /dev/null +++ b/scripts/genapi.js @@ -0,0 +1,104 @@ +const { spawn } = require('child_process'); +const { writeFileSync } = require('fs'); +const { stdin, stdout } = require('process'); +const { createInterface } = require('readline/promises'); + +let fetchFn = global.fetch; +if (!fetchFn) { + try { + fetchFn = (...args) => require('node-fetch')(...args); + } catch (err) { + console.error('本地 node 版本需18+或自行安装 node-fetch!'); + process.exit(1); + } +} else { + fetchFn = fetch; +} + +function runCommand(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: 'inherit' }); + child.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`命令退出,退出码: ${code}`)) + ); + child.on('error', reject); + }); +} + +async function promptAuth() { + try { + const rl = createInterface({ input: stdin, output: stdout }); + const username = (await rl.question('BasicAuth 用户名:')).trim(); + const password = (await rl.question('BasicAuth 密码:')).trim(); + rl.close(); + return { username, password }; + } catch (err) { + console.error('输入过程出错:', err.message); + if (rl) { + rl.close(); + } + process.exit(1); + } +} + +async function fetchSwagger(url, auth) { + try { + const headers = auth + ? { + Authorization: `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`, + } + : {}; + const res = await fetchFn(url, { method: 'GET', headers }); + if (res.status === 401 && !auth) { + const newAuth = await promptAuth(); + return await fetchSwagger(url, newAuth); + } + if (!res.ok) throw new Error(`请求失败 ${res.status}`); + return await res.text(); + } catch (err) { + throw new Error(`请求 ${url} 失败: ${err.message}`); + } +} + +async function generateTypes({ yamlPath, dtsPath }) { + try { + await runCommand('yarn', ['openapi-typescript', yamlPath, '-o', dtsPath]); + console.log('类型定义生成完毕!'); + } catch (err) { + throw new Error('类型定义生成失败:' + err.message); + } + + try { + await runCommand('yarn', ['prettier']); + console.log('格式化完成!'); + } catch (err) { + console.warn('格式化失败:', err.message); + } +} + +(async () => { + const swaggerUrls = ['https://v3.ccnubox.muxixyz.com/api/v1/swag']; + + const results = []; + for (const url of swaggerUrls) { + try { + const text = await fetchSwagger(url); + results.push({ url, text }); + } catch (err) { + console.error(err.message); + process.exit(1); + } + } + + const yamlPath = './src/common/api/openapi.yaml'; + try { + const combined = results.map((r) => `# Source: ${r.url}\n${r.text}`).join('\n\n'); + writeFileSync(yamlPath, combined); + console.log('openapi.yaml 写入完成'); + } catch (err) { + console.error('写文件失败:', err.message); + process.exit(1); + } + + await generateTypes({ yamlPath, dtsPath: './src/common/api/schema.d.ts' }); +})(); diff --git a/src/app.config.ts b/src/app.config.ts index 1f4e2d01..ef05a50a 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -4,29 +4,44 @@ export default defineAppConfig({ pages: [ 'pages/login/index', 'pages/main/index', - 'pages/publishQuestion/index', 'pages/classInfo/index', - 'pages/evaluateInfo/index', 'pages/profile/index', - 'pages/myCollection/index', - 'pages/notification/index', - 'pages/feedback/index', - 'pages/evaluate/index', - 'pages/questionInfo/index', 'pages/myclass/index', + 'pages/notification/main/index', + 'pages/notification/list/index', 'pages/research/index', - 'pages/index/index', - 'pages/editUser/index', 'pages/guide/index', - 'pages/shareGrades/index', - 'pages/questionList/index', ], + subpackages: [ { root: 'subpackages/profile', - pages: ['pages/history/index'], + pages: [ + 'pages/myCollection/index', + 'pages/evaluationHistory/index', + 'pages/editUser/index', + ], + }, + { + root: 'subpackages/feedback', + pages: [ + 'pages/main/index', + 'pages/writefeedback/index', + 'pages/history/index', + 'pages/detail/index', + ], + }, + { + root: 'subpackages/course', + pages: [ + 'pages/evaluate/index', + 'pages/evaluateInfo/index', + 'pages/questionInfo/index', + 'pages/publishQuestion/index', + ], }, ], + usingComponents: Object.assign(useGlobalIconFont()), tabBar: { custom: true, @@ -39,7 +54,7 @@ export default defineAppConfig({ pagePath: 'pages/guide/index', text: 'Guide', }, - { pagePath: 'pages/notification/index', text: 'Massage' }, + { pagePath: 'pages/notification/main/index', text: 'Massage' }, { pagePath: 'pages/profile/index', text: 'Profile' }, ], }, diff --git a/src/app.ts b/src/app.ts index 0de64dd3..bcce8c71 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,82 +1,15 @@ /* eslint-disable */ -import Taro from '@tarojs/taro'; -import { Component, PropsWithChildren } from 'react'; - import '@/common/styles/globals.scss'; +import { Component, PropsWithChildren } from 'react'; -import { checkToken } from '@/common/utils'; - -// const fonts = [ -// { -// family: 'Inter', -// source: 'url("https://kstack.muxixyz.com/statics/font/Inter.ttf")', -// }, -// { -// family: 'SourceHanSans', -// source: 'url("https://kstack.muxixyz.com/statics/font/SourceHanSans.ttf")', -// }, -// ]; - -const interceptor: Taro.interceptor = function (chain: Taro.Chain) { - const requestParams = chain.requestParams; - - return chain.proceed(requestParams).then((res) => { - // console.log(Taro.getStorageSync('shortToken')); - - if (res.statusCode === 401 && Taro.getStorageSync('visitor') !== true) { - // console.log(res, Taro.getStorageSync('visitor')); - - void Taro.reLaunch({ url: '/pages/login/index' }).then(() => { - Taro.showToast({ - title: '登录过期,请重新登录', - icon: 'none', - }); - }); - } - return res; - }) as Taro.Chain; -}; -Taro.onAppShow(() => { - Taro.setStorageSync('visitor', false); -}); -Taro.addInterceptor(interceptor); +import checkToken from '@/common/utils/checkToken'; class App extends Component { - state = { - isLoading: true, - }; - //TODO 写成加interceptor 但是我还没写明白 别急 async componentDidMount() { - // await this.loadFonts(); checkToken(); } - // loadFonts = async () => { - // try { - // await Promise.all( - // fonts.map((font) => { - // Taro.loadFontFace({ - // ...font, - // global: true, - // success: () => { - // console.log(`${font.family} 字体加载成功`); - // }, - // fail: (err) => { - // console.error(`${font.family} 字体加载失败`, err); - // Taro.showToast({ - // title: '字体加载失败', - // icon: 'none', - // }); - // }, - // }); - // }) - // ); - // this.setState({ isLoading: true }); - // } catch (error) { - // console.error('字体加载失败', error); - // } - // }; render() { return this.props.children; } diff --git a/src/common/api/get.ts b/src/common/api/get.ts deleted file mode 100644 index 1c194119..00000000 --- a/src/common/api/get.ts +++ /dev/null @@ -1,38 +0,0 @@ -import Taro from '@tarojs/taro'; - -const preUrl = 'https://kstack.muxixyz.com'; -export async function get(url: string) { - let token: string = ''; - const getToken = () => { - return new Promise((resolve, reject) => { - void Taro.getStorage({ - key: 'shortToken', - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - success: (res) => resolve(res.data), - - fail: (err) => reject(err), - }); - }); - }; - // 获取token - token = await getToken(); - const header = { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json;charset=utf-8', - }; - try { - const response = await Taro.request({ - method: 'GET', - url: `${preUrl}${url}`, - header, - }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (response.data.code !== 0) { - console.log('code不为0'); - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return response.data; - } catch (error) { - console.error(error); - } -} diff --git a/src/common/api/getUserCourses.ts b/src/common/api/getUserCourses.ts deleted file mode 100644 index c018d1e1..00000000 --- a/src/common/api/getUserCourses.ts +++ /dev/null @@ -1,38 +0,0 @@ -import Taro from '@tarojs/taro'; - -const preUrl = 'https://kstack.muxixyz.com'; -export async function getUserCourses(year: string, term: string) { - try { - let token: string = ''; - const getToken = () => { - return new Promise((resolve, reject) => { - void Taro.getStorage({ - key: 'shortToken', - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - success: (res) => resolve(res.data), - - fail: (err) => reject(err), - }); - }); - }; - // 获取token - token = await getToken(); - const header = { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json;charset=utf-8', - }; - const response = await Taro.request({ - method: 'GET', - url: `${preUrl}/courses/list/mine?year=${year}&term=${term}`, - header, - }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (response.data.code !== 0) { - console.log('code不为0'); - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return,@typescript-eslint/no-unsafe-member-access - return response.data.data; - } catch (error) { - console.error(error); - } -} diff --git a/src/common/api/handleLogin.ts b/src/common/api/handleLogin.ts deleted file mode 100644 index 185e6e53..00000000 --- a/src/common/api/handleLogin.ts +++ /dev/null @@ -1,67 +0,0 @@ -import Taro from '@tarojs/taro'; - -const preUrl = 'https://kstack.muxixyz.com'; - -export type LoginResponseHeaders = { - 'X-Jwt-Token'?: string; - 'X-Refresh-Token'?: string; -}; - -const handleLogin = async (data: Record = {}) => { - const header = { - 'Content-Type': 'application/json;charset=utf-8', - }; - - try { - void Taro.showLoading({ - title: '登录中...', - mask: true, - }); - const response = await Taro.request({ - method: 'POST', - url: `${preUrl}/users/login_ccnu`, - header, - data: JSON.stringify(data), - }); - Taro.hideLoading(); - const headers: LoginResponseHeaders = response.header || {}; - const shortToken = headers['X-Jwt-Token']; - const longToken = headers['X-Refresh-Token']; - - if (shortToken && longToken) { - void Taro.setStorage({ - key: 'shortToken', - data: shortToken.toString(), - }); - - void Taro.setStorage({ - key: 'longToken', - data: longToken.toString(), - }); - - void Taro.switchTab({ - url: '/pages/main/index', - }); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (response.data.code !== 0) { - // 登陆失败(code 不为 0) - void Taro.showToast({ - icon: 'error', - title: '登录失败,请重试', - }); - } - - if (!response.statusCode.toString().startsWith('2')) { - throw new Error(`请求失败,状态码:${response.statusCode}`); - } - } catch (error) { - void Taro.showToast({ - icon: 'error', - title: (error as Error).message || '登录过程中发生错误', - }); - } -}; - -export default handleLogin; diff --git a/src/common/assets/img/icons/TAS.png b/src/common/assets/img/icons/TAS.png new file mode 100644 index 00000000..d1d96587 Binary files /dev/null and b/src/common/assets/img/icons/TAS.png differ diff --git a/src/common/assets/img/icons/delete.png b/src/common/assets/img/icons/delete.png new file mode 100644 index 00000000..db585201 Binary files /dev/null and b/src/common/assets/img/icons/delete.png differ diff --git a/src/common/assets/img/icons/file.png b/src/common/assets/img/icons/file.png new file mode 100644 index 00000000..fdb204b9 Binary files /dev/null and b/src/common/assets/img/icons/file.png differ diff --git a/src/common/assets/img/icons/index.ts b/src/common/assets/img/icons/index.ts new file mode 100644 index 00000000..5d69ee93 --- /dev/null +++ b/src/common/assets/img/icons/index.ts @@ -0,0 +1,4 @@ +export { default as deleteIcon } from './delete.png'; +export { default as fileIcon } from './file.png'; +export { default as searchIcon } from './search.png'; +export { default as TASIcon } from './TAS.png'; diff --git a/src/common/assets/img/icons/search.png b/src/common/assets/img/icons/search.png new file mode 100644 index 00000000..d19e740b Binary files /dev/null and b/src/common/assets/img/icons/search.png differ diff --git a/src/common/assets/img/profile/book_icon.png b/src/common/assets/img/profile/book_icon.png index 6cf53799..ee79c220 100644 Binary files a/src/common/assets/img/profile/book_icon.png and b/src/common/assets/img/profile/book_icon.png differ diff --git a/src/common/assets/img/profile/clock_icon.png b/src/common/assets/img/profile/clock_icon.png index 02dab267..67932834 100644 Binary files a/src/common/assets/img/profile/clock_icon.png and b/src/common/assets/img/profile/clock_icon.png differ diff --git a/src/common/assets/img/profile/exit_icon.png b/src/common/assets/img/profile/exit_icon.png new file mode 100644 index 00000000..99a263c5 Binary files /dev/null and b/src/common/assets/img/profile/exit_icon.png differ diff --git a/src/common/assets/img/profile/feedback/index.ts b/src/common/assets/img/profile/feedback/index.ts new file mode 100644 index 00000000..cde8e774 --- /dev/null +++ b/src/common/assets/img/profile/feedback/index.ts @@ -0,0 +1,5 @@ +export { default as resolvedIcon } from './resolved.svg'; +export { default as resolvedSelectedIcon } from './resolved_selected.svg'; +export { default as resolvedShowIcon } from './resolved_show.svg'; +export { default as unresolvedIcon } from './unresolved.svg'; +export { default as unresolvedSelectedIcon } from './unresolved_selected.svg'; diff --git a/src/common/assets/img/profile/feedback/resolved.svg b/src/common/assets/img/profile/feedback/resolved.svg new file mode 100644 index 00000000..0d700f7e --- /dev/null +++ b/src/common/assets/img/profile/feedback/resolved.svg @@ -0,0 +1 @@ + diff --git a/src/common/assets/img/profile/feedback/resolved_selected.svg b/src/common/assets/img/profile/feedback/resolved_selected.svg new file mode 100644 index 00000000..fca1fb35 --- /dev/null +++ b/src/common/assets/img/profile/feedback/resolved_selected.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/common/assets/img/profile/feedback/resolved_show.svg b/src/common/assets/img/profile/feedback/resolved_show.svg new file mode 100644 index 00000000..7b5f1ae6 --- /dev/null +++ b/src/common/assets/img/profile/feedback/resolved_show.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/common/assets/img/profile/feedback/unresolved.svg b/src/common/assets/img/profile/feedback/unresolved.svg new file mode 100644 index 00000000..6355c78f --- /dev/null +++ b/src/common/assets/img/profile/feedback/unresolved.svg @@ -0,0 +1 @@ + diff --git a/src/common/assets/img/profile/feedback/unresolved_selected.svg b/src/common/assets/img/profile/feedback/unresolved_selected.svg new file mode 100644 index 00000000..a00d1693 --- /dev/null +++ b/src/common/assets/img/profile/feedback/unresolved_selected.svg @@ -0,0 +1 @@ + diff --git a/src/common/assets/img/profile/feedback_icon.png b/src/common/assets/img/profile/feedback_icon.png new file mode 100644 index 00000000..5a8704b0 Binary files /dev/null and b/src/common/assets/img/profile/feedback_icon.png differ diff --git a/src/common/assets/img/profile/index.ts b/src/common/assets/img/profile/index.ts index 87f94c94..ae2ca6b4 100644 --- a/src/common/assets/img/profile/index.ts +++ b/src/common/assets/img/profile/index.ts @@ -1,6 +1,8 @@ export { default as TopBackground } from './background.png'; export { default as BookIcon } from './book_icon.png'; export { default as ClockIcon } from './clock_icon.png'; +export { default as ExitIcon } from './exit_icon.png'; +export { default as FeedbackIcon } from './feedback_icon.png'; export { default as MailIcon } from './mail_icon.png'; export { default as MessageIcon } from './message_icon.png'; export { default as StarIcon } from './star_icon.png'; diff --git a/src/common/assets/img/profile/mail_icon.png b/src/common/assets/img/profile/mail_icon.png index 95eae53f..c5c441cf 100644 Binary files a/src/common/assets/img/profile/mail_icon.png and b/src/common/assets/img/profile/mail_icon.png differ diff --git a/src/common/assets/img/profile/star_icon.png b/src/common/assets/img/profile/star_icon.png index 3f954b82..7832e657 100644 Binary files a/src/common/assets/img/profile/star_icon.png and b/src/common/assets/img/profile/star_icon.png differ diff --git a/src/common/assets/img/search.png b/src/common/assets/img/search.png new file mode 100644 index 00000000..d19e740b Binary files /dev/null and b/src/common/assets/img/search.png differ diff --git a/src/common/assets/img/tabBar/index.ts b/src/common/assets/img/tabBar/index.ts new file mode 100644 index 00000000..c1e97bb5 --- /dev/null +++ b/src/common/assets/img/tabBar/index.ts @@ -0,0 +1,4 @@ +export { default as ManualIcon } from './manual_icon.svg'; +export { default as noticeIcon } from './notice_icon.svg'; +export { default as ProfileIcon } from './profile_icon.svg'; +export { default as SquareIcon } from './square_icon.svg'; diff --git a/src/common/assets/img/tabBar/manual_icon.svg b/src/common/assets/img/tabBar/manual_icon.svg new file mode 100644 index 00000000..56cee1e1 --- /dev/null +++ b/src/common/assets/img/tabBar/manual_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/common/assets/img/tabBar/notice_icon.svg b/src/common/assets/img/tabBar/notice_icon.svg new file mode 100644 index 00000000..c074d741 --- /dev/null +++ b/src/common/assets/img/tabBar/notice_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/common/assets/img/tabBar/profile_icon.svg b/src/common/assets/img/tabBar/profile_icon.svg new file mode 100644 index 00000000..4de11129 --- /dev/null +++ b/src/common/assets/img/tabBar/profile_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/common/assets/img/tabBar/square_icon.svg b/src/common/assets/img/tabBar/square_icon.svg new file mode 100644 index 00000000..a7d95929 --- /dev/null +++ b/src/common/assets/img/tabBar/square_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/common/assets/js/echarts.js b/src/common/assets/js/echarts.js index 5510d47a..df052756 100644 --- a/src/common/assets/js/echarts.js +++ b/src/common/assets/js/echarts.js @@ -8,10 +8,10 @@ 'use strict'; /*! ***************************************************************************** Copyright (c) Microsoft Corporation. - + Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. - + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, @@ -129,7 +129,7 @@ } env.svgSupported = typeof SVGRect !== 'undefined'; - env.touchEventsSupported = 'ontouchstart' in window && !browser.ie && !browser.edge; + env.touchEventsSupported = 'onClick' in window && !browser.ie && !browser.edge; env.pointerEventsSupported = 'onpointerdown' in window && (browser.edge || (browser.ie && +browser.version >= 11)); @@ -10662,10 +10662,10 @@ !subType ? mainType + '.' + 'type should be specified.' : 'Component ' + - mainType + - '.' + - (subType || '') + - ' is used but not imported.' + mainType + + '.' + + (subType || '') + + ' is used but not imported.' ); } diff --git a/src/common/auth/session.ts b/src/common/auth/session.ts new file mode 100644 index 00000000..7c12fafb --- /dev/null +++ b/src/common/auth/session.ts @@ -0,0 +1,36 @@ +import Taro from '@tarojs/taro'; + +import { LONG_TOKEN, SHORT_TOKEN, VISITOR } from '@/common/constants/auth'; + +const LOGIN_ROUTE = 'pages/login/index'; + +export function hasAuthenticatedSession(): boolean { + const short = Taro.getStorageSync(SHORT_TOKEN); + const long = Taro.getStorageSync(LONG_TOKEN); + return !!(short?.trim() || long?.trim()); +} + +// 正式登录态或游客模式 +export function hasStoredSession(): boolean { + return hasAuthenticatedSession() || !!Taro.getStorageSync(VISITOR); +} + +// 冷启动默认落在登录页(单页栈),用于区分分享/扫码深链 +export function isColdStartOnLoginPage(): boolean { + const pages = Taro.getCurrentPages(); + if (pages.length !== 1) return false; + return (pages[pages.length - 1]?.route || '') === LOGIN_ROUTE; +} + +export function readResponseHeader( + headers: Record | undefined, + name: string +): string | undefined { + if (!headers) return undefined; + const target = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== target) continue; + if (typeof value === 'string' && value.trim()) return value; + } + return undefined; +} diff --git a/src/common/components/ActionItem/index.scss b/src/common/components/ActionItem/index.scss new file mode 100644 index 00000000..b49f57f7 --- /dev/null +++ b/src/common/components/ActionItem/index.scss @@ -0,0 +1,56 @@ +.action_item { + @include flex_align_center; + + flex-direction: row; + gap: 10rpx; + font-size: $font_size_subtitle; + color: $color_text_medium; + + &.disabled { + opacity: 0.5; + pointer-events: none; + } + + &.active { + .action_icon { + color: $color_brand_accent; + } + } + + .action_icon { + position: relative; + width: 50rpx; + height: 50rpx; + background-color: $color_bg_page; + box-shadow: 0px 8rpx 20rpx 0px rgba(0, 0, 0, 0.05); + border-radius: $radius_round; + display: inline-flex; + align-items: center; + justify-content: center; + color: $color_text_medium; + + > IconFont { + font-size: $font_size_body; + } + + .iconfont { + @include flex_center; + + font-family: 'iconfont' !important; + font-size: $font_size_base; + font-style: normal; + color: $color_brand_accent; + line-height: 50rpx; + text-align: center; + width: 100%; + height: 100%; + } + } + + .action_count { + font-size: $font_size_body; + color: $color_text_primary; + line-height: 50rpx; + min-width: 20rpx; + } +} diff --git a/src/common/components/ActionItem/index.tsx b/src/common/components/ActionItem/index.tsx new file mode 100644 index 00000000..d2d6441b --- /dev/null +++ b/src/common/components/ActionItem/index.tsx @@ -0,0 +1,107 @@ +import { Navigator, Text, View } from '@tarojs/components'; +import React from 'react'; + +import './index.scss'; + +import IconFont from '@/common/components/iconfont'; +import { useLikeAction } from '@/common/hooks/useLikeAction'; +import type { CommentInfo } from '@/common/types/commentTypes'; + +export type ActionType = 'like' | 'comment' | 'oppose'; + +interface ActionItemProps { + type: ActionType; + count?: number; + isActive?: boolean; + disabled?: boolean; + id?: number; + stance?: number; + + onClick?: (res?: CommentInfo) => void; +} + +interface LikeActionItemProps { + count?: number; + disabled?: boolean; + id?: number; + stance?: number; + + onClick?: (res?: CommentInfo) => void; +} + +const LikeActionItem: React.FC = ({ + count = 0, + disabled = false, + id, + stance, + onClick, +}) => { + const { isLiked, likeCount, toggleLike } = useLikeAction({ + evaluationId: id ?? 0, + stance, + count, + onSuccess: onClick, + }); + + return ( + void toggleLike(e)} + > + + + + {likeCount} + + ); +}; + +const ActionItem: React.FC = ({ + type, + count = 0, + onClick, + disabled = false, + id, + stance, +}) => { + if (type === 'like') { + return ( + + ); + } + + const renderIcon = () => { + switch (type) { + case 'comment': + return ; + case 'oppose': + return ; + default: + return null; + } + }; + + const handleClick = (e: { stopPropagation: () => void }) => { + if (disabled) return; + e.stopPropagation(); + onClick?.(); + }; + + return ( + + {renderIcon()} + {count} + + ); +}; + +export default ActionItem; diff --git a/src/common/components/AnswerToStudent/index.tsx b/src/common/components/AnswerToStudent/index.tsx deleted file mode 100644 index d14a3511..00000000 --- a/src/common/components/AnswerToStudent/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Image, View } from '@tarojs/components'; - -import Ask from '@/common/assets/img/publishQuestion/img.png'; - -interface Question { - content: string; - preview_answers: Array; -} -const AnswerToStudent: React.FC = (props) => { - const { content, preview_answers } = props; - return ( - - - - {content} - - - {preview_answers?.length ?? 0} 个回答 - - - ); -}; - -export default AnswerToStudent; diff --git a/src/common/components/Avatar/index.scss b/src/common/components/Avatar/index.scss new file mode 100644 index 00000000..687764ff --- /dev/null +++ b/src/common/components/Avatar/index.scss @@ -0,0 +1,6 @@ +@import '@/common/styles/decorations/index'; + +.avatar_root { + display: inline-flex; + flex-shrink: 0; +} diff --git a/src/common/components/Avatar/index.tsx b/src/common/components/Avatar/index.tsx new file mode 100644 index 00000000..0896ed4c --- /dev/null +++ b/src/common/components/Avatar/index.tsx @@ -0,0 +1,37 @@ +import { Image, View } from '@tarojs/components'; +import { memo } from 'react'; + +import './index.scss'; + +import type { AvatarFrame } from '@/common/utils'; +import { resolveAvatarFrame } from '@/common/utils'; + +interface AvatarProps { + src: string; + size?: number | string; + username?: string; + frame?: AvatarFrame; + className?: string; + + onClick?: () => void; +} + +const Avatar: React.FC = memo( + ({ src, size = 48, username, frame, className = '', onClick }) => { + const resolvedFrame = frame ?? resolveAvatarFrame(username); + const sizeValue = typeof size === 'number' ? `${size}rpx` : size; + + return ( + + + + + + ); + } +); + +export default Avatar; diff --git a/src/common/components/BottomInput/index.scss b/src/common/components/BottomInput/index.scss new file mode 100644 index 00000000..17f54e11 --- /dev/null +++ b/src/common/components/BottomInput/index.scss @@ -0,0 +1,71 @@ +.bottomInput { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: $color_bg_page; + border-top: 2rpx solid $color_bg_hover; + box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.04); + z-index: 100; + + &_container { + display: flex; + align-items: flex-end; + gap: $space_md; + padding: $space_md 30rpx; + box-sizing: border-box; + } + + &_textarea { + flex: 1; + min-height: 80rpx; + max-height: 300rpx; + padding: $space_md 24rpx; + background: $color_bg_input; + border-radius: $radius_xl; + font-size: $font_size_body; + color: $color_text_dark; + line-height: 1.4; + overflow-y: auto; + box-sizing: border-box; + } + + &_placeholder { + color: $color_text_secondary; + } + + &_sendButton { + @include flex_center; + + width: 120rpx; + height: 80rpx; + background: $color_brand; + border-radius: $radius_xl; + color: $color_bg_page; + + padding: 0; + margin: 0; + flex-shrink: 0; + border: none; + outline: none; + + &::after { + border: none; + } + + &.disabled { + background: $color_text_light; + opacity: 0.5; + } + + &:active { + opacity: 0.8; + } + + &_btn_text { + font-size: $font_size_body; + color: $color_bg_page; + line-height: 1; + } + } +} diff --git a/src/common/components/BottomInput/index.tsx b/src/common/components/BottomInput/index.tsx new file mode 100644 index 00000000..e7c18c63 --- /dev/null +++ b/src/common/components/BottomInput/index.tsx @@ -0,0 +1,191 @@ +import { Button, Text, Textarea, View } from '@tarojs/components'; +import Taro from '@tarojs/taro'; +import { + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; + +import './index.scss'; + +interface BottomInputProps { + placeholder?: string; + maxHeight?: number; + autoFocus?: boolean; + + onSubmit: (value: string) => Promise | void; + onMentionRemoved?: () => void; +} + +export interface BottomInputRef { + focus: () => void; + insertMention: (nickname: string) => void; + removeMention: () => void; + clearValue: () => void; +} + +const MIN_HEIGHT_RPX = 80; + +const BottomInput = memo( + forwardRef( + ( + { + placeholder = '写下你的评论...', + onSubmit, + onMentionRemoved, + maxHeight = 300, + autoFocus = false, + }, + ref + ) => { + const textareaRef = useRef(null); + const [internalValue, setInternalValue] = useState(''); + const [textareaHeight, setTextareaHeight] = useState(null); + const mentionRef = useRef(null); + const focusedRef = useRef(false); + + const clampHeightPx = useCallback( + (height: number) => { + const minPx = Number(Taro.pxTransform(MIN_HEIGHT_RPX)); + const maxPx = Number(Taro.pxTransform(maxHeight)); + return Math.max(minPx, Math.min(height, maxPx)); + }, + [maxHeight] + ); + + const doFocus = () => { + setTimeout(() => { + textareaRef.current?.focus(); + }, 100); + }; + + useImperativeHandle(ref, () => ({ + focus: doFocus, + + insertMention: (nickname: string) => { + const prefix = `@${nickname} `; + mentionRef.current = prefix; + const applyMention = () => { + setInternalValue((prev) => { + const withoutMention = prev.replace(/^@\S+\s?/, ''); + return prefix + withoutMention; + }); + }; + + // 已聚焦时直接插入;未聚焦时先弹键盘,待布局稳定后再插入,减轻高度跳动 + if (focusedRef.current) { + applyMention(); + } else { + textareaRef.current?.focus(); + setTimeout(applyMention, 120); + } + }, + + removeMention: () => { + mentionRef.current = null; + setInternalValue((prev) => { + const match = prev.match(/^@\S+\s?/); + return match ? prev.slice(match[0].length) : prev; + }); + }, + + clearValue: () => { + mentionRef.current = null; + setInternalValue(''); + setTextareaHeight(null); + }, + })); + + useEffect(() => { + if (autoFocus) { + doFocus(); + } + }, [autoFocus]); + + const handleInput = useCallback( + (e: any) => { + const val = e.detail.value; + const mention = mentionRef.current; + + if (mention && !val.startsWith(mention)) { + mentionRef.current = null; + onMentionRemoved?.(); + setInternalValue(val.replace(/^@\S+\s?/, '')); + return; + } + + setInternalValue(val); + }, + [onMentionRemoved] + ); + + const handleSubmit = useCallback(() => { + const mention = mentionRef.current; + const text = mention + ? internalValue.slice(mention.length).trim() + : internalValue.trim(); + if (!text) return; + onSubmit(text); + }, [internalValue, onSubmit]); + + const handleLineChange = useCallback( + (e: { detail: { height: number; lineCount: number } }) => { + const { height, lineCount } = e.detail; + if (lineCount <= 1 && !internalValue.includes('\n')) { + setTextareaHeight(null); + return; + } + setTextareaHeight(clampHeightPx(height)); + }, + [clampHeightPx, internalValue] + ); + + return ( + + + - 字数限制{textLength}/450 - - setIsAnonymous(!isAnonymous)} - color="transparent" - > - 匿名 - - - - - ); -}; - -export default Page; diff --git a/src/pages/evaluate/style.scss b/src/pages/evaluate/style.scss deleted file mode 100644 index 94a8626a..00000000 --- a/src/pages/evaluate/style.scss +++ /dev/null @@ -1,88 +0,0 @@ -.p { - display: flex; - flex-direction: row; - margin-top: 30rpx; - margin-left: 45rpx; -} - -.p Text { - padding-right: 10rpx; - font-size: 29rpx; - font-family: - Segoe UI-Bold, - Segoe UI; - font-weight: bold; - color: #565552; - letter-spacing: 2rpx; -} - -.view { - padding-top: 30rpx; -} - -.myradio { - font-size: 29rpx; - margin-right: 20rpx; -} - -.myradio .wx-radio-input { - height: 13rpx; - width: 13rpx; - border: 5rpx solid #ffd777; -} - -.myradio .wx-radio-input.wx-radio-input-checked::before { - border-radius: 50%; - width: 18rpx; - height: 18rpx; - line-height: 18rpx; - text-align: center; - font-size: 0rpx; - background-color: #ffd777; - transform: translate(-50%, -50%); -} - -.ways { - width: 500rpx; - margin-top: -5rpx; -} - -.fea { - display: flex; - flex-wrap: wrap; - width: 500rpx; -} - -.view .myComment { - width: 659.42rpx; - height: 30vh; - background-color: #f9f9f2; - border-radius: 43.48rpx 43.48rpx 43.48rpx 43.48rpx; - margin-left: 45.29rpx; - padding: 20rpx; - padding-bottom: 0; - font-size: 25rpx; - margin-top: 47rpx; -} - -.view Button { - width: 376.86rpx; - max-height: 84.38rpx; - display: flex; - justify-content: center; - align-items: center; - font-size: 43rpx; - color: white; - background-color: #eda335; - border-radius: 10rpx; - border: 0rpx; - margin-top: 83rpx; -} - -.view .zsxz { - font-size: 18rpx; - color: #9f9f9c; - position: relative; - left: 550rpx; - top: -80rpx; -} diff --git a/src/pages/evaluateInfo/index.tsx b/src/pages/evaluateInfo/index.tsx deleted file mode 100644 index 35adbdf5..00000000 --- a/src/pages/evaluateInfo/index.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/* eslint-disable @typescript-eslint/no-misused-promises */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-shadow */ -/* eslint-disable @typescript-eslint/no-floating-promises */ -/* eslint-disable no-console */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ - -import { Button, Image, Text, Textarea, View } from '@tarojs/components'; -import Taro from '@tarojs/taro'; -import { useEffect, useRef, useState } from 'react'; - -import './style.scss'; - -import { Icon, TopBackground } from '@/common/assets/img/login'; -import { Comment } from '@/common/components'; -import CommentComponent from '@/common/components/CommentComponent/CommentComponent'; -import { get } from '@/common/utils'; -import { postBool } from '@/common/utils/fetch'; -import { NavigationBar } from '@/modules/navigation'; - -import { StatusResponse } from '../evaluate'; -import { useCourseStore } from '../main/store/store'; -import { COMMENT_ACTIONS } from '../main/store/types'; - -const Page: React.FC = () => { - const [allComments, setAllComments] = useState([]); - const [commentsLoaded, setCommentsLoaded] = useState(false); // 新增状态,标记评论是否已加载 - const [replyTo, setReplyTo] = useState(null); // 新增状态,存储被回复的评论 - const [replyContent, setReplyContent] = useState(''); // 存储回复内容 - const [placeholderContent, setplaceholderContent] = useState('写下你的评论...'); // 存储占位内容 - const inputRef = useRef(null); - - const [comment, setComment] = useState(null); //获取课评信息 - // const biz_id = 1; - const [biz_id, setBiz_id] = useState(null); - const updateInfo = useCourseStore((state) => state.comment); - useEffect(() => { - const handleQuery = () => { - const query = Taro.getCurrentInstance()?.router?.params; // 获取查询参数 - const serializedComment = query?.comment; - if (serializedComment) { - try { - // 解析字符串 - const parsedComment = JSON.parse(decodeURIComponent(serializedComment)); - setComment(parsedComment); - setBiz_id(parsedComment.id); - } catch (error) { - console.error('解析评论参数失败', error); - } - } - }; - - handleQuery(); - }, []); - useEffect(() => { - Taro.showLoading({ - title: '加载中', - }); - const fetchComments = async () => { - // console.log(biz_id) - try { - const res = await get( - `/comments/list?biz=Evaluation&biz_id=${biz_id}&cur_comment_id=0&limit=100` - ); - // console.log(res.data); - setAllComments(res.data); - setCommentsLoaded(true); - } catch (error) { - console.error('加载评论失败', error); - } - Taro.hideLoading(); - }; - - // 确保 biz_id 设置后再调用 fetchComments - if (biz_id !== null) { - fetchComments(); - } - }, [biz_id, commentsLoaded]); // 依赖项中添加biz_id - const [test, setTest] = useState(false); - useEffect(() => { - const getParams = async () => { - try { - const res = (await postBool('/checkStatus', { - name: 'kestack', - })) as StatusResponse; - - setTest(res.data.status); - } catch (error) { - console.error('Error fetching status:', error); - } - }; - - void getParams(); - }, []); - useEffect(() => { - console.log('test status updated:', test); - }, [test]); - const handleCommentClick = (comment: CommentType | null) => { - if (comment) { - setReplyTo(comment); - // 设置回复目标 - setplaceholderContent(`回复给${comment.user?.nickname}: `); // 初始化回复内容 - return; - } - if (inputRef.current) { - (inputRef.current as unknown as { focus: () => void }).focus(); - } - }; - - const handleReplyChange = (e: any) => { - setReplyContent(e.target.value); - }; - - const handleClearReply = () => { - setReplyTo(null); - setReplyContent(''); - setplaceholderContent('写下你的评论...'); - }; - - const handleReplySubmit = async () => { - if (!replyContent.trim()) return; // 忽略空内容 - const res = await updateInfo({ - biz: 'Evaluation', - action: COMMENT_ACTIONS.COMMENT, - id: biz_id ?? 0, - content: replyContent, - parentId: replyTo?.id || 0, - rootId: - replyTo?.root_comment_id === 0 ? replyTo?.id : replyTo?.root_comment_id || 0, - }); - setComment(res as CommentInfoType); - handleClearReply(); - // 评论发布成功后,重新加载评论 - setCommentsLoaded(false); // 先将commentsLoaded设为false,避免useEffect中的fetchComments不被调用 - const fetchComments = async () => { - try { - const res = await get( - `/comments/list?biz=Evaluation&biz_id=${biz_id}&cur_comment_id=0&limit=100` - ); - setAllComments(res.data); - setCommentsLoaded(true); - } catch (error) { - console.error('加载评论失败', error); - } - }; - await fetchComments(); - }; - - // 仅当评论数据加载完成时渲染CommentComponent - return !test ? ( - - - - - - - - 木犀课栈 此功能敬请期待 - - - - ) : ( - - - { - setComment({ - ...comment, - total_support_count: - props.total_support_count ?? (comment?.total_support_count || 0), - } as CommentInfoType); - }} - onCommentClick={() => handleCommentClick(null)} - /> - 评论区 - {commentsLoaded && ( - - )} - - - - - - - ); -}; - -export default Page; diff --git a/src/pages/publishQuestion/style.scss b/src/pages/publishQuestion/style.scss deleted file mode 100644 index 086bfff3..00000000 --- a/src/pages/publishQuestion/style.scss +++ /dev/null @@ -1,49 +0,0 @@ -.theClassnme { - font-size: 43rpx; - font-weight: bold; - color: #565552; - text-align: center; - margin-top: 22rpx; -} - -.teacherName { - font-size: 33rpx; - font-weight: 400; - color: #9f9f9c; - text-align: center; - margin-top: 15rpx; - margin-bottom: 27rpx; -} - -.publishView { - background-color: #f9f9f2; - position: relative; - padding: 45rpx; -} - -.askicon { - width: 44.44rpx; - height: 47.05rpx; - margin: 28rpx 0; -} - -.quesionContent { - flex: 1; - background-color: white; - font-size: 25rpx; - box-sizing: border-box; - width: 100%; - padding: 45rpx; - border-radius: 20rpx; - min-height: 40vh; -} - -.publishBtn { - margin-top: 60rpx; - width: 230.07rpx; - height: 70.65rpx; - border-radius: 50rpx; - background-color: #ffd777; - color: white; - line-height: 70.65rpx; -} diff --git a/src/pages/questionInfo/index.tsx b/src/pages/questionInfo/index.tsx deleted file mode 100644 index 36b63291..00000000 --- a/src/pages/questionInfo/index.tsx +++ /dev/null @@ -1,334 +0,0 @@ -/* eslint-disable no-console */ - -import { Input, View } from '@tarojs/components'; -import Taro from '@tarojs/taro'; -import React, { useEffect, useState } from 'react'; -import { AtFloatLayout } from 'taro-ui'; - -import './style.scss'; - -import CommentComponent from '@/common/components/CommentComponent/CommentComponent'; -import CourseInfo from '@/common/components/CourseInfo/CourseInfo'; -import QuestionDetail from '@/common/components/QuestionDetail/QuestionDetail'; -import { get, post } from '@/common/utils'; -import { postBool } from '@/common/utils/fetch'; - -import { StatusResponse } from '../evaluate'; - -interface IQuestion { - id: number; - questioner_id: number; - biz: string; - biz_id: number; - content: string; - answer_cnt: number; - preview_answers: null | Array<{ - id: number; - publisher_id: number; - question_id: number; - content: string; - utime: number; - ctime: number; - }>; - utime: number; - ctime: number; -} - -interface IAnswer { - id: number; - publisher_id: number; - question_id: number; - content: string; - stance: number; - total_support_count: number; - total_oppose_count: number; - total_comment_count: number; - utime: number; - ctime: number; -} - -interface ResponseType { - code: number; - data: number | CommentType[]; - msg: string; -} - -const Page: React.FC = () => { - const [course, setCourse] = useState(null); - - const [question, setQuestion] = useState(null); - - const [answers, setAnswers] = useState(null); - const [courseId, setCourseId] = useState(''); - const [questionId, setQuestionId] = useState(null); - const [test, setTest] = useState(false); - useEffect(() => { - const getParams = async () => { - try { - const res = (await postBool('/checkStatus', { - name: 'kestack', - })) as StatusResponse; - - setTest(res.data.status); - - // const instance = Taro.getCurrentInstance(); - // const params = instance?.router?.params || {}; - - // setId(params.id ? Number(params.id) : null); - // setName( - // params.name ? decodeURIComponent(params.name) : '只能评价自己学过的课程哦' - // ); - } catch (error) { - console.error('Error fetching status:', error); - } - }; - - void getParams(); - }, []); - useEffect(() => { - console.log('test status updated:', test); - }, [test]); - useEffect(() => { - const getParams = () => { - const instance = Taro.getCurrentInstance(); - const params = instance?.router?.params || {}; - - if (params.id) setQuestionId(params.id); - if (params.course_id) setCourseId(params.course_id); - }; - - getParams(); - }, []); - - useEffect(() => { - // eslint-disable-next-line @typescript-eslint/require-await - const getCourseData = async () => { - try { - void get(`/courses/${courseId}/detail`).then((res) => { - // 检查 res 是否有 data 属性,并且断言其类型 - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - setCourse(res?.data as Course); - }); - } catch (error) { - // 错误处理,例如弹出提示 - console.error('Failed to fetch course data:', error); - } - }; - - if (courseId) void getCourseData(); - - // eslint-disable-next-line @typescript-eslint/require-await - const getQuestionDetail = async () => { - try { - void get(`/questions/${questionId}/detail`).then((res) => { - // 检查 res 是否有 data 属性,并且断言其类型 - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - setQuestion(res?.data as IQuestion); - }); - } catch (error) { - // 错误处理,例如弹出提示 - console.error('Failed to fetch course data:', error); - } - }; - - if (courseId && questionId) void getQuestionDetail().then((r) => console.log(r)); - - // eslint-disable-next-line @typescript-eslint/require-await - const getAnswersList = async () => { - try { - void get( - `/answers/list/questions/${questionId}?cur_answer_id=${0}&limit=${100}` - ).then((res) => { - // 检查 res 是否有 data 属性,并且断言其类型 - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - setAnswers(res?.data as IAnswer[]); - }); - } catch (error) { - // 错误处理,例如弹出提示 - console.error('Failed to fetch course data:', error); - } - }; - - if (courseId && questionId) void getAnswersList().then((r) => console.log(r)); - }, [courseId, questionId]); // 在courseId变化时运行 - - const [currentAnswerId, setCurrentAnswerId] = useState(null); - const [isFloatLayoutVisible, setIsFloatLayoutVisible] = useState(false); - - // 处理浮动布局显示和隐藏的函数 - const handleFloatLayoutChange = (answerId: number | null) => { - setCurrentAnswerId(answerId); // 更新当前回答的 ID - setIsFloatLayoutVisible(answerId !== null); // 根据 answerId 是否为 null 来显示或隐藏浮动层 - }; - - //浮动弹窗逻辑 - const [allComments, setAllComments] = useState([]); - const [commentsLoaded, setCommentsLoaded] = useState(false); // 新增状态,标记评论是否已加载 - const [replyTo, setReplyTo] = useState(null); // 新增状态,存储被回复的评论 - const [replyContent, setReplyContent] = useState(''); // 存储回复内容 - const [placeholderContent, setplaceholderContent] = useState('写下你的评论...'); // 存储占位内容 - - const [commentNum, setCommentNum] = useState(null); - - // 将 fetchComments 函数移到 useEffect 外部 - const fetchComments = async () => { - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const res: ResponseType = await get( - `/comments/list?biz=Answer&biz_id=${currentAnswerId}&cur_comment_id=${0}&limit=${100}` - ); - setAllComments(res?.data as CommentType[]); - setCommentsLoaded(true); - } catch (error) { - console.error('加载评论失败', error); - } - }; - - useEffect(() => { - const fetchCommentNum = async () => { - // console.log(biz_id) - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const res: ResponseType = await get( - `/comments/count?biz=Answer&biz_id=${currentAnswerId}` - ); - // console.log(res.data); - setCommentNum(res?.data.toString()); - } catch (error) { - console.error('加载评论数目失败', error); - } - }; - - if (currentAnswerId !== null) { - void fetchCommentNum(); - void fetchComments(); - } - }, [currentAnswerId, commentsLoaded]); // 依赖项中添加biz_id - - const handleCommentClick = (comment: CommentType) => { - setReplyTo(comment); // 设置回复目标 - setplaceholderContent(`回复给${comment.user?.nickname}: `); // 初始化回复内容 - }; - - const handleReplyChange = (e) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - setReplyContent(e.target.value as string); - }; - - const handleClearReply = () => { - setReplyTo(null); - setReplyContent(''); - setplaceholderContent('写下你的评论...'); - }; - - const handleReplySubmit = async () => { - if (!replyContent.trim()) return; - - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const response = await post('/comments/publish', { - biz: 'Answer', - biz_id: currentAnswerId, - content: replyContent, - parent_id: replyTo?.id || 0, - root_id: - replyTo?.root_comment_id === 0 ? replyTo?.id : replyTo?.root_comment_id || 0, - }); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (response?.code === 409002) { - void Taro.showToast({ - title: '不能回答未上过的课', - icon: 'none', - duration: 2000, - }); - return; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (response?.code === 0) { - // 清空回复目标和输入框 - void Taro.showToast({ - title: '评论发布成功', - icon: 'success', - duration: 2000, - }); - setReplyTo(null); - setReplyContent(''); - setplaceholderContent('写下你的评论...'); - - // 评论发布成功后,重新加载评论 - setCommentsLoaded(false); - await fetchComments(); - - void Taro.showToast({ - title: '评论发布成功', - icon: 'success', - duration: 2000, - }); - } - } catch (error) { - console.error('评论发布失败:', error); - void Taro.showToast({ - title: '评论发布失败', - icon: 'error', - duration: 2000, - }); - } - }; - - return ( - - - {question ? ( - - ) : ( - '' - )} - - - handleFloatLayoutChange(null)} - > - - {commentsLoaded && ( - - )} - - { - e.stopPropagation(); - }} - onInput={handleReplyChange} - className="focus:border-primary h-10 flex-1 rounded-full border border-gray-300 px-4 text-sm focus:outline-none" - /> - { - e.stopPropagation(); - void handleReplySubmit(); - }} - > - 发布 - - - - - - - ); -}; - -export default Page; diff --git a/src/pages/questionInfo/style.scss b/src/pages/questionInfo/style.scss deleted file mode 100644 index 7d6b7e95..00000000 --- a/src/pages/questionInfo/style.scss +++ /dev/null @@ -1,47 +0,0 @@ -@import '~taro-ui/dist/style/components/float-layout.scss'; - -:root { - --primary-color: #3399ff; // 主题色 -} - -.shadow-up { - box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1); -} - -input { - &:focus { - border-color: var(--primary-color); - } -} - -.at-float-layout { - .at-float-layout__container { - border-top-left-radius: 16px; - border-top-right-radius: 16px; - } -} - -.flex-page .at-row { - margin-bottom: 40px; - - .at-col { - padding: 18px 16px; - color: #fff; - font-size: 28px; - text-align: center; - - &:nth-child(odd) { - background-color: $color-brand-light; - } - - &:nth-child(even) { - background-color: tint($color-brand-light, 20%); - } - } -} - -.panel__content { - .example-item + .example-item.example-item--card { - margin-top: 48px; - } -} diff --git a/src/pages/questionList/index.tsx b/src/pages/questionList/index.tsx deleted file mode 100644 index e207c1ba..00000000 --- a/src/pages/questionList/index.tsx +++ /dev/null @@ -1,137 +0,0 @@ -/* eslint-disable no-console */ -import { Button, Image, Text, View } from '@tarojs/components'; -import Taro, { useDidShow } from '@tarojs/taro'; -import { useEffect, useState } from 'react'; - -import { Icon, TopBackground } from '@/common/assets/img/login'; -import CourseInfo from '@/common/components/CourseInfo/CourseInfo'; -import QuestionListComponent from '@/common/components/QuestionListComponent/QuestionListComponent'; -import { get } from '@/common/utils'; -import { postBool } from '@/common/utils/fetch'; - -import { StatusResponse } from '../evaluate'; - -interface IQuestion { - id: number; - questioner_id: number; - biz: string; - biz_id: number; - content: string; - answer_cnt: number; - preview_answers: Array<{ - id: number; - publisher_id: number; - question_id: number; - content: string; - utime: number; - ctime: number; - }>; - utime: number; - ctime: number; -} - -const Page: React.FC = () => { - const [course, setCourse] = useState(null); - const [questions, setQuestions] = useState(null); - const [courseId, setCourseId] = useState(''); - const [test, setTest] = useState(false); - useEffect(() => { - const getParams = async () => { - try { - const res = (await postBool('/checkStatus', { - name: 'kestack', - })) as StatusResponse; - - setTest(res.data.status); - } catch (error) { - console.error('Error fetching status:', error); - } - }; - - void getParams(); - }, []); - useEffect(() => { - console.log('test status updated:', test); - }, [test]); - useEffect(() => { - const getParams = () => { - const instance = Taro.getCurrentInstance(); - const params = instance?.router?.params || {}; - - if (params.course_id) setCourseId(params.course_id); - }; - - getParams(); - }, []); - // eslint-disable-next-line @typescript-eslint/require-await - const getQuestionList = async () => { - try { - void get( - `/questions/list?biz=Course&biz_id=${courseId}&cur_question_id=0&limit=100` - ).then((res) => { - // 检查 res 是否有 data 属性,并且断言其类型 - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - setQuestions(res?.data as IQuestion[]); - }); - } catch (error) { - // 错误处理,例如弹出提示 - console.error('Failed to fetch course data:', error); - } - }; - - useEffect(() => { - // eslint-disable-next-line @typescript-eslint/require-await - const getCourseData = async () => { - try { - void get(`/courses/${courseId}/detail`).then((res) => { - // 检查 res 是否有 data 属性,并且断言其类型 - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - setCourse(res?.data as Course); - }); - } catch (error) { - // 错误处理,例如弹出提示 - console.error('Failed to fetch course data:', error); - } - }; - - if (courseId) void getCourseData(); - - if (courseId) void getQuestionList().then((r) => console.log(r)); - }, [courseId]); - useDidShow(() => { - if (courseId) void getQuestionList().then((r) => console.log(r)); - }); - - const handleAsk = () => { - void Taro.navigateTo({ - url: `/pages/publishQuestion/index?course_id=${courseId}`, - }); - }; - - return !test ? ( - - - - - - - - 木犀课栈 此功能敬请期待 - - - - ) : ( - - - {questions !== null && - questions.map((question, index) => ( - - ))} - - - ); -}; - -export default Page; diff --git a/src/pages/research/index.scss b/src/pages/research/index.scss new file mode 100644 index 00000000..d7bf6a52 --- /dev/null +++ b/src/pages/research/index.scss @@ -0,0 +1,87 @@ +.search_page_container { + margin-top: 24%; + height: 90vh; + width: 100%; + @include flex_column_align_center; + overflow: auto; + + .search_input_wrapper { + box-sizing: border-box; + margin-top: 10rpx; + width: 100%; + padding: $space_xl; + gap: $space_xxs; + } + + .history_section { + position: relative; + @include flex_column_align_center; + + .history_header { + margin-top: 2vh; + width: 90vw; + display: flex; + flex-direction: row; + justify-content: space-between; + + .history_title { + font-size: $font_size_heading; + font-weight: 500; + color: $color_text_dark; + } + + .clear_history_button { + height: 48.92rpx; + width: 48.92rpx; + top: 234rpx; + left: 621.38rpx; + border-radius: 100%; + @include flex_center; + .delete_icon { + width: 30rpx; + height: 30rpx; + } + } + } + + .history_result_container { + width: 90vw; + display: flex; + flex-direction: row; + flex-wrap: wrap; + } + } + + .search_empty_container { + margin-top: 30vh; + @include flex_column_align_center; + + .search_empty_text { + font-size: $font_size_title; + color: $color_text_dark; + font-weight: 500; + } + + .search_empty_hint { + margin-top: $space_sm; + font-size: $font_size_subtitle; + color: $color_text_secondary; + } + } + + .course_list_container { + margin-top: 26rpx; + width: 100%; + @include flex_column_center; + } + + &--results { + overflow: hidden; + + .course_list_container { + flex: 1; + min-height: 0; + margin-top: 0; + } + } +} diff --git a/src/pages/research/index.tsx b/src/pages/research/index.tsx index 9023b304..0fd7f742 100644 --- a/src/pages/research/index.tsx +++ b/src/pages/research/index.tsx @@ -1,184 +1,212 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ -/* eslint-disable @typescript-eslint/no-floating-promises */ -/* eslint-disable no-console */ import { Image, Text, View } from '@tarojs/components'; import Taro, { useLoad } from '@tarojs/taro'; -import React, { useEffect, useState } from 'react'; +import React, { memo, useCallback } from 'react'; -import './style.scss'; +import './index.scss'; -import Label1 from '@/common/components/label1/label1'; -import Label2 from '@/common/components/label2/label2'; -import SearchInput from '@/common/components/SearchInput/SearchInput'; -import { get } from '@/common/utils'; -import { put } from '@/common/utils/fetch'; +import { + emptySession, + SEARCH_LOCATION, + useResearchStore, +} from '@/pages/research/model'; + +import { deleteIcon } from '@/common/assets/img/icons'; +import { SearchInput, SearchLabel, VirtualList } from '@/common/components'; +import CourseLabel from '@/common/components/CourseLabel'; +import { hideLoadingThenToast } from '@/common/utils'; import { NavigationBar } from '@/modules/navigation'; -export interface Course { - id: number; - keyword: string; -} +import type { SearchHistoryItem, SearchResultCourse } from './types'; -export interface ClassInfo { - id: number; - name: string; - teacher: string; - composite_score: number; // 确保这个字段是number类型 - features: string[]; // 这个字段是一个字符串数组 - assessments?: object; // 使用?表示这个属性是可选的 -} +const COURSE_ITEM_SIZE = 369; + +const SearchCourseItem = memo( + ({ data, index }: { data: SearchResultCourse[]; index: number }) => { + const course = data[index]; + return ; + } +); interface ConditionalRenderProps { - isSpread: boolean; - classes: ClassInfo[]; - hrs: Course[]; + showResults: boolean; + session: ReturnType; + hrs: SearchHistoryItem[]; + handleSearch: (searchText: string) => void; handleDelete: () => void; + onLoadMore: () => void | Promise; + onRefresh: () => void | Promise; } const ConditionalRender: React.FC = ({ - isSpread, - classes, + showResults, + session, hrs, handleSearch, handleDelete, + onLoadMore, + onRefresh, }) => { - return isSpread ? ( - - {classes.map((each) => ( - - ))} - - ) : ( - - - 历史搜索 - - + if (!showResults) { + return ( + + + 历史搜索 + + + + + { + e.stopPropagation(); + }} + > + {hrs.map((hr) => ( + { + handleSearch(hr.keyword); + }} + /> + ))} - { - e.stopPropagation(); - }} - > - {hrs.map((hr) => ( - { - handleSearch(hr.keyword); - }} - /> - ))} - + ); + } + + return ( + + item.id} + initialLoading={session.loading} + EmptyChildren="没有找到相关课程" + /> ); }; const Page: React.FC = () => { - const [hrs, setHrs] = useState([]); - const [classes, setClasses] = useState([]); - const [isSpread, setSpread] = useState(false); - const [globalSearchText, setGlobalSearchText] = useState(''); + const history = useResearchStore((s) => s.history); + const session = useResearchStore((s) => s.sessions[SEARCH_LOCATION.HOME] ?? emptySession()); + const keyword = useResearchStore((s) => s.keyword); + const showResults = useResearchStore((s) => s.showResults); + const loadHistory = useResearchStore((s) => s.loadHistory); + const clearHistory = useResearchStore((s) => s.clearHistory); + const setKeyword = useResearchStore((s) => s.setKeyword); + const collapseResults = useResearchStore((s) => s.collapseResults); + const searchHome = useResearchStore((s) => s.searchHome); + const loadMore = useResearchStore((s) => s.loadMore); + const refreshSearch = useResearchStore((s) => s.refreshSearch); useLoad(() => { - console.log('Page loaded.'); - get('/search/history?search_location=Home').then((res) => { - console.log('获取到历史搜索信息'); - // console.log(res); - setHrs(res.data); + setKeyword(''); + collapseResults(); + void loadHistory().catch((error) => { + console.error('获取历史搜索异常:', error); + Taro.showToast({ + title: '获取历史搜索异常,请稍后重试', + icon: 'error', + duration: 2000, + }); }); }); - const handleSearchToggle = () => { - // setSpread(isSearchActive); - // Taro.navigateTo({ - // url: '/pages/research/index', - // }); - setSpread(false); - }; - const handleDelete = () => { - put('/search/history', { - remove_all: true, - remove_history_ids: [], - search_location: 'Home', - }).then((res) => { - console.log(res); - if (res.code === 0) { - setHrs([]); - Taro.showToast({ - title: '删除成功', - icon: 'success', - }); - } + Taro.showModal({ + title: '确认清除', + content: '确定要清除所有搜索历史吗?', + success: async (modalRes) => { + if (!modalRes.confirm) return; + try { + await clearHistory(); + Taro.showToast({ title: '删除成功', icon: 'success' }); + } catch (error) { + console.error('删除历史记录失败:', error); + Taro.showToast({ title: '删除失败,请稍后再试', icon: 'error' }); + } + }, }); }; - const handleSearch = (searchText: string) => { - Taro.showLoading({ - title: '搜索中', - }); - // console.log('搜索文本:', searchText); - setSpread(true); - get(`/search?biz=Course&keyword=${searchText}&search_location=Home`) - .then((res) => { - setClasses(res.data); - Taro.hideLoading(); - if (res.data.length === 0) { - Taro.showToast({ - title: '暂无内容', - icon: 'error', - }); - } - }) - .catch((err) => { - Taro.hideLoading(); - Taro.showToast({ - title: '搜索失败', - icon: 'error', - }); + const handleSearch = async (searchText: string) => { + if (!searchText || !searchText.trim()) { + Taro.showToast({ title: '请输入搜索内容', icon: 'error' }); + return; + } + + Taro.showLoading({ title: '搜索中' }); + try { + await searchHome(searchText); + Taro.hideLoading(); + } catch (error) { + console.error('搜索失败:', error); + hideLoadingThenToast({ + title: '搜索失败', + icon: 'error', }); + } }; - useEffect(() => { - console.log('课程已经被更新'); - }, [classes]); + const handleLoadMore = useCallback(async () => { + try { + await loadMore({ search_location: SEARCH_LOCATION.HOME }); + } catch (error) { + console.error('加载更多失败:', error); + Taro.showToast({ title: '加载更多失败', icon: 'none' }); + } + }, [loadMore]); + + const handleRefresh = useCallback(async () => { + try { + await refreshSearch({ search_location: SEARCH_LOCATION.HOME }); + } catch (error) { + console.error('刷新搜索失败:', error); + Taro.showToast({ title: '刷新失败', icon: 'none' }); + } + }, [refreshSearch]); return ( handleClick()} + className={`search_page_container ${showResults ? 'search_page_container--results' : ''}`} > - + - handleSearch(searchText)}> - 搜索 - + ); diff --git a/src/pages/research/model/index.ts b/src/pages/research/model/index.ts new file mode 100644 index 00000000..2101f4cc --- /dev/null +++ b/src/pages/research/model/index.ts @@ -0,0 +1,3 @@ +export { SEARCH_LOCATION, SEARCH_PAGE_SIZE, emptySession } from './searchSession'; +export type { SearchLocation, SearchSession } from './searchSession'; +export { useResearchStore } from './store'; diff --git a/src/pages/research/model/load.ts b/src/pages/research/model/load.ts new file mode 100644 index 00000000..4692d699 --- /dev/null +++ b/src/pages/research/model/load.ts @@ -0,0 +1,27 @@ +import { searchCourses } from '@/common/request/api/research'; +import type { SearchResultCourse } from '@/pages/research/types'; + +import { parseNextAfter, type SearchCursor } from './searchSession'; +import { enhanceCourses } from './transforms'; + +export interface SearchPageResult { + courses: SearchResultCourse[]; + nextAfter: SearchCursor | null; +} + +export async function fetchSearchPage( + keyword: string, + search_location: string, + cursor?: SearchCursor | null +): Promise { + const data = await searchCourses({ + biz: 'Course', + keyword, + search_location, + ...(cursor ? { id: cursor.id, score: cursor.score } : {}), + }); + return { + courses: enhanceCourses((data.courses || []) as SearchResultCourse[]), + nextAfter: parseNextAfter(data.next_after), + }; +} diff --git a/src/pages/research/model/searchSession.ts b/src/pages/research/model/searchSession.ts new file mode 100644 index 00000000..14da11eb --- /dev/null +++ b/src/pages/research/model/searchSession.ts @@ -0,0 +1,65 @@ +import type { SearchResultCourse } from '@/pages/research/types'; + +export const SEARCH_PAGE_SIZE = 10; + +export const SEARCH_LOCATION = { + HOME: 'Home', + COLLECTIONS: 'Collections', +} as const; + +export type SearchLocation = (typeof SEARCH_LOCATION)[keyof typeof SEARCH_LOCATION]; + +export interface SearchCursor { + id: number; + score: number; +} + +export interface SearchSession { + keyword: string; + results: SearchResultCourse[]; + cursor: SearchCursor | null; + hasMore: boolean; + loading: boolean; + loadingMore: boolean; + refreshing: boolean; + requestGen: number; +} + +export function emptySession(): SearchSession { + return { + keyword: '', + results: [], + cursor: null, + hasMore: true, + loading: false, + loadingMore: false, + refreshing: false, + requestGen: 0, + }; +} + +export function parseNextAfter(raw: unknown): SearchCursor | null { + if (!raw || typeof raw !== 'object') return null; + const { id, score } = raw as { id?: number; score?: number }; + if (id == null || score == null) return null; + return { id: Number(id), score: Number(score) }; +} + +/** 本页满页且后端返回 next_after → 还可继续加载 */ +export function hasMoreFromPage( + courses: SearchResultCourse[], + nextAfter: SearchCursor | null +): boolean { + if (courses.length < SEARCH_PAGE_SIZE) return false; + return nextAfter != null; +} + +export function mergeResults( + prev: SearchResultCourse[], + batch: SearchResultCourse[] +): SearchResultCourse[] { + if (!batch.length) return prev; + const seen = new Set(prev.map((c) => c.id)); + const appended = batch.filter((c) => !seen.has(c.id)); + return [...prev, ...appended]; +} diff --git a/src/pages/research/model/store.ts b/src/pages/research/model/store.ts new file mode 100644 index 00000000..a2830c67 --- /dev/null +++ b/src/pages/research/model/store.ts @@ -0,0 +1,256 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import { + deleteSearchHistory, + getSearchHistory, +} from '@/common/request/api/research'; +import type { DataSource } from '@/common/types/loadType'; +import { loadData } from '@/common/utils/loadData'; +import { registerSessionReset } from '@/common/utils/resetSession'; +import { createTaroJSONStorage } from '@/common/utils/storage'; +import type { SearchHistoryItem, SearchResultCourse } from '@/pages/research/types'; + +import { fetchSearchPage } from './load'; +import { + emptySession, + hasMoreFromPage, + mergeResults, + SEARCH_LOCATION, + type SearchLocation, + type SearchSession, +} from './searchSession'; + +interface ResearchStore { + history: SearchHistoryItem[]; + historySource: DataSource | null; + sessions: Partial>; + keyword: string; + showResults: boolean; + + loadHistory: () => Promise; + clearHistory: () => Promise; + setKeyword: (keyword: string) => void; + collapseResults: () => void; + getSession: (search_location: SearchLocation) => SearchSession; + searchFirst: ( + keyword: string, + options?: { search_location?: SearchLocation } + ) => Promise; + loadMore: (options?: { search_location?: SearchLocation }) => Promise; + refreshSearch: (options?: { search_location?: SearchLocation }) => Promise; + resetSession: (search_location: SearchLocation) => void; + clearUserData: () => void; + searchHome: (keyword: string) => Promise; +} + +function locationOf(options?: { search_location?: SearchLocation }): SearchLocation { + return options?.search_location ?? SEARCH_LOCATION.HOME; +} + +function patchSession( + location: SearchLocation, + patch: Partial | ((session: SearchSession) => Partial) +) { + return (state: ResearchStore): Partial => { + const current = state.sessions[location] ?? emptySession(); + const nextPatch = typeof patch === 'function' ? patch(current) : patch; + return { + sessions: { + ...state.sessions, + [location]: { ...current, ...nextPatch }, + }, + }; + }; +} + +export const useResearchStore = create()( + persist( + (set, get) => ({ + history: [], + historySource: null, + sessions: {}, + keyword: '', + showResults: false, + + setKeyword(keyword) { + set({ keyword }); + }, + + collapseResults() { + set({ showResults: false }); + }, + + getSession(search_location) { + return get().sessions[search_location] ?? emptySession(); + }, + + resetSession(search_location) { + set(patchSession(search_location, emptySession())); + }, + + clearUserData() { + set({ + history: [], + historySource: null, + sessions: {}, + keyword: '', + showResults: false, + }); + }, + + async loadHistory() { + const result = await loadData({ + strategy: 'network-first', + getCache: () => { + const h = get().history; + return h.length ? h : null; + }, + fetch: async () => { + const res = (await getSearchHistory({ + search_location: SEARCH_LOCATION.HOME, + })) as SearchHistoryItem[]; + return Array.isArray(res) ? res : []; + }, + setCache: (history) => set({ history }), + }); + set({ history: result.data, historySource: result.source }); + return result.data; + }, + + async clearHistory() { + await deleteSearchHistory({ + remove_all: true, + remove_history_ids: [], + search_location: SEARCH_LOCATION.HOME, + }); + set({ history: [], historySource: 'network' }); + }, + + async searchFirst(keyword, options) { + const location = locationOf(options); + const trimmed = keyword.trim(); + if (!trimmed) { + get().resetSession(location); + return []; + } + + const requestGen = (get().sessions[location]?.requestGen ?? 0) + 1; + set(patchSession(location, { + keyword: trimmed, + results: [], + cursor: null, + hasMore: true, + loading: true, + loadingMore: false, + refreshing: false, + requestGen, + })); + + try { + const page = await fetchSearchPage(trimmed, location); + if (get().sessions[location]?.requestGen !== requestGen) return []; + + const hasMore = hasMoreFromPage(page.courses, page.nextAfter); + set(patchSession(location, { + results: page.courses, + cursor: page.nextAfter, + hasMore, + loading: false, + })); + return page.courses; + } catch (e) { + if (get().sessions[location]?.requestGen === requestGen) { + set(patchSession(location, { loading: false, hasMore: false })); + } + throw e; + } + }, + + async loadMore(options) { + const location = locationOf(options); + const session = get().getSession(location); + if ( + !session.keyword || + !session.hasMore || + session.loading || + session.loadingMore || + !session.cursor + ) { + return session.results; + } + + const { requestGen, keyword, cursor, results } = session; + set(patchSession(location, { loadingMore: true })); + + try { + const page = await fetchSearchPage(keyword, location, cursor); + if (get().sessions[location]?.requestGen !== requestGen) { + return get().getSession(location).results; + } + + const merged = mergeResults(results, page.courses); + const hasMore = hasMoreFromPage(page.courses, page.nextAfter); + set(patchSession(location, { + results: merged, + cursor: page.nextAfter, + hasMore, + loadingMore: false, + })); + return merged; + } catch (e) { + set(patchSession(location, { loadingMore: false })); + throw e; + } + }, + + async refreshSearch(options) { + const location = locationOf(options); + const session = get().getSession(location); + if (!session.keyword) return []; + + const { requestGen, keyword } = session; + set(patchSession(location, { refreshing: true })); + + try { + const page = await fetchSearchPage(keyword, location); + if (get().sessions[location]?.requestGen !== requestGen) { + return get().getSession(location).results; + } + + const hasMore = hasMoreFromPage(page.courses, page.nextAfter); + set(patchSession(location, { + results: page.courses, + cursor: page.nextAfter, + hasMore, + refreshing: false, + })); + return page.courses; + } catch (e) { + set(patchSession(location, { refreshing: false })); + throw e; + } + }, + + async searchHome(keyword) { + const trimmed = keyword.trim(); + set({ keyword: trimmed, showResults: true }); + set((s) => { + const filtered = s.history.filter((item) => item.keyword !== trimmed); + return { + history: [{ id: Date.now(), keyword: trimmed }, ...filtered].slice(0, 20), + }; + }); + + return get().searchFirst(trimmed, { search_location: SEARCH_LOCATION.HOME }); + }, + }), + { + name: 'research-store', + storage: createTaroJSONStorage(), + partialize: (state) => ({ history: state.history }), + } + ) +); + +registerSessionReset(() => useResearchStore.getState().clearUserData()); diff --git a/src/pages/research/model/transforms.ts b/src/pages/research/model/transforms.ts new file mode 100644 index 00000000..a9e7960a --- /dev/null +++ b/src/pages/research/model/transforms.ts @@ -0,0 +1,18 @@ +import { + translateAssessments, + translateCourseProperty, + translateFeatures, +} from '@/common/constants/courseLabels'; +import type { SearchResultCourse } from '@/pages/research/types'; + +export function enhanceCourses(courses: SearchResultCourse[]): SearchResultCourse[] { + return courses.map((course) => ({ + ...course, + courseType: translateCourseProperty( + (course as { type?: string; property?: string }).type ?? + (course as { property?: string }).property + ), + features: translateFeatures(course.features as string[]), + assessments: translateAssessments(course.assessments), + })); +} diff --git a/src/pages/research/style.scss b/src/pages/research/style.scss deleted file mode 100644 index 493213b1..00000000 --- a/src/pages/research/style.scss +++ /dev/null @@ -1,35 +0,0 @@ -.lsss { - width: auto; - height: 60rpx; - font-size: 45rpx; - font-family: Inter-Bold-, Inter-Bold; - font-weight: normal; - color: #565656; -} -.historyResult { - width: 80vw; - display: flex; - flex-direction: row; - flex-wrap: wrap; -} -.button { - background-color: #f9f9f2; - height: 48.91rpx; - width: 48.91rpx; - top: 234rpx; - left: 621.38rpx; - border-radius: 100%; - box-shadow: 0rpx 5.43rpx 10.87rpx 1.81rpx rgba(0, 0, 0, 0.16); - display: flex; - justify-content: center; - align-items: center; -} - -.tj { - margin-top: 50rpx; - width: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} diff --git a/src/pages/research/types.ts b/src/pages/research/types.ts new file mode 100644 index 00000000..bbd677a6 --- /dev/null +++ b/src/pages/research/types.ts @@ -0,0 +1,18 @@ +export interface SearchHistoryItem { + id: number; + keyword: string; +} + +export interface SearchResultCourse { + id: number; + name: string; + teacher: string; + composite_score: number; + /** 搜索相关性分数,仅用于分页游标 */ + score?: number; + features?: string[] | Record; + assessments?: string[] | Record; + type?: number; + property?: number; + courseType?: string; +} diff --git a/src/pages/shareGrades/index.config.ts b/src/pages/shareGrades/index.config.ts deleted file mode 100644 index 84264e51..00000000 --- a/src/pages/shareGrades/index.config.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default definePageConfig({ - navigationBarTitleText: '成绩共享计划', - // navigationStyle: 'custom', - disableScroll: true, -}); diff --git a/src/pages/shareGrades/index.tsx b/src/pages/shareGrades/index.tsx deleted file mode 100644 index 783fe51c..00000000 --- a/src/pages/shareGrades/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Button, Text, View } from '@tarojs/components'; -import Taro from '@tarojs/taro'; - -import './style.scss'; - -import { post } from '@/common/utils'; - -const Page: React.FC = () => { - const handleSubmit = () => { - try { - void post('/grades/sign', { wants_to_sign: true }).then((r) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (r.msg === '重复签约') { - void Taro.showToast({ - icon: 'error', - title: '请勿重复签约', - }); - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (r.msg === 'Success') { - void Taro.showToast({ - icon: 'success', - title: '签约成功!', - }); - } - }); - } catch (err) { - console.error(err); - } - }; - - return ( - - 成绩共享计划 - - 该部分的课程成绩信息是我们在征得用户同意后, - 匿名 - - 收集用户成绩数据所得到的统计结果。查看该信息需要您加入我们的课程成绩共享计划, - 提供自己的过往成绩信息作为数据分析的一部分。我们将根据隐私条例中的内容,保证您的个人信息受到安全保护。 - - - - - ); -}; - -export default Page; diff --git a/src/pages/shareGrades/style.scss b/src/pages/shareGrades/style.scss deleted file mode 100644 index e8ed2f01..00000000 --- a/src/pages/shareGrades/style.scss +++ /dev/null @@ -1,5 +0,0 @@ -// .card { -// width: 90%; -// height: 60%; -// font-size: 35rpx; -// } diff --git a/src/common/hooks/useActiveNav.ts b/src/store/app/activeButton.ts similarity index 53% rename from src/common/hooks/useActiveNav.ts rename to src/store/app/activeButton.ts index 78987c8a..12a31e3c 100644 --- a/src/common/hooks/useActiveNav.ts +++ b/src/store/app/activeButton.ts @@ -1,5 +1,7 @@ import { create } from 'zustand'; +import { registerSessionReset } from '@/common/utils/resetSession'; + export type ActiveButtonType = 'Home' | 'Download' | '+' | 'Massage' | 'Profile'; interface ActiveButtonState { @@ -7,9 +9,10 @@ interface ActiveButtonState { setActiveButton: (button: ActiveButtonType) => void; } -const useActiveButtonStore = create((set) => ({ +/** Tab 栏高亮等全局 UI 态(非业务实体) */ +export const useActiveButtonStore = create((set) => ({ activeButton: 'Home', setActiveButton: (button) => set({ activeButton: button }), })); -export default useActiveButtonStore; +registerSessionReset(() => useActiveButtonStore.getState().setActiveButton('Home')); diff --git a/src/store/app/index.ts b/src/store/app/index.ts new file mode 100644 index 00000000..eecedcc7 --- /dev/null +++ b/src/store/app/index.ts @@ -0,0 +1,2 @@ +export { useActiveButtonStore } from './activeButton'; +export type { ActiveButtonType } from './activeButton'; diff --git a/src/store/collections/index.ts b/src/store/collections/index.ts new file mode 100644 index 00000000..c5823b89 --- /dev/null +++ b/src/store/collections/index.ts @@ -0,0 +1 @@ +export { useMyCollectionsStore } from './store'; diff --git a/src/store/collections/store.ts b/src/store/collections/store.ts new file mode 100644 index 00000000..7efde962 --- /dev/null +++ b/src/store/collections/store.ts @@ -0,0 +1,97 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import { getCollectionList } from '@/common/request/api/courses'; +import { CollectionProps } from '@/common/types/collectionsType'; +import type { DataSource } from '@/common/types/loadType'; +import { loadData } from '@/common/utils/loadData'; +import { registerSessionReset } from '@/common/utils/resetSession'; +import { createTaroJSONStorage } from '@/common/utils/storage'; + +interface CollectionsStore { + collectionsCache: CollectionProps[]; + source: DataSource | null; + + setCollections: (collections: CollectionProps[]) => void; + addCollection: (course: CollectionProps) => void; + removeCollection: (courseId: number) => void; + load: ( + params: { cur_collection_id: number; limit: number }, + options?: { force?: boolean } + ) => Promise; + refresh: () => Promise; + clearCache: () => void; + reset: () => void; +} + +export const useMyCollectionsStore = create()( + persist( + (set, get) => ({ + collectionsCache: [], + source: null, + + setCollections: (collections) => set({ collectionsCache: collections }), + + async load(params, options) { + const result = await loadData({ + strategy: 'cache-first', + force: options?.force, + getCache: () => { + const cached = get().collectionsCache; + return cached.length ? cached : null; + }, + fetch: async () => { + const data = (await getCollectionList(params)) as CollectionProps[]; + return Array.isArray(data) ? data : []; + }, + setCache: (collections) => set({ collectionsCache: collections }), + }); + set({ source: result.source }); + return result.data; + }, + + addCollection: (course) => { + const { collectionsCache } = get(); + const isExist = collectionsCache.some( + (item) => item.course_id === course.course_id + ); + if (!isExist) { + set({ collectionsCache: [course, ...collectionsCache] }); + } + }, + + removeCollection: (courseId) => { + set((state) => ({ + collectionsCache: state.collectionsCache.filter( + (item) => item.course_id !== courseId + ), + })); + }, + + refresh: async () => { + try { + const data = (await getCollectionList({ + cur_collection_id: 0, + limit: 50, + })) as CollectionProps[]; + if (Array.isArray(data) && data.length > 0) { + set({ collectionsCache: data }); + } + } catch (e) { + console.error('[useCollections] 刷新失败:', e); + } + }, + + clearCache: () => set({ collectionsCache: [] }), + + reset: () => set({ collectionsCache: [], source: null }), + }), + { + name: 'user_collections_storage', + storage: createTaroJSONStorage(), + partialize: (state) => ({ collectionsCache: state.collectionsCache }), + } + ) +); + +registerSessionReset(() => useMyCollectionsStore.getState().reset()); diff --git a/src/store/course/index.ts b/src/store/course/index.ts new file mode 100644 index 00000000..6455f598 --- /dev/null +++ b/src/store/course/index.ts @@ -0,0 +1 @@ +export { useCourseStore } from './store'; diff --git a/src/store/course/store.ts b/src/store/course/store.ts new file mode 100644 index 00000000..df71a4fe --- /dev/null +++ b/src/store/course/store.ts @@ -0,0 +1,68 @@ +import { create } from 'zustand'; + +import { getCourseDetail as getCourseDetailApi } from '@/common/request/api/courses'; +import type { CourseDetailSlice, CourseDetailsType } from '@/common/types/courseType'; +import { registerSessionReset } from '@/common/utils/resetSession'; + +const MAX_COURSE_DETAILS = 100; + +const pendingRequests = new Map>(); + +/** 课程详情实体仓:按 courseId 缓存课程名/教师/学院,首页卡片、班级页、详情页共用同一份。 */ +export const useCourseStore = create()((set, get) => ({ + courseDetail: {}, + + cacheCourseDetails(courses: Record) { + const entries = Object.entries(courses); + if (!entries.length) return; + set((state) => { + const next = { ...state.courseDetail, ...courses }; + const keys = Object.keys(next); + while (keys.length > MAX_COURSE_DETAILS) { + delete next[Number(keys.shift()!)]; + } + return { courseDetail: next }; + }); + }, + + fetchCourseDetail(courseId: number) { + const pending = pendingRequests.get(courseId); + if (pending) return pending; + + const promise = getCourseDetailApi(courseId) + .then((res: CourseDetailsType) => { + set((state) => { + const next = { ...state.courseDetail, [courseId]: res }; + const keys = Object.keys(next); + if (keys.length > MAX_COURSE_DETAILS) { + delete next[Number(keys[0])]; + } + return { courseDetail: next }; + }); + return res; + }) + .catch((e) => { + console.error('[courseStore] 获取课程详情失败:', e); + throw e; + }) + .finally(() => { + pendingRequests.delete(courseId); + }); + + pendingRequests.set(courseId, promise); + return promise; + }, + + getCourseDetail(courseId) { + if (!courseId) return Promise.resolve(null); + const local = get().courseDetail[courseId]; + return local ? Promise.resolve(local) : get().fetchCourseDetail(courseId); + }, + + clear() { + pendingRequests.clear(); + set({ courseDetail: {} }); + }, +})); + +registerSessionReset(() => useCourseStore.getState().clear()); diff --git a/src/store/evaluationHistory/cache.ts b/src/store/evaluationHistory/cache.ts new file mode 100644 index 00000000..cb6e2b7b --- /dev/null +++ b/src/store/evaluationHistory/cache.ts @@ -0,0 +1,144 @@ +import type { EvaluationStatus } from '@/common/request/api/evaluations'; +import type { CommentInfo } from '@/common/types/commentTypes'; +import { toggleCommentLike } from '@/common/utils/comment'; + +import type { EvaluationHistoryCache } from './types'; +import { PAGE_SIZE } from './types'; + +export function removeItemFromCache( + cache: Record, + status: EvaluationStatus, + evaluationId: number +): Record | null { + const current = cache[status]; + if (!current) return null; + return { + ...cache, + [status]: { + ...current, + list: current.list.filter((item) => item.id !== evaluationId), + }, + }; +} + +export function moveItemInCache( + cache: Record, + fromStatus: EvaluationStatus, + toStatus: EvaluationStatus, + evaluationId: number +): Record | null { + const fromCache = cache[fromStatus]; + if (!fromCache) return null; + + const item = fromCache.list.find((i) => i.id === evaluationId); + if (!item) return null; + + const toCache = cache[toStatus]; + const updatedItem = { ...item, status: toStatus }; + + return { + ...cache, + [fromStatus]: { + ...fromCache, + list: fromCache.list.filter((i) => i.id !== evaluationId), + }, + [toStatus]: { + list: [updatedItem, ...(toCache?.list ?? [])], + lastId: toCache?.lastId ?? (updatedItem.id as number), + hasMore: toCache?.hasMore ?? true, + status: toStatus, + }, + }; +} + +export function invalidateStatusCache( + cache: Record, + status: EvaluationStatus +): Record { + const { [status]: _, ...rest } = cache; + return rest; +} + +export function buildCacheBucket( + list: CommentInfo[], + status: EvaluationStatus +): EvaluationHistoryCache { + return { + list, + lastId: list[list.length - 1].id!, + hasMore: list.length >= PAGE_SIZE, + status, + }; +} + +export function patchLikeInCache( + cache: Record, + evaluationId: number, + willLike: boolean +): { cache: Record; patched?: CommentInfo } { + const targetId = Number(evaluationId); + let patched: CommentInfo | undefined; + let changed = false; + const nextCache = { ...cache }; + + for (const status of Object.keys(nextCache)) { + const bucket = nextCache[status]; + if (!bucket) continue; + const idx = bucket.list.findIndex((c) => Number(c.id) === targetId); + if (idx === -1) continue; + const nextList = [...bucket.list]; + patched = toggleCommentLike(nextList[idx], willLike); + nextList[idx] = patched; + nextCache[status] = { ...bucket, list: nextList }; + changed = true; + } + + return changed ? { cache: nextCache, patched } : { cache }; +} + +export function applyAppendLoadResult( + cache: Record, + status: EvaluationStatus, + prevList: CommentInfo[], + newData: CommentInfo[] +): { cache: Record; merged: CommentInfo[] } { + const merged = [...prevList, ...newData]; + return { + merged, + cache: { + ...cache, + [status]: { + list: merged, + lastId: merged[merged.length - 1]?.id, + hasMore: newData.length >= PAGE_SIZE, + status, + }, + }, + }; +} + +export function applyInitialLoadResult( + cache: Record, + status: EvaluationStatus, + data: CommentInfo[] +): Record | null { + if (!data.length) return null; + return { ...cache, [status]: buildCacheBucket(data, status) }; +} + +export function applyRefreshResult( + cache: Record, + status: EvaluationStatus, + data: CommentInfo[] +): Record | null { + if (!data.length) return null; + return { + ...cache, + [status]: { + list: data, + lastId: data[data.length - 1].id, + hasMore: data.length >= PAGE_SIZE, + status, + }, + }; +} diff --git a/src/store/evaluationHistory/index.ts b/src/store/evaluationHistory/index.ts new file mode 100644 index 00000000..7726fcc7 --- /dev/null +++ b/src/store/evaluationHistory/index.ts @@ -0,0 +1,2 @@ +export { useEvaluationHistoryStore } from './store'; +export type { EvaluationHistoryCache } from './types'; diff --git a/src/store/evaluationHistory/loaders.ts b/src/store/evaluationHistory/loaders.ts new file mode 100644 index 00000000..f32da60f --- /dev/null +++ b/src/store/evaluationHistory/loaders.ts @@ -0,0 +1,38 @@ +import type { EvaluationStatus } from '@/common/request/api/evaluations'; +import { + getEvaluationHistory, + toggleEvaluationStatus, +} from '@/common/request/api/evaluations'; +import type { CommentInfo } from '@/common/types/commentTypes'; + +import { PAGE_SIZE } from './types'; + +export async function fetchHistoryPage( + status: EvaluationStatus, + curLastId: number +): Promise { + const res = await getEvaluationHistory({ + cur_evaluation_id: curLastId || undefined, + limit: PAGE_SIZE, + status, + }); + return (Array.isArray(res) ? res : []) as CommentInfo[]; +} + +export async function fetchHistoryRefresh( + status: EvaluationStatus +): Promise { + const res = await getEvaluationHistory({ + cur_evaluation_id: undefined, + limit: PAGE_SIZE, + status, + }); + return (Array.isArray(res) ? res : []) as CommentInfo[]; +} + +export async function apiToggleEvaluationStatus( + evaluationId: number, + targetStatus: EvaluationStatus +): Promise { + await toggleEvaluationStatus(evaluationId, { status: targetStatus }); +} diff --git a/src/store/evaluationHistory/store.ts b/src/store/evaluationHistory/store.ts new file mode 100644 index 00000000..4f393b1d --- /dev/null +++ b/src/store/evaluationHistory/store.ts @@ -0,0 +1,181 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import type { CommentInfo } from '@/common/types/commentTypes'; +import { createTaroJSONStorage } from '@/common/utils/storage'; +import { loadData } from '@/common/utils/loadData'; +import { registerSessionReset } from '@/common/utils/resetSession'; + +import { + applyAppendLoadResult, + applyInitialLoadResult, + applyRefreshResult, + buildCacheBucket, + invalidateStatusCache, + moveItemInCache, + patchLikeInCache, + removeItemFromCache, +} from './cache'; +import { + apiToggleEvaluationStatus, + fetchHistoryPage, + fetchHistoryRefresh, +} from './loaders'; +import type { EvaluationHistoryStore } from './types'; + +let fetchInFlight = false; + +export const useEvaluationHistoryStore = create()( + persist( + (set, get) => ({ + cache: {}, + source: null, + activeStatus: 'Public', + loading: true, + + async setActiveStatus(status) { + if (status === get().activeStatus) return; + set({ activeStatus: status }); + await get().fetchPage(false); + }, + + async fetchPage(append) { + if (fetchInFlight) return []; + fetchInFlight = true; + const { activeStatus, cache } = get(); + const cached = cache[activeStatus]; + const curLastId = append ? (cached?.lastId ?? 0) : 0; + + set({ loading: append ? false : !(cached?.list?.length) }); + try { + return await get().load(activeStatus, curLastId, append); + } finally { + fetchInFlight = false; + set({ loading: false }); + } + }, + + async loadMore() { + const { cache, activeStatus } = get(); + const cached = cache[activeStatus]; + if (fetchInFlight || cached?.hasMore === false) return; + await get().fetchPage(true); + }, + + setCache: (status, data) => { + if (!data.list?.length) return; + set((state) => ({ + cache: { ...state.cache, [status]: data }, + })); + }, + + removeItem: (status, evaluationId) => { + set((state) => { + const next = removeItemFromCache(state.cache, status, evaluationId); + return next ? { cache: next } : state; + }); + }, + + moveItem: (fromStatus, toStatus, evaluationId) => { + set((state) => { + const next = moveItemInCache(state.cache, fromStatus, toStatus, evaluationId); + return next ? { cache: next } : state; + }); + }, + + invalidateCache: (status) => { + set((state) => ({ cache: invalidateStatusCache(state.cache, status) })); + }, + + async load(status, curLastId, append) { + const cached = get().cache[status]; + const result = await loadData({ + strategy: 'network-first', + getCache: () => { + if (append || curLastId > 0) return null; + return cached?.list?.length ? cached.list : null; + }, + fetch: () => fetchHistoryPage(status, curLastId), + setCache: (list) => { + if (list.length > 0 && !append && curLastId === 0) { + set((state) => ({ + cache: { + ...state.cache, + [status]: buildCacheBucket(list, status), + }, + })); + } + }, + }); + + if (append && curLastId > 0) { + const { cache: nextCache, merged } = applyAppendLoadResult( + get().cache, + status, + cached?.list || [], + result.data + ); + set({ cache: nextCache, source: result.source }); + return merged; + } + + if (!append) { + if (result.source === 'network') { + const nextCache = + result.data.length > 0 + ? applyInitialLoadResult(get().cache, status, result.data) + : { + ...get().cache, + [status]: { + list: [], + lastId: undefined, + hasMore: false, + status, + }, + }; + if (nextCache) set({ cache: nextCache, source: result.source }); + } else { + set({ source: result.source }); + } + } + return result.data; + }, + + async toggleStatus(evaluationId, targetStatus, currentStatus) { + await apiToggleEvaluationStatus(evaluationId, targetStatus); + get().moveItem(currentStatus, targetStatus, evaluationId); + }, + + refresh: async (status) => { + try { + const data = await fetchHistoryRefresh(status); + const nextCache = applyRefreshResult(get().cache, status, data); + if (nextCache) set({ cache: nextCache }); + return data; + } catch (e) { + console.error('[useEvaluationHistory] 刷新失败:', e); + return get().cache[status]?.list || []; + } + }, + + clearCache: () => set({ cache: {} }), + + patchEvaluationLike(evaluationId, willLike) { + let patched: CommentInfo | undefined; + set((state) => { + const result = patchLikeInCache(state.cache, evaluationId, willLike); + patched = result.patched; + return result.patched ? { cache: result.cache } : state; + }); + return patched; + }, + }), + { + name: 'evaluation_history_cache', + storage: createTaroJSONStorage(), + partialize: (state) => ({ cache: state.cache }), + } + ) +); + +registerSessionReset(() => useEvaluationHistoryStore.getState().clearCache()); diff --git a/src/store/evaluationHistory/types.ts b/src/store/evaluationHistory/types.ts new file mode 100644 index 00000000..7b7b45a4 --- /dev/null +++ b/src/store/evaluationHistory/types.ts @@ -0,0 +1,45 @@ +import type { EvaluationStatus } from '@/common/request/api/evaluations'; +import type { CommentInfo } from '@/common/types/commentTypes'; + +import type { DataSource } from '@/common/types/loadType'; + +export interface EvaluationHistoryCache { + list: CommentInfo[]; + lastId: number | undefined; + hasMore: boolean; + status: EvaluationStatus; +} + +export interface EvaluationHistoryStore { + cache: Record; + source: DataSource | null; + activeStatus: EvaluationStatus; + loading: boolean; + + setCache: (status: EvaluationStatus, data: EvaluationHistoryCache) => void; + setActiveStatus: (status: EvaluationStatus) => Promise; + fetchPage: (append: boolean) => Promise; + loadMore: () => Promise; + load: ( + status: EvaluationStatus, + curLastId: number, + append: boolean + ) => Promise; + toggleStatus: ( + evaluationId: number, + targetStatus: EvaluationStatus, + currentStatus: EvaluationStatus + ) => Promise; + removeItem: (status: EvaluationStatus, evaluationId: number) => void; + moveItem: ( + fromStatus: EvaluationStatus, + toStatus: EvaluationStatus, + evaluationId: number + ) => void; + invalidateCache: (status: EvaluationStatus) => void; + refresh: (status: EvaluationStatus) => Promise; + clearCache: () => void; + patchEvaluationLike: (evaluationId: number, willLike: boolean) => CommentInfo | undefined; +} + +export const PAGE_SIZE = 10; diff --git a/src/store/evaluations/index.ts b/src/store/evaluations/index.ts new file mode 100644 index 00000000..0038c7c7 --- /dev/null +++ b/src/store/evaluations/index.ts @@ -0,0 +1 @@ +export { useEvaluationStore } from './store'; diff --git a/src/store/evaluations/store.ts b/src/store/evaluations/store.ts new file mode 100644 index 00000000..fca9c241 --- /dev/null +++ b/src/store/evaluations/store.ts @@ -0,0 +1,114 @@ +import { create } from 'zustand'; + +import { endorseEvaluation as endorseEvaluationApi } from '@/common/request/api/evaluations'; +import type { CommentInfo } from '@/common/types/commentTypes'; +import { toggleCommentLike } from '@/common/utils/comment'; +import { useEvaluationHistoryStore } from '../evaluationHistory/store'; +import { registerSessionReset } from '@/common/utils/resetSession'; + +const MAX_ENTITIES = 500; + +/** + * 课评实体仓:全项目「一条课评」的唯一真相,按 id 存。 + * 首页 / 班级页 / 历史页 / 详情页都只引用这里的同一份; + * 点赞、评论数等就地改动只改这里一处,所有页面自动联动,无需扇出。 + */ +interface EvaluationEntityStore { + byId: Record; + + upsertMany: (items: CommentInfo[]) => void; + upsertOne: (item: CommentInfo) => void; + get: (id: number) => CommentInfo | undefined; + patch: (id: number, fields: Partial) => CommentInfo | undefined; + toggleLike: (id: number, willLike: boolean) => CommentInfo | undefined; + incrementCommentCount: (id: number, delta?: number) => CommentInfo | undefined; + /** API 点赞/取消 + 实体仓更新 + 历史页副本同步 */ + endorse: (id: number, willLike: boolean) => Promise; + clear: () => void; +} + +function idOf(item: CommentInfo): number { + return Number(item.id); +} + +export const useEvaluationStore = create()((set, get) => ({ + byId: {}, + + upsertMany(items) { + if (!items.length) return; + set((state) => { + const next = { ...state.byId }; + for (const item of items) { + const id = idOf(item); + if (!id) continue; + // 合并而非替换:字段少的接口不能洗掉字段多的接口已有的数据 + next[id] = { ...next[id], ...item }; + } + const keys = Object.keys(next); + while (keys.length > MAX_ENTITIES) { + delete next[Number(keys.shift())]; + } + return { byId: next }; + }); + }, + + upsertOne(item) { + get().upsertMany([item]); + }, + + get(id) { + return get().byId[Number(id)]; + }, + + patch(id, fields) { + const key = Number(id); + let patched: CommentInfo | undefined; + set((state) => { + const prev = state.byId[key]; + if (!prev) return state; + patched = { ...prev, ...fields }; + return { byId: { ...state.byId, [key]: patched } }; + }); + return patched; + }, + + toggleLike(id, willLike) { + const key = Number(id); + let patched: CommentInfo | undefined; + set((state) => { + const prev = state.byId[key]; + if (!prev) return state; + patched = toggleCommentLike(prev, willLike); + return { byId: { ...state.byId, [key]: patched } }; + }); + return patched; + }, + + incrementCommentCount(id, delta = 1) { + const key = Number(id); + let patched: CommentInfo | undefined; + set((state) => { + const prev = state.byId[key]; + if (!prev) return state; + patched = { + ...prev, + total_comment_count: (prev.total_comment_count || 0) + delta, + }; + return { byId: { ...state.byId, [key]: patched } }; + }); + return patched; + }, + + async endorse(id, willLike) { + await endorseEvaluationApi(id, { stance: willLike ? 1 : 0 }); + const patched = get().toggleLike(id, willLike); + useEvaluationHistoryStore.getState().patchEvaluationLike(id, willLike); + return patched; + }, + + clear() { + set({ byId: {} }); + }, +})); + +registerSessionReset(() => useEvaluationStore.getState().clear()); diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 00000000..740027db --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,16 @@ +export { useActiveButtonStore } from './app'; +export type { ActiveButtonType } from './app'; + +export { useMyCollectionsStore } from './collections'; +export { useCourseStore } from './course'; +export { usePublisherStore } from './publisher'; + +export { useEvaluationHistoryStore } from './evaluationHistory/store'; +export type { EvaluationHistoryCache } from './evaluationHistory/types'; +export { useEvaluationStore } from './evaluations'; + +export { useMyClassStore } from './myClass'; +export { useNotificationStore } from './notification'; + +export { useUserStore } from './user'; + diff --git a/src/store/myClass/index.ts b/src/store/myClass/index.ts new file mode 100644 index 00000000..fbc42709 --- /dev/null +++ b/src/store/myClass/index.ts @@ -0,0 +1 @@ +export { useMyClassStore } from './store'; diff --git a/src/store/myClass/store.ts b/src/store/myClass/store.ts new file mode 100644 index 00000000..c56d84f7 --- /dev/null +++ b/src/store/myClass/store.ts @@ -0,0 +1,117 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import { getUserCourses } from '@/common/request/api/courses'; +import type { DataSource } from '@/common/types/loadType'; +import { MyCourseProps } from '@/common/types/myCourseType'; +import { loadData } from '@/common/utils/loadData'; +import { registerSessionReset } from '@/common/utils/resetSession'; +import { createTaroJSONStorage } from '@/common/utils/storage'; + +interface MyCourseStore { + selectedYear: string; + selectedSemester: string; + coursesCache: Record; + source: DataSource | null; + + cacheCourses: (courses: MyCourseProps[], year: string, semester: string) => void; + markCourseEvaluated: (courseId: number) => void; + setSelectedYearAndSemester: (year: string, semester: string) => void; + load: ( + year: string, + semester: string, + query: { yearValue: string; termValue: string }, + options?: { force?: boolean } + ) => Promise; + clearAllCache: () => void; + reset: () => void; +} + +export const useMyClassStore = create()( + persist( + (set, get) => ({ + selectedYear: '', + selectedSemester: '', + coursesCache: {}, + source: null, + + cacheCourses: (courses, year, semester) => { + if (!courses.length) return; + const key = `${year}-${semester}`; + set((state) => ({ + coursesCache: { ...state.coursesCache, [key]: courses }, + })); + }, + + markCourseEvaluated: (courseId) => { + set((state) => { + let changed = false; + const coursesCache = Object.fromEntries( + Object.entries(state.coursesCache).map(([key, courses]) => { + const next = courses.map((course) => { + if (course.id !== courseId || course.evaluated) return course; + changed = true; + return { ...course, evaluated: true }; + }); + return [key, next]; + }) + ); + return changed ? { coursesCache } : state; + }); + }, + + setSelectedYearAndSemester: (year, semester) => { + set({ selectedYear: year, selectedSemester: semester }); + }, + + async load(year, semester, query, options) { + const key = `${year}-${semester}`; + const result = await loadData({ + strategy: 'cache-first', + force: options?.force, + getCache: () => { + const cached = get().coursesCache[key]; + return cached?.length ? cached : null; + }, + fetch: async () => { + const data = (await getUserCourses({ + year: query.yearValue, + term: query.termValue, + })) as MyCourseProps[]; + return Array.isArray(data) ? data : []; + }, + setCache: (courses) => { + if (courses.length) get().cacheCourses(courses, year, semester); + }, + }); + set({ source: result.source }); + return result.data; + }, + + clearAllCache: () => { + set({ coursesCache: {}, selectedYear: '', selectedSemester: '' }); + }, + + reset: () => { + set({ + coursesCache: {}, + selectedYear: '', + selectedSemester: '', + source: null, + }); + }, + }), + { + name: 'user_myClass_storage', + storage: createTaroJSONStorage(), + + partialize: (state) => ({ + coursesCache: state.coursesCache, + selectedYear: state.selectedYear, + selectedSemester: state.selectedSemester, + }), + } + ) +); + +registerSessionReset(() => useMyClassStore.getState().reset()); diff --git a/src/store/notification/index.ts b/src/store/notification/index.ts new file mode 100644 index 00000000..8a684e4e --- /dev/null +++ b/src/store/notification/index.ts @@ -0,0 +1 @@ +export { useNotificationStore } from './store'; diff --git a/src/store/notification/store.ts b/src/store/notification/store.ts new file mode 100644 index 00000000..32dab899 --- /dev/null +++ b/src/store/notification/store.ts @@ -0,0 +1,52 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import { createTaroJSONStorage } from '@/common/utils/storage'; +import { registerSessionReset } from '@/common/utils/resetSession'; +import type { DataSource } from '@/common/types/loadType'; + +import { emptyNotificationData, type NotificationData } from './transforms'; + +interface NotificationStore { + data: NotificationData; + source: DataSource | null; + loading: boolean; + + setLoading: (loading: boolean) => void; + setData: (data: NotificationData) => void; + commitLoadResult: (data: NotificationData, source: DataSource | null) => void; + reset: () => void; +} + +export const useNotificationStore = create()( + persist( + (set) => ({ + data: emptyNotificationData, + source: null, + loading: false, + + setLoading(loading) { + set({ loading }); + }, + + setData(data) { + set({ data }); + }, + + commitLoadResult(data, source) { + set({ data, source }); + }, + + reset() { + set({ data: emptyNotificationData, source: null, loading: false }); + }, + }), + { + name: 'notification-store', + storage: createTaroJSONStorage(), + partialize: (state) => ({ data: state.data }), + } + ) +); + +registerSessionReset(() => useNotificationStore.getState().reset()); diff --git a/src/store/notification/transforms.ts b/src/store/notification/transforms.ts new file mode 100644 index 00000000..fb1192bf --- /dev/null +++ b/src/store/notification/transforms.ts @@ -0,0 +1,145 @@ +import { getEvaluationDetail } from '@/common/request/api/evaluations'; +import { formatDate } from '@/common/utils'; +import type { MessageItemProps } from '@/pages/notification/type'; + +import type { CommentInfo } from '@/common/types/commentTypes'; + +type CourseDetail = { course_name?: string; teacher_name?: string }; +type CourseDetailIndex = Map; +type FeedItem = { type: string; ctime: number; content?: Record }; + +export interface NotificationData { + commentMessage: MessageItemProps[]; + supportMessage: MessageItemProps[]; + officialMessage: MessageItemProps[]; +} + +export const emptyNotificationData: NotificationData = { + commentMessage: [], + supportMessage: [], + officialMessage: [], +}; + +function collectCourseDetailsFromComments(comments: CommentInfo[]): CourseDetailIndex { + const index: CourseDetailIndex = new Map(); + + comments.forEach((c) => { + if (c.id && (c.course_name || c.teacher_name)) { + index.set(String(c.id), { + course_name: c.course_name, + teacher_name: c.teacher_name, + }); + } + }); + return index; +} + +function collectBizIdsFromFeeds(feeds: unknown[]): string[] { + return [ + ...new Set( + (feeds as Array<{ content?: { bizId?: string } }>).map((item) => + String(item.content?.bizId || '') + ) + ), + ].filter(Boolean); +} + +/** 自评/自赞等场景后端仍会落库,但 nickname/avatar 均为空,前端不展示 */ +function hasSenderProfile(content: Record): boolean { + const nickname = String(content.nickname ?? '').trim(); + const avatar = String(content.avatar ?? '').trim(); + return Boolean(nickname || avatar); +} + +async function fetchMissingCourseDetails( + index: CourseDetailIndex, + bizIds: string[] +): Promise { + const missingIds = bizIds.filter((id) => !index.has(id)); + if (!missingIds.length) return; + + const results = await Promise.allSettled( + missingIds.map((id) => getEvaluationDetail(Number(id))) + ); + results.forEach((result, i) => { + if (result.status === 'fulfilled' && result.value) { + const detail = result.value as CourseDetail; + index.set(missingIds[i], { + course_name: detail.course_name, + teacher_name: detail.teacher_name, + }); + } + }); +} + +function partitionFeedsIntoMessages( + feeds: unknown[], + courseDetails: CourseDetailIndex +): NotificationData { + const commentList: MessageItemProps[] = []; + const supportList: MessageItemProps[] = []; + const officialList: MessageItemProps[] = []; + + (feeds as FeedItem[]).forEach((item) => { + const content = item.content || {}; + const timeStamp = formatDate(new Date(item.ctime).toISOString(), 'yyyy.MM.dd hh:mm'); + const evaluation = courseDetails.get(String(content.bizId)); + + if (item.type === 'Comment') { + if (!hasSenderProfile(content)) return; + commentList.push({ + type: 'comment', + userName: (content.nickname as string) || '', + avatar: (content.avatar as string) || '', + reply: (content.content as string) || '', + originalComment: (content.bizContent as string) || '', + title: evaluation?.course_name || (content.bizId as string) || '', + teacher: evaluation?.teacher_name || '', + biz: (content.biz as string) || '', + bizId: (content.bizId as string) || '', + commentId: (content.commentId as string) || '', + ctime: item.ctime, + timeStamp, + }); + } else if (item.type === 'Support') { + if (!hasSenderProfile(content)) return; + supportList.push({ + type: 'support', + userName: (content.nickname as string) || '', + avatar: (content.avatar as string) || '', + originalComment: (content.content as string) || '', + title: evaluation?.course_name || (content.bizId as string) || '', + biz: (content.biz as string) || '', + bizId: (content.bizId as string) || '', + ctime: item.ctime, + timeStamp, + }); + } else { + officialList.push({ + type: 'official', + userName: '系统通知', + avatar: '', + title: (content.title as string) || item.type || '', + description: (content.content as string) || '', + ctime: item.ctime, + timeStamp, + }); + } + }); + + return { + commentMessage: commentList.reverse(), + supportMessage: supportList.reverse(), + officialMessage: officialList.reverse(), + }; +} + +export async function buildMessages( + feeds: unknown[], + comments: CommentInfo[] +): Promise { + const courseDetails = collectCourseDetailsFromComments(comments); + const bizIds = collectBizIdsFromFeeds(feeds); + await fetchMissingCourseDetails(courseDetails, bizIds); + return partitionFeedsIntoMessages(feeds, courseDetails); +} diff --git a/src/store/publisher/attachProfiles.ts b/src/store/publisher/attachProfiles.ts new file mode 100644 index 00000000..c2d4a209 --- /dev/null +++ b/src/store/publisher/attachProfiles.ts @@ -0,0 +1,52 @@ +import type { CommentType, User } from '@/common/types/commentTypes'; +import { displayNickname } from '@/common/utils'; + +function userFromUid(publishers: Record, uid: number): User | undefined { + if (!uid) return undefined; + const cached = publishers[uid]; + return { + id: uid, + nickname: displayNickname(cached?.nickname), + avatar: cached?.avatar ?? '', + using_title: cached?.using_title, + level: cached?.level, + }; +} + +export function collectCommentPublisherIds(comments: CommentType[]): number[] { + const commentatorIds = comments + .filter((c) => !c.user?.nickname?.trim() && c.commentator_id > 0) + .map((c) => c.commentator_id); + const replyToUids = comments + .filter((c) => !c.reply_to_user?.nickname?.trim() && c.reply_to_uid > 0) + .map((c) => c.reply_to_uid); + return [...new Set([...commentatorIds, ...replyToUids])]; +} + +export function attachUserProfiles( + comments: CommentType[], + publishers: Record +): CommentType[] { + return comments.map((c) => { + let next = c; + if (!c.user?.nickname?.trim() && c.commentator_id > 0) { + const user = userFromUid(publishers, c.commentator_id); + if (user) next = { ...next, user }; + } else if (c.user?.nickname != null && !c.user.nickname.trim()) { + next = { ...next, user: { ...c.user, nickname: '匿名用户' } }; + } + if (!c.reply_to_user?.nickname?.trim() && c.reply_to_uid > 0) { + const replyToUser = userFromUid(publishers, c.reply_to_uid); + if (replyToUser) next = { ...next, reply_to_user: replyToUser }; + } else if ( + c.reply_to_user?.nickname != null && + !c.reply_to_user.nickname.trim() + ) { + next = { + ...next, + reply_to_user: { ...c.reply_to_user, nickname: '匿名用户' }, + }; + } + return next; + }); +} diff --git a/src/store/publisher/enrich.ts b/src/store/publisher/enrich.ts new file mode 100644 index 00000000..03fd8840 --- /dev/null +++ b/src/store/publisher/enrich.ts @@ -0,0 +1,22 @@ +import type { CommentType } from '@/common/types/commentTypes'; + +import { attachUserProfiles, collectCommentPublisherIds } from './attachProfiles'; +import { usePublisherStore } from './store'; + +/** 拉取 publisher 缓存并挂载到评论列表 */ +export async function attachProfilesToComments( + comments: CommentType[] +): Promise { + const ids = collectCommentPublisherIds(comments); + if (ids.length) { + await usePublisherStore.getState().ensurePublishers(ids); + } + return attachUserProfiles(comments, usePublisherStore.getState().publishers); +} + +export async function ensurePublisherIds(ids: number[]): Promise { + const unique = [...new Set(ids.filter((id) => id > 0))]; + if (unique.length) { + await usePublisherStore.getState().ensurePublishers(unique); + } +} diff --git a/src/store/publisher/extract.ts b/src/store/publisher/extract.ts new file mode 100644 index 00000000..8c388682 --- /dev/null +++ b/src/store/publisher/extract.ts @@ -0,0 +1,36 @@ +import type { CommentInfo } from '@/common/types/commentTypes'; +import type { CourseDetailsType, PublisherDetailsType } from '@/common/types/courseType'; + +export function extractPublishersFromEvaluations( + list: CommentInfo[] +): PublisherDetailsType[] { + const publishers: PublisherDetailsType[] = []; + list.forEach((c) => { + if (c.publisher?.id) { + publishers.push({ + id: c.publisher.id, + avatar: c.publisher.avatar || '', + nickname: c.publisher.nickname || '匿名用户', + using_title: c.publisher.using_title, + level: c.publisher.level, + }); + } + }); + return publishers; +} + +export function extractCourseDetailsFromEvaluations( + list: CommentInfo[] +): Record { + const courseMap: Record = {}; + list.forEach((c) => { + if (c.course_id && c.course_name) { + courseMap[c.course_id] = { + name: c.course_name, + teacher: c.teacher_name || '', + school: '', + }; + } + }); + return courseMap; +} diff --git a/src/store/publisher/index.ts b/src/store/publisher/index.ts new file mode 100644 index 00000000..ed59332b --- /dev/null +++ b/src/store/publisher/index.ts @@ -0,0 +1,5 @@ +export { attachUserProfiles, collectCommentPublisherIds } from './attachProfiles'; +export { attachProfilesToComments, ensurePublisherIds } from './enrich'; +export { extractCourseDetailsFromEvaluations, extractPublishersFromEvaluations } from './extract'; +export { usePublisherStore } from './store'; +export type { PublisherStore } from './types'; diff --git a/src/store/publisher/store.ts b/src/store/publisher/store.ts new file mode 100644 index 00000000..b46f3888 --- /dev/null +++ b/src/store/publisher/store.ts @@ -0,0 +1,111 @@ +import { create } from 'zustand'; + +import { getUserProfile } from '@/common/request/api/user'; +import type { PublisherDetailsType } from '@/common/types/courseType'; +import { registerSessionReset } from '@/common/utils/resetSession'; +import { useCourseStore } from '@/store/course'; + +import { + extractCourseDetailsFromEvaluations, + extractPublishersFromEvaluations, +} from './extract'; +import type { PublisherStore } from './types'; + +const MAX_PUBLISHERS = 500; + +const pendingRequests = new Map>(); + +const DEFAULT_PUBLISHER: PublisherDetailsType = { + id: 0, + nickname: '未知用户', + avatar: '', +}; + +export const usePublisherStore = create()((set, get) => ({ + publishers: {}, + + cachePublishers(users) { + if (!users.length) return; + set((state) => { + const next = { ...state.publishers }; + users.forEach((u) => { + if (u.id) { + next[u.id] = { ...(next[u.id] || {}), ...u }; + } + }); + const keys = Object.keys(next); + if (keys.length > MAX_PUBLISHERS) { + delete next[Number(keys[0])]; + } + return { publishers: next }; + }); + }, + + fetchPublishers(publisherId) { + if (!publisherId || publisherId <= 0) return Promise.resolve(DEFAULT_PUBLISHER); + const cached = get().publishers[publisherId]; + if (cached) return Promise.resolve(cached); + + const pending = pendingRequests.get(publisherId); + if (pending) return pending; + + const promise = getUserProfile(publisherId) + .then((res: PublisherDetailsType) => { + const data = res || DEFAULT_PUBLISHER; + set((state) => { + const next = { ...state.publishers, [publisherId]: data }; + const keys = Object.keys(next); + if (keys.length > MAX_PUBLISHERS) { + delete next[Number(keys[0])]; + } + return { publishers: next }; + }); + return data; + }) + .catch((e) => { + console.error('[publisher] 获取发布者信息失败:', publisherId, e); + const fallback = { ...DEFAULT_PUBLISHER, id: publisherId }; + set((state) => { + const next = { ...state.publishers, [publisherId]: fallback }; + const keys = Object.keys(next); + if (keys.length > MAX_PUBLISHERS) { + delete next[Number(keys[0])]; + } + return { publishers: next }; + }); + return fallback; + }) + .finally(() => { + pendingRequests.delete(publisherId); + }); + + pendingRequests.set(publisherId, promise); + return promise; + }, + + async ensurePublishers(publisherIds) { + const unique = [...new Set(publisherIds.filter((id) => id > 0))]; + if (!unique.length) return; + const { publishers, fetchPublishers } = get(); + const toFetch = unique.filter((id) => !publishers[id]); + await Promise.all(toFetch.map((id) => fetchPublishers(id))); + }, + + ingestFromEvaluations(list) { + const publishers = extractPublishersFromEvaluations(list); + if (publishers.length) { + get().cachePublishers(publishers); + } + const courseMap = extractCourseDetailsFromEvaluations(list); + if (Object.keys(courseMap).length) { + useCourseStore.getState().cacheCourseDetails(courseMap); + } + }, + + clear() { + pendingRequests.clear(); + set({ publishers: {} }); + }, +})); + +registerSessionReset(() => usePublisherStore.getState().clear()); diff --git a/src/store/publisher/types.ts b/src/store/publisher/types.ts new file mode 100644 index 00000000..39c8f378 --- /dev/null +++ b/src/store/publisher/types.ts @@ -0,0 +1,12 @@ +import type { CommentInfo } from '@/common/types/commentTypes'; +import type { PublisherDetailsType } from '@/common/types/courseType'; + +export interface PublisherStore { + publishers: Record; + cachePublishers: (users: PublisherDetailsType[]) => void; + fetchPublishers: (publisherId: number) => Promise; + ensurePublishers: (publisherIds: number[]) => Promise; + /** 从课评列表写入发布者仓 + 课程详情仓 */ + ingestFromEvaluations: (list: CommentInfo[]) => void; + clear: () => void; +} diff --git a/src/store/user/index.ts b/src/store/user/index.ts new file mode 100644 index 00000000..7d57f04c --- /dev/null +++ b/src/store/user/index.ts @@ -0,0 +1,2 @@ +export { useUserStore } from './store'; +export type { UserPoints, UserProfile } from './types'; diff --git a/src/store/user/storage.ts b/src/store/user/storage.ts new file mode 100644 index 00000000..f5aa4398 --- /dev/null +++ b/src/store/user/storage.ts @@ -0,0 +1,27 @@ +import Taro from '@tarojs/taro'; + +export const taroUserStorage = { + getItem: (name: string) => { + try { + const raw = Taro.getStorageSync(name); + return raw ? JSON.parse(raw) : null; + } catch (e) { + console.error('[useUserStore] 读取缓存失败:', name, e); + return null; + } + }, + setItem: (name: string, value: unknown) => { + try { + Taro.setStorageSync(name, JSON.stringify(value)); + } catch (e) { + console.error('[useUserStore] 写入缓存失败:', name, e); + } + }, + removeItem: (name: string) => { + try { + Taro.removeStorageSync(name); + } catch (e) { + console.error('[useUserStore] 清除缓存失败:', name, e); + } + }, +}; diff --git a/src/store/user/store.ts b/src/store/user/store.ts new file mode 100644 index 00000000..5e82d491 --- /dev/null +++ b/src/store/user/store.ts @@ -0,0 +1,126 @@ +import Taro from '@tarojs/taro'; +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import { LONG_TOKEN } from '@/common/constants/auth'; +import { getUserIntegral } from '@/common/request/api/integral'; +import { editProfile, getProfile } from '@/common/request/api/user'; + +import { registerSessionReset } from '@/common/utils/resetSession'; + +import { taroUserStorage } from './storage'; +import type { UserPoints, UserProfile, UserStore } from './types'; + +let pendingProfile: Promise | null = null; +let pendingPoints: Promise | null = null; + +export const useUserStore = create()( + persist( + (set, get) => ({ + profile: null, + points: null, + profileSource: null, + pointsSource: null, + + ensureProfile: async (options) => { + if (!Taro.getStorageSync(LONG_TOKEN)) return null; + + const force = options?.force ?? false; + const cached = get().profile; + if (cached && !force) { + set({ profileSource: 'cache' }); + return cached; + } + if (pendingProfile) return pendingProfile; + + pendingProfile = getProfile() + .then((data) => { + if (data) { + set({ profile: data as UserProfile, profileSource: 'network' }); + return data as UserProfile; + } + return null; + }) + .catch((e) => { + console.error('[useUserStore] 请求失败:', e); + return null; + }) + .finally(() => { + pendingProfile = null; + }); + + return pendingProfile; + }, + + ensurePoints: async (options) => { + if (!Taro.getStorageSync(LONG_TOKEN)) return null; + + const force = options?.force ?? false; + const cached = get().points; + if (cached && !force) { + set({ pointsSource: 'cache' }); + return cached; + } + if (pendingPoints) return pendingPoints; + + pendingPoints = getUserIntegral() + .then((data) => { + if (data) { + set({ points: data as UserPoints, pointsSource: 'network' }); + return data as UserPoints; + } + return null; + }) + .catch((e) => { + console.error('[useUserStore] 请求失败:', e); + return null; + }) + .finally(() => { + pendingPoints = null; + }); + + return pendingPoints; + }, + + async updateProfile(body) { + await editProfile(body); + const current = get().profile; + if (current) { + set({ + profile: { + ...current, + ...(body.nickname !== undefined && { nickname: body.nickname }), + ...(body.avatar !== undefined && { avatar: body.avatar }), + ...(body.using_title !== undefined && { using_title: body.using_title }), + }, + }); + } + void get() + .ensureProfile({ force: true }) + .catch(() => {}); + return get().profile; + }, + + invalidateProfile: () => set({ profile: null }), + invalidateAll: () => set({ profile: null, points: null }), + + reset: () => { + pendingProfile = null; + pendingPoints = null; + set({ + profile: null, + points: null, + profileSource: null, + pointsSource: null, + }); + }, + }), + { + name: 'muxi-user-store', + storage: taroUserStorage, + partialize: (state) => ({ profile: state.profile, points: state.points }), + } + ) +); + +registerSessionReset(() => useUserStore.getState().reset()); diff --git a/src/store/user/types.ts b/src/store/user/types.ts new file mode 100644 index 00000000..128b3d10 --- /dev/null +++ b/src/store/user/types.ts @@ -0,0 +1,34 @@ +import type { DataSource } from '@/common/types/loadType'; + +export interface UserProfile { + nickname: string; + avatar: string; + using_title: string; + title_ownership: Record; + new: boolean; + studentId: string; +} + +export interface UserPoints { + level: number; + points: number; + next_level_points: number; +} + +export interface UserStore { + profile: UserProfile | null; + points: UserPoints | null; + profileSource: DataSource | null; + pointsSource: DataSource | null; + + ensureProfile: (options?: { force?: boolean }) => Promise; + ensurePoints: (options?: { force?: boolean }) => Promise; + updateProfile: (body: { + nickname?: string; + avatar?: string; + using_title?: string; + }) => Promise; + invalidateProfile: () => void; + invalidateAll: () => void; + reset: () => void; +} diff --git a/src/pages/evaluate/index.config.ts b/src/subpackages/course/pages/evaluate/index.config.ts similarity index 100% rename from src/pages/evaluate/index.config.ts rename to src/subpackages/course/pages/evaluate/index.config.ts diff --git a/src/subpackages/course/pages/evaluate/index.scss b/src/subpackages/course/pages/evaluate/index.scss new file mode 100644 index 00000000..a0bc305a --- /dev/null +++ b/src/subpackages/course/pages/evaluate/index.scss @@ -0,0 +1,182 @@ +.evaluate_page_shell { + @include flex_column; + + box-sizing: border-box; + height: 100vh; + overflow: hidden; +} + +.evaluate_page_scroll { + flex: 1; + min-height: 0; + width: 100%; + margin-top: 180rpx; + padding: 0 0 48rpx; +} + +.evaluate_page_unauthorized_container { + @include flex_column; + + .evaluate_page_background_image { + width: 100%; + } + + .evaluate_page_unauthorized_content { + @include flex_column_align_center; + + position: absolute; + top: 0; + margin-top: 15vh; + width: 100%; + gap: 32rpx; + + .evaluate_page_unauthorized_icon_wrapper { + height: 320rpx; + width: 320rpx; + overflow: hidden; + border-radius: 64rpx; + box-shadow: + 0 40rpx 52rpx -8rpx rgba(0, 0, 0, 0.1), + 0 20rpx 20rpx -8rpx rgba(0, 0, 0, 0.04); + .evaluate_page_unauthorized_icon { + height: 100%; + width: 100%; + } + } + .evaluate_page_unauthorized_text { + font-size: 72rpx; + font-weight: 600; + letter-spacing: 0.1em; + color: $color_brand_golden; + } + } +} +.evaluate_page_form { + padding-top: 30rpx; + + .evaluate_page_section { + display: flex; + flex-direction: row; + margin-top: 30rpx; + margin-left: 46rpx; + + .evaluate_page_label { + padding-right: 10rpx; + font-size: $font_size_base; + font-family: + Segoe UI-Bold, + Segoe UI; + color: $color_text_heading; + letter-spacing: 2rpx; + font-weight: 600; + } + + .evaluate_page_ways_container { + width: 500rpx; + margin-top: -4rpx; + display: flex; + flex-wrap: wrap; + gap: $space_md; + + .evaluate_page_radio { + font-size: $font_size_base; + margin-right: $space_md; + + .wx-radio-input { + height: 14rpx; + width: 14rpx; + border: 6rpx solid $color_brand_golden; + } + + .wx-radio-input.wx-radio-input-checked::before { + border-radius: $radius_round; + width: 18rpx; + height: 18rpx; + line-height: 18rpx; + text-align: center; + font-size: 0rpx; + background-color: $color_brand_golden; + transform: translate(-50%, -50%); + } + } + } + + .evaluate_page_features_container { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 26rpx; + width: 500rpx; + } + } + + .evaluate_textarea_container { + position: relative; + + .evaluate_page_textarea { + width: 650rpx; + height: 280rpx; + background-color: transparent; + border: 1px solid $color_text_light; + border-radius: 44rpx; + margin: $space_lg auto; + padding: $space_lg 24rpx; + font-size: $font_size_title; + } + + .evaluate_page_word_limit { + font-size: $font_size_caption; + color: $color_text_light; + position: absolute; + right: 8%; + bottom: 5%; + } + } + + .evaluate_page_anonymous_section { + @include flex_align_center; + + margin: 30rpx 46rpx; + + .evaluate_page_anonymous_radio { + @include flex_align_center; + + font-size: $font_size_base; + + .wx-radio-input { + height: 24rpx; + width: 24rpx; + border: 6rpx solid $color_brand_golden; + } + + .wx-radio-input.wx-radio-input-checked::before { + border-radius: $radius_round; + width: 30rpx; + height: 30rpx; + line-height: 24rpx; + text-align: center; + font-size: 0rpx; + background-color: $color_brand_golden; + transform: translate(-50%, -50%); + } + } + + .evaluate_page_anonymous_text { + font-size: $font_size_subheading; + color: $color_text_light; + } + } + + .evaluate_page_submit_button { + @include flex_center; + + margin: 64rpx auto; + padding: $space_sm 48rpx; + width: 60%; + font-size: $font_size_hero; + color: white; + text-align: center; + background-color: $color_brand; + border-radius: $radius_xl; + box-sizing: border-box; + } +} diff --git a/src/subpackages/course/pages/evaluate/index.tsx b/src/subpackages/course/pages/evaluate/index.tsx new file mode 100644 index 00000000..ce8afbf8 --- /dev/null +++ b/src/subpackages/course/pages/evaluate/index.tsx @@ -0,0 +1,251 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +import { Radio, ScrollView, Text, Textarea, View } from '@tarojs/components'; +import Taro, { useDidShow } from '@tarojs/taro'; +import { useCallback, useEffect, useState } from 'react'; + +import './index.scss'; + +import type { PublishEvaluationBody } from '@/subpackages/course/pages/evaluate/publish'; +import { publishEvaluationAndBroadcast } from '@/subpackages/course/pages/evaluate/publish'; + +import { GateScreen, StarRating } from '@/common/components'; +import FeatureLabel from '@/common/components/FeatureLabel'; +import { ASSESSMENT_MAP, COURSE_FEATURE_MAP } from '@/common/constants/courseLabels'; +import { useAuthGuard } from '@/common/hooks/useAuthGuard'; +import { useGateGuard } from '@/common/hooks/useGateGuard'; +import { BusinessError } from '@/common/request/errors/BusinessError'; +import { hideLoadingThenToast } from '@/common/utils'; +import { NavigationBar } from '@/modules/navigation'; + +function readCourseIdFromRoute(): number { + const pages = Taro.getCurrentPages(); + const current = pages[pages.length - 1] as + | { options?: Record } + | undefined; + const id = Number(current?.options?.id); + return id > 0 ? id : 0; +} + +function readCourseNameFromRoute(): string | null { + const pages = Taro.getCurrentPages(); + const current = pages[pages.length - 1] as + | { options?: Record } + | undefined; + const name = current?.options?.name; + return name ? decodeURIComponent(name) : null; +} + +function publishErrorMessage(error: unknown): string { + if (error instanceof BusinessError) { + return error.message || `发布失败(${error.code})`; + } + if (error instanceof Error && error.message.trim()) { + return error.message.length > 24 ? `${error.message.slice(0, 24)}…` : error.message; + } + return '发布失败,请稍后重试'; +} + +const Page: React.FC = () => { + const [selectedValues, setSelectedValues] = useState([]); + + const handleRadioChange = (value: string) => { + const currentIndex = selectedValues.indexOf(value); + if (currentIndex > -1) { + const newSelectedValues = selectedValues.filter((_, i) => i !== currentIndex); + setSelectedValues(newSelectedValues); + } else { + setSelectedValues([...selectedValues, value]); + } + }; + const [selectedFeatureValues, setSelectedFeatureValues] = useState([]); + const [isAnonymous, setIsAnonymous] = useState(false); + const handleFeaturesChecked = (value: string) => { + const currentIndex = selectedFeatureValues.indexOf(value); + if (currentIndex > -1) { + const newSelectedFeatureValues = selectedFeatureValues.filter( + (_, i) => i !== currentIndex + ); + setSelectedFeatureValues(newSelectedFeatureValues); + } else { + setSelectedFeatureValues([...selectedFeatureValues, value]); + } + }; + + const [textLength, setLength] = useState(0); + const [comment, setComment] = useState(''); + const [selectedStarIndex, setSelectedStarIndex] = useState(-1); + + const [courseId, setId] = useState(undefined); + const [courseName, setName] = useState('只能评价自己学过的课程哦'); + const gate = useGateGuard(); + const { guard } = useAuthGuard(); + + const syncCourseFromRoute = useCallback(() => { + const id = readCourseIdFromRoute(); + const name = readCourseNameFromRoute(); + if (id > 0) setId(id); + if (name) setName(name); + }, []); + + useEffect(() => { + syncCourseFromRoute(); + }, [syncCourseFromRoute]); + + useDidShow(() => { + syncCourseFromRoute(); + }); + + const postEvaluation = () => { + if (!guard()) return; + if (!courseId || courseId <= 0) { + void Taro.showToast({ + title: '课程信息缺失,请返回重试', + icon: 'none', + }); + return; + } + if (selectedStarIndex === -1) { + void Taro.showToast({ + title: '请为课程选择星级', + icon: 'none', + }); + return; + } + const content = comment.trim(); + if (!content) { + void Taro.showToast({ + title: '内容不能为空', + icon: 'none', + }); + return; + } + const evaluationobj: PublishEvaluationBody = { + star_rating: selectedStarIndex, + content, + course_id: courseId, + assessments: selectedValues, + features: selectedFeatureValues, + id: 0, + status: 'Public', + is_anonymous: isAnonymous, + }; + + void Taro.showLoading({ + title: '提交中', + }); + publishEvaluationAndBroadcast(evaluationobj) + .then(() => { + void Taro.hideLoading(); + void Taro.navigateBack().then(() => { + void Taro.showToast({ + title: '课评发布成功', + icon: 'none', + }); + }); + }) + .catch((error) => { + hideLoadingThenToast({ + title: publishErrorMessage(error), + icon: 'none', + }); + }); + }; + + const onStarClick = (index) => { + setSelectedStarIndex(index + 1); + }; + + const countContent = (e: any) => { + const { value } = e.detail; + setComment(value); + setLength(value.length); + }; + + if (gate === 'loading') return null; + if (gate === 'block') return ; + return ( + + + + + + 课程名字 : + {courseName} + + + 评价星级 : + + + + 考核方式 : + + {Object.entries(ASSESSMENT_MAP).map(([value, text]) => ( + handleRadioChange(value)} + > + {text} + + ))} + + + + 课程特点 : + + {Object.entries(COURSE_FEATURE_MAP).map(([value, content]) => { + return ( + handleFeaturesChecked(value)} + /> + ); + })} + + + + + 字数限制{textLength}/450 + + + setIsAnonymous(!isAnonymous)} + color="transparent" + > + 匿名 + + + 发布 + + + + + ); +}; + +export default Page; diff --git a/src/subpackages/course/pages/evaluate/publish.ts b/src/subpackages/course/pages/evaluate/publish.ts new file mode 100644 index 00000000..d672e69f --- /dev/null +++ b/src/subpackages/course/pages/evaluate/publish.ts @@ -0,0 +1,20 @@ +import { publishEvaluation } from '@/common/request/api/evaluations'; +import type { CommentInfo } from '@/common/types/commentTypes'; +import { bus } from '@/common/utils'; +import { useEvaluationHistoryStore } from '@/store/evaluationHistory/store'; +import { useMyClassStore } from '@/store/myClass/store'; + +export type PublishEvaluationBody = Parameters[0]; + +export async function publishEvaluationAndBroadcast( + body: PublishEvaluationBody +): Promise { + const data = await publishEvaluation(body); + const newEvaluation = { ...body, ...(data || {}) } as CommentInfo; + useEvaluationHistoryStore.getState().invalidateCache('Public'); + if (body.course_id) { + useMyClassStore.getState().markCourseEvaluated(body.course_id); + } + bus.emit('evaluation', newEvaluation); + return newEvaluation; +} diff --git a/src/pages/evaluateInfo/index.config.ts b/src/subpackages/course/pages/evaluateInfo/index.config.ts similarity index 100% rename from src/pages/evaluateInfo/index.config.ts rename to src/subpackages/course/pages/evaluateInfo/index.config.ts diff --git a/src/subpackages/course/pages/evaluateInfo/index.scss b/src/subpackages/course/pages/evaluateInfo/index.scss new file mode 100644 index 00000000..dbbe6910 --- /dev/null +++ b/src/subpackages/course/pages/evaluateInfo/index.scss @@ -0,0 +1,70 @@ +.evaluateInfo_page_unauthorized_container { + @include flex_column; + + .evaluateInfo_page_background_image { + width: 100%; + } + + .evaluateInfo_page_unauthorized_content { + @include flex_column_align_center; + + position: absolute; + top: 0; + margin-top: 15vh; + width: 100%; + gap: 32rpx; + + .evaluateInfo_page_unauthorized_icon_wrapper { + height: 320rpx; + width: 320rpx; + overflow: hidden; + border-radius: 64rpx; + box-shadow: + 0 40rpx 52rpx -8rpx rgba(0, 0, 0, 0.1), + 0 20rpx 20rpx -8rpx rgba(0, 0, 0, 0.04); + + .evaluateInfo_page_unauthorized_icon { + height: 100%; + width: 100%; + } + } + + .evaluateInfo_page_unauthorized_text { + font-size: 72rpx; + font-weight: 600; + letter-spacing: 0.1em; + color: $color_brand_golden; + } + } +} + +.evaluateInfo_page_container { + @include flex_column_align_center; + + margin-top: 25%; + height: calc(100vh - 25%); + + .evaluateInfo_page_comment_wrapper { + margin: $space_lg 0 32rpx; + width: 100%; + } + + .evaluateInfo_page_divider { + width: 90%; + height: 2rpx; + background-color: $color_border_light; + } + + .evaluateInfo_page_comments_title { + margin-left: $space_sm; + width: 90vw; + font-size: $font_size_heading; + color: $color_text_primary; + } + + .evaluateInfo_page_comments_list { + @include full_width_border_box; + + height: 52vh; + } +} diff --git a/src/subpackages/course/pages/evaluateInfo/index.tsx b/src/subpackages/course/pages/evaluateInfo/index.tsx new file mode 100644 index 00000000..e5ae9f40 --- /dev/null +++ b/src/subpackages/course/pages/evaluateInfo/index.tsx @@ -0,0 +1,224 @@ +import { View } from '@tarojs/components'; +import Taro, { useDidShow } from '@tarojs/taro'; +import { memo, useCallback, useRef, useState } from 'react'; + +import './index.scss'; + +import { BottomInput, FeedCard, GateScreen, ReviewDiscussion } from '@/common/components'; +import { useAuthGuard } from '@/common/hooks/useAuthGuard'; +import { useGateGuard } from '@/common/hooks/useGateGuard'; +import type { DiscussionComment } from '@/common/types/commentTypes'; +import { NavigationBar } from '@/modules/navigation'; +import { useEvaluationStore } from '@/store/evaluations'; +import { useUserStore } from '@/store/user'; + +import { + loadEvaluationComments, + prepareEvaluationDetail, + publishEvaluationReply, + useEvaluateCommentThread, +} from './model'; +import { + bumpEvaluationDetailSession, + getEvaluationDetailSessionGen, +} from './model/sessionGen'; + +/** 页面复用时从页栈顶层读参,比 getCurrentInstance().router.params 更可靠 */ +function readUrlBizId(): number { + const pages = Taro.getCurrentPages(); + const current = pages[pages.length - 1] as + | { options?: Record } + | undefined; + return Number(current?.options?.bizId); +} + +interface EvaluateCommentListProps { + bizId: number | null; + onCommentLongPressRef: React.MutableRefObject< + ((comment: DiscussionComment) => void) | null + >; + expandRootRef: React.MutableRefObject<(rootId: number) => void>; +} + +const EvaluateCommentList = memo(function EvaluateCommentList({ + bizId, + onCommentLongPressRef, + expandRootRef, +}: EvaluateCommentListProps) { + const { + comments, + hasMore, + initialLoading, + expandedReplyRootIds, + expandRoot, + handleReplyExpandedChange, + handleLoadReplies, + handleLoadMoreComments, + getReplyIndicator, + supportsReplies, + } = useEvaluateCommentThread(bizId); + + expandRootRef.current = expandRoot; + + const handleLongPress = useCallback( + (comment: DiscussionComment) => { + onCommentLongPressRef.current?.(comment); + }, + [onCommentLongPressRef] + ); + + return ( + + ); +}); + +const Page: React.FC = () => { + const gate = useGateGuard(); + const { guard } = useAuthGuard(); + const [urlBizId, setUrlBizId] = useState(0); + const lastBizIdRef = useRef(0); + + const evaluation = useEvaluationStore((s) => + urlBizId > 0 ? (s.byId[urlBizId] ?? null) : null + ); + + const bottomInputRef = + useRef(null); + const replyToRef = useRef(null); + const onCommentLongPressRef = useRef<((comment: DiscussionComment) => void) | null>( + null + ); + const expandRootRef = useRef<(rootId: number) => void>(() => {}); + + const handleReplySubmit = useCallback( + async (value: string, replyTo: DiscussionComment | null) => { + if (!value.trim() || !urlBizId) return; + + const profile = useUserStore.getState().profile; + const result = await publishEvaluationReply({ + bizId: urlBizId, + content: value, + replyTo, + user: { + nickname: profile?.nickname || '我', + avatar: profile?.avatar || '', + }, + }); + + if (result.ok) { + if (result.expandRootId) expandRootRef.current(result.expandRootId); + return; + } + + if (result.code === 409002) { + void Taro.showToast({ title: '不能回答未上过的课', icon: 'none' }); + return; + } + void Taro.showToast({ title: '评论失败', icon: 'error' }); + }, + [urlBizId] + ); + + const handleLongPress = useCallback( + (comment: DiscussionComment | null) => { + if (!guard()) return; + if (comment) { + replyToRef.current = comment; + bottomInputRef.current?.insertMention(comment.user?.nickname || ''); + } + }, + [guard] + ); + + onCommentLongPressRef.current = (comment: DiscussionComment) => { + handleLongPress(comment); + }; + + const clearReply = useCallback(() => { + replyToRef.current = null; + }, []); + + const handleMentionRemoved = useCallback(() => { + replyToRef.current = null; + }, []); + + const onReplySubmit = useCallback( + (value: string) => { + if (!guard()) return; + void handleReplySubmit(value, replyToRef.current); + clearReply(); + bottomInputRef.current?.clearValue(); + }, + [guard, handleReplySubmit, clearReply] + ); + + const runDetailSession = useCallback(() => { + const bizId = readUrlBizId(); + if (!(bizId > 0)) return; + + let gen = getEvaluationDetailSessionGen(); + if (bizId !== lastBizIdRef.current) { + lastBizIdRef.current = bizId; + setUrlBizId(bizId); + gen = bumpEvaluationDetailSession(); + } + + void (async () => { + await prepareEvaluationDetail(bizId); + if (gen !== getEvaluationDetailSessionGen()) return; + await loadEvaluationComments(bizId, true, 0, gen); + })(); + }, []); + + useDidShow(runDetailSession); + + if (gate === 'loading') return null; + if (gate === 'block') return ; + + return ( + + + + {evaluation ? ( + handleLongPress(null)} + /> + ) : null} + + + 评论区 + + + + + + + + + ); +}; + +export default Page; diff --git a/src/subpackages/course/pages/evaluateInfo/model/api.ts b/src/subpackages/course/pages/evaluateInfo/model/api.ts new file mode 100644 index 00000000..aa0e15ee --- /dev/null +++ b/src/subpackages/course/pages/evaluateInfo/model/api.ts @@ -0,0 +1,66 @@ +import { + getCommentReplies, + getTopLevelComments, + publishComment, +} from '@/common/request/api/comments'; +import { getEvaluationDetail } from '@/common/request/api/evaluations'; +import { BusinessError } from '@/common/request/errors/BusinessError'; +import type { CommentInfo, CommentType } from '@/common/types/commentTypes'; + +export async function fetchEvaluationDetail(bizId: number): Promise { + const data = await getEvaluationDetail(bizId); + return data as CommentInfo; +} + +export const TOP_LEVEL_COMMENT_PAGE_SIZE = 10; + +export async function fetchTopLevelComments( + bizId: number, + lastId: number, + limit = TOP_LEVEL_COMMENT_PAGE_SIZE +): Promise<{ raw: CommentType[]; hasMore: boolean }> { + const data = await getTopLevelComments({ + biz: 'Evaluation', + biz_id: bizId, + cur_comment_id: lastId, + limit, + }); + const raw = (data as CommentType[]) || []; + return { raw, hasMore: raw.length === limit }; +} + +export async function fetchCommentReplies( + rootId: number, + lastId: number, + limit: number +): Promise { + const data = await getCommentReplies({ + root_id: rootId, + cur_comment_id: lastId, + limit, + }); + if (!Array.isArray(data) || !data.length) return []; + return data as CommentType[]; +} + +export async function submitCommentReply(params: { + bizId: number; + content: string; + parentId: number; + rootId: number; +}): Promise { + try { + await publishComment({ + biz: 'Evaluation', + biz_id: params.bizId, + content: params.content, + parent_id: params.parentId, + root_id: params.rootId, + }); + } catch (error) { + if (error instanceof BusinessError && error.code === 409002) { + throw error; + } + throw new Error('评论失败'); + } +} diff --git a/src/subpackages/course/pages/evaluateInfo/model/commentTree.ts b/src/subpackages/course/pages/evaluateInfo/model/commentTree.ts new file mode 100644 index 00000000..1fcbfa43 --- /dev/null +++ b/src/subpackages/course/pages/evaluateInfo/model/commentTree.ts @@ -0,0 +1,130 @@ +import type { CommentType, User } from '@/common/types/commentTypes'; +import { displayNickname } from '@/common/utils'; + +/** 合并顶层评论:刷新时整列替换(保留已展开的楼中楼),翻页时追加去重 */ +export function mergeComments( + existing: CommentType[], + incoming: CommentType[], + isRefresh: boolean +): CommentType[] { + const existingById = new Map(existing.map((c) => [c.id, c])); + const preserveLoadedReplies = (list: CommentType[]) => + list.map((c) => { + const prev = existingById.get(c.id); + if (!prev?.replies?.length) return c; + return { + ...c, + replies: prev.replies, + reply_count: prev.reply_count ?? c.reply_count, + has_replies: prev.has_replies ?? c.has_replies, + // total_comment_count: prev.total_comment_count ?? c.total_comment_count, // 待后端提供整楼准确总数 + replies_has_more: prev.replies_has_more ?? c.replies_has_more, + }; + }); + + if (isRefresh) return preserveLoadedReplies(incoming); + + const existingIds = new Set(existing.map((c) => c.id)); + const nextPage = incoming.filter((c) => !existingIds.has(c.id)); + return [...existing, ...preserveLoadedReplies(nextPage)]; +} + +/** 合并某条根评论下的回复(去重 + 跳过仍在等待的临时回复) */ +export function mergeReplies( + existing: CommentType[], + incoming: CommentType[] +): CommentType[] { + if (!incoming.length) return existing; + const ids = new Set(existing.map((r) => r.id)); + const pendingKeys = new Set( + existing.filter((r) => r.id < 0).map((r) => `${r.parent_comment_id}:${r.content}`) + ); + const next = incoming.filter((r) => { + if (ids.has(r.id)) return false; + if (pendingKeys.has(`${r.parent_comment_id}:${r.content}`)) return false; + return true; + }); + if (!next.length) return existing; + return [...existing, ...next]; +} + +export function createTempId(): number { + return -Date.now(); +} + +export function createPendingComment(params: { + tempId: number; + bizId: number; + content: string; + rootId: number; + parentId: number; + replyToUid: number; + replyToUser?: User; + user: User; +}): CommentType { + const { tempId, bizId, content, rootId, parentId, replyToUid, replyToUser, user } = + params; + return { + id: tempId, + commentator_id: 0, + biz: 'Evaluation', + biz_id: bizId, + content, + root_comment_id: rootId, + parent_comment_id: parentId, + reply_to_uid: replyToUid, + ...(replyToUid > 0 && replyToUser + ? { + reply_to_user: { + ...replyToUser, + nickname: displayNickname(replyToUser.nickname), + }, + } + : {}), + ctime: Date.now(), + utime: Date.now(), + user, + }; +} + +/** 临时评论写入列表:顶层前插,楼中楼尾部追加(与 ReplySection 展示反转一致) */ +export function applyPendingComment( + comments: CommentType[], + reply: CommentType, + rootId: number +): CommentType[] { + if (rootId === 0) { + return [reply, ...comments]; + } + return comments.map((c) => + c.id === rootId + ? { + ...c, + reply_count: (c.reply_count || 0) + 1, + has_replies: true, + replies: c.replies ? [...c.replies, reply] : [reply], + // total_comment_count: (c.total_comment_count || 0) + 1, // 待后端提供整楼准确总数 + } + : c + ); +} + +export function removePendingComment( + comments: CommentType[], + tempId: number, + rootId: number +): CommentType[] { + if (rootId === 0) { + return comments.filter((c) => c.id !== tempId); + } + return comments.map((c) => { + if (c.id !== rootId) return c; + const replies = c.replies?.filter((r) => r.id !== tempId); + return { + ...c, + replies, + reply_count: Math.max((c.reply_count || 1) - 1, 0), + // total_comment_count: Math.max((c.total_comment_count || 1) - 1, 0), // 待后端提供整楼准确总数 + }; + }); +} diff --git a/src/subpackages/course/pages/evaluateInfo/model/entry.ts b/src/subpackages/course/pages/evaluateInfo/model/entry.ts new file mode 100644 index 00000000..69660342 --- /dev/null +++ b/src/subpackages/course/pages/evaluateInfo/model/entry.ts @@ -0,0 +1,21 @@ +import type { EvaluationDetailEntry } from './types'; + +export function emptyEvaluationEntry(): EvaluationDetailEntry { + return { comments: [], commentsLoaded: false, hasMore: false }; +} + +export function getEvaluationEntry( + byId: Record, + bizId: number +): EvaluationDetailEntry { + return byId[bizId] ?? emptyEvaluationEntry(); +} + +export function patchEvaluationEntry( + byId: Record, + bizId: number, + patch: Partial +): Record { + const prev = getEvaluationEntry(byId, bizId); + return { ...byId, [bizId]: { ...prev, ...patch } }; +} diff --git a/src/subpackages/course/pages/evaluateInfo/model/index.ts b/src/subpackages/course/pages/evaluateInfo/model/index.ts new file mode 100644 index 00000000..8d0012db --- /dev/null +++ b/src/subpackages/course/pages/evaluateInfo/model/index.ts @@ -0,0 +1,11 @@ +export { + loadEvaluationComments, + loadEvaluationReplies, + prepareEvaluationDetail, + syncEvaluationSession, +} from './load'; +export { publishEvaluationReply } from './publish'; +export type { PublishEvaluationReplyResult } from './publish'; +export { selectEvaluationEntry } from './selectors'; +export { useEvaluateDetailStore } from './store'; +export { useEvaluateCommentThread } from './useEvaluateCommentThread'; diff --git a/src/subpackages/course/pages/evaluateInfo/model/load.ts b/src/subpackages/course/pages/evaluateInfo/model/load.ts new file mode 100644 index 00000000..2ab06613 --- /dev/null +++ b/src/subpackages/course/pages/evaluateInfo/model/load.ts @@ -0,0 +1,46 @@ +import { useEvaluationStore } from '@/store/evaluations'; +import { attachProfilesToComments } from '@/store/publisher/enrich'; + +import { fetchCommentReplies, fetchTopLevelComments } from './api'; +import { getEvaluationDetailSessionGen } from './sessionGen'; +import { useEvaluateDetailStore } from './store'; + +/** URL 驱动:按 bizId 准备详情会话,实体缺失时拉取 */ +export async function prepareEvaluationDetail(bizId: number): Promise { + if (!(bizId > 0)) return; + const detail = useEvaluateDetailStore.getState(); + if (!useEvaluationStore.getState().get(bizId)) { + await detail.loadEvaluation(bizId); + } + detail.activate(bizId); +} + +export async function loadEvaluationComments( + bizId: number, + isRefresh: boolean, + lastId = 0, + expectedGen?: number +) { + const gen = expectedGen ?? getEvaluationDetailSessionGen(); + const { raw, hasMore } = await fetchTopLevelComments(bizId, lastId); + if (gen !== getEvaluationDetailSessionGen()) return []; + const filled = await attachProfilesToComments(raw); + if (gen !== getEvaluationDetailSessionGen()) return []; + return useEvaluateDetailStore + .getState() + .setComments(bizId, isRefresh, filled, hasMore, gen); +} + +export async function loadEvaluationReplies( + bizId: number, + rootId: number, + lastId: number, + limit: number +) { + const raw = await fetchCommentReplies(rootId, lastId, limit); + const filled = raw.length ? await attachProfilesToComments(raw) : []; + useEvaluateDetailStore.getState().appendReplies(bizId, rootId, filled, limit); +} + +/** @deprecated 使用 prepareEvaluationDetail */ +export const syncEvaluationSession = prepareEvaluationDetail; diff --git a/src/subpackages/course/pages/evaluateInfo/model/publish.ts b/src/subpackages/course/pages/evaluateInfo/model/publish.ts new file mode 100644 index 00000000..f65a5eb3 --- /dev/null +++ b/src/subpackages/course/pages/evaluateInfo/model/publish.ts @@ -0,0 +1,47 @@ +import { BusinessError } from '@/common/request/errors/BusinessError'; +import type { CommentInfo, DiscussionComment, User } from '@/common/types/commentTypes'; +import { useEvaluationStore } from '@/store/evaluations'; + +import { useEvaluateDetailStore } from './store'; + +export type PublishEvaluationReplyResult = + | { ok: true; updated?: CommentInfo; expandRootId?: number } + | { ok: false; code?: number }; + +/** 乐观回复 → API → 评论数写入实体仓;失败回滚 */ +export async function publishEvaluationReply(params: { + bizId: number; + content: string; + replyTo: DiscussionComment | null; + user: Pick; +}): Promise { + const { bizId, content, replyTo, user } = params; + const rootId = + !replyTo || replyTo.root_comment_id == null || replyTo.root_comment_id === 0 + ? (replyTo?.id ?? 0) + : replyTo.root_comment_id; + const parentId = replyTo?.id ?? 0; + + const store = useEvaluateDetailStore.getState(); + const { tempId, expandRootId } = store.addPendingReply({ + bizId, + content, + rootId, + parentId, + replyToUid: replyTo?.commentator_id ?? replyTo?.user?.id ?? 0, + replyToUser: replyTo?.user, + user: { id: 0, nickname: user.nickname, avatar: user.avatar }, + }); + + try { + await store.publishReply({ bizId, content, parentId, rootId }); + const updated = useEvaluationStore.getState().incrementCommentCount(bizId); + return { ok: true, updated, expandRootId }; + } catch (error) { + store.removePendingReply(bizId, tempId, rootId); + if (error instanceof BusinessError && error.code === 409002) { + return { ok: false, code: 409002 }; + } + return { ok: false }; + } +} diff --git a/src/subpackages/course/pages/evaluateInfo/model/selectors.ts b/src/subpackages/course/pages/evaluateInfo/model/selectors.ts new file mode 100644 index 00000000..8baa7f62 --- /dev/null +++ b/src/subpackages/course/pages/evaluateInfo/model/selectors.ts @@ -0,0 +1,14 @@ +import { getEvaluationEntry } from './entry'; +import type { EvaluationDetailEntry } from './types'; + +export interface EvaluateDetailState { + byId: Record; +} + +export function selectEvaluationEntry( + state: EvaluateDetailState, + bizId: number | null +): EvaluationDetailEntry | null { + if (bizId == null) return null; + return getEvaluationEntry(state.byId, bizId); +} diff --git a/src/subpackages/course/pages/evaluateInfo/model/sessionGen.ts b/src/subpackages/course/pages/evaluateInfo/model/sessionGen.ts new file mode 100644 index 00000000..91dcc0e8 --- /dev/null +++ b/src/subpackages/course/pages/evaluateInfo/model/sessionGen.ts @@ -0,0 +1,10 @@ +let sessionGen = 0; + +export function bumpEvaluationDetailSession(): number { + sessionGen += 1; + return sessionGen; +} + +export function getEvaluationDetailSessionGen(): number { + return sessionGen; +} diff --git a/src/subpackages/course/pages/evaluateInfo/model/store.ts b/src/subpackages/course/pages/evaluateInfo/model/store.ts new file mode 100644 index 00000000..7dfa6f40 --- /dev/null +++ b/src/subpackages/course/pages/evaluateInfo/model/store.ts @@ -0,0 +1,200 @@ +import { create } from 'zustand'; + +import type { CommentInfo, CommentType, User } from '@/common/types/commentTypes'; +import { registerSessionReset } from '@/common/utils/resetSession'; +import { useEvaluationStore } from '@/store/evaluations'; + +import type { DataSource } from '@/common/types/loadType'; + +import { + fetchEvaluationDetail, + submitCommentReply, + TOP_LEVEL_COMMENT_PAGE_SIZE, +} from './api'; +import { getEvaluationDetailSessionGen } from './sessionGen'; +import { + applyPendingComment, + createPendingComment, + createTempId, + mergeComments, + mergeReplies, + removePendingComment, +} from './commentTree'; +import { getEvaluationEntry, patchEvaluationEntry } from './entry'; +import type { EvaluationDetailEntry } from './types'; + +const commentsLoadGen: Record = {}; + +interface EvaluateDetailStore { + byId: Record; + source: DataSource | null; + + activate: (bizId: number) => void; + reset: () => void; + resetComments: (bizId: number) => void; + loadEvaluation: (bizId: number) => Promise; + setComments: ( + bizId: number, + isRefresh: boolean, + comments: CommentType[], + hasMore: boolean, + expectedGen?: number + ) => CommentType[]; + appendReplies: ( + bizId: number, + rootId: number, + replies: CommentType[], + limit: number + ) => void; + publishReply: (params: { + bizId: number; + content: string; + parentId: number; + rootId: number; + }) => Promise; + addPendingReply: (params: { + bizId: number; + content: string; + rootId: number; + parentId: number; + replyToUid: number; + replyToUser?: User; + user: User; + }) => { tempId: number; expandRootId?: number }; + removePendingReply: (bizId: number, tempId: number, rootId: number) => void; +} + +export const useEvaluateDetailStore = create()((set, get) => ({ + byId: {}, + source: null, + + activate(bizId) { + if (!(bizId > 0)) return; + set((s) => ({ + byId: patchEvaluationEntry(s.byId, bizId, { + comments: [], + commentsLoaded: false, + hasMore: false, + }), + })); + }, + + reset() { + for (const key of Object.keys(commentsLoadGen)) { + delete commentsLoadGen[Number(key)]; + } + set({ byId: {}, source: null }); + }, + + resetComments(bizId) { + set((s) => ({ + byId: patchEvaluationEntry(s.byId, bizId, { + comments: [], + commentsLoaded: false, + hasMore: false, + }), + })); + }, + + async loadEvaluation(bizId) { + const data = await fetchEvaluationDetail(bizId); + useEvaluationStore.getState().upsertOne(data); + set({ source: 'network' }); + return data; + }, + + setComments(bizId, isRefresh, comments, hasMore, expectedGen) { + if (expectedGen != null && expectedGen !== getEvaluationDetailSessionGen()) { + return comments; + } + + commentsLoadGen[bizId] = (commentsLoadGen[bizId] ?? 0) + 1; + const gen = commentsLoadGen[bizId]; + if (isRefresh) get().resetComments(bizId); + + let nextHasMore = + comments.length === TOP_LEVEL_COMMENT_PAGE_SIZE ? hasMore : false; + + let result = comments; + set((state) => { + if (gen !== commentsLoadGen[bizId]) return state; + if (expectedGen != null && expectedGen !== getEvaluationDetailSessionGen()) { + return state; + } + const entry = getEvaluationEntry(state.byId, bizId); + const prevLen = entry.comments.length; + result = mergeComments(entry.comments, comments, isRefresh); + if (!isRefresh && comments.length > 0 && result.length === prevLen) { + nextHasMore = false; + } + return { + byId: patchEvaluationEntry(state.byId, bizId, { + comments: result, + hasMore: nextHasMore, + commentsLoaded: true, + }), + source: 'network', + }; + }); + return result; + }, + + appendReplies(bizId, rootId, replies, limit) { + set((s) => { + const entry = getEvaluationEntry(s.byId, bizId); + const comments = entry.comments.map((c) => { + if (c.id !== rootId) return c; + if (!replies.length) { + return { ...c, replies_has_more: false }; + } + const merged = mergeReplies(c.replies ?? [], replies); + const added = merged.length - (c.replies?.length ?? 0); + return { + ...c, + replies: merged, + has_replies: merged.length > 0, + // 不以 reply_count 判断整楼是否还有更多,用本次是否满页 + replies_has_more: replies.length >= limit && added > 0, + }; + }); + return { byId: patchEvaluationEntry(s.byId, bizId, { comments }) }; + }); + }, + + publishReply(params) { + return submitCommentReply(params); + }, + + addPendingReply(params) { + const tempId = createTempId(); + const reply = createPendingComment({ tempId, ...params }); + const { bizId, rootId } = params; + + set((s) => { + const entry = getEvaluationEntry(s.byId, bizId); + return { + byId: patchEvaluationEntry(s.byId, bizId, { + comments: applyPendingComment(entry.comments, reply, rootId), + }), + }; + }); + + return { + tempId, + expandRootId: rootId !== 0 ? rootId : undefined, + }; + }, + + removePendingReply(bizId, tempId, rootId) { + set((s) => { + const entry = getEvaluationEntry(s.byId, bizId); + return { + byId: patchEvaluationEntry(s.byId, bizId, { + comments: removePendingComment(entry.comments, tempId, rootId), + }), + }; + }); + }, +})); + +registerSessionReset(() => useEvaluateDetailStore.getState().reset()); diff --git a/src/subpackages/course/pages/evaluateInfo/model/types.ts b/src/subpackages/course/pages/evaluateInfo/model/types.ts new file mode 100644 index 00000000..2a7effb9 --- /dev/null +++ b/src/subpackages/course/pages/evaluateInfo/model/types.ts @@ -0,0 +1,8 @@ +import type { CommentType } from '@/common/types/commentTypes'; + +/** 单条课评详情的评论区会话(按 bizId 隔离)。课评本体在 store/evaluations。 */ +export interface EvaluationDetailEntry { + comments: CommentType[]; + commentsLoaded: boolean; + hasMore: boolean; +} diff --git a/src/subpackages/course/pages/evaluateInfo/model/useEvaluateCommentThread.ts b/src/subpackages/course/pages/evaluateInfo/model/useEvaluateCommentThread.ts new file mode 100644 index 00000000..22be2f09 --- /dev/null +++ b/src/subpackages/course/pages/evaluateInfo/model/useEvaluateCommentThread.ts @@ -0,0 +1,77 @@ +import { useCallback, useState } from 'react'; + +import type { DiscussionComment } from '@/common/types/commentTypes'; + +import { loadEvaluationComments, loadEvaluationReplies } from './load'; +import { selectEvaluationEntry } from './selectors'; +import { useEvaluateDetailStore } from './store'; + +/** 评课评论区 UI:服务端数据来自 model store,展开态留在 hook 内 */ +export function useEvaluateCommentThread(bizId: number | null) { + const entry = useEvaluateDetailStore((s) => selectEvaluationEntry(s, bizId)); + const [expandedReplyRootIds, setExpandedReplyRootIds] = useState([]); + + const comments = entry?.comments ?? []; + const hasMore = entry?.hasMore ?? false; + const initialLoading = bizId != null && !(entry?.commentsLoaded ?? false); + + const expandRoot = useCallback((rootId: number) => { + setExpandedReplyRootIds((prev) => (prev.includes(rootId) ? prev : [...prev, rootId])); + }, []); + + const handleReplyExpandedChange = useCallback((rootId: number, expanded: boolean) => { + setExpandedReplyRootIds((prev) => { + const has = prev.includes(rootId); + if (expanded && !has) return [...prev, rootId]; + if (!expanded && has) return prev.filter((id) => id !== rootId); + return prev; + }); + }, []); + + const handleLoadReplies = useCallback( + async (rootId: number, lastId: number, limit: number) => { + if (!bizId) return; + try { + await loadEvaluationReplies(bizId, rootId, lastId, limit); + } catch (e) { + console.error(e); + } + }, + [bizId] + ); + + // 主评下楼中楼暂不展示 @xxx,待 reply_to 数据稳定后再启用 + const getReplyIndicator = useCallback((_reply: DiscussionComment) => { + return { show: false }; + // const nickname = reply.reply_to_user?.nickname?.trim(); + // const hasTarget = (reply.reply_to_uid ?? 0) > 0 || Boolean(nickname); + // if (!hasTarget) return { show: false }; + // return { show: true, nickname: nickname || '匿名用户' }; + }, []); + + const handleLoadMoreComments = useCallback(async () => { + if (!bizId) return; + const list = + selectEvaluationEntry(useEvaluateDetailStore.getState(), bizId)?.comments ?? []; + const lastId = list[list.length - 1]?.id ?? 0; + await loadEvaluationComments(bizId, false, lastId); + }, [bizId]); + + const supportsReplies = useCallback( + (item: DiscussionComment) => 'root_comment_id' in item, + [] + ); + + return { + comments, + hasMore, + initialLoading, + expandedReplyRootIds, + expandRoot, + handleReplyExpandedChange, + handleLoadReplies, + handleLoadMoreComments, + getReplyIndicator, + supportsReplies, + }; +} diff --git a/src/pages/publishQuestion/index.config.ts b/src/subpackages/course/pages/publishQuestion/index.config.ts similarity index 100% rename from src/pages/publishQuestion/index.config.ts rename to src/subpackages/course/pages/publishQuestion/index.config.ts diff --git a/src/subpackages/course/pages/publishQuestion/index.scss b/src/subpackages/course/pages/publishQuestion/index.scss new file mode 100644 index 00000000..684e44d6 --- /dev/null +++ b/src/subpackages/course/pages/publishQuestion/index.scss @@ -0,0 +1,95 @@ +.publish_question_shell { + @include flex_column; + + box-sizing: border-box; + height: 100vh; + overflow: hidden; + background-color: $color_bg_page; +} + +.publish_question_scroll { + flex: 1; + min-height: 0; + width: 100%; +} + +.publish_question_card { + margin: $space_lg 30rpx; + padding: 46rpx; + background-color: $color_bg_cream; + border-radius: $radius_lg; + @include shadow_card; +} + +.publish_question_icon { + width: 48rpx; + height: 48rpx; + margin: 28rpx 0; +} + +.publish_question_textarea_wrapper { + position: relative; +} + +.publish_question_textarea { + @include full_width_border_box; + + background-color: $color_bg_page; + font-size: $font_size_body; + padding: $space_lg; + border-radius: $radius_md; + border: 1rpx solid $color_divider; + height: 280rpx; +} + +.publish_question_word_count { + position: absolute; + right: 24rpx; + bottom: 24rpx; + font-size: $font_size_caption; + color: $color_text_light; +} + +.publish_question_anonymous_section { + @include flex_align_center; + + margin: 30rpx 46rpx; + + .publish_question_anonymous_radio { + @include flex_align_center; + + .wx-radio-input { + height: 24rpx; + width: 24rpx; + border: 6rpx solid $color_brand_golden; + } + + .wx-radio-input.wx-radio-input-checked::before { + border-radius: $radius_round; + width: 30rpx; + height: 30rpx; + line-height: 24rpx; + text-align: center; + font-size: 0rpx; + background-color: $color_brand_golden; + transform: translate(-50%, -50%); + } + } + + .publish_question_anonymous_text { + font-size: $font_size_body; + color: $color_text_secondary; + margin-left: $space_sm; + } +} + +.publish_question_submit_btn { + margin: 60rpx auto 48rpx; + width: 50%; + padding: $space_sm 40rpx; + font-size: $font_size_base; + color: white; + background-color: $color_brand; + border-radius: $radius_lg; + border: 0; +} diff --git a/src/subpackages/course/pages/publishQuestion/index.tsx b/src/subpackages/course/pages/publishQuestion/index.tsx new file mode 100644 index 00000000..c7de91d8 --- /dev/null +++ b/src/subpackages/course/pages/publishQuestion/index.tsx @@ -0,0 +1,114 @@ +import { Button, Image, Radio, ScrollView, Text, Textarea, View } from '@tarojs/components'; +import Taro from '@tarojs/taro'; +import { useEffect, useState } from 'react'; + +import './index.scss'; + +import { publishQuestionAndBroadcast } from '@/subpackages/course/pages/publishQuestion/publish'; + +import askicon from '@/common/assets/img/publishQuestion/ask.png'; +import { CourseInfo, GateScreen, PublishHeader } from '@/common/components'; +import { useAuthGuard } from '@/common/hooks/useAuthGuard'; +import { useGateGuard } from '@/common/hooks/useGateGuard'; + +const Page: React.FC = () => { + const [courseId, setCourseId] = useState(null); + useEffect(() => { + const getParams = () => { + const instance = Taro.getCurrentInstance(); + const params = instance?.router?.params || {}; + + if (params.course_id) setCourseId(params.course_id); + }; + + getParams(); + }, []); + + const gate = useGateGuard(); + const { guard } = useAuthGuard(); + const [content, setContent] = useState(''); + const [textLength, setTextLength] = useState(0); + const [isAnonymous, setIsAnonymous] = useState(false); + + const onInput = (e: any) => { + const { value } = e?.detail ?? {}; + if (typeof value === 'string') { + setContent(value); + setTextLength(value.length); + } + }; + + const postQuestion = () => { + if (!guard()) return; + if (!content) { + void Taro.showToast({ + title: '内容不能为空', + icon: 'none', + }); + return; + } + const questionobj = { + biz: 'Course', + biz_id: Number(courseId), + content, + is_anonymous: isAnonymous, + }; + publishQuestionAndBroadcast(questionobj, { + content, + biz_id: Number(courseId), + biz: 'Course', + is_anonymous: isAnonymous, + }) + .then(() => { + void Taro.showToast({ title: '发布问题成功', icon: 'success' }); + void Taro.navigateBack(); + }) + .catch((error) => { + void Taro.showToast({ title: '发布失败,请稍后重试', icon: 'none' }); + console.error('发布问题请求失败:', error); + }); + }; + + if (gate === 'loading') return null; + if (gate === 'block') return ; + + return ( + + {courseId ? : null} + + + + + +