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 (
+
+
+
+
+ );
+ }
+ )
+);
+
+BottomInput.displayName = 'BottomInput';
+
+export default BottomInput;
diff --git a/src/common/components/CollectionCourse/CollectionCourse.tsx b/src/common/components/CollectionCourse/CollectionCourse.tsx
deleted file mode 100644
index 333e7c69..00000000
--- a/src/common/components/CollectionCourse/CollectionCourse.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import { View } from '@tarojs/components';
-import Taro from '@tarojs/taro';
-import React from 'react';
-import { AtRate } from 'taro-ui';
-
-import './index.scss';
-
-interface CollectionCourseProps {
- courseType?: string;
- courseName?: string;
- courseRate?: number;
- courseTeacher?: string;
- isCollected?: boolean;
- courseId?: number;
-}
-
-const CollectionCourse: React.FC = ({
- courseType,
- courseName,
- courseRate,
- courseTeacher,
- isCollected,
- courseId,
-}) => {
- if (!isCollected) {
- return null;
- }
- const navigateToPage = async () => {
- await Taro.navigateTo({
- url: `/pages/classInfo/index?course_id=${courseId}`, // 传递 course_id 参数
- });
- };
-
- const handleClickToClass = () => {
- // console.log('Clicked on course:');
- void navigateToPage().then((r) => console.log(r)); // 这里调用异步函数,但不返回 Promise
- };
- let courseIcon: string = '';
- // CoursePropertyGeneralCore = 通识核心课
- // CoursePropertyGeneralElective = 通识选修课
- // CoursePropertyGeneralRequired = 通识必修课
- // CoursePropertyMajorCore = 专业主干课程
- // CoursePropertyMajorElective = 个性发展课程
- switch (courseType) {
- case 'CoursePropertyGeneralCore':
- courseIcon = '通核';
- break;
- case 'CoursePropertyGeneralElective':
- courseIcon = '通选';
- break;
- case 'CoursePropertyGeneralRequired':
- courseIcon = '通必';
- break;
- case 'CoursePropertyMajorCore':
- courseIcon = '专';
- break;
- case 'CoursePropertyMajorElective':
- courseIcon = '个';
- break;
- }
- return (
-
- {courseIcon}
-
- {courseName}
-
- {courseTeacher}
-
-
-
-
- {isCollected ? '已收藏' : '未收藏'}
-
-
- );
-};
-
-export default CollectionCourse;
diff --git a/src/common/components/CollectionCourse/index.scss b/src/common/components/CollectionCourse/index.scss
deleted file mode 100644
index b89c0db1..00000000
--- a/src/common/components/CollectionCourse/index.scss
+++ /dev/null
@@ -1,78 +0,0 @@
-@import '~taro-ui/dist/style/components/rate.scss';
-@import '~taro-ui/dist/style/components/icon.scss';
-
-.collection_course {
- width: 100%;
- height: 15vh;
- min-height: 15vh;
- border-radius: 2vh;
- background-color: #fffaec;
- color: #333333;
- display: flex;
- align-items: center;
- position: relative;
-}
-
-.collection_course .collection_course_type {
- width: 8vh;
- height: 8vh;
- border-radius: 2vh;
- background-color: white;
- position: absolute;
- left: 4vw;
- display: flex;
- justify-content: center;
- align-items: center;
- font-size: 3vh;
- color: #eda335;
- // box-shadow: -0.5vw 0.5vh 4px rgba(0, 0, 0, 0.1);
-}
-
-.collection_course .collection_course_detail {
- height: 8vh;
- display: flex;
- flex-direction: column;
- justify-content: space-around;
- position: absolute;
- left: 25vw;
-}
-
-.collection_course .collection_course_name {
- font-size: 34rpx;
- // font-weight: bold;
-}
-
-.collection_course .collection_course_description {
- width: 40vw;
- position: relative;
-}
-
-.collection_course .collection_course_teacher {
- margin-right: 5vw;
- font-size: 28rpx;
-}
-
-.collection_course .collection_course_rate {
- position: absolute;
- bottom: 0.1vh;
- right: 0;
-}
-
-.collection_course .collection_course_collected {
- width: 22vw;
- height: 5vh;
- background-color: white;
- border-radius: 2.5vh;
- position: absolute;
- right: 3vw;
- display: flex;
- justify-content: center;
- align-items: center;
- color: #eda335;
- // box-shadow: -0.5vw 0.5vh 4px rgba(0, 0, 0, 0.1);
-}
-
-.collection_course .collection_course_collected.active {
- background-color: #eda335;
- color: #ffffff;
-}
diff --git a/src/common/components/Comment/index.tsx b/src/common/components/Comment/index.tsx
deleted file mode 100644
index 6ac4bee5..00000000
--- a/src/common/components/Comment/index.tsx
+++ /dev/null
@@ -1,191 +0,0 @@
-/* eslint-disable simple-import-sort/imports */
-
-import { Image, Navigator, Text, View } from '@tarojs/components';
-import Taro from '@tarojs/taro';
-import React, { memo, useCallback, useEffect, useState } from 'react';
-
-import './style.scss';
-
-import { useCourseStore } from '@/pages/main/store/store';
-
-import IconFont from '@/common/components/iconfont';
-import { formatIsoDate } from '@/common/utils';
-import { features as featureMap } from '@/pages/evaluate/const';
-
-import {
- COMMENT_ACTIONS,
- CourseDetailsType,
- PublisherDetailsType,
-} from '@/pages/main/store/types';
-import Label3 from '../label3/label3';
-import ShowStar from '../showStar/showStar';
-
-interface CommentProps extends CommentInfo {
- type?: string;
- isHot?: boolean;
- showTag?: boolean;
- showAll?: boolean;
- onClick?: (comment: CommentInfo) => void;
- onCommentClick?: (comment: CommentInfo) => void;
- onLikeClick?: (comment: CommentProps) => void;
- classNames?: string;
-}
-
-const CommentHeader: React.FC = memo((props) => {
- const { course_id, publisher_id, ctime, isHot, star_rating } = props;
- const [publisher_info, setPublisherInfo] = useState();
- const [course_info, setCourseInfo] = useState();
- useEffect(() => {
- void useCourseStore
- .getState()
- .getCourseDetail(course_id || 0)
- .then((res) => {
- setCourseInfo(res);
- });
- void useCourseStore
- .getState()
- .getPublishers(publisher_id || 0)
- .then((res) => {
- setPublisherInfo(res);
- });
- }, [props.course_id, props.publisher_id]);
- const navigateToPage = async () => {
- await Taro.navigateTo({
- url: `/pages/classInfo/index?course_id=${course_id}`, // 传递 course_id 参数
- });
- };
-
- const handleClickToClass = () => {
- void navigateToPage().then((r) => console.log(r));
- };
-
- return (
- <>
- <>
-
- {course_info?.name
- ? `${course_info?.name} (${course_info?.teacher}) `
- : '加载中 ...'}
-
- >
-
- <>
-
- {publisher_info?.nickname}
-
- {' '}
- {formatIsoDate(new Date(ctime as number).toISOString())}
-
-
-
-
-
- >
-
- >
- );
-});
-const Comment: React.FC = memo((props) => {
- const {
- showAll,
- type,
- content,
- id,
- onClick,
- stance,
- onCommentClick,
- onLikeClick,
- features,
- } = props;
- const [shouldSupport, setShouldSupport] = useState(stance === 1);
- const { getComment, enrose } = useCourseStore((state) => ({
- getComment: state.getComment,
- enrose: state.enrose,
- }));
- useEffect(() => {
- setShouldSupport(stance === 1);
- }, [stance]);
- const father_record = getComment(id ?? 0);
- const handleClick = useCallback(() => {
- onClick && onClick(props);
- }, [onClick]);
-
- const handlEndorse = async () => {
- setShouldSupport(!shouldSupport);
- const res = await enrose(
- id ?? 0,
- shouldSupport ? COMMENT_ACTIONS.DISLIKE : COMMENT_ACTIONS.LIKE
- );
- onLikeClick && onLikeClick(res);
- };
- return (
- {
- e.stopPropagation();
- handleClick();
- }}
- >
-
-
-
- {props.showTag &&
- features?.map((item, index) => {
- const feature = featureMap.find((feat) => feat.value === item);
- return feature && ;
- })}
-
-
- {content}
- {/* {!showAll && ...} */}
-
-
- {type === 'inner' && (
- e.stopPropagation()}>
- void handlEndorse()}
- >
-
- {/* */}
-
- {props.total_support_count}
- onCommentClick && father_record && onCommentClick(props)}
- >
-
- {/* */}
-
- {props.total_comment_count}
-
- )}
-
- {type !== 'inner' && (
-
-
-
-
- {props.total_support_count}
-
-
-
- {props.total_comment_count}
-
-
-
- {props.total_oppose_count}
-
- )}
-
- );
-});
-
-export default Comment;
diff --git a/src/common/components/Comment/style.scss b/src/common/components/Comment/style.scss
deleted file mode 100644
index 52eff4c7..00000000
--- a/src/common/components/Comment/style.scss
+++ /dev/null
@@ -1,165 +0,0 @@
-//@import '../../lib/style/font.scss';
-$content-line-height: 30;
-/* 在线链接服务仅供平台体验和调试使用,平台不承诺服务的稳定性,企业客户需下载字体包自行发布使用并做好备份。 */
-// @font-face {
-// font-family: 'iconfont'; /* Project id 4326563 */
-// src:
-// url('//123/t/c/font_4326563_5kfmphwtn65.woff2?t=1699759598184') format('woff2'),
-// url('//123/t/c/font_4326563_5kfmphwtn65.woff?t=1699759598184') format('woff'),
-// url('//123/t/c/font_4326563_5kfmphwtn65.ttf?t=1699759598184') format('truetype');
-// }
-
-.iconfont {
- font-family: 'iconfont' !important;
- font-size: 30rpx;
- font-style: normal;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- color: #f18900;
-}
-
-.comment {
- position: relative;
- .tx {
- width: 92.39rpx;
- height: 92.39rpx;
- background-color: white;
- border-radius: 50%;
- border: #d2d5d8 solid 5rpx;
- float: left;
- background-size: contain;
- //background-image: url("https://thirdqq.qlogo.cn/qqapp/1109891254/F03D55D191E57BA9D69CF8DF5F80414D/100");
- }
- .userName {
- position: absolute;
- left: 130rpx;
- top: 13.5rpx;
- font-size: 22rpx;
- font-weight: bold;
- color: #565552;
- }
- .likes {
- position: absolute;
- right: 0rpx;
- bottom: 20rpx;
- }
-}
-
-.bigcomment {
- .commentplus {
- position: relative;
- margin-left: 45rpx;
- margin-right: 45rpx;
- background: #fffaec;
- border-radius: 43rpx 43rpx 43rpx 43rpx;
- opacity: 1;
- border: 2rpx solid #f9f9f2;
- padding-top: 38rpx;
- padding-left: 36rpx;
- padding-right: 25rpx;
- display: flex;
- flex-direction: column;
- .classTitle {
- height: 43rpx;
- font-family:
- Segoe UI,
- Segoe UI;
- font-weight: bold;
- font-size: 33rpx;
- color: #3d3d3d;
- font-style: normal;
- text-transform: none;
- margin-bottom: 40rpx;
- }
- .comment {
- position: relative;
- width: 600rpx;
- display: flex;
- flex-direction: column;
- margin-top: 10rpx;
- .stars {
- position: absolute;
- left: 130rpx;
- top: 50rpx;
- }
- .fire {
- width: 38rpx;
- height: 38rpx;
- position: absolute;
- top: 50rpx;
- right: 0rpx;
- }
- .time {
- position: absolute;
- top: 13.5rpx;
- right: 0rpx;
- // margin-right: 36rpx;
- font-size: 22rpx;
- font-family:
- Segoe UI-Regular,
- Segoe UI;
- font-weight: 400;
- color: #565552;
- }
- }
- .content {
- // clear: both;
- font-size: 28rpx;
- font-family:
- Segoe UI-Regular,
- Segoe UI;
- font-weight: 400;
- line-height: calc(#{$content-line-height + 6}rpx);
- color: #565552;
- margin: 30rpx 0;
- // height: calc(#{$content-line-height}rpx);
- overflow: hidden;
- }
- .likes {
- margin-top: 25rpx;
- margin-left: 45rpx;
- display: flex;
- justify-content: flex-end;
- margin-bottom: 25rpx;
- }
- }
- .icon {
- position: relative;
- width: 50rpx;
- height: 50rpx;
- background-color: #ffffff;
- box-shadow: 0px 4px 10px 0px rgba(0, 0, 0, 0.05);
- opacity: 1;
- border-radius: 50%;
- display: inline-block;
- text-align: center;
- line-height: 40rpx;
- margin-left: 30rpx;
- margin-right: 15rpx;
- > Iconfont {
- position: absolute;
- left: 50%;
- top: 50%;
- transform: translate(-50%, -50%);
- }
- }
- .text1 {
- font-size: 28rpx;
- // font-weight: bold;
- // color: black;
- color: #3d3d3d;
- line-height: 50rpx;
- }
-}
-.text-overflow {
- line-clamp: 3;
- -webkit-box-orient: vertical;
- display: -webkit-box;
- -webkit-line-clamp: 3; //多行在这里修改数字即可
- overflow: hidden;
- /* autoprefixer: ignore next */
- -webkit-box-orient: vertical;
-}
-.text-showAll {
- height: auto !important;
-}
diff --git a/src/common/components/CommentCard/index.scss b/src/common/components/CommentCard/index.scss
new file mode 100644
index 00000000..03e27e3f
--- /dev/null
+++ b/src/common/components/CommentCard/index.scss
@@ -0,0 +1,65 @@
+.comment_card {
+ box-sizing: border-box;
+ position: relative;
+ width: 100%;
+ background: $color_bg_page;
+ padding: $space_md $space_xl $space_sm;
+
+ &::after {
+ @include border-line(bottom);
+ }
+
+ &.no_border::after {
+ display: none;
+ }
+
+ &.comment_card_secondary {
+ padding-left: 0rpx;
+ }
+
+ .comment_card_header {
+ @include flex_between;
+
+ box-sizing: border-box;
+ margin-bottom: $space_xxs;
+
+ .comment_card_header_left {
+ @include flex_align_center;
+
+ .cc_avatar {
+ border: $color_border_light solid 4rpx;
+ border-radius: 100%;
+ }
+
+ .comment_card_identity {
+ .ui_user_name {
+ font-weight: $font_weight_bold;
+ font-size: $font_size_body;
+ color: $color_text_primary;
+ }
+ }
+ }
+
+ .comment_card_header_right {
+ .comment_card_header_time {
+ font-weight: $font_weight_normal;
+ font-size: $font_size_sm;
+ color: $color_text_secondary;
+ }
+ }
+ }
+
+ .comment_card_content {
+ margin-top: 0;
+ margin-left: calc(82rpx + $space_sm);
+ font-weight: $font_weight_normal;
+ font-size: $font_size_subtitle;
+ color: $color_text_primary;
+ line-height: $line_height_body;
+
+ .comment_card_content_reply_indicator {
+ color: $color_brand_accent;
+ margin-right: 8rpx;
+ }
+ }
+}
diff --git a/src/common/components/CommentCard/index.tsx b/src/common/components/CommentCard/index.tsx
new file mode 100644
index 00000000..fb11be0a
--- /dev/null
+++ b/src/common/components/CommentCard/index.tsx
@@ -0,0 +1,79 @@
+import { Text, View } from '@tarojs/components';
+import React, { memo } from 'react';
+
+import './index.scss';
+
+import { UserIdentity } from '@/common/components';
+import type { DiscussionComment } from '@/common/types/commentTypes';
+import { formatDate } from '@/common/utils';
+
+interface CommentCardProps {
+ comment: DiscussionComment;
+ level?: 'primary' | 'secondary';
+ showReplyIndicator?: boolean;
+ replyToNickname?: string;
+ showBorder?: boolean;
+
+ onClick?: (comment: DiscussionComment) => void;
+ onLongPress?: (comment: DiscussionComment) => void;
+}
+
+const CommentCard: React.FC = memo(
+ ({
+ comment,
+ level = 'primary',
+ showReplyIndicator = false,
+ replyToNickname,
+ showBorder = true,
+ onClick,
+ onLongPress,
+ }) => {
+ const userInfo = comment.user || comment.publisher;
+
+ return (
+ {
+ e.stopPropagation();
+ onClick?.(comment);
+ }}
+ onLongPress={(e) => {
+ e.stopPropagation();
+ onLongPress?.(comment);
+ }}
+ >
+
+
+
+
+
+
+ {formatDate(new Date(comment.ctime).toISOString(), 'yyyy.MM.dd. hh:mm')}
+
+
+
+
+
+
+ {showReplyIndicator && replyToNickname && (
+
+ @{replyToNickname}:
+
+ )}
+ {comment.content}
+
+
+
+ );
+ }
+);
+
+export default CommentCard;
diff --git a/src/common/components/CommentComponent/CommentComponent.tsx b/src/common/components/CommentComponent/CommentComponent.tsx
deleted file mode 100644
index d48e3dc7..00000000
--- a/src/common/components/CommentComponent/CommentComponent.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-/* eslint-disable @typescript-eslint/no-unsafe-argument */
-/* eslint-disable @typescript-eslint/no-unsafe-return */
-/* eslint-disable @typescript-eslint/no-unsafe-call */
-/* eslint-disable @typescript-eslint/no-unsafe-assignment */
-/* eslint-disable @typescript-eslint/no-unsafe-member-access */
-
-// Comment.tsx
-import { Image, Text, View } from '@tarojs/components';
-import React, { useEffect, useState } from 'react';
-
-import './index.scss';
-
-import { formatIsoDate, get } from '@/common/utils';
-import { useCourseStore } from '@/pages/main/store/store';
-
-interface CommentProps {
- comments: CommentType[];
- onCommentClick: (comment: CommentType) => void;
-}
-
-const CommentComponent: React.FC = ({ comments, onCommentClick }) => {
- const [allComments, setAllComments] = useState(comments);
- const dispatch = useCourseStore(({ getPublishers }) => ({ getPublishers }));
- useEffect(() => {
- const fetchAllReplies = async () => {
- const topLevelComments = allComments.filter(
- (c) => c.parent_comment_id === 0 && c.root_comment_id === 0
- );
-
- const promises = topLevelComments.map(async (comment) => {
- const res = await get(
- `/comments/replies/list?root_id=${comment.id}&cur_comment_id=0&limit=10`
- );
- if (res.code === 0 && Array.isArray(res.data)) {
- const replies = res.data.map((reply) => ({
- ...reply,
- }));
- return { ...comment, replies };
- }
- return { ...comment, replies: [] };
- });
-
- const updatedComments = await Promise.all(promises);
-
- const commentsWithUserInfo = await Promise.all(
- updatedComments.map(async (comment) => {
- const user = await dispatch.getPublishers(comment.commentator_id);
- return { ...comment, user };
- })
- );
-
- const commentsWithRepliesAndUserInfo = await Promise.all(
- commentsWithUserInfo.map(async (comment) => {
- const replies = comment.replies || [];
- const repliesWithUserInfo = await Promise.all(
- replies.map(async (reply) => {
- const user = await dispatch.getPublishers(reply.commentator_id);
- return { ...reply, user };
- })
- );
- return { ...comment, replies: repliesWithUserInfo };
- })
- );
-
- // console.log(commentsWithRepliesAndUserInfo);
-
- setAllComments(commentsWithRepliesAndUserInfo);
- };
-
- void fetchAllReplies();
- }, []);
-
- // 辅助函数:获取回复者的昵称
- const getReplyToNickname = (replyToUid: number): string => {
- // 遍历所有评论的回复
- for (const comment of allComments) {
- const reply = comment.replies?.find((r) => r.commentator_id === replyToUid);
- if (reply && reply.user) {
- return reply.user.nickname;
- }
- }
- return '未知用户'; // 如果没有找到回复者,返回默认昵称
- };
-
- return (
-
- {allComments.map((comment) => (
- {
- e.stopPropagation();
- onCommentClick(comment);
- }}
- >
-
-
- {comment.user?.nickname}
-
- {formatIsoDate(new Date(comment.ctime).toISOString())}
-
-
-
- {comment.content}
-
-
- {comment.replies?.map((reply) => (
- {
- onCommentClick(reply);
- e.stopPropagation();
- }}
- >
-
-
-
- {reply.user?.nickname}
- {reply.root_comment_id !== reply.parent_comment_id ? (
-
- 回复 {getReplyToNickname(reply.reply_to_uid)}
-
- ) : null}
-
-
- {formatIsoDate(new Date(comment.ctime).toISOString())}
-
-
-
- {reply.content}
-
-
- ))}
-
-
- ))}
-
- );
-};
-
-export default CommentComponent;
diff --git a/src/common/components/CommentComponent/index.scss b/src/common/components/CommentComponent/index.scss
deleted file mode 100644
index f682ec5b..00000000
--- a/src/common/components/CommentComponent/index.scss
+++ /dev/null
@@ -1,81 +0,0 @@
-// style.scss
-.comments {
- padding: 10px;
-}
-
-.acomment {
- width: 659rpx;
- margin: 30rpx 0;
- background: #f9f9f2;
- border-radius: 43rpx 43rpx 43rpx 43rpx;
- border: 2rpx solid #f9f9f2;
- padding: 30rpx;
-}
-
-.comment-header {
- display: flex;
- align-items: center;
- margin-bottom: 20rpx;
-}
-
-.avatar {
- width: 57.97rpx !important;
- height: 57.97rpx !important;
- border-radius: 100%;
- margin-right: 10px;
- border: #d2d5d8 solid 5rpx;
-}
-
-.nickname {
- font-weight: bold;
- font-size: 22rpx;
- color: #565552;
-}
-
-.comment-content {
- margin-top: 5px;
- margin-left: 80px;
- font-weight: 400;
- font-size: 22rpx;
- color: #565552;
-}
-
-.replies {
- margin-top: 20px;
- padding-left: 10px;
-}
-
-.reply .nickname {
- font-weight: 400;
- font-size: 22rpx;
- color: #565552;
-}
-
-.reply {
- padding-left: 10px;
- margin-bottom: 5px;
-}
-
-.reply-header {
- display: flex;
- align-items: center;
-}
-
-.reply-content {
- margin-top: 5px;
- font-weight: 400;
- font-size: 22rpx;
- color: #565552;
- margin-left: 70rpx;
-}
-
-.time {
- font-weight: 400;
- font-size: 18rpx;
- color: #a6a193;
- margin-left: calc(90% - 200rpx);
-}
-
-.reply-indicator {
- margin-left: 10rpx;
-}
diff --git a/src/common/components/CourseInfo/CourseInfo.tsx b/src/common/components/CourseInfo/CourseInfo.tsx
deleted file mode 100644
index 093d2f0a..00000000
--- a/src/common/components/CourseInfo/CourseInfo.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { View } from '@tarojs/components';
-import React from 'react';
-
-import './index.scss';
-
-interface ICourseInfoProps {
- name?: string;
- school?: string;
- teacher?: string;
-}
-
-const CourseInfo: React.FC = ({ name, school, teacher }) => {
- return (
-
- {name}
-
- {school} {teacher}
-
-
- );
-};
-
-export default CourseInfo;
diff --git a/src/common/components/CourseInfo/index.scss b/src/common/components/CourseInfo/index.scss
index 03c45434..ee901f04 100644
--- a/src/common/components/CourseInfo/index.scss
+++ b/src/common/components/CourseInfo/index.scss
@@ -1,16 +1,16 @@
.theClassName {
- font-size: 43rpx;
+ font-size: $font_size_hero;
font-weight: bold;
- color: #565552;
+ color: $color_text_heading;
text-align: center;
margin-top: 22rpx;
}
.teacherName {
- font-size: 33rpx;
+ font-size: $font_size_subheading;
font-weight: 400;
- color: #9f9f9c;
+ color: $color_text_secondary;
text-align: center;
- margin-top: 15rpx;
- margin-bottom: 27rpx;
+ margin-top: 16rpx;
+ margin-bottom: 28rpx;
}
diff --git a/src/common/components/CourseInfo/index.tsx b/src/common/components/CourseInfo/index.tsx
new file mode 100644
index 00000000..fa2232a8
--- /dev/null
+++ b/src/common/components/CourseInfo/index.tsx
@@ -0,0 +1,34 @@
+import { View } from '@tarojs/components';
+import React, { useEffect } from 'react';
+
+import './index.scss';
+
+import { useCourseStore } from '@/store/course';
+
+interface ICourseInfoProps {
+ courseId: number;
+}
+
+const CourseInfo: React.FC = ({ courseId }) => {
+ const courseDetail = useCourseStore((s) => s.courseDetail[courseId]);
+
+ useEffect(() => {
+ if (!courseDetail && courseId > 0) {
+ useCourseStore
+ .getState()
+ .fetchCourseDetail(courseId)
+ .catch(() => {});
+ }
+ }, [courseId, courseDetail]);
+
+ return (
+
+ {courseDetail?.name || '加载中...'}
+
+ {courseDetail?.school} {courseDetail?.teacher}
+
+
+ );
+};
+
+export default CourseInfo;
diff --git a/src/common/components/CourseLabel/index.scss b/src/common/components/CourseLabel/index.scss
new file mode 100644
index 00000000..1271b59c
--- /dev/null
+++ b/src/common/components/CourseLabel/index.scss
@@ -0,0 +1,49 @@
+.courseLabel {
+ margin: auto;
+ width: 85%;
+ padding: $space_lg 36rpx;
+ border-radius: 36rpx;
+ opacity: 1;
+ margin-bottom: 48rpx;
+ background: $color_bg_page;
+ box-shadow:
+ 0 -4rpx 6rpx rgba(0, 0, 0, 0.07),
+ 0 4rpx 6rpx rgba(0, 0, 0, 0.07);
+
+ .courseLabel_header {
+ margin-bottom: $space_xxs;
+ }
+
+ .courseLabel_star_left {
+ margin-bottom: $space_xxs;
+ }
+
+ .class_type_container {
+ @include flex_align_center;
+
+ margin: $space_xxs 0 24rpx;
+ width: 100%;
+
+ .class_type_text {
+ font-size: $font_size_sm;
+ color: $color_text_cool_gray;
+ letter-spacing: 4rpx;
+ }
+ }
+
+ .class_feature_container {
+ @include flex_align_center;
+
+ width: 100%;
+ flex-wrap: wrap;
+ justify-content: flex-start;
+ gap: 14rpx;
+ }
+
+ .class_footer {
+ @include flex_align_center;
+
+ margin-top: $space_sm;
+ padding-top: $space_sm;
+ }
+}
diff --git a/src/common/components/CourseLabel/index.tsx b/src/common/components/CourseLabel/index.tsx
new file mode 100644
index 00000000..b169251a
--- /dev/null
+++ b/src/common/components/CourseLabel/index.tsx
@@ -0,0 +1,72 @@
+import { Text, View } from '@tarojs/components';
+import Taro from '@tarojs/taro';
+import { ReactNode } from 'react';
+
+import './index.scss';
+
+import CourseTitleBlock from '../CourseTitleBlock';
+import FeatureLabel from '../FeatureLabel';
+import ShowStar from '../ShowStar';
+
+export interface CourseLabelCourse {
+ id: number | string;
+ name: string;
+ teacher: string;
+ composite_score: number;
+ features?: string[] | Record;
+ courseType?: string;
+}
+
+interface CourseLabelProps {
+ course: CourseLabelCourse;
+ starPlacement?: 'left' | 'right';
+ FooterComponent?: ReactNode;
+}
+
+export default function CourseLabel({
+ course,
+ starPlacement,
+ FooterComponent,
+}: CourseLabelProps) {
+ const { id, name, teacher, composite_score, features, courseType } = course;
+
+ const renderFeatures = () => {
+ const commentsArray = Array.isArray(features) && features.length > 0 ? features : [];
+
+ return commentsArray.map((item, index) => {
+ if (item) {
+ return ;
+ }
+ return null;
+ });
+ };
+
+ const starNode = ;
+
+ return (
+ {
+ void Taro.navigateTo({
+ url: `/pages/classInfo/index?course_id=${id}`,
+ });
+ }}
+ >
+
+ {starPlacement === 'left' ? (
+ {starNode}
+ ) : null}
+
+ {courseType}
+
+ {features && {renderFeatures()}}
+
+ {FooterComponent && FooterComponent}
+
+ );
+}
diff --git a/src/common/components/CourseTitleBlock/index.scss b/src/common/components/CourseTitleBlock/index.scss
new file mode 100644
index 00000000..c59a6b32
--- /dev/null
+++ b/src/common/components/CourseTitleBlock/index.scss
@@ -0,0 +1,58 @@
+.course_title_block {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: $space_sm;
+ width: 100%;
+ min-width: 0;
+
+ &__main {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4rpx;
+ }
+
+ &__main--clickable {
+ cursor: pointer;
+ }
+
+ &__name {
+ display: block;
+ width: 100%;
+ font-size: $font_size_base;
+ font-weight: 600;
+ color: $color_text_primary;
+ @include text_ellipsis;
+ }
+
+ &__teacher {
+ display: block;
+ width: 100%;
+ font-size: $font_size_subtitle;
+ color: $color_text_cool_gray;
+ @include text_ellipsis;
+ }
+
+ &__trailing {
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: $space_xxs;
+ }
+
+ &.inline &__main {
+ flex-direction: row;
+ align-items: baseline;
+ gap: $space_sm;
+ }
+
+ &.inline &__name,
+ &.inline &__teacher {
+ width: auto;
+ flex: 1;
+ min-width: 0;
+ }
+}
diff --git a/src/common/components/CourseTitleBlock/index.tsx b/src/common/components/CourseTitleBlock/index.tsx
new file mode 100644
index 00000000..3c4255aa
--- /dev/null
+++ b/src/common/components/CourseTitleBlock/index.tsx
@@ -0,0 +1,50 @@
+import { Text, View } from '@tarojs/components';
+import { ReactNode } from 'react';
+
+import './index.scss';
+
+interface CourseTitleBlockProps {
+ name: string;
+ teacher?: string;
+ layout?: 'stacked' | 'inline';
+ trailing?: ReactNode;
+ className?: string;
+ onMainClick?: (e?: any) => void;
+}
+
+export default function CourseTitleBlock({
+ name,
+ teacher,
+ layout = 'stacked',
+ trailing,
+ className,
+ onMainClick,
+}: CourseTitleBlockProps) {
+ const showTeacher = Boolean(teacher?.trim());
+
+ return (
+
+ {
+ e.stopPropagation();
+ onMainClick(e);
+ }
+ : undefined
+ }
+ >
+
+ {name}
+
+ {showTeacher && (
+
+ {teacher}
+
+ )}
+
+ {trailing ? {trailing} : null}
+
+ );
+}
diff --git a/src/common/components/Drawer/index.scss b/src/common/components/Drawer/index.scss
new file mode 100644
index 00000000..458cb738
--- /dev/null
+++ b/src/common/components/Drawer/index.scss
@@ -0,0 +1,86 @@
+.drawer_mask {
+ position: fixed;
+ z-index: 1000;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ pointer-events: none;
+
+ &.drawer_mask_show {
+ pointer-events: auto;
+ opacity: 1;
+ }
+}
+.drawer_content {
+ @include flex_column;
+
+ position: fixed;
+ z-index: 1001;
+ background-color: $color_bg_page;
+ transition: transform 0.3s ease;
+ pointer-events: auto;
+
+ &.drawer_content_bottom {
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-radius: $radius_lg 24rpx 0 0;
+ transform: translateY(100%);
+
+ &.drawer_content_show {
+ transform: translateY(0);
+ }
+ }
+
+ &.drawer_content_side {
+ top: 0;
+ bottom: 0;
+ border-radius: $radius_lg 0 0 24rpx;
+
+ &.drawer_content_left {
+ left: 0;
+ transform: translateX(-100%);
+ border-radius: 0 24rpx 24rpx 0;
+ }
+
+ &.drawer_content_right {
+ right: 0;
+ transform: translateX(100%);
+ }
+
+ &.drawer_content_show {
+ transform: translateX(0);
+ }
+ }
+
+ .drawer_header {
+ @include flex_center;
+
+ position: relative;
+ padding: 30rpx;
+
+ .drawer_title {
+ font-size: $font_size_heading;
+ font-weight: 600;
+ color: $color_text_primary;
+ }
+
+ .drawer_close {
+ position: absolute;
+ right: 3%;
+ font-size: $font_size_heading;
+ color: $color_text_primary;
+ padding: 0 10rpx;
+ }
+ }
+
+ .drawer_body {
+ padding: 30rpx;
+ overflow-y: auto;
+ flex: 1;
+ }
+}
diff --git a/src/common/components/Drawer/index.tsx b/src/common/components/Drawer/index.tsx
new file mode 100644
index 00000000..ed1ab84f
--- /dev/null
+++ b/src/common/components/Drawer/index.tsx
@@ -0,0 +1,59 @@
+import { Text, View } from '@tarojs/components';
+import React from 'react';
+
+import './index.scss';
+
+interface DrawerProps {
+ isOpened: boolean;
+ title?: string;
+ children?: React.ReactNode;
+ mode?: 'bottom' | 'side';
+ side?: 'left' | 'right';
+ height?: string | number;
+ width?: string | number;
+
+ onClose: () => void;
+}
+
+const Drawer: React.FC = ({
+ isOpened,
+ onClose,
+ title,
+ children,
+ mode = 'bottom',
+ side = 'right',
+ height = '70vh',
+ width = '70vw',
+}) => {
+ const getContentStyle = (): React.CSSProperties => {
+ if (mode === 'bottom') {
+ return { height };
+ } else {
+ return { width, height: '100%' };
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+ {title || '提示'}
+
+ X
+
+
+ {children}
+
+ >
+ );
+};
+
+export default Drawer;
diff --git a/src/common/components/EllipsisText/index.scss b/src/common/components/EllipsisText/index.scss
new file mode 100644
index 00000000..c673b628
--- /dev/null
+++ b/src/common/components/EllipsisText/index.scss
@@ -0,0 +1,16 @@
+.ellipsis_text {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ @include text_ellipsis;
+
+ &.clamp_2 {
+ white-space: normal;
+ @include line_clamp(2);
+ }
+
+ &.clamp_3 {
+ white-space: normal;
+ @include line_clamp(3);
+ }
+}
diff --git a/src/common/components/EllipsisText/index.tsx b/src/common/components/EllipsisText/index.tsx
new file mode 100644
index 00000000..6066e17f
--- /dev/null
+++ b/src/common/components/EllipsisText/index.tsx
@@ -0,0 +1,33 @@
+import { Text } from '@tarojs/components';
+import { ReactNode } from 'react';
+
+import './index.scss';
+
+interface EllipsisTextProps {
+ children: ReactNode;
+ className?: string;
+ lines?: number;
+}
+
+export default function EllipsisText({
+ children,
+ className,
+ lines = 1,
+}: EllipsisTextProps) {
+ if (lines <= 1) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/common/components/FeatureLabel/index.scss b/src/common/components/FeatureLabel/index.scss
new file mode 100644
index 00000000..c754c5e1
--- /dev/null
+++ b/src/common/components/FeatureLabel/index.scss
@@ -0,0 +1,10 @@
+.featureLabel {
+ width: max-content;
+ border-radius: $radius_lg 24rpx 24rpx 24rpx;
+ border: 1px solid transparent;
+ font-size: $font_size_caption;
+ font-family:
+ Segoe UI-Regular,
+ Segoe UI;
+ padding: 6rpx 24rpx;
+}
diff --git a/src/common/components/FeatureLabel/index.tsx b/src/common/components/FeatureLabel/index.tsx
new file mode 100644
index 00000000..c75947ea
--- /dev/null
+++ b/src/common/components/FeatureLabel/index.tsx
@@ -0,0 +1,42 @@
+import { View } from '@tarojs/components';
+import { CSSProperties } from 'react';
+
+import './index.scss';
+
+interface FeatrueLabelProps {
+ id?: number | string;
+ content: string;
+ checked?: boolean;
+ style?: CSSProperties;
+ handleChecked?: (id: number | string) => void;
+ handleClick?: () => void;
+}
+
+export default function FeatrueLabel({
+ id,
+ content,
+ checked,
+ style,
+ handleChecked,
+ handleClick,
+}: FeatrueLabelProps) {
+ const labelChecked = () => {
+ id && handleChecked && handleChecked(id);
+ handleClick && handleClick();
+ };
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/src/common/components/FeedCard/index.scss b/src/common/components/FeedCard/index.scss
new file mode 100644
index 00000000..1d55ed5d
--- /dev/null
+++ b/src/common/components/FeedCard/index.scss
@@ -0,0 +1,194 @@
+// 统一度量衡了,原本想抄b站的,R.I.P
+$level-styles: (
+ 0: (
+ background: linear-gradient(135deg, #a9a9a9, #8a8a8a),
+ color: $color_bg_page,
+ shadow: 0 4rpx 8rpx rgba(109, 109, 109, 0.3),
+ ),
+ 1: (
+ background: linear-gradient(135deg, #bfbfbf, #d0d0d0),
+ color: $color_bg_page,
+ shadow: 0 4rpx 8rpx rgba(179, 179, 179, 0.3),
+ ),
+ 2: (
+ background: linear-gradient(135deg, #7cc47c, #a8d5a8),
+ color: $color_bg_page,
+ shadow: 0 4rpx 8rpx rgba(124, 196, 124, 0.3),
+ ),
+ 3: (
+ background: linear-gradient(135deg, #fedb5f, #ffe066),
+ color: $color_bg_page,
+ shadow: 0 4rpx 8rpx rgba(254, 219, 95, 0.3),
+ ),
+ 4: (
+ background: linear-gradient(135deg, #ff9f3d, #ffb347),
+ color: $color_bg_page,
+ shadow: 0 4rpx 8rpx rgba(255, 159, 61, 0.3),
+ ),
+ 5: (
+ background: linear-gradient(135deg, #ff7722, #ff8c42),
+ color: $color_bg_page,
+ shadow: 0 4rpx 8rpx rgba(255, 119, 34, 0.3),
+ ),
+ 6: (
+ background: linear-gradient(135deg, #ff6a2b, #ff6b6b),
+ color: $color_bg_page,
+ shadow: 0 4rpx 8rpx rgba(255, 82, 82, 0.3),
+ ),
+);
+
+$content-line-height: 30;
+
+.iconfont {
+ font-family: 'iconfont' !important;
+ font-size: $font_size_base;
+ font-style: normal;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ color: $color_brand_accent;
+}
+
+.feed_card_container {
+ .review_content_wrapper {
+ @include flex_column;
+
+ position: relative;
+ margin-left: 26rpx;
+ margin-right: 26rpx;
+ background: $color_bg_page;
+ border-radius: 44rpx 44rpx 44rpx 44rpx;
+ opacity: 1;
+ box-shadow:
+ 0 -4rpx 6rpx rgba(0, 0, 0, 0.05),
+ 0 4rpx 6rpx rgba(0, 0, 0, 0.05);
+ padding: $space_xl 36rpx 0;
+
+ .review_header_row {
+ margin-bottom: $space_md;
+
+ .visibility_btn {
+ padding: 4rpx 12rpx;
+ border-radius: $radius_lg;
+ font-size: $font_size_caption;
+
+ &.public {
+ background-color: $color_text_light;
+ }
+
+ &.private {
+ background-color: $color_brand_muted;
+ }
+
+ .btn_text {
+ color: $color_bg_page;
+ line-height: 1;
+ }
+ }
+ }
+
+ .reviewer_info {
+ position: relative;
+
+ .cr_avatar {
+ border: $color_brand_accent solid 1rpx;
+ border-radius: $radius_round;
+ }
+
+ .hot_badge {
+ width: 38rpx;
+ height: 38rpx;
+ position: absolute;
+ top: 50rpx;
+ right: 0rpx;
+ }
+ }
+
+ .review_text {
+ font-size: $font_size_body;
+ font-family:
+ Segoe UI-Regular,
+ Segoe UI;
+ font-weight: 400;
+ line-height: calc(#{$content-line-height + 6}rpx);
+ color: $color_text_heading;
+ margin: $space_xs 0;
+ overflow: hidden;
+
+ &.text_clamped {
+ line-clamp: 3;
+ -webkit-box-orient: vertical;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ }
+
+ &.text_expanded {
+ height: auto !important;
+ }
+ }
+
+ .feature_tags_container {
+ margin: $space_xs 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: $space_xs;
+ width: 100%;
+ }
+
+ .review_footer {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ margin: $space_xs 0 24rpx;
+
+ .footer_time {
+ white-space: nowrap;
+ font-size: $font_size_sm;
+ color: $color_text_secondary;
+ }
+
+ .footer_actions {
+ @include flex_align_center;
+
+ flex-direction: row;
+ gap: $space_xxl;
+
+ .action_item {
+ @include flex_align_center;
+
+ flex-direction: row;
+ gap: 10rpx;
+ font-size: $font_size_subtitle;
+ color: $color_text_medium;
+
+ .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);
+ opacity: 1;
+ border-radius: $radius_round;
+ display: inline-block;
+ text-align: center;
+ line-height: 40rpx;
+
+ > Iconfont {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ }
+ }
+
+ .action_count {
+ font-size: $font_size_body;
+ color: $color_text_primary;
+ line-height: 50rpx;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/common/components/FeedCard/index.tsx b/src/common/components/FeedCard/index.tsx
new file mode 100644
index 00000000..c3727da9
--- /dev/null
+++ b/src/common/components/FeedCard/index.tsx
@@ -0,0 +1,264 @@
+import { Image, Text, View } from '@tarojs/components';
+import Taro from '@tarojs/taro';
+import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
+
+import './index.scss';
+
+import { useCourseStore } from '@/store/course';
+import { useEvaluationStore } from '@/store/evaluations';
+
+import { ActionItem, CourseTitleBlock, UserIdentity } from '@/common/components';
+import { COURSE_FEATURE_MAP } from '@/common/constants/courseLabels';
+import type { CommentInfo } from '@/common/types/commentTypes';
+import { CourseDetailsType, PublisherDetailsType } from '@/common/types/courseType';
+import { formatDate } from '@/common/utils';
+
+import FeatureLabel from '../FeatureLabel';
+import ShowStar from '../ShowStar';
+
+interface FeedCardProps {
+ comment: CommentInfo;
+ type?: string;
+ isHot?: boolean;
+ showTag?: boolean;
+ showAll?: boolean;
+ classNames?: string;
+ isEditable?: boolean;
+ initialVisibility?: 'public' | 'private';
+ hideStar?: boolean;
+
+ onClick?: (comment: CommentInfo) => void;
+ onCommentClick?: (comment: CommentInfo) => void;
+ onVisibilityChange?: (id: number, visibility: 'public' | 'private') => void;
+}
+
+interface ReviewHeaderProps {
+ comment: CommentInfo;
+ isHot?: boolean;
+ isEditable?: boolean;
+ initialVisibility?: 'public' | 'private';
+ hideStar?: boolean;
+
+ onVisibilityChange?: (id: number, visibility: 'public' | 'private') => void;
+}
+
+interface ReviewFooterProps {
+ comment: CommentInfo;
+ type?: string;
+ fatherRecord?: CommentInfo;
+
+ onCommentClick?: (comment: CommentInfo) => void;
+}
+
+const ReviewHeader: React.FC = ({
+ comment: { course_id, publisher, course_name, teacher_name, star_rating, id },
+ isHot,
+ isEditable,
+ initialVisibility = 'public',
+ onVisibilityChange,
+ hideStar,
+}) => {
+ const [publisherInfo, setPublisherInfo] = useState();
+ const [courseInfo, setCourseInfo] = useState();
+ const fetchIdRef = useRef(0);
+
+ useEffect(() => {
+ const fetchId = ++fetchIdRef.current;
+
+ if (course_id && course_name) {
+ setCourseInfo({ name: course_name, teacher: teacher_name || '', school: '' });
+ } else if (course_id) {
+ useCourseStore
+ .getState()
+ .getCourseDetail(course_id)
+ .then((res) => {
+ if (fetchId === fetchIdRef.current && res) setCourseInfo(res);
+ })
+ .catch(() => {});
+ }
+
+ if (publisher?.nickname) {
+ setPublisherInfo({
+ id: publisher.id ?? 0,
+ avatar: publisher.avatar || '',
+ nickname: publisher.nickname,
+ using_title: publisher.using_title,
+ level: publisher.level,
+ });
+ }
+ }, [
+ course_id,
+ course_name,
+ teacher_name,
+ publisher?.id,
+ publisher?.avatar,
+ publisher?.nickname,
+ publisher?.using_title,
+ publisher?.level,
+ ]);
+
+ const handleClickToClass = useCallback(
+ (e: any) => {
+ e.stopPropagation();
+ void Taro.navigateTo({ url: `/pages/classInfo/index?course_id=${course_id}` });
+ },
+ [course_id]
+ );
+
+ const toggleVisibility = useCallback(
+ (e: any) => {
+ e.stopPropagation();
+ if (!isEditable || !id) return;
+ const newVisibility = initialVisibility === 'public' ? 'private' : 'public';
+ onVisibilityChange?.(id, newVisibility);
+ },
+ [isEditable, id, initialVisibility, onVisibilityChange]
+ );
+
+ const userLevel = publisherInfo?.level ?? 0;
+ const userTitle = publisherInfo?.using_title;
+
+ return (
+ <>
+
+ {isEditable && (
+
+
+ {initialVisibility === 'public' ? '设为私密' : '设为公开'}
+
+
+ )}
+ {!hideStar && }
+ >
+ }
+ />
+
+
+
+
+ >
+ );
+};
+
+const ReviewFooter: React.FC = ({
+ comment: {
+ ctime,
+ stance,
+ total_comment_count,
+ total_oppose_count,
+ total_support_count,
+ id,
+ },
+ comment,
+ type,
+ fatherRecord,
+ onCommentClick,
+}) => {
+ const isInner = type === 'inner';
+
+ return (
+
+
+ {ctime ? formatDate(ctime as number) : ''}
+
+
+
+ onCommentClick?.(comment)}
+ disabled={!isInner || !fatherRecord}
+ />
+ {!isInner && }
+
+
+ );
+};
+
+const FeedCard: React.FC = memo(
+ ({
+ comment,
+ type,
+ showAll,
+ showTag,
+ onClick,
+ onCommentClick,
+ classNames,
+ isHot,
+ isEditable,
+ initialVisibility,
+ onVisibilityChange,
+ hideStar,
+ }) => {
+ const handleClick = useCallback(() => {
+ if (comment) onClick?.(comment);
+ }, [onClick, comment]);
+
+ if (!comment) return null;
+ const { content, id, features } = comment;
+ const fatherRecord = id ? useEvaluationStore.getState().get(id) : undefined;
+
+ return (
+
+
+
+
+
+ {content}
+
+
+ {showTag && features && features.length > 0 && (
+
+ {features.map((item, index) => {
+ const label = COURSE_FEATURE_MAP[item] || item;
+ return ;
+ })}
+
+ )}
+
+
+
+
+ );
+ }
+);
+
+export default FeedCard;
diff --git a/src/common/components/FloatButton/index.scss b/src/common/components/FloatButton/index.scss
new file mode 100644
index 00000000..011f216d
--- /dev/null
+++ b/src/common/components/FloatButton/index.scss
@@ -0,0 +1,35 @@
+.floatButton_container {
+ @include flex_column_center;
+
+ position: fixed;
+ z-index: 369;
+ min-width: 64rpx;
+ min-height: 64rpx;
+ padding: $space_sm;
+ background-color: $color_bg_page;
+ color: $color_text_primary;
+ border: 2rpx solid transparent;
+ box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
+ transition: all 0.25s ease;
+
+ .floatButton_icon {
+ font-size: $font_size_title;
+ line-height: 1;
+ color: $color_text_primary;
+ }
+
+ .floatButton_content {
+ margin-top: $space_xxs;
+ font-size: $font_size_caption;
+ color: $color_text_primary;
+ text-align: center;
+ }
+
+ &.floatButton_circle {
+ border-radius: $radius_round;
+ }
+
+ &.floatButton_square {
+ border-radius: $radius_md;
+ }
+}
diff --git a/src/common/components/FloatButton/index.tsx b/src/common/components/FloatButton/index.tsx
new file mode 100644
index 00000000..121f2c85
--- /dev/null
+++ b/src/common/components/FloatButton/index.tsx
@@ -0,0 +1,74 @@
+import { View } from '@tarojs/components';
+import React, { useState } from 'react';
+
+import './index.scss';
+
+export type FloatButtonProps = {
+ icon?: React.ReactNode;
+ content?: React.ReactNode;
+ shape?: 'circle' | 'square';
+ side?: 'left' | 'right';
+ verticalOffset?: number | string;
+ horizontalOffset?: number | string;
+ halfHidden?: boolean;
+ styles?: React.CSSProperties | ((props: FloatButtonProps) => React.CSSProperties);
+ onClick?: (e: any) => void;
+};
+
+const FloatButton: React.FC = (props) => {
+ const {
+ icon,
+ content,
+ shape = 'circle',
+ side = 'right',
+ verticalOffset = '70%',
+ horizontalOffset = 0,
+ halfHidden = false,
+ styles,
+ onClick,
+ } = props;
+
+ const [active, setActive] = useState(false);
+
+ const getOffset = (value?: number | string) => {
+ if (typeof value === 'number') return `${value}rpx`;
+ return value;
+ };
+
+ const top = getOffset(verticalOffset);
+ const sideValue = halfHidden ? '0rpx' : getOffset(horizontalOffset);
+ const computedStyle = typeof styles === 'function' ? styles(props) : styles;
+
+ const transformX = halfHidden
+ ? side === 'right'
+ ? active
+ ? 'translateX(0)'
+ : 'translateX(50%)'
+ : active
+ ? 'translateX(0)'
+ : 'translateX(-50%)'
+ : 'translateX(0)';
+
+ return (
+ setActive(true)}
+ onTouchEnd={() => setActive(false)}
+ onTouchCancel={() => setActive(false)}
+ >
+ {icon && {icon}}
+ {content && {content}}
+
+ );
+};
+
+export default FloatButton;
diff --git a/src/common/components/GateScreen/index.scss b/src/common/components/GateScreen/index.scss
new file mode 100644
index 00000000..b4183e36
--- /dev/null
+++ b/src/common/components/GateScreen/index.scss
@@ -0,0 +1,57 @@
+.gate_screen {
+ position: relative;
+ min-height: 100vh;
+ width: 100%;
+ padding-top: 180rpx;
+ box-sizing: border-box;
+ overflow: hidden;
+}
+
+.gate_screen_bg {
+ width: 100%;
+}
+
+.gate_screen_header {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ margin-top: 15vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 32rpx;
+}
+
+.gate_screen_logo_wrapper {
+ width: 320rpx;
+ height: 320rpx;
+ overflow: hidden;
+ border-radius: 32rpx;
+ box-shadow: 0 20rpx 40rpx rgba(0, 0, 0, 0.12);
+}
+
+.gate_screen_logo {
+ width: 100%;
+ height: 100%;
+}
+
+.gate_screen_title {
+ font-size: 60rpx;
+ font-weight: 600;
+ letter-spacing: 0.2em;
+ color: $color_brand_golden;
+}
+
+.gate_screen_hint {
+ position: fixed;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 1;
+ font-size: 60rpx;
+ font-weight: 600;
+ letter-spacing: 0.3em;
+ color: $color_brand_golden;
+ white-space: nowrap;
+}
diff --git a/src/common/components/GateScreen/index.tsx b/src/common/components/GateScreen/index.tsx
new file mode 100644
index 00000000..1a589fb9
--- /dev/null
+++ b/src/common/components/GateScreen/index.tsx
@@ -0,0 +1,34 @@
+import { Image, Text, View } from '@tarojs/components';
+
+import { Icon, TopBackground } from '@/common/assets/img/login';
+import { NavigationBar } from '@/modules/navigation';
+
+import './index.scss';
+
+interface GateScreenProps {
+ className?: string;
+ message?: string;
+ title?: string;
+}
+
+const GateScreen: React.FC = ({
+ className,
+ message = '木犀课栈',
+ title = '',
+}) => {
+ return (
+
+
+
+
+
+
+
+ {message}
+
+ 敬请期待
+
+ );
+};
+
+export default GateScreen;
diff --git a/src/common/components/LineChart/index.tsx b/src/common/components/LineChart/index.tsx
new file mode 100644
index 00000000..71211d32
--- /dev/null
+++ b/src/common/components/LineChart/index.tsx
@@ -0,0 +1,288 @@
+import { Canvas, Image, View } from '@tarojs/components';
+import Taro, { CanvasContext } from '@tarojs/taro';
+import React, { CSSProperties, useCallback, useEffect, useMemo } from 'react';
+
+import {
+ drawBottomInfo,
+ drawCoordinateDiagram,
+ drawHightlightScore,
+ drawRoundedRectangle,
+} from './utils';
+
+export interface GradeChartData {
+ data: number[];
+ xLabels: string[];
+ maxScore: number;
+ minScore: number;
+ avgScore: number;
+}
+
+interface LineChartProps {
+ data?: number[];
+ style?: CSSProperties;
+ className?: string;
+ /** x轴标签 */
+ xLabels?: string[];
+ /** y轴标签 */
+ yLabels?: string[];
+ /** 与x轴标签对应的范围 */
+ xRange?: [number, number][];
+ /** x、y、data之间的映射函数 */
+ mappingFunction?: (data: number[], x: [number, number]) => number;
+ /** 边距: 左右上下 */
+ padding?: number[];
+ /** 成绩分布数据(合并 data/xLabels/max/min/avg 为一个对象) */
+ gradeData?: GradeChartData;
+ /** 容器id,默认为lineCanvas */
+ id?: string;
+ /** 标题 */
+ title?: string;
+ /** 画布宽高 */
+ width?: number;
+ height?: number;
+ /** 底部内容高度 */
+ bottomHeight?: number;
+}
+const DEFAULT_HIGHLIGHT_COLOR = '#FFA500';
+const DEFAULT_AXIS_TEXT_COLOR = '#3D3D3D';
+const DEFAULT_LINE_COLOR = '#F48500';
+const DEFAULT_Axis_LINE_COLOR = '#3D3D3D';
+const DEFAULT_PADDING = [25, 45, 35, 35]; //左右上下
+const DEFAULT_WIDTH = Taro.getSystemInfoSync().screenWidth * 0.9;
+const DEFAULT_HEIGHT = 260;
+const DEFAULT_BOTTOM_HEIGTH = 40;
+const DEFAULT_DATA = [10, 20, 30, 40, 50, 60, 70];
+const DEFAULT_X_LABELS = ['0', '60', '70', '80', '90', '100'];
+const DEFUALT_Y_LABELS = ['0%', '20%', '40%', '60%', '80%', '100%'];
+const DEFAUTL_X_RANGE: [number, number][] = [
+ [0, 0],
+ [1, 59],
+ [60, 69],
+ [70, 79],
+ [80, 89],
+ [90, 100],
+];
+const DEFAULT_MAPPING_FUNCTION = (
+ data: number[],
+ x: [number, number],
+ height: number,
+ y: number
+) => {
+ const sum = data.length;
+ let cnt = 0;
+ data.forEach((d) => {
+ if (d >= x[0] && d <= x[1]) cnt++;
+ });
+ const ratio = cnt / sum;
+ return y - ratio * height;
+};
+const DEFAULT_CHART_ID = 'lineCanvas';
+const DEFAULT_GAP_LINE_COLOR = '#FE9F00';
+
+const LineChart: React.FC = (props) => {
+ const {
+ width: propWidth,
+ height: propHeight,
+ bottomHeight: propBHeight,
+ gradeData,
+ id,
+ data: propData,
+ className,
+ padding: propPadding,
+ xLabels: propX,
+ yLabels: propY,
+ xRange: propXRange,
+ mappingFunction: propMFunc,
+ style,
+ } = props;
+
+ const maxScore = gradeData?.maxScore ?? 0;
+ const minScore = gradeData?.minScore ?? 0;
+ const avgScore = gradeData?.avgScore ?? 0;
+ const resolvedData = gradeData?.data ?? propData;
+ const resolvedXLabels = gradeData?.xLabels ?? propX;
+ const canvasId = id ?? DEFAULT_CHART_ID;
+
+ const hasData = useMemo(() => {
+ return !resolvedData?.some((data) => data);
+ }, [resolvedData]);
+
+ const drawChart = useCallback(
+ (ctx: CanvasContext) => {
+ // 初始化
+ const data = resolvedData ?? DEFAULT_DATA;
+ const width = propWidth ?? DEFAULT_WIDTH;
+ const height = propHeight ?? DEFAULT_HEIGHT;
+ const bottomHeight = propBHeight ?? DEFAULT_BOTTOM_HEIGTH;
+ const [pl, pr, pt, pb] = propPadding ?? DEFAULT_PADDING;
+ const xLabels = resolvedXLabels ?? DEFAULT_X_LABELS;
+ const yLabels = propY ?? DEFUALT_Y_LABELS;
+ const xRange = propXRange ?? DEFAUTL_X_RANGE;
+ const mappingFunction = propMFunc ?? DEFAULT_MAPPING_FUNCTION;
+ ctx.clearRect(0, 0, width, height);
+ // // 适配不同比例
+ // if (width / height > 2) {
+ // ctx.scale((height * 2) / width, 1);
+ // } else {
+ // ctx.scale(1, width / 2 / height);
+ // }
+ // const barWidth = (width - 2 * padding) / data.length;
+ // const lines = 5;
+
+ //绘制背景
+ drawRoundedRectangle(
+ ctx,
+ 0,
+ 0,
+ width,
+ height,
+ [10, 10, 10, 10],
+ () => null,
+ '#FEF8E4'
+ );
+
+ //绘制坐标图
+ drawCoordinateDiagram(
+ ctx,
+ data,
+ [pl, height - pb - bottomHeight],
+ [true, false],
+ [height - pb - bottomHeight + 15, width - pr + 8],
+ width - pl - pr,
+ height - pt - pb - bottomHeight,
+ xLabels,
+ yLabels,
+ xRange,
+ mappingFunction,
+ DEFAULT_Axis_LINE_COLOR,
+ DEFAULT_GAP_LINE_COLOR,
+ DEFAULT_AXIS_TEXT_COLOR,
+ DEFAULT_LINE_COLOR
+ );
+
+ drawBottomInfo(
+ ctx,
+ avgScore,
+ minScore,
+ maxScore,
+ [pl, height - Math.floor(bottomHeight / 2)],
+ width - pl
+ );
+
+ //高亮平均值
+ const avgXValue = avgScore;
+ const avgXPos = (() => {
+ // 找到avgXValue的区间
+ let rangeIdx = -1;
+ let rangeLength = -1;
+ let property = -1;
+ for (let i = 0; i < xRange.length; i++) {
+ const [min, max] = xRange[i];
+ if (avgXValue >= min && avgXValue <= max) {
+ rangeIdx = i;
+ rangeLength = Math.max(max - min, 1);
+ property = avgXValue - min;
+ break;
+ }
+ }
+
+ if (rangeIdx === -1) return -1; // 不在任何区间,直接不画
+
+ // 计算该区间对应的 x 像素位置
+ const chartWidth = width - pl - pr;
+ const singleWidth = Math.floor(chartWidth / (xLabels.length - 1));
+ //注意[0,0]区间不占长度,所以要-1
+ const posX =
+ pl + (rangeIdx - 1) * singleWidth + singleWidth * (property / rangeLength);
+ return posX;
+ })();
+ if (avgXPos !== -1)
+ drawHightlightScore(
+ ctx,
+ [avgXPos, height - pb - bottomHeight],
+ pl,
+ height - pb - pt - bottomHeight,
+ 10,
+ `${avgScore.toFixed(2)}`,
+ DEFAULT_HIGHLIGHT_COLOR,
+ (gradient: Taro.CanvasGradient) => {
+ gradient.addColorStop(0, DEFAULT_HIGHLIGHT_COLOR); // 顶部不透明橙色
+ gradient.addColorStop(1, 'rgba(255, 165, 0, 0)'); // 底部完全透明
+ }
+ );
+
+ void ctx.draw();
+ },
+ [
+ resolvedData,
+ resolvedXLabels,
+ propWidth,
+ propHeight,
+ propBHeight,
+ propPadding,
+ propY,
+ propXRange,
+ propMFunc,
+ avgScore,
+ minScore,
+ maxScore,
+ ]
+ );
+
+ useEffect(() => {
+ if (resolvedData) {
+ const ctx = Taro.createCanvasContext(canvasId);
+ void drawChart(ctx);
+ }
+ }, [resolvedData, canvasId, drawChart]);
+
+ return (
+
+ {hasData && 数据量太少,暂不支持预览}
+ {hasData ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default LineChart;
diff --git a/src/common/components/chart/utils/drawCoordinateDiagram.ts b/src/common/components/LineChart/utils/drawCoordinateDiagram.ts
similarity index 100%
rename from src/common/components/chart/utils/drawCoordinateDiagram.ts
rename to src/common/components/LineChart/utils/drawCoordinateDiagram.ts
diff --git a/src/common/components/chart/utils/drawOtherInfo.ts b/src/common/components/LineChart/utils/drawOtherInfo.ts
similarity index 100%
rename from src/common/components/chart/utils/drawOtherInfo.ts
rename to src/common/components/LineChart/utils/drawOtherInfo.ts
diff --git a/src/common/components/chart/utils/drawRoundedRect.ts b/src/common/components/LineChart/utils/drawRoundedRect.ts
similarity index 100%
rename from src/common/components/chart/utils/drawRoundedRect.ts
rename to src/common/components/LineChart/utils/drawRoundedRect.ts
diff --git a/src/common/components/chart/utils/index.ts b/src/common/components/LineChart/utils/index.ts
similarity index 100%
rename from src/common/components/chart/utils/index.ts
rename to src/common/components/LineChart/utils/index.ts
diff --git a/src/common/components/Loading/index.scss b/src/common/components/Loading/index.scss
new file mode 100644
index 00000000..afb1f604
--- /dev/null
+++ b/src/common/components/Loading/index.scss
@@ -0,0 +1,26 @@
+.loading_wrapper {
+ @include flex_center;
+}
+.loading_container {
+ @include flex_column_center;
+}
+.loading_center {
+ height: 100%;
+}
+.loading_inline {
+ padding: $space_md 0;
+}
+.loading_overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.3);
+ z-index: 999;
+}
+.loading_text {
+ margin-top: 20rpx;
+ font-size: 32rpx;
+ color: $color_text_medium;
+}
diff --git a/src/common/components/Loading/index.tsx b/src/common/components/Loading/index.tsx
new file mode 100644
index 00000000..2b1cc720
--- /dev/null
+++ b/src/common/components/Loading/index.tsx
@@ -0,0 +1,42 @@
+import { Loading as TaroLoading } from '@taroify/core';
+import { Text, View } from '@tarojs/components';
+import React from 'react';
+
+import './index.scss';
+
+export interface LoadingProps {
+ text?: string;
+ type?: 'circular' | 'spinner';
+ direction?: 'horizontal' | 'vertical';
+ size?: number | string;
+ containerStyle?: React.CSSProperties;
+ textStyle?: React.CSSProperties;
+ isCenter?: boolean;
+ showMask?: boolean;
+}
+
+const Loading: React.FC = ({
+ text = '加载中...',
+ size = 40,
+ type = 'spinner',
+ direction = 'vertical',
+ containerStyle,
+ textStyle,
+ isCenter = true,
+ showMask = false,
+}) => {
+ return (
+
+
+
+
+ {text}
+
+
+
+ );
+};
+
+export default Loading;
diff --git a/src/common/components/Modal/index.scss b/src/common/components/Modal/index.scss
new file mode 100644
index 00000000..4159451c
--- /dev/null
+++ b/src/common/components/Modal/index.scss
@@ -0,0 +1,171 @@
+.modal_back {
+ @include flex_center;
+
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 9999;
+ background-color: rgba(0, 0, 0, 0.2);
+
+ .modal_overlay {
+ flex: 1;
+ display: flex;
+
+ &.middle {
+ align-items: center;
+ justify-content: center;
+ padding: 0 40rpx;
+ }
+
+ &.bottom {
+ align-items: center;
+ justify-content: flex-end;
+ }
+
+ .modal_background {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: transparent;
+ }
+
+ .modal_content {
+ width: 92%;
+ border-radius: 40rpx;
+ margin: 0;
+ overflow: hidden;
+ background-color: $color_bg_page;
+
+ .modal_title_container {
+ margin-top: 48rpx;
+ align-items: center;
+ display: flex;
+ justify-content: center;
+
+ .model_title {
+ font-size: $font_size_title;
+ font-weight: 600;
+ color: #000000;
+ line-height: 32rpx;
+ }
+ }
+
+ .modal_children_container {
+ margin-top: 48rpx;
+ padding: 0 60rpx;
+ text-align: center;
+
+ .modal_children {
+ font-size: $font_size_body;
+ line-height: 42rpx;
+ color: #374151;
+ }
+ }
+
+ .bottom_choice {
+ margin-top: 40rpx;
+ flex-direction: row;
+ justify-content: space-evenly;
+ margin-bottom: 60rpx;
+ display: flex;
+
+ .button_style {
+ @include flex_center;
+
+ padding: $space_sm 56rpx;
+ border-radius: 50rpx;
+ text-align: center;
+ box-sizing: border-box;
+ }
+
+ .cancel_view {
+ border: 1px solid $color_brand;
+ background-color: $color_bg_page;
+
+ .cancel_text {
+ color: $color_brand;
+ }
+ }
+
+ .confirm_view {
+ background-color: $color_brand;
+
+ .confirm_text {
+ color: $color_bg_page;
+ }
+ }
+ }
+ }
+
+ .transparent_modal_content {
+ width: 92%;
+ border-radius: 40rpx;
+ margin: 0;
+
+ .modal_title_container {
+ margin-top: 48rpx;
+ align-items: center;
+ display: flex;
+ justify-content: center;
+
+ .model_title {
+ font-size: $font_size_title;
+ font-weight: 600;
+ color: #111827;
+ line-height: 32rpx;
+ }
+ }
+
+ .modal_children_container {
+ margin-top: 48rpx;
+ padding: 0 60rpx;
+ text-align: center;
+
+ .modal_children {
+ font-size: $font_size_body;
+ line-height: 42rpx;
+ color: #374151;
+ }
+ }
+
+ .bottom_choice {
+ margin-top: 40rpx;
+ flex-direction: row;
+ justify-content: space-evenly;
+ margin-bottom: 60rpx;
+ display: flex;
+
+ .button_style {
+ @include flex_center;
+
+ padding: $space_sm 56rpx;
+ border-radius: 50rpx;
+ text-align: center;
+ box-sizing: border-box;
+ }
+
+ .cancel_view {
+ border: 1px solid $color_brand;
+ background-color: $color_bg_page;
+
+ .cancel_text {
+ color: $color_brand;
+ }
+ }
+
+ .confirm_view {
+ background-color: $color_brand;
+
+ .confirm_text {
+ color: $color_bg_page;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/common/components/Modal/index.tsx b/src/common/components/Modal/index.tsx
new file mode 100644
index 00000000..760cae60
--- /dev/null
+++ b/src/common/components/Modal/index.tsx
@@ -0,0 +1,165 @@
+import { Button, Text, View, ViewProps } from '@tarojs/components';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+
+import './index.scss';
+
+import { ModalBackgroundProps, ModalProps } from './type';
+
+export const Modal: React.FC = ({
+ visible: initVisible = false,
+ onClose,
+ children,
+ title,
+ onCancel,
+ onConfirm,
+ showCancel = true,
+ confirmText,
+ cancelText,
+ isTransparent = false,
+ mode = 'middle',
+}) => {
+ const [visible, setVisible] = useState(initVisible);
+ useEffect(() => {
+ setVisible(initVisible);
+ }, [initVisible]);
+
+ const handleClose = useCallback(() => {
+ setVisible(false);
+ onClose?.();
+ }, [onClose]);
+
+ const handleConfirm = useCallback(() => {
+ onConfirm?.();
+ setVisible(false);
+ onClose?.();
+ }, [onConfirm, onClose]);
+
+ const handleCancel = useCallback(() => {
+ onCancel?.();
+ setVisible(false);
+ onClose?.();
+ }, [onCancel, onClose]);
+
+ const showButtons = useMemo(() => {
+ const result = confirmText || onConfirm || (showCancel && (cancelText || onCancel));
+ return result;
+ }, [confirmText, onConfirm, showCancel, cancelText, onCancel]);
+
+ const modalContent = useMemo(
+ () => (
+
+ {title && (
+
+ {typeof title === 'string' ? (
+ {title}
+ ) : (
+ title
+ )}
+
+ )}
+
+
+ {typeof children === 'string' ? (
+ {children}
+ ) : (
+ children
+ )}
+
+
+ {showButtons && (
+
+ {showCancel && (cancelText || onCancel) && (
+
+ )}
+ {(confirmText || onConfirm) && (
+
+ )}
+
+ )}
+
+ ),
+ [
+ children,
+ title,
+ showButtons,
+ showCancel,
+ cancelText,
+ confirmText,
+ isTransparent,
+ handleCancel,
+ handleConfirm,
+ ]
+ );
+
+ return (
+
+
+
+ {modalContent}
+
+
+ );
+};
+
+const ModalBackground: React.FC = ({ onPress }) => (
+ {
+ onPress();
+ }}
+ />
+);
+
+export const ModalBack: React.FC<
+ {
+ children?: React.ReactElement;
+ visible: boolean;
+ onAnimationEnd?: (visible: boolean) => void;
+ } & ViewProps
+> = ({ children, visible, onAnimationEnd }) => {
+ const [shouldRender, setShouldRender] = useState(visible);
+ const [opacity, setOpacity] = useState(0);
+
+ useEffect(() => {
+ if (visible) {
+ setShouldRender(true);
+
+ setTimeout(() => setOpacity(1), 10);
+ } else {
+ setOpacity(0);
+
+ const timer = setTimeout(() => {
+ setShouldRender(false);
+ onAnimationEnd && onAnimationEnd(false);
+ }, 300);
+
+ return () => clearTimeout(timer);
+ }
+ }, [visible, onAnimationEnd]);
+
+ useEffect(() => {
+ if (visible && opacity === 1) {
+ onAnimationEnd && onAnimationEnd(true);
+ }
+ }, [visible, opacity, onAnimationEnd]);
+
+ if (!shouldRender) return null;
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default Modal;
diff --git a/src/common/components/Modal/type.ts b/src/common/components/Modal/type.ts
new file mode 100644
index 00000000..829c871f
--- /dev/null
+++ b/src/common/components/Modal/type.ts
@@ -0,0 +1,31 @@
+import React from 'react';
+
+export interface ModalProps {
+ // 控制Modal是否显示
+ visible?: boolean;
+ // 关闭Modal
+ onClose?: () => void;
+ // 自定义结构
+ children?: React.ReactNode;
+ // 是否具有title,不填则没有
+ title?: React.ReactNode;
+ // 点击确认
+ onConfirm?: () => void;
+ // 点击取消
+ onCancel?: () => void;
+ // 取消文字
+ cancelText?: string;
+ // 确认文字
+ confirmText?: string;
+ // 是否显示取消按钮
+ showCancel?: boolean;
+ // modal 类型
+ mode?: 'bottom' | 'middle';
+ //背景是否透明
+ isTransparent?: boolean;
+}
+
+export interface ModalBackgroundProps {
+ onPress: () => void;
+ style?: any;
+}
diff --git a/src/common/components/NotificationGateScreen/index.scss b/src/common/components/NotificationGateScreen/index.scss
new file mode 100644
index 00000000..5357ca9f
--- /dev/null
+++ b/src/common/components/NotificationGateScreen/index.scss
@@ -0,0 +1,81 @@
+.notification_gate {
+ @include flex_column_align_center;
+
+ width: 100%;
+ min-height: 100vh;
+ margin-top: $space_xxl;
+ padding-bottom: 170rpx;
+ background-color: $color_bg_page;
+ box-sizing: border-box;
+}
+
+.notification_gate_header {
+ width: 100%;
+ padding: 0 32rpx;
+ margin-bottom: 24rpx;
+ box-sizing: border-box;
+}
+
+.notification_gate_header_title {
+ display: block;
+ font-size: $font_size_heading;
+ font-weight: 700;
+ color: $color_text_heading;
+ margin-bottom: 8rpx;
+}
+
+.notification_gate_header_desc {
+ display: block;
+ font-size: $font_size_caption;
+ color: $color_text_secondary;
+}
+
+.notification_gate_scroll {
+ width: 100%;
+ flex: 1;
+ padding: 0 32rpx;
+ box-sizing: border-box;
+}
+
+.notification_gate_card {
+ position: relative;
+ margin-bottom: 24rpx;
+ padding: 32rpx;
+ border-radius: $radius_md;
+ background-color: $color_bg_page;
+ box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
+}
+
+.notification_gate_card_badge {
+ display: inline-flex;
+ align-self: flex-start;
+ margin-bottom: 16rpx;
+ padding: 4rpx 16rpx;
+ border-radius: $radius_round;
+ background: rgba(237, 163, 53, 0.15);
+ font-size: $font_size_caption;
+ color: $color_brand_hover;
+ font-weight: 600;
+}
+
+.notification_gate_card_title {
+ display: block;
+ font-size: $font_size_heading;
+ font-weight: 700;
+ color: $color_text_primary;
+ margin-bottom: 12rpx;
+}
+
+.notification_gate_card_content {
+ display: block;
+ font-size: $font_size_body;
+ color: $color_text_medium;
+ line-height: 1.7;
+ margin-bottom: 16rpx;
+}
+
+.notification_gate_card_time {
+ display: block;
+ font-size: $font_size_caption;
+ color: $color_text_cool_gray;
+}
diff --git a/src/common/components/NotificationGateScreen/index.tsx b/src/common/components/NotificationGateScreen/index.tsx
new file mode 100644
index 00000000..e08b1df5
--- /dev/null
+++ b/src/common/components/NotificationGateScreen/index.tsx
@@ -0,0 +1,52 @@
+import { ScrollView, Text, View } from '@tarojs/components';
+
+import { NavigationBar } from '@/modules/navigation';
+
+import './index.scss';
+
+const OFFICIAL_MESSAGES = [
+ {
+ title: '欢迎使用木犀课栈',
+ content: '感谢使用木犀课栈小程序。本平台致力于为大家提供课程查询与选课辅助服务。',
+ time: '2026-03-01',
+ },
+ {
+ title: '消息中心说明',
+ content:
+ '消息中心用于接收平台官方通知与重要公告。请留意本页更新,课程与教务信息以学校官方发布为准。',
+ time: '2026-02-15',
+ },
+ {
+ title: '功能更新预告',
+ content: '团队正在优化产品体验,相关能力开放后将通过本页第一时间通知,敬请留意。',
+ time: '2026-01-20',
+ },
+] as const;
+
+const NotificationGateScreen: React.FC = () => {
+ return (
+
+
+
+
+ 官方公告
+ 仅展示平台运营通知
+
+
+
+ {OFFICIAL_MESSAGES.map((item) => (
+
+
+ 官方
+
+ {item.title}
+ {item.content}
+ {item.time}
+
+ ))}
+
+
+ );
+};
+
+export default NotificationGateScreen;
diff --git a/src/common/components/PlazaGateScreen/index.scss b/src/common/components/PlazaGateScreen/index.scss
new file mode 100644
index 00000000..2d491833
--- /dev/null
+++ b/src/common/components/PlazaGateScreen/index.scss
@@ -0,0 +1,118 @@
+.plaza_gate {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ min-height: 100vh;
+ margin-top: 80rpx;
+ background-color: $color_bg_page;
+}
+
+.plaza_gate_tabs {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ margin-top: 36rpx;
+ margin-bottom: 24rpx;
+ padding: 0 32rpx;
+ gap: 28rpx;
+}
+
+.plaza_gate_tab {
+ flex: 1;
+ height: 60rpx;
+ border-radius: $radius_lg;
+ font-size: $font_size_body;
+ font-weight: bold;
+ line-height: 60rpx;
+ text-align: center;
+ background: $color_bg_cream;
+ color: $color_text_primary;
+}
+
+.plaza_gate_tab_active {
+ background-color: $color_brand;
+ box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.1);
+ color: $color_bg_page;
+}
+
+.plaza_gate_search {
+ @include flex_center;
+
+ flex: 1;
+ height: 60rpx;
+ border-radius: $radius_lg;
+ font-size: $font_size_body;
+ background: $color_bg_page;
+ color: $color_text_secondary;
+}
+
+.plaza_gate_scroll {
+ width: 100%;
+ height: 70vh;
+ padding: 0 32rpx 160rpx;
+ box-sizing: border-box;
+}
+
+.plaza_gate_intro {
+ margin-bottom: 28rpx;
+}
+
+.plaza_gate_intro_title {
+ display: block;
+ font-size: $font_size_heading;
+ font-weight: 700;
+ color: $color_text_heading;
+ margin-bottom: 12rpx;
+}
+
+.plaza_gate_intro_desc {
+ display: block;
+ font-size: $font_size_caption;
+ color: $color_text_secondary;
+ line-height: 1.6;
+}
+
+.plaza_gate_card {
+ position: relative;
+ margin-bottom: 24rpx;
+ padding: 32rpx;
+ border-radius: $radius_lg;
+ background: $color_bg_cream;
+ box-shadow: 0 8rpx 24rpx rgba(237, 163, 53, 0.12);
+}
+
+.plaza_gate_card_title {
+ display: block;
+ font-size: $font_size_heading;
+ font-weight: 700;
+ color: $color_text_primary;
+ margin-bottom: 16rpx;
+}
+
+.plaza_gate_card_body {
+ display: block;
+ font-size: $font_size_body;
+ color: $color_text_medium;
+ line-height: 1.7;
+}
+
+.plaza_gate_card_tag {
+ position: absolute;
+ top: 28rpx;
+ right: 28rpx;
+ padding: 4rpx 16rpx;
+ border-radius: $radius_round;
+ font-size: $font_size_caption;
+ color: $color_brand_hover;
+ background: rgba(237, 163, 53, 0.15);
+}
+
+.plaza_gate_footer {
+ @include flex_center;
+
+ padding: 48rpx 0 32rpx;
+ font-size: $font_size_caption;
+ color: $color_text_light;
+}
diff --git a/src/common/components/PlazaGateScreen/index.tsx b/src/common/components/PlazaGateScreen/index.tsx
new file mode 100644
index 00000000..b474512a
--- /dev/null
+++ b/src/common/components/PlazaGateScreen/index.tsx
@@ -0,0 +1,67 @@
+import { ScrollView, Text, View } from '@tarojs/components';
+
+import { NavigationBar } from '@/modules/navigation';
+
+import './index.scss';
+
+const TABS = ['全部', '专业', '个性', '通核'] as const;
+
+const NOTICES = [
+ {
+ title: '关于木犀课栈',
+ body: '木犀课栈是面向华中师范大学学生的课程信息辅助工具,帮助同学们便捷查询课程资料与选课相关信息。',
+ },
+ {
+ title: '使用说明',
+ body: '可在底部「手册」查看选课指引。课程信息请以学校教务系统与教务处官方通知为准。',
+ },
+ {
+ title: '服务公告',
+ body: '平台持续优化中,部分功能将陆续开放。如有疑问,可通过「我的」-「意见反馈」联系团队。',
+ },
+] as const;
+
+const PlazaGateScreen: React.FC = () => {
+ return (
+
+
+
+
+ {TABS.map((label, index) => (
+
+ {label}
+
+ ))}
+
+ 搜索
+
+
+
+
+
+ 木犀课栈 · 课程服务
+
+ 以下为平台官方说明,内容定期更新。
+
+
+
+ {NOTICES.map((item) => (
+
+ {item.title}
+ {item.body}
+ 官方
+
+ ))}
+
+
+ —— 木犀网络工作室 ——
+
+
+
+ );
+};
+
+export default PlazaGateScreen;
diff --git a/src/common/components/PublishHeader/PublishHeader.tsx b/src/common/components/PublishHeader/PublishHeader.tsx
deleted file mode 100644
index 8e1c8040..00000000
--- a/src/common/components/PublishHeader/PublishHeader.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Image, Text, View } from '@tarojs/components';
-import React from 'react';
-
-import './index.scss';
-
-interface IPublishHeaderProps {
- avatarUrl: string;
- nickName: string;
- date: string;
-}
-
-const PublishHeader: React.FC = ({ avatarUrl, nickName, date }) => {
- return (
-
-
-
- {nickName}
- {date}
-
-
- );
-};
-
-export default PublishHeader;
diff --git a/src/common/components/PublishHeader/index.scss b/src/common/components/PublishHeader/index.scss
index 34d2ffeb..dd0f2433 100644
--- a/src/common/components/PublishHeader/index.scss
+++ b/src/common/components/PublishHeader/index.scss
@@ -1,27 +1,23 @@
-.avatar {
- width: 90.58rpx;
- height: 90.58rpx;
+.ph_avatar {
+ border: #d2d5d8 solid 4rpx;
border-radius: 100%;
- margin-right: 10px;
- border: #d2d5d8 solid 5rpx;
- margin-right: 27rpx;
+ margin-right: 28rpx;
}
.nickname {
font-weight: bold;
- font-size: 22rpx;
- color: #565552;
+ font-size: $font_size_sm;
+ color: $color_text_heading;
}
.currentDate {
font-weight: 400;
font-size: 14rpx;
color: #9f9f9c;
- margin-top: 20rpx;
+ margin-top: $space_md;
}
.publish-header {
- display: flex;
- align-items: center;
+ @include flex_align_center;
flex: 1;
}
diff --git a/src/common/components/PublishHeader/index.tsx b/src/common/components/PublishHeader/index.tsx
new file mode 100644
index 00000000..ceff23d4
--- /dev/null
+++ b/src/common/components/PublishHeader/index.tsx
@@ -0,0 +1,47 @@
+import { Text, View } from '@tarojs/components';
+import React, { useEffect } from 'react';
+
+import './index.scss';
+
+import { useUserStore } from '@/store/user';
+
+import { Avatar } from '@/common/components';
+import { formatDate } from '@/common/utils';
+
+interface IPublishHeaderProps {
+ publisher?: {
+ avatar?: string;
+ nickname?: string;
+ };
+ timestamp?: number;
+}
+
+const PublishHeader: React.FC = ({ publisher, timestamp }) => {
+ const profile = useUserStore((s) => s.profile);
+
+ useEffect(() => {
+ if (!publisher && !profile) {
+ useUserStore
+ .getState()
+ .ensureProfile()
+ .catch(() => {});
+ }
+ }, [publisher, profile]);
+
+ const resolved = publisher || profile;
+ const avatarUrl = resolved?.avatar ?? '';
+ const nickName = resolved?.nickname ?? '匿名用户';
+ const date = timestamp ? formatDate(timestamp) : '';
+
+ return (
+
+
+
+ {nickName}
+ {date}
+
+
+ );
+};
+
+export default PublishHeader;
diff --git a/src/common/components/QuestionDetail/QuestionDetail.tsx b/src/common/components/QuestionDetail/QuestionDetail.tsx
deleted file mode 100644
index e7c89579..00000000
--- a/src/common/components/QuestionDetail/QuestionDetail.tsx
+++ /dev/null
@@ -1,313 +0,0 @@
-import '@/common/components/QuestionDetail/QuestionDetail';
-import { Button, Icon, Image, Text, Textarea, View } from '@tarojs/components';
-import Taro from '@tarojs/taro';
-import React, { useEffect, useState } from 'react';
-
-import { TopBackground } from '@/common/assets/img/login';
-import answericon from '@/common/assets/img/publishQuestion/answer.png';
-import askicon from '@/common/assets/img/publishQuestion/ask.png';
-import IconFont from '@/common/components/iconfont';
-import PublishHeader from '@/common/components/PublishHeader/PublishHeader';
-import { get, post } from '@/common/utils';
-import { postBool } from '@/common/utils/fetch';
-import { StatusResponse } from '@/pages/evaluate';
-import { useCourseStore } from '@/pages/main/store/store';
-
-interface IUser {
- avatar: string;
- id: number;
- nickname: string;
-}
-
-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;
- user?: IUser;
-}
-
-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;
- user?: IUser;
-}
-
-interface IQuestionProps {
- question: IQuestion;
- answers: IAnswer[] | null;
- handleFloatLayoutChange: (answerId: number | null) => void;
- onAnswer?: (questionId: number) => void;
-}
-
-const formatTime = (timestamp: number) => {
- const date = new Date(timestamp);
- const year = date.getFullYear();
- const month = (date.getMonth() + 1).toString().padStart(2, '0');
- const day = date.getDate().toString().padStart(2, '0');
- const hour = date.getHours().toString().padStart(2, '0');
- const minute = date.getMinutes().toString().padStart(2, '0');
- const second = date.getSeconds().toString().padStart(2, '0');
- return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
-};
-
-const QuestionDetail: React.FC = ({
- question,
- answers,
- handleFloatLayoutChange,
- onAnswer,
-}) => {
- const dispatch = useCourseStore(({ getPublishers }) => ({ getPublishers }));
-
- const [questionDetail, setQuestion] = useState(question);
- const [answersDetail, setAnswersDetail] = useState(answers);
- const [showAnswerForm, setShowAnswerForm] = useState(false);
- const [answerContent, setAnswerContent] = useState('');
-
- useEffect(() => {
- const fetchAllPublishers = async () => {
- // 函数,用于获取每个答案的用户信息
- async function getAnswersWithUserInfo(ianswers: IAnswer[]) {
- try {
- const answersWithUserInfo = await Promise.all(
- ianswers.map(async (answer) => {
- const user = await dispatch.getPublishers(answer.publisher_id);
- return { ...answer, user };
- })
- );
- return answersWithUserInfo;
- } catch (error) {
- // 处理错误,返回一个空数组或其他适当的默认值
- console.error('Error fetching user info:', error);
- return []; // 返回一个空数组
- }
- }
-
- // 确保 answers 不为 null
- if (answers !== null) {
- const answersWithUserInfo = await getAnswersWithUserInfo(answers);
- setAnswersDetail(answersWithUserInfo);
- } else {
- // 如果 answers 为 null,可以选择设置一个空数组或者执行其他逻辑
- setAnswersDetail([]);
- }
-
- // 函数,用于获取问题的提问者用户信息
- async function getQuestionWithUserInfo(iquestion: IQuestion) {
- try {
- const user = await dispatch.getPublishers(iquestion.questioner_id);
- const questionWithUserInfo = { ...iquestion, user };
- return questionWithUserInfo;
- } catch (error) {
- console.error('Error fetching question with user info:', error);
- throw error; // 重新抛出错误,可以在调用此函数的地方进行处理
- }
- }
-
- // 调用函数并处理结果
- const questionWithUserInfo = await getQuestionWithUserInfo(question);
- setQuestion(questionWithUserInfo);
- };
-
- void fetchAllPublishers();
- }, [question]);
-
- const handlePublishAnswer = async () => {
- if (!answerContent.trim()) return;
-
- try {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- const response = await post('/answers/publish', {
- content: answerContent,
- question_id: question.id,
- });
-
- // 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) {
- setAnswerContent('');
- setShowAnswerForm(false);
-
- // 刷新回答列表
- try {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- const res = await get(
- `/answers/list/questions/${question.id}?cur_answer_id=${0}&limit=${100}`
- );
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
- setAnswersDetail(res?.data as IAnswer[]);
-
- void Taro.showToast({
- title: '回答发布成功',
- icon: 'success',
- duration: 2000,
- });
- } catch (error: unknown) {
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
- console.error('获取回答列表失败:', errorMessage);
- }
- }
- } catch (error) {
- console.error('发布回答失败:', error);
- void Taro.showToast({
- title: '发布回答失败',
- icon: 'error',
- duration: 2000,
- });
- }
- };
- 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]);
- return !test ? (
-
-
-
-
-
-
-
- 木犀课栈 此功能敬请期待
-
-
-
- ) : (
-
-
-
-
-
-
- {question.content}
-
-
-
-
- {answersDetail &&
- answersDetail.map((answer, index) => (
- handleFloatLayoutChange(answer.id)}
- >
-
-
-
-
- {answer?.content}
-
-
-
-
-
-
-
- {answer.total_comment_count}
-
-
-
-
-
- {answer.total_support_count}
-
-
-
- ))}
-
-
- {!showAnswerForm ? (
-
- ) : (
-
-
- )}
-
-
- );
-};
-
-export default QuestionDetail;
diff --git a/src/common/components/QuestionListComponent/QuestionListComponent.tsx b/src/common/components/QuestionListComponent/QuestionListComponent.tsx
deleted file mode 100644
index 81c3b02a..00000000
--- a/src/common/components/QuestionListComponent/QuestionListComponent.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-import { Image, View } from '@tarojs/components';
-import Taro from '@tarojs/taro';
-import React, { useEffect, useState } from 'react';
-
-import './index.scss';
-
-import answericon from '@/common/assets/img/publishQuestion/answer.png';
-import askicon from '@/common/assets/img/publishQuestion/ask.png';
-import PublishHeader from '@/common/components/PublishHeader/PublishHeader';
-import { useCourseStore } from '@/pages/main/store/store';
-
-interface IUser {
- avatar: string;
- id: number;
- nickname: string;
-}
-
-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;
- user?: IUser;
- }>;
- utime: number;
- ctime: number;
- user?: IUser;
-}
-
-const formatTime = (timestamp: number) => {
- const date = new Date(timestamp);
- const year = date.getFullYear();
- const month = (date.getMonth() + 1).toString().padStart(2, '0');
- const day = date.getDate().toString().padStart(2, '0');
- const hour = date.getHours().toString().padStart(2, '0');
- const minute = date.getMinutes().toString().padStart(2, '0');
- const second = date.getSeconds().toString().padStart(2, '0');
- return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
-};
-
-const QuestionListComponent: React.FC<{ question: IQuestion; courseId: string }> = ({
- question,
- courseId,
-}) => {
- const dispatch = useCourseStore(({ getPublishers }) => ({ getPublishers }));
-
- const [questionDetail, setQuestion] = useState(question);
-
- useEffect(() => {
- const fetchQuestionWithUserInfo = async () => {
- try {
- const user = await dispatch.getPublishers(question.questioner_id);
- const questionWithUserInfo = { ...question, user };
- setQuestion(questionWithUserInfo);
-
- // 检查 preview_answers 是否存在并且是一个数组
- const previewAnswersWithUserInfo =
- Array.isArray(question.preview_answers) && question.preview_answers.length > 0
- ? await Promise.all(
- question.preview_answers.map(async (answer) => {
- try {
- const newuser = await dispatch.getPublishers(answer.publisher_id);
- return { ...answer, user: newuser };
- } catch (error) {
- console.error('Error fetching user for answer:', answer, error);
- // 可以选择返回原始答案
- return { ...answer };
- }
- })
- )
- : [];
-
- // console.log('previewAnswersWithUserInfo:', previewAnswersWithUserInfo);
- // 更新问题状态,包括填充了用户信息的 preview_answers
- setQuestion((prev) => ({
- ...prev,
- user: questionWithUserInfo.user, // 确保用户信息被设置
- preview_answers: previewAnswersWithUserInfo,
- }));
- } catch (error) {
- console.error('Error fetching question with user info:', error);
- }
- };
-
- void fetchQuestionWithUserInfo();
- }, [question]);
-
- const handleQuestionDetailClick = () => {
- void Taro.navigateTo({
- url: `/pages/questionInfo/index?id=${questionDetail.id}&&course_id=${courseId}`,
- });
- };
-
- return (
-
-
-
-
- {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
-
- }
- {questionDetail.content}
-
-
-
- {questionDetail.preview_answers &&
- questionDetail.preview_answers.map((answer, index) => (
-
-
-
- {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
-
- }
- {answer.content}
-
-
- ))}
-
-
- );
-};
-
-export default QuestionListComponent;
diff --git a/src/common/components/QuestionListComponent/index.scss b/src/common/components/QuestionListComponent/index.scss
deleted file mode 100644
index a8b44ae8..00000000
--- a/src/common/components/QuestionListComponent/index.scss
+++ /dev/null
@@ -1,73 +0,0 @@
-.questionDetail {
- background-color: #f9f9f2;
- width: 659.42rpx;
- position: relative;
- left: 50%;
- transform: translateX(-50%);
- display: flex;
- flex-direction: column;
- align-items: center;
- margin: 20rpx 0;
-}
-
-.question-detail {
- width: 603.26rpx;
- border-bottom: #e3e3e3 solid 2rpx;
- padding-top: 40rpx;
- padding-bottom: 35rpx;
-}
-
-.answer-item {
- @extend .question-detail;
-}
-
-.askicon {
- width: 44.44rpx;
- height: 47.05rpx;
- margin-left: 10rpx;
- margin-right: 20rpx;
-}
-
-.question-item {
- font-size: 21.74rpx;
- color: #565552;
- display: flex;
- margin-top: 20rpx;
-}
-
-.question-value {
- max-width: 471rpx;
- margin-top: 5rpx;
-}
-
-.answer-content {
- @extend .question-value;
-}
-
-.icon {
- width: 40rpx;
- height: 40rpx;
- box-shadow: 0rpx 5rpx 11rpx 2rpx rgba(0, 0, 0, 0.16);
- opacity: 1;
- border-radius: 50%;
- display: inline-block;
- text-align: center;
- line-height: 40rpx;
- // margin-left: 15rpx;
- Image {
- width: 100%;
- height: 100%;
- }
-}
-.text1 {
- font-size: 22rpx;
- font-weight: bold;
- color: #565552;
- margin: 0 10rpx;
-}
-
-.answer-statistics {
- display: flex;
- align-items: center;
- margin-left: 450rpx;
-}
diff --git a/src/common/components/ReplySection/index.scss b/src/common/components/ReplySection/index.scss
new file mode 100644
index 00000000..46f87336
--- /dev/null
+++ b/src/common/components/ReplySection/index.scss
@@ -0,0 +1,101 @@
+$reply_indent: 60rpx;
+
+@mixin reply_action-text {
+ font-size: $font_size_subtitle;
+ color: $color_brand_warm;
+}
+
+.secondary_replies_container {
+ padding-left: $reply_indent;
+
+ .secondary_replies_toggle {
+ @include flex_between;
+
+ padding: 6rpx 0;
+
+ .secondary_replies_toggle_text {
+ @include reply_action-text;
+ }
+
+ .secondary_replies_toggle_icon {
+ font-size: $font_size_title;
+ color: $color_brand_warm;
+ transform: rotate(0deg);
+ transition: transform 0.3s ease;
+
+ &.expanded {
+ transform: rotate(180deg);
+ }
+ }
+
+ &:active {
+ opacity: 0.7;
+ }
+ }
+
+ .secondary_replies_list {
+ margin-bottom: $space_xxs;
+ overflow: hidden;
+
+ .reply_item_wrapper {
+ .comment_card {
+ transform: translateY(-20rpx);
+ opacity: 0;
+ animation: replySlideInUp 0.2s ease forwards;
+ }
+ }
+
+ @keyframes replySlideInUp {
+ from {
+ transform: translateY(-20rpx);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+ }
+ }
+
+ .secondary_replies_footer {
+ .secondary_replies_loading {
+ @include flex_center;
+
+ box-sizing: border-box;
+ width: calc(100% + #{$reply_indent});
+ margin-left: -$reply_indent;
+ padding: $space_md 0;
+ }
+
+ .secondary_replies_expanded_footer {
+ @include flex_align_center;
+
+ padding-bottom: $space_xs;
+ gap: 42rpx;
+
+ .secondary_replies_more {
+ padding: 6rpx 0;
+
+ .secondary_replies_more_text {
+ @include reply_action-text;
+ }
+
+ &:active {
+ opacity: 0.7;
+ }
+ }
+
+ .secondary_replies_collapse {
+ padding: 6rpx 0;
+
+ .secondary_replies_collapse_text {
+ @include reply_action-text;
+ }
+
+ &:active {
+ opacity: 0.7;
+ }
+ }
+ }
+ }
+}
diff --git a/src/common/components/ReplySection/index.tsx b/src/common/components/ReplySection/index.tsx
new file mode 100644
index 00000000..00c0f061
--- /dev/null
+++ b/src/common/components/ReplySection/index.tsx
@@ -0,0 +1,126 @@
+import { Text, View } from '@tarojs/components';
+import React, { useCallback, useMemo, useState } from 'react';
+
+import './index.scss';
+
+import { CommentCard, Loading } from '@/common/components';
+import type { DiscussionComment } from '@/common/types/commentTypes';
+
+interface ReplySectionProps {
+ rootId: number;
+ // replyCount当前后端仅计算直接子评论,存在误差,目前前端仅用来判断是否有子评论
+ replyCount: number;
+ repliesHasMore?: boolean;
+ replies?: DiscussionComment[];
+ expanded: boolean;
+
+ onExpandedChange: (expanded: boolean) => void;
+ onLoadReplies: (rootId: number, lastId: number, limit: number) => Promise;
+ onCommentClick?: (comment: DiscussionComment) => void;
+ onCommentLongPress?: (comment: DiscussionComment) => void;
+ getReplyIndicator?: (reply: DiscussionComment) => { show: boolean; nickname?: string };
+}
+
+const ReplySection: React.FC = ({
+ rootId,
+ replyCount,
+ repliesHasMore = false,
+ replies = [],
+ expanded,
+ onExpandedChange,
+ onLoadReplies,
+ onCommentClick,
+ onCommentLongPress,
+ getReplyIndicator,
+}) => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleExpand = useCallback(async () => {
+ onExpandedChange(true);
+ setIsLoading(true);
+ try {
+ await onLoadReplies(rootId, 0, 3);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [rootId, onLoadReplies, onExpandedChange]);
+
+ const handleLoadMore = useCallback(async () => {
+ const lastServerId = [...replies].reverse().find((r) => r.id > 0)?.id ?? 0;
+ setIsLoading(true);
+ try {
+ await onLoadReplies(rootId, lastServerId, 10);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [rootId, onLoadReplies, replies]);
+
+ const handleCollapse = useCallback(() => {
+ onExpandedChange(false);
+ }, [onExpandedChange]);
+
+ // store 里子评论按接口顺序旧到新存放;展示层反转为新到旧,分页游标仍用原数组
+ const displayReplies = useMemo(() => [...replies].reverse(), [replies]);
+
+ const showReplies = expanded && (replies.length > 0 || isLoading);
+ const showExpandOnly = !expanded && replyCount > 0;
+ // 整楼总数待后端修正;分页以上次拉取是否满页为准,不用 reply_count
+ const subHasMore = repliesHasMore;
+ // const remainingCount = replyCount - replies.length;
+
+ if (replyCount === 0) return null;
+
+ return (
+
+ {showReplies && (
+
+ {displayReplies.map((reply) => {
+ const indicator = getReplyIndicator?.(reply);
+ return (
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {showExpandOnly && !isLoading && (
+
+ 展开回复
+
+ )}
+
+ {expanded && isLoading && (
+
+
+
+ )}
+
+ {showReplies && !isLoading && (
+
+ {subHasMore && (
+
+ 展开更多回复
+
+ )}
+
+ 收起
+
+
+ )}
+
+
+ );
+};
+
+export default ReplySection;
diff --git a/src/common/components/ReviewDiscussion/index.scss b/src/common/components/ReviewDiscussion/index.scss
new file mode 100644
index 00000000..5987e4ce
--- /dev/null
+++ b/src/common/components/ReviewDiscussion/index.scss
@@ -0,0 +1,6 @@
+.review_comments_list {
+ @include flex_column;
+
+ height: 100%;
+ padding: 20rpx;
+}
diff --git a/src/common/components/ReviewDiscussion/index.tsx b/src/common/components/ReviewDiscussion/index.tsx
new file mode 100644
index 00000000..220c0e4a
--- /dev/null
+++ b/src/common/components/ReviewDiscussion/index.tsx
@@ -0,0 +1,182 @@
+import { View } from '@tarojs/components';
+import React, {
+ createContext,
+ memo,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+} from 'react';
+
+import './index.scss';
+
+import { CommentCard } from '@/common/components';
+import ReplySection from '@/common/components/ReplySection';
+import type { DiscussionComment } from '@/common/types/commentTypes';
+import VirtualList from '../VirtualList';
+
+interface ReviewDiscussionProps {
+ comments: DiscussionComment[];
+ hasMore?: boolean;
+ initialLoading?: boolean;
+ expandedReplyRootIds?: readonly number[];
+
+ onLoadMore?: () => void | Promise;
+ onLoadMoreReplies?: (rootId: number, lastId: number, limit: number) => Promise;
+ onCommentClick?: (comment: DiscussionComment) => void;
+ onCommentLongPress?: (comment: DiscussionComment) => void;
+ supportsReplies?: (item: DiscussionComment) => boolean;
+ getReplyIndicator?: (reply: DiscussionComment) => { show: boolean; nickname?: string };
+ onReplyExpandedChange?: (rootId: number, expanded: boolean) => void;
+}
+
+type IsReplyExpandedFn = (rootId: number) => boolean;
+
+const ReplyExpandedContext = createContext(() => false);
+
+interface DiscussionRowProps {
+ item: DiscussionComment;
+ showReplies: boolean;
+
+ onCommentClick?: (comment: DiscussionComment) => void;
+ onCommentLongPress?: (comment: DiscussionComment) => void;
+ onLoadMoreReplies?: (rootId: number, lastId: number, limit: number) => Promise;
+ onReplyExpandedChange?: (rootId: number, expanded: boolean) => void;
+ getReplyIndicator?: (reply: DiscussionComment) => { show: boolean; nickname?: string };
+}
+
+const DiscussionRow = memo(function DiscussionRow({
+ item,
+ showReplies,
+ onCommentClick,
+ onCommentLongPress,
+ onLoadMoreReplies,
+ onReplyExpandedChange,
+ getReplyIndicator,
+}: DiscussionRowProps) {
+ const isReplyExpanded = useContext(ReplyExpandedContext);
+ const expanded = isReplyExpanded(item.id);
+
+ return (
+
+
+
+ {showReplies && onLoadMoreReplies && (
+ onReplyExpandedChange?.(item.id, open)}
+ onLoadReplies={onLoadMoreReplies}
+ onCommentClick={onCommentClick}
+ onCommentLongPress={onCommentLongPress}
+ getReplyIndicator={getReplyIndicator}
+ />
+ )}
+
+ );
+});
+
+const ReviewDiscussion: React.FC = memo(
+ ({
+ comments,
+ hasMore = false,
+ initialLoading = false,
+ onLoadMore,
+ onLoadMoreReplies,
+ onCommentClick,
+ onCommentLongPress,
+ supportsReplies,
+ getReplyIndicator,
+ expandedReplyRootIds,
+ onReplyExpandedChange,
+ }) => {
+ const isReplyExpanded = useMemo(() => {
+ if (!expandedReplyRootIds?.length) return () => false;
+ const ids = expandedReplyRootIds;
+ return (rootId: number) => ids.includes(rootId);
+ }, [expandedReplyRootIds]);
+
+ const handlersRef = useRef({
+ onCommentClick,
+ onCommentLongPress,
+ supportsReplies,
+ getReplyIndicator,
+ onLoadMoreReplies,
+ onReplyExpandedChange,
+ });
+ handlersRef.current = {
+ onCommentClick,
+ onCommentLongPress,
+ supportsReplies,
+ getReplyIndicator,
+ onLoadMoreReplies,
+ onReplyExpandedChange,
+ };
+
+ const Row = useMemo(
+ () =>
+ ({ data, index }: { id: number; data: DiscussionComment[]; index: number }) => {
+ const item = data[index];
+ if (!item) return null;
+
+ const {
+ onCommentClick: onClick,
+ onCommentLongPress: onLongPress,
+ supportsReplies: supports,
+ getReplyIndicator: getIndicator,
+ onLoadMoreReplies: loadReplies,
+ onReplyExpandedChange: onExpandedChange,
+ } = handlersRef.current;
+
+ const showReplies = supports?.(item) ?? false;
+
+ return (
+
+ );
+ },
+ []
+ );
+
+ const getItemKey = useCallback((item: DiscussionComment) => item.id, []);
+
+ return (
+
+
+
+ );
+ }
+);
+
+ReviewDiscussion.displayName = 'ReviewDiscussion';
+
+export default ReviewDiscussion;
diff --git a/src/common/components/SearchInput/SearchInput.scss b/src/common/components/SearchInput/SearchInput.scss
deleted file mode 100644
index 393d4a7c..00000000
--- a/src/common/components/SearchInput/SearchInput.scss
+++ /dev/null
@@ -1,17 +0,0 @@
-.searchInput {
- background-color: #f9f9f2;
- width: 80vw;
- height: 4vh;
- border-radius: 18.12rpx 18.12rpx 18.12rpx 18.12rpx;
- padding: 3.5vw;
- padding-left: 8vw;
- padding-right: 0;
-}
-.search {
- position: absolute;
- top: 50%;
- transform: translateY(-50%);
- left: 4.5vw;
- z-index: 999;
- display: flex;
-}
diff --git a/src/common/components/SearchInput/SearchInput.tsx b/src/common/components/SearchInput/SearchInput.tsx
deleted file mode 100644
index c7e9d1ec..00000000
--- a/src/common/components/SearchInput/SearchInput.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-/* eslint-disable @typescript-eslint/no-unsafe-argument */
-
-/* eslint-disable @typescript-eslint/no-unsafe-member-access */
-/* eslint-disable @typescript-eslint/no-unsafe-call */
-import { Image, Input, View } from '@tarojs/components';
-import React, { CSSProperties, useRef, useState } from 'react';
-
-import './SearchInput.scss';
-
-type SearchInputProps = {
- onSearchToggle: () => void;
- onSearch: (searchText: string) => void; // 添加搜索请求的回调函数
- searchPlaceholder: string;
- searchPlaceholderStyle: string;
- searchIconSrc: string;
- searchText?: string; //受控模式
- setSearchText?: (searchText: string) => void;
- autoFocus?: boolean;
- disabled?: boolean;
- style?: CSSProperties;
-};
-
-const SearchInput: React.FC = ({
- onSearchToggle,
- onSearch,
- searchPlaceholder,
- searchPlaceholderStyle,
- searchIconSrc,
- autoFocus,
- disabled,
- style,
- searchText,
- setSearchText,
-}) => {
- // const [isSearchActive, setIsSearchActive] = useState(true);
- const [customSearchText, setCustomSearchText] = useState(searchText || ''); // 添加状态来存储搜索文本
-
- // 添加点击图片时的搜索逻辑
- const handleImageClick = (e: any) => {
- // 阻止事件冒泡
- e.stopPropagation();
- if (searchText) {
- onSearch(searchText);
- return;
- }
- onSearch(customSearchText); // 发送搜索请求
- };
-
- const handleInputChange = (e: any) => {
- if (setSearchText) {
- setSearchText(e.target.value);
- return;
- }
- setCustomSearchText(e.target.value); // 更新搜索文本
- };
-
- const inputRef = useRef(null);
-
- const handleClick = (e: any) => {
- // 阻止事件冒泡
- e.stopPropagation();
- // 你的其他逻辑...
- onSearchToggle();
- };
-
- // ...组件的其余部分
-
- return (
-
-
-
-
- );
-};
-
-export default SearchInput;
diff --git a/src/common/components/SearchInput/index.scss b/src/common/components/SearchInput/index.scss
new file mode 100644
index 00000000..e41034aa
--- /dev/null
+++ b/src/common/components/SearchInput/index.scss
@@ -0,0 +1,98 @@
+$search_field_height: 44rpx;
+
+.search_container {
+ position: relative;
+ width: 100%;
+ margin: 0 auto;
+
+ .search_icon {
+ position: absolute;
+ left: 16rpx;
+ top: 50%;
+ z-index: 1;
+ width: 32rpx;
+ height: 32rpx;
+ color: $color_text_cool_gray;
+ transform: translateY(-50%);
+ }
+
+ .search_input_box {
+ box-sizing: border-box;
+ overflow: hidden;
+ width: 100%;
+ height: calc(#{$search_field_height} + #{$space_xs} * 2);
+ padding: $space_xs 0;
+ border-radius: 30rpx;
+ background-color: $color_bg_page;
+
+ &:not(.active) {
+ border: 1rpx solid $color_border_input;
+ }
+
+ &.active {
+ border: 1rpx solid $color_brand_accent;
+ }
+ }
+
+ .search_right_area {
+ @include flex_align_center;
+
+ float: right;
+ gap: $space_sm;
+ height: $search_field_height;
+ padding: 0 16rpx 0 0;
+ }
+
+ .search_input_wrap {
+ overflow: hidden;
+ height: $search_field_height;
+ }
+
+ .search_input {
+ width: 100%;
+ height: $search_field_height;
+ min-height: $search_field_height;
+ max-height: $search_field_height;
+ box-sizing: border-box;
+ line-height: $search_field_height;
+ padding: 0 8rpx 0 56rpx;
+ border: none;
+ border-radius: 0;
+ background-color: transparent;
+ }
+
+ .search_suffix_container {
+ @include flex_align_center;
+
+ height: 100%;
+ background-color: transparent;
+ }
+
+ .search_suffix {
+ color: $color_brand_accent;
+ font-size: $font_size_body;
+ padding-left: $space_sm;
+ border-left: 2rpx solid $color_brand;
+ line-height: $search_field_height;
+ background-color: transparent;
+ }
+
+ .search_clear_area {
+ @include flex_align_center;
+
+ flex-shrink: 0;
+ width: 32rpx;
+ height: 100%;
+ padding: 0;
+
+ &.search_clear_area_hidden {
+ visibility: hidden;
+ pointer-events: none;
+ }
+
+ .search_clear_button {
+ color: $color_text_cool_gray;
+ font-size: $font_size_body;
+ }
+ }
+}
diff --git a/src/common/components/SearchInput/index.tsx b/src/common/components/SearchInput/index.tsx
new file mode 100644
index 00000000..c727621b
--- /dev/null
+++ b/src/common/components/SearchInput/index.tsx
@@ -0,0 +1,165 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/no-unsafe-argument */
+
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+import { Icon, Image, Input, Text, View } from '@tarojs/components';
+import React, { CSSProperties, useRef, useState } from 'react';
+
+import './index.scss';
+
+// 自由后缀
+type SuffixConfig = {
+ text: string;
+ onClick?: () => void;
+ style?: CSSProperties;
+};
+
+type SearchInputProps = {
+ onSearchToggle?: () => void;
+ onSearch: (searchText: string) => void; // 添加搜索请求的回调函数
+ searchPlaceholder: string;
+ searchPlaceholderStyle: string;
+ searchIconSrc: string;
+ searchText?: string; //受控模式
+ setSearchText?: (searchText: string) => void;
+ autoFocus?: boolean;
+ disabled?: boolean;
+ style?: CSSProperties;
+ showClearButton?: boolean;
+ suffix?: SuffixConfig;
+};
+
+const SearchInput: React.FC = ({
+ onSearchToggle,
+ onSearch,
+ searchPlaceholder,
+ searchPlaceholderStyle,
+ searchIconSrc,
+ disabled,
+ style,
+ searchText,
+ setSearchText,
+ showClearButton = true,
+ suffix,
+}) => {
+ // const [isSearchActive, setIsSearchActive] = useState(true);
+ const [customSearchText, setCustomSearchText] = useState(searchText || ''); // 添加状态来存储搜索文本
+ const [isFocused, setIsFocused] = useState(false);
+
+ const isControlled = searchText !== undefined;
+ const value = isControlled ? searchText : customSearchText;
+
+ // 添加点击图片时的搜索逻辑
+ const handleImageClick = (e: any) => {
+ e.stopPropagation();
+ onSearch(value);
+ };
+
+ const handleInputChange = (e: any) => {
+ const nextValue = e.detail?.value ?? e.target?.value ?? '';
+ if (setSearchText) {
+ setSearchText(nextValue);
+ return;
+ }
+ setCustomSearchText(nextValue);
+ };
+
+ const inputRef = useRef(null);
+
+ const handleClick = (e: any) => {
+ // 阻止事件冒泡
+ e.stopPropagation();
+ onSearchToggle?.();
+ };
+
+ const handleClear = () => {
+ if (isControlled) {
+ setSearchText?.('');
+ } else {
+ setCustomSearchText('');
+ }
+ };
+
+ const handleSuffixClick = (e: any) => {
+ e.stopPropagation();
+ if (suffix?.onClick) {
+ suffix.onClick();
+ } else {
+ handleImageClick(e);
+ }
+ };
+
+ const { height, background, backgroundColor, ...boxStyle } = style ?? {};
+ const fieldHeight = height ?? '44rpx';
+ const inputStyle: CSSProperties = {
+ height: fieldHeight,
+ minHeight: fieldHeight,
+ maxHeight: fieldHeight,
+ lineHeight: fieldHeight,
+ };
+ const searchBoxStyle: CSSProperties = {
+ ...(background ? { background } : {}),
+ ...(backgroundColor ? { backgroundColor } : {}),
+ ...boxStyle,
+ };
+
+ return (
+
+
+
+
+
+ {showClearButton && (
+ 0 ? '' : 'search_clear_area_hidden'}`}
+ >
+
+
+ )}
+ {suffix && (
+
+
+ {suffix.text}
+
+
+ )}
+
+
+
+ setIsFocused(true)}
+ onBlur={() => setIsFocused(false)}
+ type="text"
+ placeholder={searchPlaceholder}
+ placeholderStyle={searchPlaceholderStyle}
+ ref={inputRef}
+ onConfirm={handleImageClick}
+ disabled={disabled}
+ confirmType="search"
+ />
+
+
+
+ );
+};
+
+export default SearchInput;
diff --git a/src/common/components/SearchLabel/index.scss b/src/common/components/SearchLabel/index.scss
new file mode 100644
index 00000000..6ade5560
--- /dev/null
+++ b/src/common/components/SearchLabel/index.scss
@@ -0,0 +1,25 @@
+.searchLabel {
+ display: inline-flex;
+ align-items: center;
+ background-color: $color_bg_page;
+ border: 1rpx solid $color_border_light;
+ border-radius: $radius_xl;
+ padding: 0 30rpx;
+ height: 40rpx;
+ margin-right: $space_md;
+ margin-top: $space_lg;
+ padding: $space_xs 36rpx;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ box-shadow: 0rpx 2rpx 4rpx rgba(0, 0, 0, 0.05);
+
+ .labeltext {
+ font-size: $font_size_subtitle;
+ font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
+ font-weight: 500;
+ color: $color_text_primary;
+ line-height: 40rpx;
+ white-space: nowrap;
+ text-align: center;
+ }
+}
diff --git a/src/common/components/label1/label1.tsx b/src/common/components/SearchLabel/index.tsx
similarity index 70%
rename from src/common/components/label1/label1.tsx
rename to src/common/components/SearchLabel/index.tsx
index 6ff77029..b27e9126 100644
--- a/src/common/components/label1/label1.tsx
+++ b/src/common/components/SearchLabel/index.tsx
@@ -3,19 +3,19 @@
import { Text, View } from '@tarojs/components';
import { useCallback } from 'react';
-import './label1.scss';
+import './index.scss';
-export default function Label1(props) {
+export default function SearchLabel(props) {
const handleClick = useCallback(
(event) => {
- event.stopPropagation(); // 阻止事件冒泡
+ event.stopPropagation();
props.onClick(props.content);
},
[props]
);
return (
-
+
{props.content}
);
diff --git a/src/common/components/showStar/showStar.scss b/src/common/components/ShowStar/index.scss
similarity index 64%
rename from src/common/components/showStar/showStar.scss
rename to src/common/components/ShowStar/index.scss
index 9ebcfc03..cd19d4ff 100644
--- a/src/common/components/showStar/showStar.scss
+++ b/src/common/components/ShowStar/index.scss
@@ -3,7 +3,7 @@
}
.score .star {
- width: 25rpx;
+ width: 26rpx;
height: 24rpx;
- margin-right: 9rpx;
+ margin-right: 10rpx;
}
diff --git a/src/common/components/ShowStar/index.tsx b/src/common/components/ShowStar/index.tsx
new file mode 100644
index 00000000..f1ee5009
--- /dev/null
+++ b/src/common/components/ShowStar/index.tsx
@@ -0,0 +1,43 @@
+import { Image, View } from '@tarojs/components';
+import { useEffect, useState } from 'react';
+
+import './index.scss';
+
+interface ShowStarProps {
+ score: number;
+}
+
+const STAR_FULL = 'https://s2.loli.net/2023/08/29/rENVFz7xU9n2bd6.png';
+const STAR_HALF = 'https://s2.loli.net/2023/08/29/fB8wqj5mcQFiS7V.png';
+const STAR_EMPTY = 'https://s2.loli.net/2023/08/29/NRLD54kzG9nEOHW.png';
+
+const ShowStar: React.FC = ({ score }) => {
+ const [starNum, setStarNum] = useState([
+ STAR_EMPTY,
+ STAR_EMPTY,
+ STAR_EMPTY,
+ STAR_EMPTY,
+ STAR_EMPTY,
+ ]);
+
+ useEffect(() => {
+ let remaining = score;
+ setStarNum(
+ Array.from({ length: 5 }, () => {
+ const star = remaining >= 1 ? STAR_FULL : remaining > 0 ? STAR_HALF : STAR_EMPTY;
+ remaining -= 1;
+ return star;
+ })
+ );
+ }, [score]);
+
+ return (
+
+ {starNum.map((item, index) => (
+
+ ))}
+
+ );
+};
+
+export default ShowStar;
diff --git a/src/common/components/StarRating/index.scss b/src/common/components/StarRating/index.scss
new file mode 100644
index 00000000..b8e46cb7
--- /dev/null
+++ b/src/common/components/StarRating/index.scss
@@ -0,0 +1,5 @@
+.star {
+ width: 36rpx;
+ height: 36rpx;
+ margin-right: $space_md;
+}
diff --git a/src/common/components/star/star.tsx b/src/common/components/StarRating/index.tsx
similarity index 89%
rename from src/common/components/star/star.tsx
rename to src/common/components/StarRating/index.tsx
index 153efa12..4a72bc5d 100644
--- a/src/common/components/star/star.tsx
+++ b/src/common/components/StarRating/index.tsx
@@ -3,14 +3,14 @@
import { Image, View } from '@tarojs/components';
import { useState } from 'react';
-import './star.scss';
+import './index.scss';
interface Props {
onStarClick: (index: number) => void;
// ...其他属性...
}
-const Star: React.FC = ({ onStarClick }) => {
+const StarRating: React.FC = ({ onStarClick }) => {
const star0 = 'https://s2.loli.net/2023/08/29/NRLD54kzG9nEOHW.png';
const star1 = 'https://s2.loli.net/2023/08/29/rENVFz7xU9n2bd6.png';
@@ -42,4 +42,4 @@ const Star: React.FC = ({ onStarClick }) => {
);
};
-export default Star;
+export default StarRating;
diff --git a/src/common/components/TabBar/index.scss b/src/common/components/TabBar/index.scss
new file mode 100644
index 00000000..230535e5
--- /dev/null
+++ b/src/common/components/TabBar/index.scss
@@ -0,0 +1,24 @@
+.tab_bar_container {
+ margin-top: 25%;
+ width: 100%;
+ display: flex;
+ justify-content: space-around;
+}
+.tab_bar_button {
+ @include flex_column_align_center;
+
+ gap: $space_sm;
+}
+.tab_bar_icon_wrapper {
+ @include flex_center;
+
+ height: 120rpx;
+ width: 120rpx;
+ border-radius: $radius_xl;
+ background-color: $color_bg_cream;
+ box-shadow: 0 12rpx 20rpx -4rpx rgba(0, 0, 0, 0.2);
+}
+.tab_bar_text {
+ font-size: $font_size_heading;
+ line-height: 1.5;
+}
diff --git a/src/common/components/TabBar/index.tsx b/src/common/components/TabBar/index.tsx
new file mode 100644
index 00000000..42cbb1f1
--- /dev/null
+++ b/src/common/components/TabBar/index.tsx
@@ -0,0 +1,29 @@
+import { Text, View } from '@tarojs/components';
+import { memo } from 'react';
+
+import './index.scss';
+
+import IconFont from '@/common/components/iconfont';
+import { TabBarProps } from '@/common/types/tabBarType';
+import { uniqueKey } from '@/common/utils';
+
+const TabBar: React.FC = memo(({ tabs, onTabClick }) => (
+
+ {tabs.map((item) => (
+ {
+ onTabClick(item.key);
+ }}
+ >
+
+
+
+ {item.name}
+
+ ))}
+
+));
+
+export default TabBar;
diff --git a/src/common/components/TitleButton/index.scss b/src/common/components/TitleButton/index.scss
new file mode 100644
index 00000000..599026bc
--- /dev/null
+++ b/src/common/components/TitleButton/index.scss
@@ -0,0 +1,21 @@
+.title_button {
+ @include flex_center;
+ white-space: nowrap;
+ border-radius: 100rpx;
+ padding: $space_xs 32rpx;
+ border: 1rpx solid $color_text_light;
+ background-color: white;
+ color: black;
+ .title_button_text {
+ font-size: $font_size_caption;
+ color: black;
+ }
+ &_selected {
+ border-color: $color_brand_muted;
+ background-color: $color_brand_muted;
+ .title_button_text {
+ font-size: $font_size_caption;
+ color: white;
+ }
+ }
+}
diff --git a/src/common/components/TitleButton.tsx b/src/common/components/TitleButton/index.tsx
similarity index 54%
rename from src/common/components/TitleButton.tsx
rename to src/common/components/TitleButton/index.tsx
index f8073c0b..d8f49f3b 100644
--- a/src/common/components/TitleButton.tsx
+++ b/src/common/components/TitleButton/index.tsx
@@ -1,25 +1,31 @@
-import { Button } from '@tarojs/components';
-import { memo } from 'react';
-
-interface TitleButtonProps {
- title: string;
- isSelected?: boolean;
- isDisabled?: boolean;
- onClick?: () => void;
-}
-
-const TitleButton: React.FC = memo(
- ({ title, isSelected, isDisabled, onClick }) => {
- return (
-
- );
- }
-);
-
-export default TitleButton;
+import { Button, Text } from '@tarojs/components';
+import { memo } from 'react';
+
+import './index.scss';
+
+interface TitleButtonProps {
+ title: string;
+ isSelected?: boolean;
+ isDisabled?: boolean;
+ onClick?: () => void;
+}
+
+const TitleButton: React.FC = memo(
+ ({ title, isSelected, isDisabled, onClick }) => {
+ return (
+
+ );
+ }
+);
+
+export default TitleButton;
diff --git a/src/common/components/UserIdentity/index.scss b/src/common/components/UserIdentity/index.scss
new file mode 100644
index 00000000..e400cc78
--- /dev/null
+++ b/src/common/components/UserIdentity/index.scss
@@ -0,0 +1,47 @@
+.user_identity {
+ @include flex_align_center;
+
+ min-width: 0;
+ width: 100%;
+
+ .ui_user_meta {
+ @include flex_align_center;
+
+ flex: 1;
+ min-width: 0;
+ gap: $space_xs;
+ margin-left: 16rpx;
+ overflow: hidden;
+
+ .ui_user_name {
+ flex: 0 1 auto;
+ min-width: 0;
+ max-width: 100%;
+ width: auto;
+ font-size: $font_size_subtitle;
+ color: $color_text_secondary;
+ }
+
+ .ui_user_title {
+ font-size: $font_size_subtitle;
+ color: $color_text_secondary;
+ }
+
+ .ui_user_level {
+ display: inline-block;
+ flex-shrink: 0;
+ padding: 2rpx 8rpx;
+ font-size: $font_size_xs;
+ font-weight: 500;
+ height: 24rpx;
+ line-height: 24rpx;
+ color: $color_bg_page;
+ background-color: $color_brand_muted;
+ white-space: nowrap;
+ letter-spacing: 2rpx;
+ border-radius: 8rpx;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
+ }
+ }
+}
diff --git a/src/common/components/UserIdentity/index.tsx b/src/common/components/UserIdentity/index.tsx
new file mode 100644
index 00000000..f04055ef
--- /dev/null
+++ b/src/common/components/UserIdentity/index.tsx
@@ -0,0 +1,44 @@
+import { Text, View } from '@tarojs/components';
+import React from 'react';
+
+import './index.scss';
+
+import { Avatar, EllipsisText } from '@/common/components';
+
+interface UserIdentityProps {
+ avatar: string;
+ username: string;
+ level?: number;
+ title?: string;
+ avatarSize?: number;
+ avatarClassName?: string;
+ className?: string;
+}
+
+const UserIdentity: React.FC = ({
+ avatar,
+ username,
+ level = 1,
+ title,
+ avatarSize = 48,
+ avatarClassName,
+ className,
+}) => (
+
+
+
+
+ {username || '匿名用户'}
+ {title && title !== 'None' ? ` (${title})` : ''}
+
+ Lv{level}
+
+
+);
+
+export default UserIdentity;
diff --git a/src/common/components/VirtualList.tsx b/src/common/components/VirtualList.tsx
deleted file mode 100644
index 0b0d65f9..00000000
--- a/src/common/components/VirtualList.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { ScrollView, View } from '@tarojs/components';
-import type { VirtualListProps as TaroVirtualListProps } from '@tarojs/components-advanced/dist/components/virtual-list';
-import { memo, useCallback, useRef } from 'react';
-
-interface VirtualListProps extends TaroVirtualListProps {
- item: React.FC<{ data: any; index: number }>;
- itemSize: number;
-}
-
-const VirtualList: React.FC = memo(
- ({ height, width, item: Item, itemData, itemSize, onScroll }) => {
- const scrollTop = useRef(0);
- const throttle = useCallback((fn, delay) => {
- let timer: NodeJS.Timeout | null = null;
- return (...args) => {
- if (timer) return;
- timer = setTimeout(() => {
- fn(...args);
- timer = null;
- }, delay);
- };
- }, []);
- const handleScroll = throttle((event) => {
- // console.log('fetching');
- onScroll && onScroll(event);
- }, 1000);
- return (
- {
- scrollTop.current = event.detail.scrollTop;
- }}
- scrollTop={scrollTop.current}
- >
- {itemData.length > 0 &&
- itemData.map((_, index) => (
-
- ))}
-
-
- );
- }
-);
-
-export default VirtualList;
diff --git a/src/common/components/VirtualList/index.tsx b/src/common/components/VirtualList/index.tsx
new file mode 100644
index 00000000..121de265
--- /dev/null
+++ b/src/common/components/VirtualList/index.tsx
@@ -0,0 +1,227 @@
+import { ScrollView, Text, View } from '@tarojs/components';
+import type { VirtualListProps as TaroVirtualListProps } from '@tarojs/components-advanced/dist/components/virtual-list';
+import { memo, ReactNode, useCallback, useRef, useState } from 'react';
+
+import { usePullToRefresh } from '@/common/hooks/usePullToRefresh';
+
+import Loading from '../Loading';
+
+interface VirtualListProps extends TaroVirtualListProps {
+ item: React.FC<{ id: any; data: any; index: number }>;
+ itemSize: number;
+ bottomPadding?: number;
+ hasMore?: boolean;
+ FooterChildren?: string | ReactNode;
+ EmptyChildren?: string | ReactNode;
+ initialLoading?: boolean;
+ timeout?: number;
+
+ onTimeout?: () => void;
+ onLoadMore?: () => void | Promise;
+ onRefresh?: () => void | Promise;
+ getItemKey?: (item: any, index: number) => string | number;
+}
+
+const VirtualList: React.FC = memo(
+ ({
+ height,
+ width,
+ item: Item,
+ itemData,
+ hasMore = false,
+ bottomPadding = 0,
+ getItemKey = (_, index) => index,
+ onLoadMore,
+ onRefresh,
+ FooterChildren = '—— 没有更多了 ——',
+ EmptyChildren = '暂无数据',
+ initialLoading = false,
+ timeout = 10000,
+ onTimeout,
+ }) => {
+ const scrollTop = useRef(0);
+ const loadingRef = useRef(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const pullRefresh = usePullToRefresh(onRefresh ?? (async () => {}));
+ const refreshEnabled = !!onRefresh;
+
+ const handleScrollToLower = useCallback(async () => {
+ if (
+ !onLoadMore ||
+ loadingRef.current ||
+ !hasMore ||
+ (refreshEnabled && pullRefresh.refresherTriggered)
+ ) {
+ return;
+ }
+
+ const IstimeOut = new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('加载超时')), timeout)
+ );
+
+ loadingRef.current = true;
+ setIsLoading(true);
+ try {
+ await Promise.race([Promise.resolve(onLoadMore()), IstimeOut]);
+ } catch (error) {
+ console.error('加载更多失败', error);
+ onTimeout?.();
+ } finally {
+ loadingRef.current = false;
+ setIsLoading(false);
+ }
+ }, [
+ onLoadMore,
+ hasMore,
+ timeout,
+ onTimeout,
+ refreshEnabled,
+ pullRefresh.refresherTriggered,
+ ]);
+
+ const renderFooter = () => {
+ const footerStyle = {
+ height: `${bottomPadding}rpx`,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '100%',
+ marginTop: '36rpx',
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!hasMore && itemData.length > 0) {
+ return (
+
+ {typeof FooterChildren === 'string' ? (
+
+ {FooterChildren}
+
+ ) : (
+ FooterChildren
+ )}
+
+ );
+ }
+
+ return ;
+ };
+
+ const renderEmpty = () => (
+
+ {EmptyChildren}
+
+ );
+
+ if (initialLoading && itemData.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ const useScrollContainer = itemData.length > 0 || refreshEnabled;
+
+ if (!useScrollContainer) {
+ return (
+
+
+ {EmptyChildren}
+
+
+ );
+ }
+
+ return (
+ {
+ scrollTop.current = event.detail.scrollTop;
+ }}
+ >
+ {itemData.length === 0
+ ? renderEmpty()
+ : itemData.map((item, index) => (
+
+ ))}
+ {itemData.length > 0 ? renderFooter() : null}
+
+
+ );
+ }
+);
+
+export default VirtualList;
diff --git a/src/common/components/chart/index.tsx b/src/common/components/chart/index.tsx
deleted file mode 100644
index ec85011a..00000000
--- a/src/common/components/chart/index.tsx
+++ /dev/null
@@ -1,258 +0,0 @@
-import { Canvas, Image, View } from '@tarojs/components';
-import Taro, { CanvasContext } from '@tarojs/taro';
-import React, { CSSProperties, useEffect, useMemo } from 'react';
-
-import {
- drawBottomInfo,
- drawCoordinateDiagram,
- drawHightlightScore,
- drawRoundedRectangle,
-} from './utils';
-
-interface LineChartProps {
- data?: number[];
- style?: CSSProperties;
- className?: string;
- /** x轴标签 */
- xLabels?: string[];
- /** y轴标签 */
- yLabels?: string[];
- /** 与x轴标签对应的范围 */
- xRange?: [number, number][];
- /** x、y、data之间的映射函数 */
- mappingFunction?: (data: number[], x: [number, number]) => number;
- /** 边距: 左右上下 */
- padding?: number[];
- /** 最大分、最小分、平均分 */
- maxScore?: number;
- minScore?: number;
- avgScore?: number;
- /** 容器id,默认为lineCanvas */
- id?: string;
- /** 标题 */
- title?: string;
- /** 画布宽高 */
- width?: number;
- height?: number;
- /** 底部内容高度 */
- bottomHeight?: number;
-}
-const DEFAULT_HIGHLIGHT_COLOR = '#FFA500';
-const DEFAULT_AXIS_TEXT_COLOR = '#3D3D3D';
-const DEFAULT_LINE_COLOR = '#F48500';
-const DEFAULT_Axis_LINE_COLOR = '#3D3D3D';
-const DEFAULT_PADDING = [25, 45, 35, 35]; //左右上下
-const DEFAULT_WIDTH = Taro.getSystemInfoSync().screenWidth * 0.9;
-const DEFAULT_HEIGHT = 260;
-const DEFAULT_BOTTOM_HEIGTH = 40;
-const DEFAULT_DATA = [10, 20, 30, 40, 50, 60, 70];
-const DEFAULT_X_LABELS = ['0', '60', '70', '80', '90', '100'];
-const DEFUALT_Y_LABELS = ['0%', '20%', '40%', '60%', '80%', '100%'];
-const DEFAUTL_X_RANGE: [number, number][] = [
- [0, 0],
- [1, 59],
- [60, 69],
- [70, 79],
- [80, 89],
- [90, 100],
-];
-const DEFAULT_MAPPING_FUNCTION = (
- data: number[],
- x: [number, number],
- height: number,
- y: number
-) => {
- const sum = data.length;
- let cnt = 0;
- data.forEach((d) => {
- if (d >= x[0] && d <= x[1]) cnt++;
- });
- const ratio = cnt / sum;
- return y - ratio * height;
-};
-const DEFAULT_CHART_ID = 'lineCanvas';
-const DEFAULT_GAP_LINE_COLOR = '#FE9F00';
-
-const LineChart: React.FC = (props) => {
- const {
- width: propWidth,
- height: propHeight,
- bottomHeight: propBHeight,
- maxScore = 0,
- minScore = 0,
- avgScore = 0,
- id,
- data: propData,
- className,
- padding: propPadding,
- xLabels: propX,
- yLabels: propY,
- xRange: propXRange,
- mappingFunction: propMFunc,
- style,
- } = props;
- useEffect(() => {
- if (propData) {
- const ctx = Taro.createCanvasContext(id ?? DEFAULT_CHART_ID);
- void drawChart(ctx);
- }
- }, [propData]);
- const hasData = useMemo(() => {
- return !propData?.some((data) => data);
- }, [propData]);
- const drawChart = (ctx: CanvasContext) => {
- // 初始化
- const data = propData ?? DEFAULT_DATA;
- const width = propWidth ?? DEFAULT_WIDTH;
- const height = propHeight ?? DEFAULT_HEIGHT;
- const bottomHeight = propBHeight ?? DEFAULT_BOTTOM_HEIGTH;
- const [pl, pr, pt, pb] = propPadding ?? DEFAULT_PADDING;
- const xLabels = propX ?? DEFAULT_X_LABELS;
- const yLabels = propY ?? DEFUALT_Y_LABELS;
- const xRange = propXRange ?? DEFAUTL_X_RANGE;
- const mappingFunction = propMFunc ?? DEFAULT_MAPPING_FUNCTION;
- ctx.clearRect(0, 0, width, height);
- // // 适配不同比例
- // if (width / height > 2) {
- // ctx.scale((height * 2) / width, 1);
- // } else {
- // ctx.scale(1, width / 2 / height);
- // }
- // const barWidth = (width - 2 * padding) / data.length;
- // const lines = 5;
-
- //绘制背景
- drawRoundedRectangle(
- ctx,
- 0,
- 0,
- width,
- height,
- [10, 10, 10, 10],
- () => null,
- '#FEF8E4'
- );
-
- //绘制坐标图
- drawCoordinateDiagram(
- ctx,
- data,
- [pl, height - pb - bottomHeight],
- [true, false],
- [height - pb - bottomHeight + 15, width - pr + 8],
- width - pl - pr,
- height - pt - pb - bottomHeight,
- xLabels,
- yLabels,
- xRange,
- mappingFunction,
- DEFAULT_Axis_LINE_COLOR,
- DEFAULT_GAP_LINE_COLOR,
- DEFAULT_AXIS_TEXT_COLOR,
- DEFAULT_LINE_COLOR
- );
-
- drawBottomInfo(
- ctx,
- avgScore,
- minScore,
- maxScore,
- [pl, height - Math.floor(bottomHeight / 2)],
- width - pl
- );
-
- //高亮平均值
- const avgXValue = avgScore;
- const avgXPos = (() => {
- // 找到avgXValue的区间
- let rangeIdx = -1;
- let rangeLength = -1;
- let property = -1;
- for (let i = 0; i < xRange.length; i++) {
- const [min, max] = xRange[i];
- if (avgXValue >= min && avgXValue <= max) {
- rangeIdx = i;
- rangeLength = Math.max(max - min, 1);
- property = avgXValue - min;
- break;
- }
- }
-
- if (rangeIdx === -1) return -1; // 不在任何区间,直接不画
-
- // 计算该区间对应的 x 像素位置
- const chartWidth = width - pl - pr;
- const singleWidth = Math.floor(chartWidth / (xLabels.length - 1));
- //注意[0,0]区间不占长度,所以要-1
- const posX =
- pl + (rangeIdx - 1) * singleWidth + singleWidth * (property / rangeLength);
- return posX;
- })();
- if (avgXPos !== -1)
- drawHightlightScore(
- ctx,
- [avgXPos, height - pb - bottomHeight],
- pl,
- height - pb - pt - bottomHeight,
- 10,
- `${avgScore.toFixed(2)}`,
- DEFAULT_HIGHLIGHT_COLOR,
- (gradient: Taro.CanvasGradient) => {
- gradient.addColorStop(0, DEFAULT_HIGHLIGHT_COLOR); // 顶部不透明橙色
- gradient.addColorStop(1, 'rgba(255, 165, 0, 0)'); // 底部完全透明
- }
- );
-
- void ctx.draw();
- };
-
- return (
-
- {hasData && 数据量太少,暂不支持预览}
- {hasData ? (
-
- ) : (
-
- )}
-
- );
-};
-
-export default LineChart;
diff --git a/src/common/components/historyResearch/historyResearch.scss b/src/common/components/historyResearch/historyResearch.scss
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/common/components/historyResearch/historyResearch.tsx b/src/common/components/historyResearch/historyResearch.tsx
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/common/components/index.ts b/src/common/components/index.ts
index 43870138..cfcfa2b6 100644
--- a/src/common/components/index.ts
+++ b/src/common/components/index.ts
@@ -1,4 +1,29 @@
-export { default as CollectionCourse } from './CollectionCourse/CollectionCourse';
-export { default as Comment } from './Comment';
+export { default as ActionItem } from './ActionItem';
+export { default as Avatar } from './Avatar';
+export { default as BottomInput } from './BottomInput';
+export { default as CommentCard } from './CommentCard';
+export { default as CourseInfo } from './CourseInfo';
+export { default as CourseLabel } from './CourseLabel';
+export { default as CourseTitleBlock } from './CourseTitleBlock';
+export { default as Drawer } from './Drawer';
+export { default as EllipsisText } from './EllipsisText';
+export { default as FeatureLabel } from './FeatureLabel';
+export { default as FeedCard } from './FeedCard';
+export { default as FloatButton } from './FloatButton';
+export { default as GateScreen } from './GateScreen';
+export { default as LineChart } from './LineChart';
+export { default as Loading } from './Loading';
+export { default as Modal } from './Modal';
+export { default as NotificationGateScreen } from './NotificationGateScreen';
+export { default as PlazaGateScreen } from './PlazaGateScreen';
+export { default as PublishHeader } from './PublishHeader';
+export { default as ReplySection } from './ReplySection';
+export { default as ReviewDiscussion } from './ReviewDiscussion';
+export { default as SearchInput } from './SearchInput';
+export { default as SearchLabel } from './SearchLabel';
+export { default as ShowStar } from './ShowStar';
+export { default as StarRating } from './StarRating';
+export { default as TabBar } from './TabBar';
export { default as TitleButton } from './TitleButton';
+export { default as UserIdentity } from './UserIdentity';
export { default as VirtualList } from './VirtualList';
diff --git a/src/common/components/label1/label1.scss b/src/common/components/label1/label1.scss
deleted file mode 100644
index 2c627a16..00000000
--- a/src/common/components/label1/label1.scss
+++ /dev/null
@@ -1,24 +0,0 @@
-.label1 {
- width: max-content;
- display: flex;
- align-items: center;
- background: #fffaec;
- height: 36rpx;
- border-radius: 18rpx;
- opacity: 1;
- padding-left: 40rpx;
- padding-right: 40rpx;
- padding-top: 10rpx;
- padding-bottom: 10rpx;
- margin-right: 21rpx;
- margin-top: 27rpx;
-}
-.labeltext {
- font-size: 24rpx;
- font-family:
- Segoe UI-Regular,
- Segoe UI;
- font-weight: 400;
- color: #3d3d3d;
- line-height: 36rpx;
-}
diff --git a/src/common/components/label2/label2.scss b/src/common/components/label2/label2.scss
deleted file mode 100644
index 4e004ee4..00000000
--- a/src/common/components/label2/label2.scss
+++ /dev/null
@@ -1,63 +0,0 @@
-.label2 {
- width: 659rpx;
- height: 188rpx;
- background: #f9f9f2;
- border-radius: 43rpx 43rpx 43rpx 43rpx;
- opacity: 1;
- border: 2rpx solid #f9f9f2;
- margin-bottom: 47.1rpx;
-}
-
-.labeltext1 {
- font-size: 33rpx;
- font-family:
- Segoe UI-Bold,
- Segoe UI;
- font-weight: bold;
- color: #565552;
- // line-height: 0rpx;
- position: relative;
- margin-top: 35.36rpx;
- margin-left: 34.42rpx;
- width: 80vw;
- text-overflow: ellipsis;
- white-space: nowrap;
- overflow: hidden;
-}
-
-.labeltext2 {
- font-size: 29rpx;
- font-family:
- Segoe UI-Bold,
- Segoe UI;
- font-weight: bold;
- color: #565552;
- position: relative;
- margin-top: 10rpx;
- margin-left: 35.36rpx;
- width: 80vw;
- text-overflow: ellipsis;
- white-space: nowrap;
- overflow: hidden;
-}
-
-.score {
- position: relative;
- margin-left: 34.42rpx;
-}
-
-.star {
- width: 25rpx;
- height: 24rpx;
- margin-right: 9rpx;
-}
-
-.comment {
- left: 416rpx;
- width: 250rpx;
- top: 0rpx;
- position: relative;
- display: flex;
- flex-wrap: wrap;
- justify-content: center;
-}
diff --git a/src/common/components/label2/label2.tsx b/src/common/components/label2/label2.tsx
deleted file mode 100644
index 99736543..00000000
--- a/src/common/components/label2/label2.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-/* eslint-disable react-hooks/exhaustive-deps */
-/* eslint-disable @typescript-eslint/no-unsafe-call */
-/* eslint-disable @typescript-eslint/no-unsafe-return */
-/* eslint-disable @typescript-eslint/no-unsafe-member-access */
-/* eslint-disable @typescript-eslint/no-unsafe-assignment */
-import { View } from '@tarojs/components';
-import Taro from '@tarojs/taro';
-import { useEffect, useState } from 'react';
-
-import './label2.scss';
-
-import Label3 from '../label3/label3';
-import ShowStar from '../showStar/showStar';
-
-export default function Label2(props) {
- const star2 = 'https://s2.loli.net/2023/08/29/rENVFz7xU9n2bd6.png';
- const star1 = 'https://s2.loli.net/2023/08/29/fB8wqj5mcQFiS7V.png';
- const star0 = 'https://s2.loli.net/2023/08/29/NRLD54kzG9nEOHW.png';
-
- const [starNum, setstarNum] = useState([star0, star0, star0, star0, star0]);
-
- const getStar = (num) => {
- const newStar = starNum.map(() => {
- const star = num >= 1 ? star2 : num > 0 ? star1 : star0;
- --num;
- return star;
- });
- setstarNum(newStar);
- };
-
- // 修正renderFeatures函数
- const renderFeatures = () => {
- // 检查props.features是否为数组,并且不为空
- const commentsArray =
- Array.isArray(props.features) && props.features.length > 0 ? props.features : [];
-
- // 返回map的结果
- return commentsArray.map((item, index) => {
- // 确保item存在并且有内容可以显示
- if (item) {
- const obj = { content: item };
- return ; // 添加key属性
- }
- return null;
- });
- };
-
- // 修正useEffect
- useEffect(() => {
- getStar(props.composite_score);
- }, [getStar, props.composite_score]); // 添加依赖项props.composite_score
-
- // ...其他代码
-
- return (
- {
- void Taro.navigateTo({
- url: `/pages/classInfo/index?course_id=${props.id}`,
- });
- }}
- >
- {props.name}
- {props.teacher}
-
-
- {renderFeatures()} {/* 直接渲染返回的元素数组 */}
-
-
- );
-}
diff --git a/src/common/components/label3/label3.scss b/src/common/components/label3/label3.scss
deleted file mode 100644
index f39f4c35..00000000
--- a/src/common/components/label3/label3.scss
+++ /dev/null
@@ -1,17 +0,0 @@
-.label3 {
- width: max-content;
- background: #f9b94f;
- // box-shadow: 0rpx 5rpx 11rpx 2rpx rgba(0, 0, 0, 0.16);
- border-radius: 16rpx 16rpx 16rpx 16rpx;
- opacity: 1;
- font-size: 25rpx;
- font-family:
- Segoe UI-Regular,
- Segoe UI;
- font-weight: 400;
- color: #ffffff;
- padding: 7rpx 25rpx;
- margin-bottom: 15rpx;
- margin-right: 15rpx;
- display: inline-block;
-}
diff --git a/src/common/components/label3/label3.tsx b/src/common/components/label3/label3.tsx
deleted file mode 100644
index 9081b915..00000000
--- a/src/common/components/label3/label3.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-/* eslint-disable @typescript-eslint/no-unsafe-assignment */
-/* eslint-disable @typescript-eslint/no-unsafe-call */
-/* eslint-disable @typescript-eslint/no-unsafe-member-access */
-import { View } from '@tarojs/components';
-
-import './label3.scss';
-
-export default function Label3(props) {
- const { handleChecked, handleClick } = props;
-
- const label3Checked = (id) => {
- id && handleChecked && handleChecked(id);
- handleClick && handleClick();
- // console.log("已点击")
- };
-
- return (
- {
- return label3Checked(props.id);
- }}
- className="label3"
- style={
- props.checked
- ? 'background:#FFFAEC;color:#F9B94F;'
- : 'background: #F9B94F;color: #FFFFFF;'
- }
- >
- {props.content}
-
- );
-}
diff --git a/src/common/components/showStar/showStar.tsx b/src/common/components/showStar/showStar.tsx
deleted file mode 100644
index a7e162a5..00000000
--- a/src/common/components/showStar/showStar.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-/* eslint-disable @typescript-eslint/no-unsafe-member-access */
-/* eslint-disable react-hooks/rules-of-hooks */
-import { Image, View } from '@tarojs/components';
-import { useEffect, useState } from 'react';
-
-import './showStar.scss';
-
-export default function showStar(props) {
- const star2 = 'https://s2.loli.net/2023/08/29/rENVFz7xU9n2bd6.png';
- const star1 = 'https://s2.loli.net/2023/08/29/fB8wqj5mcQFiS7V.png';
- const star0 = 'https://s2.loli.net/2023/08/29/NRLD54kzG9nEOHW.png';
-
- const [starNum, setstarNum] = useState([star0, star0, star0, star0, star0]);
-
- const getStar = (num) => {
- const newStar = starNum.map(() => {
- const star = num >= 1 ? star2 : num > 0 ? star1 : star0;
- --num;
- return star;
- });
- setstarNum(newStar);
- };
-
- useEffect(() => {
- getStar(props.score);
- });
-
- return (
-
- {starNum.map((item, index) => {
- return ;
- })}
-
- );
-}
diff --git a/src/common/components/star/star.scss b/src/common/components/star/star.scss
deleted file mode 100644
index 9a331e9f..00000000
--- a/src/common/components/star/star.scss
+++ /dev/null
@@ -1,5 +0,0 @@
-.star {
- width: 35rpx;
- height: 35rpx;
- margin-right: 20rpx;
-}
diff --git a/src/common/config/features.ts b/src/common/config/features.ts
new file mode 100644
index 00000000..333a92da
--- /dev/null
+++ b/src/common/config/features.ts
@@ -0,0 +1,28 @@
+/**
+ * 项目特性开关 & 调试配置
+ *
+ * 修改后重新编译即可生效,调用方无需改动。
+ */
+
+// 老登遗留的神秘接口,审核状态检查。关闭后跳过
+export const FEATURES = {
+ CHECK_STATUS: true,
+};
+
+/**
+ * 调试配置 —— 仅开发环境生效
+ *
+ * ERROR_LEVEL 控制台输出级别:
+ * 'verbose' — 所有错误信息(含完整堆栈、请求上下文)
+ * 'normal' — 仅错误摘要(默认)
+ * 'silent' — 关闭控制台输出,只走全局管道
+ *
+ * ERROR_TOAST 控制 Toast 提示:
+ * true — 显示 Toast(默认)
+ * false — 关闭所有 Toast,静默处理
+ */
+
+export const DEBUG = {
+ ERROR_LEVEL: 'verbose' as 'verbose' | 'normal' | 'silent',
+ ERROR_TOAST: true,
+};
diff --git a/src/common/constants/auth.ts b/src/common/constants/auth.ts
new file mode 100644
index 00000000..b457c96f
--- /dev/null
+++ b/src/common/constants/auth.ts
@@ -0,0 +1,3 @@
+export const SHORT_TOKEN = 'shortToken';
+export const LONG_TOKEN = 'longToken';
+export const VISITOR = 'visitor';
diff --git a/src/common/constants/courseLabels.ts b/src/common/constants/courseLabels.ts
new file mode 100644
index 00000000..95b9c271
--- /dev/null
+++ b/src/common/constants/courseLabels.ts
@@ -0,0 +1,93 @@
+// 课程属性映射(字符串键 + 整数枚举值)
+const PROPERTY_STR_MAP: Record = {
+ CoursePropertyAny: '全部',
+ CoursePropertyUnknown: '其它',
+ CoursePropertyGeneralCore: '通识核心课',
+ CoursePropertyGeneralElective: '通识选修课',
+ CoursePropertyGeneralRequired: '通识必修课',
+ CoursePropertyMajorCore: '专业主干课程',
+ CoursePropertyMajorElective: '个性发展课程',
+};
+
+const PROPERTY_INT_MAP: Record = {
+ 0: '全部',
+ 1: '其它',
+ 2: '通识核心课',
+ 3: '通识选修课',
+ 4: '通识必修课',
+ 5: '专业主干课程',
+ 6: '个性发展课程',
+};
+
+export const COURSE_PROPERTY_MAP = PROPERTY_STR_MAP;
+
+// features映射(字符串键 + 整数枚举值)
+export const COURSE_FEATURE_MAP: Record = {
+ EasyToLearn: '课程简单易学',
+ RichInContent: '课程干货满满',
+ Challenging: '课程很有挑战',
+ RigorousAndResponsible: '老师严谨负责',
+ KindAndEasygoing: '老师温柔随和',
+ Humorous: '老师风趣幽默',
+ LessHomework: '平时作业少',
+ KeyPointsForFinal: '期末划重点',
+ ComprehensiveOnlineMaterials: '云课堂资料全',
+};
+
+const FEATURE_INT_MAP: Record = {
+ 0: '课程简单易学',
+ 1: '课程干货满满',
+ 2: '课程很有挑战',
+ 3: '老师严谨负责',
+ 4: '老师温柔随和',
+ 5: '老师风趣幽默',
+ 6: '平时作业少',
+ 7: '期末划重点',
+ 8: '云课堂资料全',
+};
+
+// 考核方式映射(字符串键 + 整数枚举值)
+export const ASSESSMENT_MAP: Record = {
+ OpenBookExamination: '开卷考试',
+ ClosedBookExamination: '闭卷考试',
+ ThesisExamination: '论文考核',
+ GroupReporting: '小组汇报',
+ NoAssessment: '无考核',
+};
+
+const ASSESSMENT_INT_MAP: Record = {
+ 0: '开卷考试',
+ 1: '闭卷考试',
+ 2: '论文考核',
+ 3: '小组汇报',
+ 4: '无考核',
+};
+
+// 将 features翻译为中文标签数组
+export const translateFeatures = (
+ features?: string[] | Record
+): string[] => {
+ if (!features) return [];
+ const list = Array.isArray(features) ? features : Object.keys(features);
+ return list
+ .map((f) => COURSE_FEATURE_MAP[f] || FEATURE_INT_MAP[Number(f)] || f)
+ .filter(Boolean);
+};
+
+// 将 assessments翻译为中文标签数组
+export const translateAssessments = (
+ assessments?: string[] | Record
+): string[] => {
+ if (!assessments) return [];
+ const list = Array.isArray(assessments) ? assessments : Object.keys(assessments);
+ return list
+ .map((a) => ASSESSMENT_MAP[a] || ASSESSMENT_INT_MAP[Number(a)] || a)
+ .filter(Boolean);
+};
+
+// 翻译单个课程属性
+export const translateCourseProperty = (prop?: string | number): string => {
+ if (prop === undefined || prop === null) return '未分类';
+ if (typeof prop === 'number') return PROPERTY_INT_MAP[prop] || '未分类';
+ return PROPERTY_STR_MAP[prop] || PROPERTY_INT_MAP[Number(prop)] || prop;
+};
diff --git a/src/common/constants/feedback.ts b/src/common/constants/feedback.ts
new file mode 100644
index 00000000..0b0c7949
--- /dev/null
+++ b/src/common/constants/feedback.ts
@@ -0,0 +1,27 @@
+export const STATUS_LABELS = ['待处理', '处理中', '已完成'];
+
+export const ISSUE_TYPE_MAP: Record = {
+ function: '功能异常',
+ improvement: '产品改进',
+};
+
+export const MODULE_MAP: Record = {
+ function: ['首页板块', '课表板块', '其他板块', '登录相关', '加载/闪退', '其他问题'],
+ improvement: ['界面设计', '功能建议', '体验问题', '账号相关', '其他问题'],
+};
+
+export const FAQ_TABLE_IDENTIFY = 'kstack-faq';
+export const FEEDBACK_TABLE_IDENTIFY = 'kstack';
+
+export const FAQ_RECORD_NAMES = ['问题名称', '问题描述', '解决方案', '已解决', '未解决'];
+
+export const FEEDBACK_RECORD_NAMES = [
+ '联系方式(QQ/邮箱)',
+ '反馈内容',
+ '截图',
+ '问题类型',
+ '问题来源',
+ '进度',
+ '提交时间',
+ '回复内容',
+];
diff --git a/src/common/constants/routes.ts b/src/common/constants/routes.ts
new file mode 100644
index 00000000..f4d8d498
--- /dev/null
+++ b/src/common/constants/routes.ts
@@ -0,0 +1,19 @@
+export const ROUTES = {
+ profile: {
+ myCollection: '/subpackages/profile/pages/myCollection/index',
+ evaluationHistory: '/subpackages/profile/pages/evaluationHistory/index',
+ editUser: '/subpackages/profile/pages/editUser/index',
+ },
+ feedback: {
+ main: '/subpackages/feedback/pages/main/index',
+ write: '/subpackages/feedback/pages/writefeedback/index',
+ history: '/subpackages/feedback/pages/history/index',
+ detail: '/subpackages/feedback/pages/detail/index',
+ },
+ course: {
+ evaluate: '/subpackages/course/pages/evaluate/index',
+ evaluateInfo: '/subpackages/course/pages/evaluateInfo/index',
+ questionInfo: '/subpackages/course/pages/questionInfo/index',
+ publishQuestion: '/subpackages/course/pages/publishQuestion/index',
+ },
+} as const;
diff --git a/src/common/constants/semester.ts b/src/common/constants/semester.ts
new file mode 100644
index 00000000..ff9e36d9
--- /dev/null
+++ b/src/common/constants/semester.ts
@@ -0,0 +1,16 @@
+export const SEMESTER_NAMES = ['第一学期', '第二学期', '第三学期'] as const;
+export type SemesterName = (typeof SEMESTER_NAMES)[number];
+
+export const SEMESTER_NAME_TO_NUM: Record = {
+ 第一学期: 1,
+ 第二学期: 2,
+ 第三学期: 3,
+};
+
+export const SEMESTER_ALL = '全部学期';
+
+export function getSemesterNumber(month: number): number {
+ if (month >= 9 || month <= 2) return 1;
+ if (month <= 6) return 2;
+ return 3;
+}
diff --git a/src/common/constants/user.ts b/src/common/constants/user.ts
new file mode 100644
index 00000000..28b25513
--- /dev/null
+++ b/src/common/constants/user.ts
@@ -0,0 +1,2 @@
+export const USER_INFO = 'userInfo';
+export const STUDENT_ID = 'student_id';
diff --git a/src/common/hooks/useAuthGuard.ts b/src/common/hooks/useAuthGuard.ts
new file mode 100644
index 00000000..5526c8df
--- /dev/null
+++ b/src/common/hooks/useAuthGuard.ts
@@ -0,0 +1,15 @@
+import Taro from '@tarojs/taro';
+
+import { VISITOR } from '@/common/constants/auth';
+
+export function useAuthGuard() {
+ const guard = (): boolean => {
+ if (Taro.getStorageSync(VISITOR)) {
+ Taro.showToast({ title: '请先登录', icon: 'none' });
+ return false;
+ }
+ return true;
+ };
+
+ return { guard };
+}
diff --git a/src/common/hooks/useGateGuard.ts b/src/common/hooks/useGateGuard.ts
new file mode 100644
index 00000000..36cc255a
--- /dev/null
+++ b/src/common/hooks/useGateGuard.ts
@@ -0,0 +1,74 @@
+import { useEffect, useState } from 'react';
+
+import { FEATURES } from '@/common/config/features';
+import { checkStatus } from '@/common/request/api/status';
+
+type GateStatus = 'loading' | 'pass' | 'block';
+type GateResult = { status: boolean };
+
+let cachedPromise: Promise | null = null;
+let cachedResult: GateResult | null = null;
+
+function gateFromResult(result: GateResult): GateStatus {
+ return result.status ? 'pass' : 'block';
+}
+
+function gateFromCache(): GateStatus {
+ if (cachedResult) return gateFromResult(cachedResult);
+ return 'loading';
+}
+
+async function resolveGateStatus(): Promise {
+ if (!FEATURES.CHECK_STATUS) {
+ return { status: true };
+ }
+
+ if (cachedResult) return cachedResult;
+
+ if (!cachedPromise) {
+ cachedPromise = checkStatus()
+ .then((result) => {
+ cachedResult = result;
+ return result;
+ })
+ .catch(() => {
+ const result = { status: false };
+ cachedResult = result;
+ return result;
+ })
+ .finally(() => {
+ cachedPromise = null;
+ });
+ }
+
+ return cachedPromise;
+}
+
+export function invalidateGateGuardCache(): void {
+ cachedPromise = null;
+ cachedResult = null;
+}
+
+export function useGateGuard(): GateStatus {
+ const [status, setStatus] = useState(() => {
+ if (!FEATURES.CHECK_STATUS) return 'pass';
+ return gateFromCache();
+ });
+
+ useEffect(() => {
+ if (!FEATURES.CHECK_STATUS) return;
+ if (cachedResult) return;
+
+ let cancelled = false;
+
+ void resolveGateStatus().then((res) => {
+ if (!cancelled) setStatus(gateFromResult(res));
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return status;
+}
diff --git a/src/common/hooks/useKeyboardStatus.ts b/src/common/hooks/useKeyboardStatus.ts
new file mode 100644
index 00000000..59430892
--- /dev/null
+++ b/src/common/hooks/useKeyboardStatus.ts
@@ -0,0 +1,31 @@
+import Taro from '@tarojs/taro';
+import { useEffect, useState } from 'react';
+
+interface keyboardStatus {
+ isKeyboardShow: boolean;
+ keyboardHeight: number;
+}
+
+const useKeyboardStatus = () => {
+ const [keyboardInfo, setKeyboardInfo] = useState({
+ keyboardHeight: 0,
+ isKeyboardShow: false,
+ });
+
+ useEffect(() => {
+ const cancelKeyboardListener = Taro.onKeyboardHeightChange((res) => {
+ setKeyboardInfo({
+ keyboardHeight: res.height,
+ isKeyboardShow: res.height > 0,
+ });
+ });
+
+ return () => {
+ Taro.offKeyboardHeightChange(void cancelKeyboardListener);
+ };
+ }, []);
+
+ return keyboardInfo;
+};
+
+export default useKeyboardStatus;
diff --git a/src/common/hooks/useLikeAction.ts b/src/common/hooks/useLikeAction.ts
new file mode 100644
index 00000000..70531dca
--- /dev/null
+++ b/src/common/hooks/useLikeAction.ts
@@ -0,0 +1,79 @@
+import Taro from '@tarojs/taro';
+import { useCallback, useEffect, useState } from 'react';
+
+import { useAuthGuard } from '@/common/hooks/useAuthGuard';
+import type { CommentInfo } from '@/common/types/commentTypes';
+import { useEvaluationStore } from '@/store/evaluations';
+
+export interface UseLikeActionOptions {
+ evaluationId: number;
+ stance?: number;
+ count?: number;
+
+ onSuccess?: (updated?: CommentInfo) => void;
+}
+
+export function useLikeAction({
+ evaluationId,
+ stance = 0,
+ count = 0,
+ onSuccess,
+}: UseLikeActionOptions) {
+ const [isLiked, setIsLiked] = useState(stance === 1);
+ const [likeCount, setLikeCount] = useState(count);
+ const { guard } = useAuthGuard();
+
+ useEffect(() => {
+ setIsLiked(stance === 1);
+ }, [stance]);
+
+ useEffect(() => {
+ setLikeCount(count);
+ }, [count]);
+
+ const toggleLike = useCallback(
+ async (e?: { stopPropagation?: () => void }) => {
+ e?.stopPropagation?.();
+ if (!guard()) return;
+
+ const wasLiked = isLiked;
+ const prevCount = likeCount;
+ const willLike = !wasLiked;
+
+ void Taro.showLoading({ title: '点赞中' });
+ try {
+ const updated = await useEvaluationStore
+ .getState()
+ .endorse(evaluationId, willLike);
+ if (updated) {
+ setIsLiked(updated.stance === 1);
+ if (updated.total_support_count != null) {
+ setLikeCount(updated.total_support_count);
+ }
+ onSuccess?.(updated);
+ } else {
+ const nextCount = prevCount + (willLike ? 1 : -1);
+ setIsLiked(willLike);
+ setLikeCount(nextCount);
+ onSuccess?.({
+ id: evaluationId,
+ total_support_count: nextCount,
+ stance: willLike ? 1 : 0,
+ });
+ }
+ void Taro.showToast({
+ title: willLike ? '点赞成功' : '取消成功',
+ icon: 'success',
+ duration: 1000,
+ });
+ } catch {
+ void Taro.showToast({ title: '服务端错误', icon: 'error' });
+ } finally {
+ Taro.hideLoading();
+ }
+ },
+ [evaluationId, isLiked, likeCount, guard, onSuccess]
+ );
+
+ return { isLiked, likeCount, toggleLike };
+}
diff --git a/src/common/hooks/usePullToRefresh.ts b/src/common/hooks/usePullToRefresh.ts
new file mode 100644
index 00000000..f342abdd
--- /dev/null
+++ b/src/common/hooks/usePullToRefresh.ts
@@ -0,0 +1,25 @@
+import { useCallback, useRef, useState } from 'react';
+
+export function usePullToRefresh(onRefresh: () => void | Promise) {
+ const [refresherTriggered, setRefresherTriggered] = useState(false);
+ const busyRef = useRef(false);
+
+ const onRefresherRefresh = useCallback(async () => {
+ if (busyRef.current) return;
+
+ busyRef.current = true;
+ setRefresherTriggered(true);
+ try {
+ await onRefresh();
+ } finally {
+ busyRef.current = false;
+ setRefresherTriggered(false);
+ }
+ }, [onRefresh]);
+
+ return {
+ refresherEnabled: true as const,
+ refresherTriggered,
+ onRefresherRefresh,
+ };
+}
diff --git a/src/common/hooks/useUpdateEffect.ts b/src/common/hooks/useUpdateEffect.ts
new file mode 100644
index 00000000..4ef372d6
--- /dev/null
+++ b/src/common/hooks/useUpdateEffect.ts
@@ -0,0 +1,14 @@
+import { useEffect, useRef } from 'react';
+
+export function useUpdateEffect(effect: () => void | (() => void), deps: unknown[]) {
+ const isFirstMount = useRef(true);
+
+ useEffect(() => {
+ if (isFirstMount.current) {
+ isFirstMount.current = false;
+ return;
+ }
+
+ return effect();
+ }, deps);
+}
diff --git a/src/common/request/api/answers/getAnswerDetail.ts b/src/common/request/api/answers/getAnswerDetail.ts
new file mode 100644
index 00000000..d39a291a
--- /dev/null
+++ b/src/common/request/api/answers/getAnswerDetail.ts
@@ -0,0 +1,9 @@
+import { request } from '../..';
+
+export interface GetAnswerDetailRequest {}
+
+const getAnswerDetail = async (answerId: number) => {
+ return await request.get(`/answers/${answerId}/detail`);
+};
+
+export default getAnswerDetail;
diff --git a/src/common/request/api/answers/getAnswersList.ts b/src/common/request/api/answers/getAnswersList.ts
new file mode 100644
index 00000000..b6c0f399
--- /dev/null
+++ b/src/common/request/api/answers/getAnswersList.ts
@@ -0,0 +1,12 @@
+import { request } from '../..';
+
+export interface GetAnswersListRequest {
+ cur_answer_id: number;
+ limit: number;
+}
+
+const getAnswersList = async (questionId: number, query: GetAnswersListRequest) => {
+ return await request.get(`/answers/list/questions/${questionId}`, { query });
+};
+
+export default getAnswersList;
diff --git a/src/common/request/api/answers/index.ts b/src/common/request/api/answers/index.ts
new file mode 100644
index 00000000..b59405d7
--- /dev/null
+++ b/src/common/request/api/answers/index.ts
@@ -0,0 +1,3 @@
+export { default as getAnswerDetail } from './getAnswerDetail';
+export { default as getAnswersList } from './getAnswersList';
+export { default as publishAnswer } from './publishAnswer';
diff --git a/src/common/request/api/answers/publishAnswer.ts b/src/common/request/api/answers/publishAnswer.ts
new file mode 100644
index 00000000..895a213e
--- /dev/null
+++ b/src/common/request/api/answers/publishAnswer.ts
@@ -0,0 +1,12 @@
+import { request } from '../..';
+
+export interface PublishAnswerRequest {
+ content: string;
+ question_id: number;
+}
+
+const publishAnswer = async (body: PublishAnswerRequest) => {
+ return await request.post('/answers/publish', body);
+};
+
+export default publishAnswer;
diff --git a/src/common/request/api/comments/getCommentById.ts b/src/common/request/api/comments/getCommentById.ts
new file mode 100644
index 00000000..dbd72e12
--- /dev/null
+++ b/src/common/request/api/comments/getCommentById.ts
@@ -0,0 +1,7 @@
+import { request } from '../..';
+
+const getCommentById = async (commentId) => {
+ return await request.post(`/comments/${commentId}/detail`);
+};
+
+export default getCommentById;
diff --git a/src/common/request/api/comments/getCommentReplies.ts b/src/common/request/api/comments/getCommentReplies.ts
new file mode 100644
index 00000000..ca33e05a
--- /dev/null
+++ b/src/common/request/api/comments/getCommentReplies.ts
@@ -0,0 +1,13 @@
+import { request } from '../..';
+
+export interface GetCommentRepliesRequest {
+ root_id: number;
+ cur_comment_id: number;
+ limit: number;
+}
+
+const getCommentReplies = async (query: GetCommentRepliesRequest) => {
+ return await request.get('/comments/replies/list', { query });
+};
+
+export default getCommentReplies;
diff --git a/src/common/request/api/comments/getCommentsCount.ts b/src/common/request/api/comments/getCommentsCount.ts
new file mode 100644
index 00000000..cbb040d0
--- /dev/null
+++ b/src/common/request/api/comments/getCommentsCount.ts
@@ -0,0 +1,12 @@
+import { request } from '../..';
+
+export interface GetCommentsCountRequest {
+ biz: string;
+ biz_id: number;
+}
+
+const getCommentsCount = async (query: GetCommentsCountRequest) => {
+ return await request.get('/comments/count', { query });
+};
+
+export default getCommentsCount;
diff --git a/src/common/request/api/comments/getTopLevelComments.ts b/src/common/request/api/comments/getTopLevelComments.ts
new file mode 100644
index 00000000..ec612316
--- /dev/null
+++ b/src/common/request/api/comments/getTopLevelComments.ts
@@ -0,0 +1,14 @@
+import { request } from '../..';
+
+export interface GetCommentsListRequest {
+ biz: string;
+ biz_id: number;
+ cur_comment_id: number;
+ limit: number;
+}
+
+const getCommentsList = async (query: GetCommentsListRequest) => {
+ return await request.get('/comments/list', { query });
+};
+
+export default getCommentsList;
diff --git a/src/common/request/api/comments/index.ts b/src/common/request/api/comments/index.ts
new file mode 100644
index 00000000..1e79a0e3
--- /dev/null
+++ b/src/common/request/api/comments/index.ts
@@ -0,0 +1,5 @@
+export { default as getCommentById } from './getCommentById';
+export { default as getCommentReplies } from './getCommentReplies';
+export { default as getCommentsCount } from './getCommentsCount';
+export { default as getTopLevelComments } from './getTopLevelComments';
+export { default as publishComment } from './publishComment';
diff --git a/src/common/request/api/comments/publishComment.ts b/src/common/request/api/comments/publishComment.ts
new file mode 100644
index 00000000..f58709ed
--- /dev/null
+++ b/src/common/request/api/comments/publishComment.ts
@@ -0,0 +1,15 @@
+import { request } from '../..';
+
+export interface PublishCommentRequest {
+ biz: string;
+ biz_id: number;
+ content: string;
+ parent_id: number;
+ root_id: number;
+}
+
+const publishComment = async (body: PublishCommentRequest) => {
+ return await request.post('/comments/publish', body);
+};
+
+export default publishComment;
diff --git a/src/common/request/api/courses/collectCourses.ts b/src/common/request/api/courses/collectCourses.ts
new file mode 100644
index 00000000..825d2826
--- /dev/null
+++ b/src/common/request/api/courses/collectCourses.ts
@@ -0,0 +1,11 @@
+import { request } from '../..';
+
+export interface CollectCoursesRequest {
+ collect: boolean;
+}
+
+const collectCourses = async (courseId: string | null, body: CollectCoursesRequest) => {
+ return await request.post(`/courses/${courseId}/collect`, body);
+};
+
+export default collectCourses;
diff --git a/src/common/request/api/courses/getCollectionCount.ts b/src/common/request/api/courses/getCollectionCount.ts
new file mode 100644
index 00000000..dccfdb09
--- /dev/null
+++ b/src/common/request/api/courses/getCollectionCount.ts
@@ -0,0 +1,9 @@
+import { request } from '../..';
+
+export interface GetCollectionListRequest {}
+
+const getCollectionCount = async () => {
+ return await request.get('/courses/collections/count/mine');
+};
+
+export default getCollectionCount;
diff --git a/src/common/request/api/courses/getCollectionList.ts b/src/common/request/api/courses/getCollectionList.ts
new file mode 100644
index 00000000..9c5b2277
--- /dev/null
+++ b/src/common/request/api/courses/getCollectionList.ts
@@ -0,0 +1,12 @@
+import { request } from '../..';
+
+export interface GetCollectionListRequest {
+ cur_collection_id: number;
+ limit: number;
+}
+
+const getCollectionList = async (query: GetCollectionListRequest) => {
+ return await request.get('/courses/collections/list/mine', { query });
+};
+
+export default getCollectionList;
diff --git a/src/common/request/api/courses/getCourseDetail.ts b/src/common/request/api/courses/getCourseDetail.ts
new file mode 100644
index 00000000..a4700ef0
--- /dev/null
+++ b/src/common/request/api/courses/getCourseDetail.ts
@@ -0,0 +1,9 @@
+import { request } from '../..';
+
+export interface GetCourseDetail {}
+
+const getCourseDetail = async (courseId: number) => {
+ return await request.get(`/courses/${courseId}/detail`);
+};
+
+export default getCourseDetail;
diff --git a/src/common/request/api/courses/getUserCourses.ts b/src/common/request/api/courses/getUserCourses.ts
new file mode 100644
index 00000000..49ea5487
--- /dev/null
+++ b/src/common/request/api/courses/getUserCourses.ts
@@ -0,0 +1,12 @@
+import { request } from '../..';
+
+export interface GetUserCoursesRequest {
+ year: string;
+ term: string;
+}
+
+const getUserCourses = async (query: GetUserCoursesRequest) => {
+ return await request.get('/courses/list/mine', { query });
+};
+
+export default getUserCourses;
diff --git a/src/common/request/api/courses/index.ts b/src/common/request/api/courses/index.ts
new file mode 100644
index 00000000..4040a437
--- /dev/null
+++ b/src/common/request/api/courses/index.ts
@@ -0,0 +1,5 @@
+export { default as collectCourses } from './collectCourses';
+export { default as getCollectionCount } from './getCollectionCount';
+export { default as getCollectionList } from './getCollectionList';
+export { default as getCourseDetail } from './getCourseDetail';
+export { default as getUserCourses } from './getUserCourses';
diff --git a/src/common/request/api/evaluations/endorseEvaluation.ts b/src/common/request/api/evaluations/endorseEvaluation.ts
new file mode 100644
index 00000000..a7d98ab0
--- /dev/null
+++ b/src/common/request/api/evaluations/endorseEvaluation.ts
@@ -0,0 +1,14 @@
+import { request } from '../..';
+
+export interface EndorseEvaluationRequest {
+ stance: number;
+}
+
+const endorseEvaluation = async (
+ evaluationId: number,
+ body: EndorseEvaluationRequest
+) => {
+ return await request.post(`/evaluations/${evaluationId}/endorse`, body);
+};
+
+export default endorseEvaluation;
diff --git a/src/common/request/api/evaluations/getCourseEvaluations.ts b/src/common/request/api/evaluations/getCourseEvaluations.ts
new file mode 100644
index 00000000..f9640173
--- /dev/null
+++ b/src/common/request/api/evaluations/getCourseEvaluations.ts
@@ -0,0 +1,15 @@
+import { request } from '../..';
+
+export interface GetCourseEvaluationsRequest {
+ cur_evaluation_id: number;
+ limit: number;
+}
+
+const getCourseEvaluations = async (
+ courseId: number,
+ query: GetCourseEvaluationsRequest
+) => {
+ return await request.get(`/evaluations/list/courses/${courseId}`, { query });
+};
+
+export default getCourseEvaluations;
diff --git a/src/common/request/api/evaluations/getEvaluationDetail.ts b/src/common/request/api/evaluations/getEvaluationDetail.ts
new file mode 100644
index 00000000..296b0397
--- /dev/null
+++ b/src/common/request/api/evaluations/getEvaluationDetail.ts
@@ -0,0 +1,9 @@
+import { request } from '../..';
+
+export interface GetEvaluationDetailRequest {}
+
+const getEvaluationDetail = async (evaluationId: number) => {
+ return await request.get(`/evaluations/${evaluationId}/detail`);
+};
+
+export default getEvaluationDetail;
diff --git a/src/common/request/api/evaluations/getEvaluationHistory.ts b/src/common/request/api/evaluations/getEvaluationHistory.ts
new file mode 100644
index 00000000..f80191e3
--- /dev/null
+++ b/src/common/request/api/evaluations/getEvaluationHistory.ts
@@ -0,0 +1,13 @@
+import { request } from '../..';
+
+export interface EvaluationHistoryProps {
+ cur_evaluation_id?: number;
+ limit?: number;
+ status?: string;
+}
+
+const getEvaluationHistory = async (query: EvaluationHistoryProps) => {
+ return await request.get('/evaluations/list/mine', { query });
+};
+
+export default getEvaluationHistory;
diff --git a/src/common/request/api/evaluations/getEvaluationList.ts b/src/common/request/api/evaluations/getEvaluationList.ts
new file mode 100644
index 00000000..588d2b11
--- /dev/null
+++ b/src/common/request/api/evaluations/getEvaluationList.ts
@@ -0,0 +1,13 @@
+import { request } from '../..';
+
+export interface GetEvaluationListRequest {
+ cur_evaluation_id: number;
+ limit: number;
+ property: string;
+}
+
+const getEvaluationList = async (query: GetEvaluationListRequest) => {
+ return await request.get('/evaluations/list/all', { query });
+};
+
+export default getEvaluationList;
diff --git a/src/common/request/api/evaluations/getMyEvaluations.ts b/src/common/request/api/evaluations/getMyEvaluations.ts
new file mode 100644
index 00000000..609a869b
--- /dev/null
+++ b/src/common/request/api/evaluations/getMyEvaluations.ts
@@ -0,0 +1,13 @@
+import { request } from '../..';
+
+export interface GetMyEvaluationsRequest {
+ cur_evaluation_id: number;
+ limit: number;
+ status: string;
+}
+
+const getMyEvaluations = async (query: GetMyEvaluationsRequest) => {
+ return await request.get('/evaluations/list/mine', { query });
+};
+
+export default getMyEvaluations;
diff --git a/src/common/request/api/evaluations/index.ts b/src/common/request/api/evaluations/index.ts
new file mode 100644
index 00000000..58e5171b
--- /dev/null
+++ b/src/common/request/api/evaluations/index.ts
@@ -0,0 +1,9 @@
+export { default as endorseEvaluation } from './endorseEvaluation';
+export { default as getCourseEvaluations } from './getCourseEvaluations';
+export { default as getEvaluationDetail } from './getEvaluationDetail';
+export { default as getEvaluationHistory } from './getEvaluationHistory';
+export { default as getEvaluationList } from './getEvaluationList';
+export { default as getMyEvaluations } from './getMyEvaluations';
+export { default as publishEvaluation } from './publishEvaluation';
+export { default as toggleEvaluationStatus } from './toggleEvaluationStatus';
+export type { EvaluationStatus } from './toggleEvaluationStatus';
diff --git a/src/common/request/api/evaluations/publishEvaluation.ts b/src/common/request/api/evaluations/publishEvaluation.ts
new file mode 100644
index 00000000..69caa18d
--- /dev/null
+++ b/src/common/request/api/evaluations/publishEvaluation.ts
@@ -0,0 +1,18 @@
+import { request } from '../..';
+
+export interface PublishEvaluationRequest {
+ assessments?: string[];
+ content?: string;
+ course_id?: number;
+ features?: string[];
+ id?: number;
+ is_anonymous?: boolean;
+ star_rating?: number;
+ status?: 'Public' | 'Private';
+}
+
+const publishEvaluation = async (body: PublishEvaluationRequest) => {
+ return await request.post('/evaluations/save', body);
+};
+
+export default publishEvaluation;
diff --git a/src/common/request/api/evaluations/toggleEvaluationStatus.ts b/src/common/request/api/evaluations/toggleEvaluationStatus.ts
new file mode 100644
index 00000000..fcf5fe5c
--- /dev/null
+++ b/src/common/request/api/evaluations/toggleEvaluationStatus.ts
@@ -0,0 +1,16 @@
+import { request } from '../..';
+
+export type EvaluationStatus = 'Public' | 'Private' | 'Folded';
+
+export interface ToggleEvaluationStatusRequest {
+ status: EvaluationStatus;
+}
+
+const toggleEvaluationStatus = async (
+ evaluationId: number,
+ body: ToggleEvaluationStatusRequest
+) => {
+ return await request.post(`/evaluations/${evaluationId}/status`, body);
+};
+
+export default toggleEvaluationStatus;
diff --git a/src/common/request/api/feed/getFeeds.ts b/src/common/request/api/feed/getFeeds.ts
new file mode 100644
index 00000000..6d4dbd0e
--- /dev/null
+++ b/src/common/request/api/feed/getFeeds.ts
@@ -0,0 +1,13 @@
+import { request } from '../..';
+
+export interface GetFeedsRequest {
+ last_time: number;
+ direction: 'Before' | 'After';
+ limit: number;
+}
+
+const getFeeds = async (query: GetFeedsRequest) => {
+ return await request.get('/feed/events_list', { query });
+};
+
+export default getFeeds;
diff --git a/src/common/request/api/feed/index.ts b/src/common/request/api/feed/index.ts
new file mode 100644
index 00000000..69ac52df
--- /dev/null
+++ b/src/common/request/api/feed/index.ts
@@ -0,0 +1 @@
+export { default as getFeeds } from './getFeeds';
diff --git a/src/common/request/api/feedback/config.ts b/src/common/request/api/feedback/config.ts
new file mode 100644
index 00000000..e164e356
--- /dev/null
+++ b/src/common/request/api/feedback/config.ts
@@ -0,0 +1,95 @@
+import Taro from '@tarojs/taro';
+
+import { FAQ_TABLE_IDENTIFY, FEEDBACK_TABLE_IDENTIFY } from '@/common/constants/feedback';
+
+import { FEEDBACK_BASE_URL, FEEDBACK_DEV_BASE_URL } from '../../constants';
+import getFeedbackToken from './getFeedbackToken';
+import getFeishuToken from './getFeishuToken';
+
+const ISDEV = process.env.NODE_ENV === 'development';
+
+// export const BASE_URL = FEEDBACK_BASE_URL;
+export const BASE_URL = ISDEV ? FEEDBACK_DEV_BASE_URL : FEEDBACK_BASE_URL;
+
+export const FIXED_CONFIG = {
+ parentType: 'bitable_image',
+ // parentNode: 'LC8aboXkCaAJaksSACOc9OS5nHf',
+ parentNode: ISDEV ? 'N4TcbHEPgaCvAIsrUspciX13nq8' : 'LC8aboXkCaAJaksSACOc9OS5nHf',
+};
+
+interface GetAndSetTokenProps {
+ tokenKey: string;
+ fetchToken: () => Promise;
+ getToken: (res: T) => string;
+ errorMessage: string;
+}
+
+const setItem = async (key: string, value: string): Promise => {
+ try {
+ await Taro.setStorage({
+ key,
+ data: value,
+ });
+ } catch (error) {
+ console.error(`存储 ${key} 失败:`, error);
+ throw error;
+ }
+};
+
+const getAndSetToken = async ({
+ tokenKey,
+ fetchToken,
+ getToken,
+ errorMessage,
+}: GetAndSetTokenProps): Promise => {
+ const res = await fetchToken();
+
+ const token = getToken(res);
+
+ if (!token) {
+ throw new Error(errorMessage);
+ }
+
+ await setItem(tokenKey, token);
+
+ return token;
+};
+/*
+ 这里对于飞书的上传token单独处理,不通过setItem存储,从而使得每次都通过refresh重新获取token,
+ 因为飞书token过期返回400,不会走拦截器401的自动刷新,而且没必要为了这个修改拦截逻辑,所以先这样了
+*/
+export const FeishuUploadTokenConfig = {
+ name: 'FeishuUploadToken',
+ refresh: async () => {
+ const res = await getFeishuToken();
+ const token = res?.data?.access_token;
+
+ if (!token) {
+ throw new Error('获取飞书上传 token 失败');
+ }
+
+ return token;
+ },
+};
+
+export const UserSheetTokenConfig = {
+ name: 'UserSheetToken',
+ refresh: () =>
+ getAndSetToken({
+ tokenKey: 'UserSheetToken',
+ fetchToken: () => getFeedbackToken({ table_identify: FEEDBACK_TABLE_IDENTIFY }),
+ getToken: (res) => res?.data?.access_token,
+ errorMessage: '获取用户反馈表token失败',
+ }),
+};
+
+export const FAQTokenConfig = {
+ name: 'FAQToken',
+ refresh: () =>
+ getAndSetToken({
+ tokenKey: 'FAQToken',
+ fetchToken: () => getFeedbackToken({ table_identify: FAQ_TABLE_IDENTIFY }),
+ getToken: (res) => res?.data?.access_token,
+ errorMessage: '获取FAQToken失败',
+ }),
+};
diff --git a/src/common/request/api/feedback/createFeedbackRecord.ts b/src/common/request/api/feedback/createFeedbackRecord.ts
new file mode 100644
index 00000000..7bfea753
--- /dev/null
+++ b/src/common/request/api/feedback/createFeedbackRecord.ts
@@ -0,0 +1,24 @@
+import { request } from '../..';
+import { BASE_URL, UserSheetTokenConfig } from './config';
+
+type RecordValue = number | string | boolean;
+
+export interface CreateFeedbackRecordRequest {
+ table_identify: string;
+ content: string;
+ student_id: string;
+ contact_info?: string;
+ images?: (string | undefined | null)[];
+ extra_record?: {
+ [key: string]: RecordValue;
+ };
+}
+
+const createFeedbackRecord = async (body: CreateFeedbackRecordRequest) => {
+ return await request.post('/api/v2/sheet/records', body, {
+ tokenConfig: UserSheetTokenConfig,
+ baseUrl: BASE_URL,
+ });
+};
+
+export default createFeedbackRecord;
diff --git a/src/common/request/api/feedback/feedbackFAQ.ts b/src/common/request/api/feedback/feedbackFAQ.ts
new file mode 100644
index 00000000..9524720a
--- /dev/null
+++ b/src/common/request/api/feedback/feedbackFAQ.ts
@@ -0,0 +1,20 @@
+import { request } from '../..';
+import { BASE_URL, FAQTokenConfig } from './config';
+
+export interface FeedbackFAQRequest {
+ table_identify: string;
+ is_resolved: boolean;
+ record_id: string;
+ resolved_field_name: string;
+ unresolved_field_name: string;
+ user_id: string;
+}
+
+const feedbackFAQ = async (body: FeedbackFAQRequest) => {
+ return await request.post('/api/v2/sheet/records/faq', body, {
+ tokenConfig: FAQTokenConfig,
+ baseUrl: BASE_URL,
+ });
+};
+
+export default feedbackFAQ;
diff --git a/src/common/request/api/feedback/getFAQ.ts b/src/common/request/api/feedback/getFAQ.ts
new file mode 100644
index 00000000..9d03da7f
--- /dev/null
+++ b/src/common/request/api/feedback/getFAQ.ts
@@ -0,0 +1,16 @@
+import { request } from '../..';
+import { BASE_URL, FAQTokenConfig } from './config';
+
+export interface GetFAQRequest {
+ table_identify?: string;
+ student_id: string;
+}
+
+const getFAQ = async (query: GetFAQRequest) => {
+ return await request.get('/api/v2/sheet/records/faq', { query } as any, {
+ tokenConfig: FAQTokenConfig,
+ baseUrl: BASE_URL,
+ });
+};
+
+export default getFAQ;
diff --git a/src/common/request/api/feedback/getFeedbackImg.ts b/src/common/request/api/feedback/getFeedbackImg.ts
new file mode 100644
index 00000000..995120a7
--- /dev/null
+++ b/src/common/request/api/feedback/getFeedbackImg.ts
@@ -0,0 +1,15 @@
+import { request } from '../..';
+import { BASE_URL, UserSheetTokenConfig } from './config';
+
+export interface GetFeedbackImgRequest {
+ file_tokens: string[];
+}
+
+const getFeedbackImg = async (query: GetFeedbackImgRequest) => {
+ return await request.get('/api/v1/sheet/photos/url', { query } as any, {
+ tokenConfig: UserSheetTokenConfig,
+ baseUrl: BASE_URL,
+ });
+};
+
+export default getFeedbackImg;
diff --git a/src/common/request/api/feedback/getFeedbackToken.ts b/src/common/request/api/feedback/getFeedbackToken.ts
new file mode 100644
index 00000000..5072b607
--- /dev/null
+++ b/src/common/request/api/feedback/getFeedbackToken.ts
@@ -0,0 +1,15 @@
+import { request } from '../..';
+import { BASE_URL } from './config';
+
+export interface GetFeedbackTokenRequest {
+ table_identify: string;
+}
+
+const getFeedbackToken = async (tableId: GetFeedbackTokenRequest) => {
+ return await request.post('/api/v1/auth/table-config/token', tableId, {
+ withToken: false,
+ baseUrl: BASE_URL,
+ });
+};
+
+export default getFeedbackToken;
diff --git a/src/common/request/api/feedback/getFeishuToken.ts b/src/common/request/api/feedback/getFeishuToken.ts
new file mode 100644
index 00000000..9eed2385
--- /dev/null
+++ b/src/common/request/api/feedback/getFeishuToken.ts
@@ -0,0 +1,11 @@
+import { request } from '../..';
+import { BASE_URL } from './config';
+
+const getFeishuToken = async () => {
+ return await request.post('/api/v1/auth/tenant/token', undefined, {
+ withToken: false,
+ baseUrl: BASE_URL,
+ });
+};
+
+export default getFeishuToken;
diff --git a/src/common/request/api/feedback/index.ts b/src/common/request/api/feedback/index.ts
new file mode 100644
index 00000000..3e70a2df
--- /dev/null
+++ b/src/common/request/api/feedback/index.ts
@@ -0,0 +1,6 @@
+export { default as createFeedbackRecord } from './createFeedbackRecord';
+export { default as feedbackFAQ } from './feedbackFAQ';
+export { default as getFAQ } from './getFAQ';
+export { default as getFeedbackImg } from './getFeedbackImg';
+export { default as queryUserFeedbackSheet } from './queryUserFeedbackSheet';
+export { uploadFileToFeishuBitable } from './uploadFeishu';
diff --git a/src/common/request/api/feedback/queryUserFeedbackSheet.ts b/src/common/request/api/feedback/queryUserFeedbackSheet.ts
new file mode 100644
index 00000000..a7454470
--- /dev/null
+++ b/src/common/request/api/feedback/queryUserFeedbackSheet.ts
@@ -0,0 +1,24 @@
+import { request } from '../..';
+import { BASE_URL, UserSheetTokenConfig } from './config';
+
+export interface GetUserFeedbackSheetRequest {
+ student_id: string;
+ table_identify: string;
+ page_token?: string;
+ limit_size?: number;
+}
+
+const getUserFeedbackSheet = async (query: GetUserFeedbackSheetRequest) => {
+ return await request.get(
+ '/api/v2/sheet/records',
+ {
+ query,
+ } as any,
+ {
+ tokenConfig: UserSheetTokenConfig,
+ baseUrl: BASE_URL,
+ }
+ );
+};
+
+export default getUserFeedbackSheet;
diff --git a/src/common/request/api/feedback/uploadFeishu.ts b/src/common/request/api/feedback/uploadFeishu.ts
new file mode 100644
index 00000000..edce2c52
--- /dev/null
+++ b/src/common/request/api/feedback/uploadFeishu.ts
@@ -0,0 +1,52 @@
+import { calculateAdler32, getFileInfo } from '@/common/utils';
+
+import { request } from '../..';
+import { FeishuUploadTokenConfig, FIXED_CONFIG } from './config';
+
+export interface UploadResult {
+ code: number;
+ msg: string;
+ data?: {
+ file_token: string;
+ [key: string]: any;
+ };
+}
+
+export async function uploadFileToFeishuBitable(
+ filePath: string,
+ fileName: string
+): Promise {
+ try {
+ const fileInfo = await getFileInfo(filePath);
+
+ const checkSum = calculateAdler32(fileInfo.arrayBuffer);
+
+ const res = (await request.post(
+ 'https://open.feishu.cn/open-apis/drive/v1/medias/upload_all',
+ {
+ file_name: fileName,
+ parent_type: FIXED_CONFIG.parentType,
+ parent_node: FIXED_CONFIG.parentNode,
+ size: fileInfo.size.toString(),
+ checksum: checkSum,
+ },
+ {
+ upload: {
+ filePath,
+ fileFieldName: 'file',
+ },
+ tokenConfig: FeishuUploadTokenConfig,
+ }
+ )) as UploadResult;
+
+ return res;
+ } catch (error: any) {
+ console.error('飞书上传失败:', {
+ message: error.message,
+ fileName,
+ error,
+ });
+
+ throw error;
+ }
+}
diff --git a/src/common/request/api/grade/getCourseGrades.ts b/src/common/request/api/grade/getCourseGrades.ts
new file mode 100644
index 00000000..3e52eae5
--- /dev/null
+++ b/src/common/request/api/grade/getCourseGrades.ts
@@ -0,0 +1,9 @@
+import { request } from '../..';
+
+export interface GetCourseGrades {}
+
+const getCourseGrades = async (courseId: number) => {
+ return await request.get(`/grades/courses/${courseId}`);
+};
+
+export default getCourseGrades;
diff --git a/src/common/request/api/grade/index.ts b/src/common/request/api/grade/index.ts
new file mode 100644
index 00000000..cdb09779
--- /dev/null
+++ b/src/common/request/api/grade/index.ts
@@ -0,0 +1,2 @@
+export { default as getCourseGrades } from './getCourseGrades';
+export { default as signGradeSharing } from './signGradeSharing';
diff --git a/src/common/request/api/grade/signGradeSharing.ts b/src/common/request/api/grade/signGradeSharing.ts
new file mode 100644
index 00000000..067b2779
--- /dev/null
+++ b/src/common/request/api/grade/signGradeSharing.ts
@@ -0,0 +1,11 @@
+import { request } from '../..';
+
+export interface signGradeSharingRequest {
+ wants_to_sign: true;
+}
+
+const signGradeSharing = async (body: signGradeSharingRequest) => {
+ return await request.post('/grades/sign', body);
+};
+
+export default signGradeSharing;
diff --git a/src/common/request/api/integral/getUserIntegral.ts b/src/common/request/api/integral/getUserIntegral.ts
new file mode 100644
index 00000000..c4d80811
--- /dev/null
+++ b/src/common/request/api/integral/getUserIntegral.ts
@@ -0,0 +1,9 @@
+import { request } from '../..';
+
+export interface GetUserIntegral {}
+
+const getUserIntegral = async () => {
+ return await request.get(`/points/users/mine`);
+};
+
+export default getUserIntegral;
diff --git a/src/common/request/api/integral/index.ts b/src/common/request/api/integral/index.ts
new file mode 100644
index 00000000..379d3d4c
--- /dev/null
+++ b/src/common/request/api/integral/index.ts
@@ -0,0 +1 @@
+export { default as getUserIntegral } from './getUserIntegral';
diff --git a/src/common/request/api/qiniu/getAccessToken.ts b/src/common/request/api/qiniu/getAccessToken.ts
new file mode 100644
index 00000000..016936ea
--- /dev/null
+++ b/src/common/request/api/qiniu/getAccessToken.ts
@@ -0,0 +1,9 @@
+import { request } from '../..';
+
+export interface GetAccessTokenRequest {}
+
+const getAccessToken = async () => {
+ return await request.get('/tube/access_token');
+};
+
+export default getAccessToken;
diff --git a/src/common/request/api/qiniu/index.ts b/src/common/request/api/qiniu/index.ts
new file mode 100644
index 00000000..26a72c83
--- /dev/null
+++ b/src/common/request/api/qiniu/index.ts
@@ -0,0 +1 @@
+export { default as getAccessToken } from './getAccessToken';
diff --git a/src/common/request/api/questions/getQuestionDetail.ts b/src/common/request/api/questions/getQuestionDetail.ts
new file mode 100644
index 00000000..45ed55ce
--- /dev/null
+++ b/src/common/request/api/questions/getQuestionDetail.ts
@@ -0,0 +1,9 @@
+import { request } from '../..';
+
+export interface GetQuestionDetailRequest {}
+
+const getQuestionDetail = async (questionId: number) => {
+ return await request.get(`/questions/${questionId}/detail`);
+};
+
+export default getQuestionDetail;
diff --git a/src/common/request/api/questions/getQuestionsList.ts b/src/common/request/api/questions/getQuestionsList.ts
new file mode 100644
index 00000000..d6033887
--- /dev/null
+++ b/src/common/request/api/questions/getQuestionsList.ts
@@ -0,0 +1,14 @@
+import { request } from '../..';
+
+export interface GetQuestionListRequest {
+ biz: string;
+ biz_id: number;
+ cur_question_id: number;
+ limit: number;
+}
+
+const getQuestionsList = async (query: GetQuestionListRequest) => {
+ return await request.get('/questions/list', { query });
+};
+
+export default getQuestionsList;
diff --git a/src/common/request/api/questions/index.ts b/src/common/request/api/questions/index.ts
new file mode 100644
index 00000000..43cd36f3
--- /dev/null
+++ b/src/common/request/api/questions/index.ts
@@ -0,0 +1,3 @@
+export { default as getQuestionDetail } from './getQuestionDetail';
+export { default as getQuestionsList } from './getQuestionsList';
+export { default as publishQuestion } from './publishQuestion';
diff --git a/src/common/request/api/questions/publishQuestion.ts b/src/common/request/api/questions/publishQuestion.ts
new file mode 100644
index 00000000..6fb7eb20
--- /dev/null
+++ b/src/common/request/api/questions/publishQuestion.ts
@@ -0,0 +1,13 @@
+import { request } from '../..';
+
+export interface PublishQuestionRequest {
+ biz: string;
+ biz_id: number;
+ content: string;
+}
+
+const publishQuestion = async (body: PublishQuestionRequest) => {
+ return await request.post('/questions/publish', body);
+};
+
+export default publishQuestion;
diff --git a/src/common/request/api/research/deleteSearchHistory.ts b/src/common/request/api/research/deleteSearchHistory.ts
new file mode 100644
index 00000000..3fbf0f8a
--- /dev/null
+++ b/src/common/request/api/research/deleteSearchHistory.ts
@@ -0,0 +1,13 @@
+import { request } from '../..';
+
+export interface DeleteSearchHistoryRequest {
+ remove_all: boolean;
+ remove_history_ids: number[];
+ search_location: string;
+}
+
+const deleteSearchHistory = async (body: DeleteSearchHistoryRequest) => {
+ return await request.put('/search/history', body);
+};
+
+export default deleteSearchHistory;
diff --git a/src/common/request/api/research/getSearchHistory.ts b/src/common/request/api/research/getSearchHistory.ts
new file mode 100644
index 00000000..aa0c8db7
--- /dev/null
+++ b/src/common/request/api/research/getSearchHistory.ts
@@ -0,0 +1,11 @@
+import { request } from '../..';
+
+export interface GetSearchHistoryRequest {
+ search_location: string;
+}
+
+const getSearchHistory = async (query: GetSearchHistoryRequest) => {
+ return await request.get('/search/history', { query });
+};
+
+export default getSearchHistory;
diff --git a/src/common/request/api/research/index.ts b/src/common/request/api/research/index.ts
new file mode 100644
index 00000000..f93e8d4d
--- /dev/null
+++ b/src/common/request/api/research/index.ts
@@ -0,0 +1,3 @@
+export { default as deleteSearchHistory } from './deleteSearchHistory';
+export { default as getSearchHistory } from './getSearchHistory';
+export { default as searchCourses } from './searchCourses';
diff --git a/src/common/request/api/research/searchCourses.ts b/src/common/request/api/research/searchCourses.ts
new file mode 100644
index 00000000..10a55a46
--- /dev/null
+++ b/src/common/request/api/research/searchCourses.ts
@@ -0,0 +1,15 @@
+import { request } from '../..';
+
+export interface SearchCoursesRequest {
+ biz: string;
+ keyword: string;
+ search_location: string;
+ id?: number;
+ score?: number;
+}
+
+const searchCourses = async (query: SearchCoursesRequest) => {
+ return await request.get('/search', { query });
+};
+
+export default searchCourses;
diff --git a/src/common/request/api/statics/getMatchLabels.ts b/src/common/request/api/statics/getMatchLabels.ts
new file mode 100644
index 00000000..2b464d60
--- /dev/null
+++ b/src/common/request/api/statics/getMatchLabels.ts
@@ -0,0 +1,11 @@
+import { request } from '../..';
+
+export interface GetMatchLabelsRequest {
+ [key: string]: string;
+}
+
+const getMatchLabels = async (query: GetMatchLabelsRequest) => {
+ return await request.get('/statics/match/labels', { query });
+};
+
+export default getMatchLabels;
diff --git a/src/common/request/api/statics/index.ts b/src/common/request/api/statics/index.ts
new file mode 100644
index 00000000..b4e97bcf
--- /dev/null
+++ b/src/common/request/api/statics/index.ts
@@ -0,0 +1 @@
+export { default as getMatchLabels } from './getMatchLabels';
diff --git a/src/common/request/api/status/checkStatus.ts b/src/common/request/api/status/checkStatus.ts
new file mode 100644
index 00000000..483bfc45
--- /dev/null
+++ b/src/common/request/api/status/checkStatus.ts
@@ -0,0 +1,17 @@
+import { request } from '../..';
+
+const MINIPROGRAMS_BASE_URL = 'https://miniprograms.muxixyz.com';
+
+// 用来逃避审核的,搭配相应的hook和组件使用
+const checkStatus = async (): Promise<{ status: boolean }> => {
+ const res = await request.post(
+ '/checkStatus',
+ { name: 'kestack' },
+ { baseUrl: MINIPROGRAMS_BASE_URL, withToken: false }
+ );
+
+ const data = res as { status: boolean };
+ return { status: typeof data.status === 'boolean' ? data.status : false };
+};
+
+export default checkStatus;
diff --git a/src/common/request/api/status/index.ts b/src/common/request/api/status/index.ts
new file mode 100644
index 00000000..c209be8b
--- /dev/null
+++ b/src/common/request/api/status/index.ts
@@ -0,0 +1 @@
+export { default as checkStatus } from './checkStatus';
diff --git a/src/common/request/api/user/editProfile.ts b/src/common/request/api/user/editProfile.ts
new file mode 100644
index 00000000..b15c7921
--- /dev/null
+++ b/src/common/request/api/user/editProfile.ts
@@ -0,0 +1,13 @@
+import { request } from '../..';
+
+export interface EditProfileRequest {
+ avatar?: string;
+ nickname?: string;
+ using_title?: string;
+}
+
+const editProfile = async (body: EditProfileRequest) => {
+ return await request.post('/users/edit', body);
+};
+
+export default editProfile;
diff --git a/src/common/request/api/user/getProfile.ts b/src/common/request/api/user/getProfile.ts
new file mode 100644
index 00000000..55316289
--- /dev/null
+++ b/src/common/request/api/user/getProfile.ts
@@ -0,0 +1,9 @@
+import { request } from '../..';
+
+export interface GetProfileRequest {}
+
+const getProfile = async () => {
+ return await request.get('/users/profile');
+};
+
+export default getProfile;
diff --git a/src/common/request/api/user/getUserProfile.ts b/src/common/request/api/user/getUserProfile.ts
new file mode 100644
index 00000000..324c796a
--- /dev/null
+++ b/src/common/request/api/user/getUserProfile.ts
@@ -0,0 +1,9 @@
+import { request } from '../..';
+
+export interface GetUserProfileRequest {}
+
+const getUserProfile = async (userId: number) => {
+ return await request.get(`/users/${userId}/profile`);
+};
+
+export default getUserProfile;
diff --git a/src/common/request/api/user/index.ts b/src/common/request/api/user/index.ts
new file mode 100644
index 00000000..d0647a8b
--- /dev/null
+++ b/src/common/request/api/user/index.ts
@@ -0,0 +1,5 @@
+export { default as editProfile } from './editProfile';
+export { default as getProfile } from './getProfile';
+export { default as getUserProfile } from './getUserProfile';
+export { default as userLogin } from './userLogin';
+export { default as userLogout } from './userLogout';
diff --git a/src/common/request/api/user/userLogin.ts b/src/common/request/api/user/userLogin.ts
new file mode 100644
index 00000000..c34e2cce
--- /dev/null
+++ b/src/common/request/api/user/userLogin.ts
@@ -0,0 +1,15 @@
+import { request } from '../..';
+
+export interface UserLoginRequest {
+ password: string;
+ student_id: string;
+}
+
+const userLogin = async (body: UserLoginRequest) => {
+ return await request.post('/users/login_ccnu', body, {
+ withToken: false,
+ returnFullResponse: true,
+ });
+};
+
+export default userLogin;
diff --git a/src/common/request/api/user/userLogout.ts b/src/common/request/api/user/userLogout.ts
new file mode 100644
index 00000000..917a87f5
--- /dev/null
+++ b/src/common/request/api/user/userLogout.ts
@@ -0,0 +1,7 @@
+import { request } from '../..';
+
+const userLogout = async () => {
+ return await request.post('/users/logout');
+};
+
+export default userLogout;
diff --git a/src/common/request/auth/interceptor.ts b/src/common/request/auth/interceptor.ts
new file mode 100644
index 00000000..e33f80eb
--- /dev/null
+++ b/src/common/request/auth/interceptor.ts
@@ -0,0 +1,203 @@
+import Taro from '@tarojs/taro';
+
+import { InterceptorContext, RequestConfig } from '@/common/types/requestType';
+import { isVisitorMode } from '@/common/utils/isVisitor';
+
+import { AuthError } from '../errors/AuthError';
+import { BusinessError } from '../errors/BusinessError';
+import { ClientError } from '../errors/ClientError';
+import { getErrorMeta } from '../errors/errorCodeMap';
+import { NetworkError } from '../errors/NetworkError';
+import { ServerError } from '../errors/ServerError';
+import { refreshToken, tryGetStoredToken } from './token';
+
+export async function requestInterceptors(config?: RequestConfig) {
+ const cfg = config ?? ({} as RequestConfig);
+ if (cfg.withToken === false) return cfg;
+
+ const token = await tryGetStoredToken(cfg.tokenConfig);
+
+ if (token) {
+ cfg.header = cfg.header || {};
+ cfg.header['Authorization'] = `Bearer ${token.trim()}`;
+ }
+
+ return cfg;
+}
+
+function requestHadAuthorization(
+ requestConfig: Taro.request.Option | Taro.uploadFile.Option
+): boolean {
+ const header = requestConfig.header as Record | undefined;
+ const auth = header?.Authorization ?? header?.authorization;
+ return typeof auth === 'string' && auth.trim().length > 0;
+}
+
+export async function responseInterceptors(
+ context: InterceptorContext,
+ config?: RequestConfig
+): Promise {
+ const { response } = context;
+
+ switch (response.statusCode) {
+ case 200:
+ case 201:
+ case 204:
+ if (config?.returnFullResponse) return response;
+ return unwrapResponse(response);
+
+ case 401:
+ return await handleTokenRefresh(context);
+
+ case 403:
+ throw new AuthError(403);
+
+ case 500:
+ case 502:
+ case 503:
+ throw new ServerError(response.statusCode);
+
+ default: {
+ if (response.statusCode >= 400 && response.statusCode < 500) {
+ const body = response.data as { msg?: string; message?: string } | undefined;
+ const msg = body?.msg || body?.message || `请求错误 (${response.statusCode})`;
+ // 非 401/403 的 4xx 是普通客户端错误,不是登录态问题,不能抛 AuthError 触发登出
+ throw new ClientError(response.statusCode, msg);
+ }
+ if (response.statusCode >= 500) {
+ throw new ServerError(response.statusCode);
+ }
+ throw new NetworkError();
+ }
+ }
+}
+
+function unwrapResponse(response: Taro.request.SuccessCallbackResult): unknown {
+ const body = response.data as
+ | { code?: number; data?: unknown; msg?: string }
+ | undefined;
+
+ if (!body || typeof body.code !== 'number') {
+ return response.data;
+ }
+
+ if (body.code === 0) {
+ const data = body.data !== undefined ? body.data : body;
+ if (data !== null && typeof data === 'object') {
+ try {
+ Object.defineProperty(data, 'data', {
+ value: data,
+ enumerable: false,
+ writable: false,
+ configurable: false,
+ });
+ } catch {
+ //
+ }
+ }
+ return data;
+ }
+
+ const meta = getErrorMeta(body.code);
+ const message = body.msg || meta.msg;
+ throw new BusinessError(body.code, message, body.data);
+}
+
+// 刷新 token 后重发请求:上传请求要走 Taro.uploadFile,普通请求走 Taro.request,
+// 否则用 Taro.request 重发上传请求会丢掉 filePath 导致重试无效
+async function retryWithToken(
+ requestConfig: Taro.request.Option | Taro.uploadFile.Option,
+ newToken: string
+): Promise {
+ const authHeader = {
+ ...requestConfig.header,
+ Authorization: `Bearer ${newToken}`,
+ };
+
+ if ('filePath' in requestConfig && requestConfig.filePath) {
+ const uploaded = await Taro.uploadFile({
+ ...(requestConfig as Taro.uploadFile.Option),
+ header: authHeader,
+ });
+ let parsedData: unknown;
+ try {
+ parsedData = JSON.parse(uploaded.data);
+ } catch {
+ parsedData = uploaded.data;
+ }
+ return {
+ data: parsedData,
+ statusCode: uploaded.statusCode,
+ header: {},
+ cookies: [],
+ errMsg: uploaded.errMsg,
+ } as Taro.request.SuccessCallbackResult;
+ }
+
+ return await Taro.request({
+ ...(requestConfig as Taro.request.Option),
+ header: authHeader,
+ });
+}
+
+async function handleTokenRefresh(context: InterceptorContext): Promise {
+ const { config, requestConfig } = context;
+
+ // 带 tokenConfig 的请求属于独立 token 域(如反馈表/飞书),
+ // 其 401 不应被当作主登录过期,统一标记为 resource 域,交由 authHandler 跳过登出
+ const tokenConfig = config.tokenConfig;
+ const isResourceScope = !!tokenConfig;
+ const scopeContext = isResourceScope ? { meta: { scope: 'resource' } } : undefined;
+
+ if (config.withToken === false) {
+ throw new AuthError(401, '请求不需要token', scopeContext);
+ }
+
+ if (!requestHadAuthorization(requestConfig) || isVisitorMode()) {
+ throw new AuthError(401, '请先登录', scopeContext);
+ }
+
+ const maxRetry = tokenConfig?.maxRetry || 1;
+
+ for (let retryCount = 0; retryCount < maxRetry; retryCount++) {
+ try {
+ const newToken = await refreshToken(tokenConfig);
+ tokenConfig?.onRefreshSuccess?.(newToken);
+
+ const retryResponse = await retryWithToken(requestConfig, newToken);
+
+ if (
+ retryResponse.statusCode === 200 ||
+ retryResponse.statusCode === 201 ||
+ retryResponse.statusCode === 204
+ ) {
+ if (config?.returnFullResponse) return retryResponse;
+ return unwrapResponse(retryResponse);
+ } else if (retryResponse.statusCode === 401) {
+ continue;
+ } else {
+ throw new ServerError(
+ retryResponse.statusCode,
+ `刷新后请求失败: ${retryResponse.statusCode}`
+ );
+ }
+ } catch (refreshError: unknown) {
+ if (refreshError instanceof AuthError || refreshError instanceof BusinessError) {
+ throw refreshError;
+ }
+
+ console.error(
+ `第${retryCount + 1}次 token 刷新失败:`,
+ (refreshError as Error)?.message
+ );
+
+ const isLastAttempt = retryCount === maxRetry - 1;
+ if (isLastAttempt) {
+ tokenConfig?.onRefreshError?.(refreshError as Error);
+ throw new AuthError(401, '登录已过期,请重新登录', scopeContext);
+ }
+ }
+ }
+
+ throw new AuthError(401, '登录已过期,请重新登录', scopeContext);
+}
diff --git a/src/common/request/auth/token.ts b/src/common/request/auth/token.ts
new file mode 100644
index 00000000..9a21a139
--- /dev/null
+++ b/src/common/request/auth/token.ts
@@ -0,0 +1,92 @@
+import Taro from '@tarojs/taro';
+
+import { readResponseHeader } from '@/common/auth/session';
+import { LONG_TOKEN, SHORT_TOKEN } from '@/common/constants/auth';
+import { TokenConfig } from '@/common/types/requestType';
+
+import { BASE_URL } from '../constants';
+
+function isValidToken(token: string | undefined): boolean {
+ return !!token && token.trim().length > 0;
+}
+
+let pendingMainRefresh: Promise | null = null;
+
+// 有有效 token 则返回,否则返回 null(不抛错,因为可能是游客请求)
+export async function tryGetStoredToken(config?: TokenConfig): Promise {
+ try {
+ const token = await getStoredToken(config);
+ return isValidToken(token) ? token : null;
+ } catch {
+ return null;
+ }
+}
+
+export async function getStoredToken(config?: TokenConfig): Promise {
+ if (!config) {
+ const shortToken = Taro.getStorageSync(SHORT_TOKEN);
+ if (isValidToken(shortToken)) {
+ return shortToken;
+ }
+ return await refreshToken();
+ }
+
+ if (config.token) return config.token;
+
+ const cached = Taro.getStorageSync(config.name);
+ if (isValidToken(cached)) {
+ config.onRefreshSuccess?.(cached);
+ return cached;
+ }
+
+ return await refreshToken(config);
+}
+
+export async function refreshToken(config?: TokenConfig): Promise {
+ try {
+ if (!config) {
+ if (pendingMainRefresh) return pendingMainRefresh;
+
+ pendingMainRefresh = refreshMainShortToken().finally(() => {
+ pendingMainRefresh = null;
+ });
+ return pendingMainRefresh;
+ }
+
+ if (config.refresh) {
+ return await config.refresh();
+ }
+
+ throw new Error(`${config.name} 未配置 refresh`);
+ } catch (err) {
+ throw new Error(`刷新token失败: ${err}`);
+ }
+}
+
+async function refreshMainShortToken(): Promise {
+ const longToken = Taro.getStorageSync(LONG_TOKEN);
+ if (!isValidToken(longToken)) {
+ throw new Error('未登录或游客模式,无法刷新token');
+ }
+
+ const response = await Taro.request({
+ method: 'GET',
+ url: `${BASE_URL}/users/refresh_token`,
+ header: { Authorization: `Bearer ${longToken}` },
+ });
+
+ if (response.statusCode === 200 || response.statusCode === 201) {
+ const newShortToken = readResponseHeader(
+ response.header as Record,
+ 'x-jwt-token'
+ );
+ if (!isValidToken(newShortToken)) {
+ throw new Error('刷新短 token 失败');
+ }
+ Taro.setStorageSync(SHORT_TOKEN, newShortToken);
+
+ return newShortToken as string;
+ }
+
+ throw new Error('刷新短 token 失败');
+}
diff --git a/src/common/request/constants.ts b/src/common/request/constants.ts
new file mode 100644
index 00000000..0aad5122
--- /dev/null
+++ b/src/common/request/constants.ts
@@ -0,0 +1,4 @@
+export const BASE_URL = 'https://kstack.muxixyz.com';
+export const FEEDBACK_BASE_URL = 'https://feedback.muxixyz.com';
+export const FEEDBACK_DEV_BASE_URL = 'https://feedback-dev.muxixyz.com';
+export const DEFAULT_TIMEOUT = 15000;
diff --git a/src/common/request/errors/AppError.ts b/src/common/request/errors/AppError.ts
new file mode 100644
index 00000000..9a726fab
--- /dev/null
+++ b/src/common/request/errors/AppError.ts
@@ -0,0 +1,40 @@
+/**
+ * 错误严重级别
+ * - silent: 静默,不提示用户
+ * - toast: 轻提示(默认)
+ * - modal: 模态框,需要用户确认
+ * - fatal: 严重错误,可能需要跳转
+ */
+export type ErrorSeverity = 'silent' | 'toast' | 'modal' | 'fatal';
+
+/** 错误上下文,自动捕获调用信息 */
+export interface ErrorContext {
+ /** 发生错误的页面/模块名 */
+ page?: string;
+ /** 触发的操作 */
+ action?: string;
+ /** 错误发生时间戳 */
+ timestamp: number;
+ /** 请求 ID,用于追踪 */
+ requestId?: string;
+ /** 额外元数据 */
+ meta?: Record;
+}
+
+export class AppError extends Error {
+ severity: ErrorSeverity = 'toast';
+ context: ErrorContext;
+ /** 是否可恢复(可重试) */
+ recoverable: boolean = false;
+ /** 原始错误(链式包装时保留) */
+ cause?: unknown;
+
+ constructor(message: string, context?: Partial) {
+ super(message);
+ this.name = 'AppError';
+ this.context = {
+ timestamp: Date.now(),
+ ...context,
+ };
+ }
+}
diff --git a/src/common/request/errors/AuthError.ts b/src/common/request/errors/AuthError.ts
new file mode 100644
index 00000000..c68e8e85
--- /dev/null
+++ b/src/common/request/errors/AuthError.ts
@@ -0,0 +1,16 @@
+import { AppError, ErrorContext } from './AppError';
+
+/** 认证/授权错误:401 Token 过期、403 无权限 */
+export class AuthError extends AppError {
+ statusCode: number;
+
+ constructor(statusCode: number, message?: string, context?: Partial) {
+ super(
+ message || (statusCode === 401 ? '登录已过期,请重新登录' : '无权限访问'),
+ context
+ );
+ this.name = 'AuthError';
+ this.statusCode = statusCode;
+ this.severity = statusCode === 401 ? 'modal' : 'toast';
+ }
+}
diff --git a/src/common/request/errors/BusinessError.ts b/src/common/request/errors/BusinessError.ts
new file mode 100644
index 00000000..e58c7838
--- /dev/null
+++ b/src/common/request/errors/BusinessError.ts
@@ -0,0 +1,19 @@
+import { AppError, ErrorContext } from './AppError';
+
+/** 业务层错误:后端返回 code !== 0 */
+export class BusinessError extends AppError {
+ code: number;
+ data?: unknown;
+
+ constructor(
+ code: number,
+ message: string,
+ data?: unknown,
+ context?: Partial
+ ) {
+ super(message, context);
+ this.name = 'BusinessError';
+ this.code = code;
+ this.data = data;
+ }
+}
diff --git a/src/common/request/errors/ClientError.ts b/src/common/request/errors/ClientError.ts
new file mode 100644
index 00000000..458edfb9
--- /dev/null
+++ b/src/common/request/errors/ClientError.ts
@@ -0,0 +1,13 @@
+import { AppError, ErrorContext } from './AppError';
+
+// 客户端请求错误:400/404/409 等非认证类 4xx
+export class ClientError extends AppError {
+ statusCode: number;
+
+ constructor(statusCode: number, message?: string, context?: Partial) {
+ super(message || `请求错误 (${statusCode})`, context);
+ this.name = 'ClientError';
+ this.statusCode = statusCode;
+ this.severity = 'toast';
+ }
+}
diff --git a/src/common/request/errors/ErrorPipeline.ts b/src/common/request/errors/ErrorPipeline.ts
new file mode 100644
index 00000000..b7a3499a
--- /dev/null
+++ b/src/common/request/errors/ErrorPipeline.ts
@@ -0,0 +1,91 @@
+import { AppError } from './AppError';
+
+/** 内置处理器优先级常量 */
+export const HandlerPriority = {
+ AUTH: 10,
+ RETRY: 20,
+ TOAST: 50,
+ LOG: 100,
+} as const;
+
+/**
+ * 错误处理上下文(在管道中传递)
+ * 每个 handler 接收此对象,可调用 stop() 终止后续处理
+ */
+export class HandlerContext {
+ error: AppError;
+ private _stopped = false;
+
+ constructor(error: AppError) {
+ this.error = error;
+ }
+
+ /** 终止管道,后续 handler 不再执行 */
+ stop(): void {
+ this._stopped = true;
+ }
+
+ get stopped(): boolean {
+ return this._stopped;
+ }
+}
+
+/** 错误处理器函数签名 */
+export type ErrorHandler = (ctx: HandlerContext) => void | Promise;
+
+interface HandlerEntry {
+ handler: ErrorHandler;
+ priority: number;
+ id: string;
+}
+
+export class ErrorPipeline {
+ private handlers: HandlerEntry[] = [];
+ private idCounter = 0;
+
+ /**
+ * 注册错误处理器
+ * @param handler - 处理函数
+ * @param priority - 数字越小越先执行,默认 100。
+ * 内置: auth=10, retry=20, toast=50, log=100
+ * 业务方可插入任意位置
+ * @returns handlerId,用于 remove
+ */
+ use(handler: ErrorHandler, priority: number = 100): string {
+ const id = `h_${++this.idCounter}_${priority}`;
+ this.handlers.push({ handler, priority, id });
+ this.handlers.sort((a, b) => a.priority - b.priority);
+ return id;
+ }
+
+ /** 移除处理器 */
+ remove(id: string): boolean {
+ const idx = this.handlers.findIndex((h) => h.id === id);
+ if (idx >= 0) {
+ this.handlers.splice(idx, 1);
+ return true;
+ }
+ return false;
+ }
+
+ /** 执行管道:依次调用所有 handler,遇到 stop() 终止 */
+ async handle(error: unknown): Promise {
+ if (!(error instanceof AppError)) {
+ // 非 AppError 的未知错误,包装后处理
+ const wrapped = new AppError(error instanceof Error ? error.message : '未知错误');
+ wrapped.cause = error;
+ const ctx = new HandlerContext(wrapped);
+ for (const entry of this.handlers) {
+ await entry.handler(ctx);
+ if (ctx.stopped) break;
+ }
+ return;
+ }
+
+ const ctx = new HandlerContext(error);
+ for (const entry of this.handlers) {
+ await entry.handler(ctx);
+ if (ctx.stopped) break;
+ }
+ }
+}
diff --git a/src/common/request/errors/NetworkError.ts b/src/common/request/errors/NetworkError.ts
new file mode 100644
index 00000000..57790067
--- /dev/null
+++ b/src/common/request/errors/NetworkError.ts
@@ -0,0 +1,13 @@
+import { AppError, ErrorContext } from './AppError';
+
+/** 网络层错误:超时、断网、DNS 解析失败等 */
+export class NetworkError extends AppError {
+ constructor(
+ message = '网络连接异常,请检查网络后重试',
+ context?: Partial
+ ) {
+ super(message, context);
+ this.name = 'NetworkError';
+ this.recoverable = true;
+ }
+}
diff --git a/src/common/request/errors/ServerError.ts b/src/common/request/errors/ServerError.ts
new file mode 100644
index 00000000..dd69ff11
--- /dev/null
+++ b/src/common/request/errors/ServerError.ts
@@ -0,0 +1,13 @@
+import { AppError, ErrorContext } from './AppError';
+
+/** 服务端错误:500/502/503 等 */
+export class ServerError extends AppError {
+ statusCode: number;
+
+ constructor(statusCode: number, message?: string, context?: Partial) {
+ super(message || `服务器异常 (${statusCode}),请稍后重试`, context);
+ this.name = 'ServerError';
+ this.statusCode = statusCode;
+ this.recoverable = true;
+ }
+}
diff --git a/src/common/request/errors/errorCodeMap.ts b/src/common/request/errors/errorCodeMap.ts
new file mode 100644
index 00000000..9a529493
--- /dev/null
+++ b/src/common/request/errors/errorCodeMap.ts
@@ -0,0 +1,41 @@
+import type { ErrorSeverity } from './AppError';
+
+interface ErrorCodeMeta {
+ msg: string;
+ severity?: ErrorSeverity;
+ /** 特殊行为: redirectLogin | silent | none */
+ action?: 'redirectLogin' | 'silent' | 'none';
+}
+
+/**
+ * 业务错误码映射表
+ *
+ * 后端通过 ginx.Result { code, msg, data } 返回错误。
+ * code === 0 表示成功,code !== 0 表示业务错误,msg 字段包含服务端返回的描述。
+ *
+ * 已确认的错误码:
+ * - 407001: 重复签约
+ * - 409002: 不能回答未上过的课
+ *
+ * 其余错误码需通过 curl 测试各接口的错误分支来确认。
+ * 未知错误码会走 getErrorMeta 的 fallback,用服务端返回的 msg 兜底。
+ */
+export const ERROR_CODE_MAP: Record = {
+ // 成功
+ 0: { msg: '操作成功', severity: 'silent', action: 'none' },
+
+ // 认证 / 授权(待 curl 确认具体码值)
+
+ // 评价 / 评论(待 curl 确认具体码值)
+
+ // 回答
+ 409002: { msg: '不能回答未上过的课', severity: 'toast' },
+
+ // 签约
+ 407001: { msg: '重复签约', severity: 'silent', action: 'silent' },
+};
+
+/** 根据错误码获取元信息(未定义则用服务端返回的 msg 兜底) */
+export function getErrorMeta(code: number): ErrorCodeMeta {
+ return ERROR_CODE_MAP[code] || { msg: `请求失败 (${code})`, severity: 'toast' };
+}
diff --git a/src/common/request/errors/index.ts b/src/common/request/errors/index.ts
new file mode 100644
index 00000000..dd5fd772
--- /dev/null
+++ b/src/common/request/errors/index.ts
@@ -0,0 +1,10 @@
+export { AppError } from './AppError';
+export type { ErrorContext, ErrorSeverity } from './AppError';
+export { AuthError } from './AuthError';
+export { BusinessError } from './BusinessError';
+export { ClientError } from './ClientError';
+export { ERROR_CODE_MAP, getErrorMeta } from './errorCodeMap';
+export { ErrorPipeline } from './ErrorPipeline';
+export type { ErrorHandler, HandlerPriority } from './ErrorPipeline';
+export { NetworkError } from './NetworkError';
+export { ServerError } from './ServerError';
diff --git a/src/common/request/handlers/authHandler.ts b/src/common/request/handlers/authHandler.ts
new file mode 100644
index 00000000..90c6aa7b
--- /dev/null
+++ b/src/common/request/handlers/authHandler.ts
@@ -0,0 +1,34 @@
+import Taro from '@tarojs/taro';
+
+import { isVisitorMode } from '@/common/utils/isVisitor';
+
+import { resetSession } from '@/common/utils/resetSession';
+import { AuthError } from '../errors/AuthError';
+import type { ErrorHandler } from '../errors/ErrorPipeline';
+
+//401 → 清除 token + 跳转登录
+export const authHandler: ErrorHandler = async (ctx) => {
+ if (!(ctx.error instanceof AuthError) || ctx.error.statusCode !== 401) return;
+
+ // resource 域(带 tokenConfig 的反馈/飞书请求)的 401 不代表主登录过期,
+ // 不清主 token、不跳登录,交由调用页面自行处理
+ if (ctx.error.context?.meta?.scope === 'resource') {
+ ctx.stop();
+ return;
+ }
+
+ if (isVisitorMode()) {
+ ctx.stop();
+ return;
+ }
+
+ resetSession('auth-expired');
+
+ await Taro.showModal({
+ title: '登录过期',
+ content: '登录已过期,请重新登录',
+ showCancel: false,
+ });
+ Taro.reLaunch({ url: '/pages/login/index' });
+ ctx.stop();
+};
diff --git a/src/common/request/handlers/globalErrorHandler.ts b/src/common/request/handlers/globalErrorHandler.ts
new file mode 100644
index 00000000..014c85b8
--- /dev/null
+++ b/src/common/request/handlers/globalErrorHandler.ts
@@ -0,0 +1,40 @@
+import type { ErrorHandler } from '../errors/ErrorPipeline';
+import { ErrorPipeline, HandlerPriority } from '../errors/ErrorPipeline';
+import { authHandler } from './authHandler';
+import { logHandler } from './logHandler';
+import { toastHandler } from './toastHandler';
+
+// 全局错误处理器单例,在 app 启动时自动初始化,注册内置 handler
+class GlobalErrorHandler {
+ private pipeline: ErrorPipeline;
+ private initialized = false;
+
+ constructor() {
+ this.pipeline = new ErrorPipeline();
+ }
+
+ // 初始化内置处理器
+ init(): void {
+ if (this.initialized) return;
+ this.initialized = true;
+
+ this.pipeline.use(authHandler, HandlerPriority.AUTH);
+ this.pipeline.use(toastHandler, HandlerPriority.TOAST);
+ this.pipeline.use(logHandler, HandlerPriority.LOG);
+ }
+
+ use(handler: ErrorHandler, priority?: number): string {
+ return this.pipeline.use(handler, priority);
+ }
+
+ remove(id: string): boolean {
+ return this.pipeline.remove(id);
+ }
+
+ async handle(error: unknown): Promise {
+ this.init();
+ await this.pipeline.handle(error);
+ }
+}
+
+export const globalErrorHandler = new GlobalErrorHandler();
diff --git a/src/common/request/handlers/logHandler.ts b/src/common/request/handlers/logHandler.ts
new file mode 100644
index 00000000..ff498607
--- /dev/null
+++ b/src/common/request/handlers/logHandler.ts
@@ -0,0 +1,25 @@
+import { DEBUG } from '@/common/config/features';
+
+import { BusinessError } from '../errors/BusinessError';
+import type { ErrorHandler } from '../errors/ErrorPipeline';
+
+export const logHandler: ErrorHandler = (ctx) => {
+ if (DEBUG.ERROR_LEVEL === 'silent') return;
+
+ const { error } = ctx;
+ const tag = error.context.page || 'unknown';
+
+ if (DEBUG.ERROR_LEVEL === 'verbose') {
+ console.error(
+ `[${error.name}] at ${tag}: ${error.message}`,
+ '\nstack:',
+ error.stack,
+ '\ncontext:',
+ JSON.stringify(error.context, null, 2),
+ error instanceof BusinessError ? `\ncode: ${error.code}` : ''
+ );
+ } else {
+ const extra = error instanceof BusinessError ? ` [code=${error.code}]` : '';
+ console.error(`[${error.name}]${extra} at ${tag}: ${error.message}`);
+ }
+};
diff --git a/src/common/request/handlers/toastHandler.ts b/src/common/request/handlers/toastHandler.ts
new file mode 100644
index 00000000..56aed26b
--- /dev/null
+++ b/src/common/request/handlers/toastHandler.ts
@@ -0,0 +1,30 @@
+import Taro from '@tarojs/taro';
+
+import { DEBUG } from '@/common/config/features';
+
+import { getErrorMeta } from '../errors';
+import { BusinessError } from '../errors/BusinessError';
+import type { ErrorHandler } from '../errors/ErrorPipeline';
+
+export const toastHandler: ErrorHandler = async (ctx) => {
+ const { error } = ctx;
+
+ if (error.severity === 'silent') return;
+
+ let message = error.message;
+ if (error instanceof BusinessError) {
+ const meta = getErrorMeta(error.code);
+ if (meta.action === 'silent') return;
+ if (!error.message || error.message === '请求失败') {
+ message = meta.msg;
+ }
+ }
+
+ if (!DEBUG.ERROR_TOAST) return;
+
+ if (error.severity === 'toast') {
+ Taro.showToast({ title: message, icon: 'none', duration: 2000 });
+ } else if (error.severity === 'modal') {
+ await Taro.showModal({ title: '提示', content: message, showCancel: false });
+ }
+};
diff --git a/src/common/request/index.ts b/src/common/request/index.ts
new file mode 100644
index 00000000..4e63e253
--- /dev/null
+++ b/src/common/request/index.ts
@@ -0,0 +1,271 @@
+import Taro from '@tarojs/taro';
+
+import {
+ InterceptorContext,
+ RequestConfig,
+ UploadConfig,
+} from '@/common/types/requestType';
+
+import { requestInterceptors, responseInterceptors } from './auth/interceptor';
+import { BASE_URL, DEFAULT_TIMEOUT } from './constants';
+import { globalErrorHandler } from './handlers/globalErrorHandler';
+
+type ParamsOption = {
+ params?: Record;
+ query?: string | object;
+};
+
+function resolvePathWithParams(
+ path: string,
+ option?: ParamsOption,
+ baseUrl?: string
+): string {
+ if (!option) {
+ return combineUrl(path, baseUrl);
+ }
+
+ let resolvedPath = path;
+ const { params, query } = option;
+
+ if (params && typeof params === 'object') {
+ resolvedPath = resolvedPath.replace(/:(\w+)\b/g, (match, paramName) => {
+ if (Object.prototype.hasOwnProperty.call(params, paramName)) {
+ return params[paramName as keyof typeof params];
+ }
+ return match;
+ });
+ }
+
+ if (query) {
+ let queryString: string;
+
+ if (typeof query === 'string') {
+ queryString = query.replace(/^\?/, '').trim();
+ } else {
+ const queryParams = new URLSearchParams();
+
+ Object.entries(query).forEach(([key, value]) => {
+ if (value == null) return;
+
+ if (Array.isArray(value)) {
+ value.forEach((item) => {
+ if (item != null) {
+ queryParams.append(key, String(item));
+ }
+ });
+ } else {
+ const stringValue =
+ typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value);
+ queryParams.append(key, stringValue);
+ }
+ });
+
+ queryString = queryParams.toString();
+ }
+
+ if (queryString) {
+ resolvedPath += `?${queryString}`;
+ }
+ }
+
+ return combineUrl(resolvedPath, baseUrl);
+}
+
+function combineUrl(path: string, baseUrl?: string): string {
+ if (/^https?:\/\//i.test(path)) {
+ return path;
+ }
+
+ const effectiveBaseUrl = baseUrl || BASE_URL;
+
+ const cleanPath = path.startsWith('/') ? path.slice(1) : path;
+
+ return `${effectiveBaseUrl}/${cleanPath}`;
+}
+
+async function baseRequest(
+ path: string,
+ method: keyof Taro.request.Method,
+ body?: any,
+ params?: ParamsOption,
+ config?: RequestConfig
+): Promise {
+ const url = resolvePathWithParams(path, params, config?.baseUrl);
+
+ const interceptedConfig = await requestInterceptors(config);
+
+ let context: InterceptorContext;
+
+ if (interceptedConfig.upload?.filePath) {
+ context = await handleFileUpload(
+ url,
+ body,
+ interceptedConfig as RequestConfig & { upload: UploadConfig }
+ );
+ } else {
+ context = await handleNormalRequest(url, method, body, interceptedConfig);
+ }
+
+ try {
+ const res = await responseInterceptors(context, interceptedConfig);
+
+ if (interceptedConfig.returnFullResponse) return res as T;
+
+ return res as T;
+ } catch (error: unknown) {
+ return handleRequestError(error, interceptedConfig);
+ }
+}
+
+function handleRequestError(error: unknown, config: RequestConfig): never {
+ const errorConfig = config.errorConfig;
+
+ // 自定义错误处理
+ if (errorConfig?.onError) {
+ errorConfig.onError(error);
+ if (errorConfig.fallback !== undefined) {
+ return errorConfig.fallback as never;
+ }
+
+ throw error;
+ }
+
+ if (!errorConfig?.silent) {
+ globalErrorHandler.handle(error).catch(() => {});
+ }
+
+ if (errorConfig?.fallback !== undefined) {
+ return errorConfig.fallback as never;
+ }
+
+ throw error;
+}
+
+async function handleNormalRequest(
+ url: string,
+ method: keyof Taro.request.Method,
+ body: any,
+ config: RequestConfig
+): Promise {
+ // 只透传 Taro.request 真正认识的字段,避免把 tokenConfig/errorConfig/upload 等
+ // 自定义字段塞进原生 API(会被带进 401 重试,且语义不属于网络请求)
+ const requestConfig: Taro.request.Option = {
+ url,
+ method,
+ data: body,
+ header: config.header,
+ timeout: config.timeout ?? DEFAULT_TIMEOUT,
+ };
+
+ const response = await Taro.request(requestConfig);
+
+ return {
+ requestConfig,
+ config,
+ response,
+ };
+}
+
+async function handleFileUpload(
+ url: string,
+ body: any,
+ config: RequestConfig & { upload: NonNullable }
+): Promise {
+ const { upload } = config;
+ const fileFieldName = upload.fileFieldName || 'file';
+
+ const formData: Record = { ...(upload.formData || {}) };
+
+ if (upload.mergeBodyToFormData !== false && body) {
+ if (typeof body === 'object') {
+ Object.assign(formData, body);
+ } else {
+ formData['_payload'] = body;
+ }
+ }
+
+ const uploadOption: Taro.uploadFile.Option = {
+ url,
+ filePath: upload.filePath,
+ name: fileFieldName,
+ formData,
+ timeout: config.timeout ?? DEFAULT_TIMEOUT,
+ header: {
+ ...config.header,
+ },
+ };
+
+ const response = await new Promise(
+ (resolve, reject) => {
+ Taro.uploadFile(uploadOption).then(resolve).catch(reject);
+ }
+ );
+
+ let parsedData;
+ try {
+ parsedData = JSON.parse(response.data);
+ } catch (e) {
+ parsedData = { raw: response.data };
+ }
+
+ const processedResponse = {
+ ...response,
+ data: parsedData,
+ statusCode: response.statusCode,
+ header: response.header || {},
+ };
+
+ return {
+ requestConfig: uploadOption,
+ config,
+ response: processedResponse,
+ };
+}
+
+const request = {
+ async get(
+ path: string,
+ params?: ParamsOption,
+ config?: RequestConfig
+ ): Promise {
+ return baseRequest(path, 'GET', undefined, params, config);
+ },
+
+ async post(path: string, body?: any, config?: RequestConfig): Promise {
+ return baseRequest(path, 'POST', body, undefined, config);
+ },
+
+ async put(path: string, body?: any, config?: RequestConfig): Promise {
+ return baseRequest(path, 'PUT', body, undefined, config);
+ },
+
+ async delete(
+ path: string,
+ params?: ParamsOption,
+ config?: RequestConfig
+ ): Promise {
+ return baseRequest(path, 'DELETE', undefined, params, config);
+ },
+
+ async patch(path: string, body?: any, config?: RequestConfig): Promise {
+ return baseRequest(path, 'PATCH', body, undefined, config);
+ },
+
+ async head(
+ path: string,
+ params?: ParamsOption,
+ config?: RequestConfig
+ ): Promise {
+ return baseRequest(path, 'HEAD', undefined, params, config);
+ },
+
+ async options(
+ path: string,
+ params?: ParamsOption,
+ config?: RequestConfig
+ ): Promise {
+ return baseRequest(path, 'OPTIONS', undefined, params, config);
+ },
+};
+
+export { request };
diff --git a/src/common/api/qiniu.ts b/src/common/request/qiniu.ts
similarity index 89%
rename from src/common/api/qiniu.ts
rename to src/common/request/qiniu.ts
index e228ab6d..e806bd7b 100644
--- a/src/common/api/qiniu.ts
+++ b/src/common/request/qiniu.ts
@@ -3,7 +3,7 @@
import Taro from '@tarojs/taro';
-import { get } from '../api/get';
+import { getAccessToken } from './api/qiniu/index';
export interface WebGetTubeTokenData {
access_token: string;
@@ -20,10 +20,7 @@ export interface ResponseQiniu {
}
export const fetchQiniuToken = async () => {
try {
- const url = '/tube/access_token';
-
- const response: ResponseQiniu = await get(url);
- // console.log('response', response);
+ const response: ResponseQiniu = await getAccessToken();
return response.data;
} catch (error) {
console.error('Error fetching Qiniu token:', error);
diff --git a/src/common/styles/base/_font.scss b/src/common/styles/base/_font.scss
new file mode 100644
index 00000000..634e95ae
--- /dev/null
+++ b/src/common/styles/base/_font.scss
@@ -0,0 +1,6 @@
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-size: $font_size_body;
+ color: $color_text_primary;
+ line-height: $line_height_body;
+}
diff --git a/src/common/styles/base/_index.scss b/src/common/styles/base/_index.scss
new file mode 100644
index 00000000..5b3618b1
--- /dev/null
+++ b/src/common/styles/base/_index.scss
@@ -0,0 +1,2 @@
+@import './reset';
+@import './font';
diff --git a/src/common/styles/base/_reset.scss b/src/common/styles/base/_reset.scss
new file mode 100644
index 00000000..96ba8a33
--- /dev/null
+++ b/src/common/styles/base/_reset.scss
@@ -0,0 +1,5 @@
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
diff --git a/src/common/styles/decorations/_avatar_frames.scss b/src/common/styles/decorations/_avatar_frames.scss
new file mode 100644
index 00000000..bb841642
--- /dev/null
+++ b/src/common/styles/decorations/_avatar_frames.scss
@@ -0,0 +1,87 @@
+@import '@/common/styles/tokens/index';
+
+.avatar_frame {
+ border-radius: $radius_round;
+ overflow: hidden;
+ aspect-ratio: 1 / 1;
+ flex-shrink: 0;
+}
+
+.avatar_frame_default {
+}
+
+.avatar_frame_colorful {
+ background: linear-gradient(
+ 135deg,
+ #ff0000,
+ #ff8800,
+ #ffff00,
+ #00ff00,
+ #0088ff,
+ #8800ff,
+ #ff0000
+ );
+ background-size: 400% 400%;
+ animation:
+ avatar_flow 2s linear infinite,
+ avatar_glow 4s ease-in-out infinite;
+ padding: 6rpx;
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: $radius_round;
+ background: linear-gradient(
+ 135deg,
+ transparent 30%,
+ rgba(255, 255, 255, 0.4) 50%,
+ transparent 70%
+ );
+ background-size: 300% 300%;
+ animation: avatar_shimmer 5s ease-in-out infinite;
+ pointer-events: none;
+ }
+}
+
+@keyframes avatar_flow {
+ 0% {
+ background-position: 0% 50%;
+ }
+ 50% {
+ background-position: 100% 50%;
+ }
+ 100% {
+ background-position: 0% 50%;
+ }
+}
+
+@keyframes avatar_glow {
+ 0%,
+ 100% {
+ box-shadow:
+ 0 0 12rpx rgba(255, 0, 100, 0.5),
+ 0 0 28rpx rgba(0, 150, 255, 0.3);
+ }
+ 50% {
+ box-shadow:
+ 0 0 12rpx rgba(0, 200, 255, 0.5),
+ 0 0 28rpx rgba(200, 0, 255, 0.3);
+ }
+}
+
+@keyframes avatar_shimmer {
+ 0% {
+ background-position: 150% 150%;
+ }
+ 100% {
+ background-position: -50% -50%;
+ }
+}
+
+.avatar_img {
+ width: 100%;
+ height: 100%;
+ border-radius: $radius_round;
+}
diff --git a/src/common/styles/decorations/_index.scss b/src/common/styles/decorations/_index.scss
new file mode 100644
index 00000000..b36329a8
--- /dev/null
+++ b/src/common/styles/decorations/_index.scss
@@ -0,0 +1 @@
+@forward 'avatar_frames';
diff --git a/src/common/styles/functions/_color.scss b/src/common/styles/functions/_color.scss
new file mode 100644
index 00000000..7ebdbaa7
--- /dev/null
+++ b/src/common/styles/functions/_color.scss
@@ -0,0 +1,11 @@
+@function tint($color, $percentage) {
+ @return mix(white, $color, $percentage);
+}
+
+@function shade($color, $percentage) {
+ @return mix(black, $color, $percentage);
+}
+
+@function alpha($color, $opacity) {
+ @return rgba($color, $opacity);
+}
diff --git a/src/common/styles/functions/_index.scss b/src/common/styles/functions/_index.scss
new file mode 100644
index 00000000..a2a66708
--- /dev/null
+++ b/src/common/styles/functions/_index.scss
@@ -0,0 +1 @@
+@import './color';
diff --git a/src/common/styles/globals.scss b/src/common/styles/globals.scss
index 01ca4c12..39644246 100644
--- a/src/common/styles/globals.scss
+++ b/src/common/styles/globals.scss
@@ -1,3 +1,7 @@
@import 'tailwindcss/base';
@import 'tailwindcss/utilities';
@import 'tailwindcss/components';
+
+@import '~taro-ui/dist/style/components/icon.scss';
+
+@import 'base/index';
diff --git a/src/common/styles/mixins/_border.scss b/src/common/styles/mixins/_border.scss
new file mode 100644
index 00000000..d22a13ef
--- /dev/null
+++ b/src/common/styles/mixins/_border.scss
@@ -0,0 +1,36 @@
+@mixin border_line($position, $color: $color_border_dark) {
+ content: '';
+ position: absolute;
+ #{$position}: 0;
+ left: 50%;
+ width: 85%;
+ height: 1px;
+ background: $color;
+ transform: translateX(-50%) scaleY(0.2);
+ transform-origin: 0 0;
+}
+
+@mixin line_decoration {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+
+ &::before,
+ &::after {
+ content: '';
+ width: 27%;
+ height: 4rpx;
+ background-color: $color_border_light;
+ position: absolute;
+ }
+
+ &::before {
+ left: 5%;
+ }
+
+ &::after {
+ right: 5%;
+ }
+}
diff --git a/src/common/styles/mixins/_box.scss b/src/common/styles/mixins/_box.scss
new file mode 100644
index 00000000..c90b6d9d
--- /dev/null
+++ b/src/common/styles/mixins/_box.scss
@@ -0,0 +1,5 @@
+// 撑满 + 边框盒模型
+@mixin full_width_border_box {
+ width: 100%;
+ box-sizing: border-box;
+}
diff --git a/src/common/styles/mixins/_index.scss b/src/common/styles/mixins/_index.scss
new file mode 100644
index 00000000..1e6b0162
--- /dev/null
+++ b/src/common/styles/mixins/_index.scss
@@ -0,0 +1,6 @@
+@import './layout';
+@import './text';
+@import './border';
+@import './position';
+@import './box';
+@import './visual';
diff --git a/src/common/styles/mixins/_layout.scss b/src/common/styles/mixins/_layout.scss
new file mode 100644
index 00000000..a295c77c
--- /dev/null
+++ b/src/common/styles/mixins/_layout.scss
@@ -0,0 +1,53 @@
+// flex 水平居中
+@mixin flex_align_center {
+ display: flex;
+ align-items: center;
+}
+
+// flex 水平垂直居中
+@mixin flex_center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+// flex 两端对齐 + 垂直居中
+@mixin flex_between {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+// flex 纵向排列
+@mixin flex_column {
+ display: flex;
+ flex-direction: column;
+}
+
+// flex 纵向 + 水平居中
+@mixin flex_column_align_center {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+// flex 纵向 + 垂直水平居中
+@mixin flex_column_center {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+// flex 顶部对齐
+@mixin flex_align_start {
+ display: flex;
+ align-items: flex-start;
+}
+
+// flex 换行
+@mixin flex_wrap {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+}
diff --git a/src/common/styles/mixins/_position.scss b/src/common/styles/mixins/_position.scss
new file mode 100644
index 00000000..647ba25a
--- /dev/null
+++ b/src/common/styles/mixins/_position.scss
@@ -0,0 +1,13 @@
+// 绝对定位居中
+@mixin absolute_center {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+// 绝对定位撑满父容器
+@mixin absolute_fill {
+ position: absolute;
+ inset: 0;
+}
diff --git a/src/common/styles/mixins/_text.scss b/src/common/styles/mixins/_text.scss
new file mode 100644
index 00000000..dde6c5e4
--- /dev/null
+++ b/src/common/styles/mixins/_text.scss
@@ -0,0 +1,13 @@
+@mixin text_ellipsis {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+@mixin line_clamp($lines: 2) {
+ display: -webkit-box;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: $lines;
+ word-break: break-all;
+}
diff --git a/src/common/styles/mixins/_visual.scss b/src/common/styles/mixins/_visual.scss
new file mode 100644
index 00000000..54321b72
--- /dev/null
+++ b/src/common/styles/mixins/_visual.scss
@@ -0,0 +1,7 @@
+@mixin shadow_card {
+ box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
+}
+
+@mixin shadow_float {
+ box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.12);
+}
diff --git a/src/common/styles/tokens/_animation.scss b/src/common/styles/tokens/_animation.scss
new file mode 100644
index 00000000..8e3e1d2b
--- /dev/null
+++ b/src/common/styles/tokens/_animation.scss
@@ -0,0 +1,7 @@
+// 动画效果
+$duration_fast: 0.15s;
+$duration_normal: 0.2s;
+$duration_slow: 0.3s;
+
+$ease_default: cubic-bezier(0.4, 0, 0.2, 1);
+$ease_in: ease;
diff --git a/src/common/styles/tokens/_colors.scss b/src/common/styles/tokens/_colors.scss
new file mode 100644
index 00000000..19ba7127
--- /dev/null
+++ b/src/common/styles/tokens/_colors.scss
@@ -0,0 +1,36 @@
+// 主色
+$color_brand: #eda335;
+$color_brand_hover: #f2a93b;
+$color_brand_muted: #f9b94f;
+$color_brand_accent: #f18900;
+
+// 衍生色
+$color_brand_golden: #ffd777;
+$color_brand_warm: #ffbf60;
+
+// 文字色
+$color_text_primary: #3d3d3d;
+$color_text_secondary: #999999;
+$color_text_dark: #333333;
+$color_text_light: #cccccc;
+$color_text_heading: #050403;
+$color_text_medium: #666666;
+$color_text_cool_gray: #9ca3af;
+$color_text_hint: #9e9e9e;
+
+// 背景色
+$color_bg_page: #ffffff;
+$color_bg_cream: #fffaec;
+
+// 背景色
+$color_bg_input: #f5f5f5;
+$color_bg_hover: #f0f0f0;
+
+// 边框/分割线
+$color_border_light: #d8d8d8;
+$color_border_dark: #4e4e4e;
+$color_border_input: #c7c7c7;
+$color_divider: #e3e3e3;
+
+// 特殊文字色
+$color_text_deep: #1f1f1f;
diff --git a/src/common/styles/tokens/_font.scss b/src/common/styles/tokens/_font.scss
new file mode 100644
index 00000000..5401547d
--- /dev/null
+++ b/src/common/styles/tokens/_font.scss
@@ -0,0 +1,20 @@
+// 字号
+$font_size_xs: 20rpx;
+$font_size_sm: 22rpx;
+$font_size_caption: 24rpx;
+$font_size_subtitle: 26rpx;
+$font_size_body: 28rpx;
+$font_size_base: 30rpx;
+$font_size_title: 32rpx;
+$font_size_subheading: 34rpx;
+$font_size_heading: 36rpx;
+$font_size_display: 38rpx;
+$font_size_hero: 44rpx;
+
+// 字重
+$font_weight_normal: 400;
+$font_weight_medium: 500;
+$font_weight_bold: 600;
+
+// 行高
+$line_height_body: 1.5;
diff --git a/src/common/styles/tokens/_index.scss b/src/common/styles/tokens/_index.scss
new file mode 100644
index 00000000..4f760f05
--- /dev/null
+++ b/src/common/styles/tokens/_index.scss
@@ -0,0 +1,5 @@
+@import './colors';
+@import './font';
+@import './spacing';
+@import './radii';
+@import './animation';
diff --git a/src/common/styles/tokens/_radii.scss b/src/common/styles/tokens/_radii.scss
new file mode 100644
index 00000000..317eb440
--- /dev/null
+++ b/src/common/styles/tokens/_radii.scss
@@ -0,0 +1,6 @@
+// 圆角
+$radius_sm: 12rpx;
+$radius_md: 16rpx;
+$radius_lg: 24rpx;
+$radius_xl: 40rpx;
+$radius_round: 50%;
diff --git a/src/common/styles/tokens/_spacing.scss b/src/common/styles/tokens/_spacing.scss
new file mode 100644
index 00000000..dcb6b376
--- /dev/null
+++ b/src/common/styles/tokens/_spacing.scss
@@ -0,0 +1,8 @@
+// 间距
+$space_xxs: 8rpx;
+$space_xs: 12rpx;
+$space_sm: 16rpx;
+$space_md: 20rpx;
+$space_lg: 24rpx;
+$space_xl: 32rpx;
+$space_xxl: 40rpx;
diff --git a/src/common/types/collectionsType.ts b/src/common/types/collectionsType.ts
new file mode 100644
index 00000000..c98c7241
--- /dev/null
+++ b/src/common/types/collectionsType.ts
@@ -0,0 +1,10 @@
+export interface CollectionProps {
+ id: number;
+ course_id: number;
+ name: string;
+ teacher: string;
+ composite_score: number;
+ courseType: string;
+ features?: string[];
+ is_collected?: boolean;
+}
diff --git a/src/common/types/commentTypes.d.ts b/src/common/types/commentTypes.d.ts
deleted file mode 100644
index 7c178fb8..00000000
--- a/src/common/types/commentTypes.d.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-declare type CommentType = {
- id: number;
- commentator_id: number;
- biz: string;
- biz_id: number;
- content: string;
- root_comment_id: number;
- parent_comment_id: number;
- reply_to_uid: number;
- utime: number;
- ctime: number;
- user?: User; // 存储用户信息
- replies?: CommentType[]; // 存储二级评论
-};
-
-// 定义评论详情的类型
-declare type CommentInfoType = {
- nickname: string;
- avatar: string;
- id: number;
- content: string;
- class_name: string;
- teacher: string;
- star_rating: number;
- total_support_count: number;
- total_oppose_count: number;
- total_comment_count: number;
- utime: number;
- ctime: number;
-};
-
-declare type User = {
- id: number;
- avatar: string;
- nickname: string;
-};
-
-/** 评论详情 */
-declare type CommentInfo = {
- /**
- * 考核方式,支持多选
- */
- assessments?: string[];
- content?: string;
- course_id?: number;
- ctime?: number;
- /**
- * 课程特点,支持多选
- */
- features?: string[];
- id?: number;
- publisher_id?: number;
- /**
- * 1支持,0无,-1反对
- */
- stance?: number;
- star_rating?: number;
- status?: string;
- total_comment_count?: number;
- total_oppose_count?: number;
- total_support_count?: number;
- utime?: number;
-};
-
-declare type Course = {
- id: number;
- name: string;
- teacher: string;
- school: string;
- type: string;
- credit: number;
- composite_score: number;
- rater_count: number;
- assessments: Record; // 使用 Record 类型来表示对象,具体类型根据实际结构定义
- features: Record;
- is_collected: boolean;
- is_subscribed: boolean;
-};
diff --git a/src/common/types/commentTypes.ts b/src/common/types/commentTypes.ts
new file mode 100644
index 00000000..92ffedf3
--- /dev/null
+++ b/src/common/types/commentTypes.ts
@@ -0,0 +1,124 @@
+export type CommentType = {
+ id: number;
+ commentator_id: number;
+ biz: string;
+ biz_id: number;
+ content: string;
+ root_comment_id: number;
+ parent_comment_id: number;
+ reply_to_uid: number;
+ utime: number;
+ ctime: number;
+ user?: User; // 存储用户信息
+ publisher?: User; // 后端可能用 publisher 内嵌返回用户信息
+ replies?: CommentType[]; // 存储二级评论
+ reply_count?: number; // 直接回复数(一级评论上不含楼中楼嵌套,勿用于展示整楼总数)
+ has_replies?: boolean; // 是否有回复
+ total_comment_count?: number; // 整楼回复总数(待后端修正后启用)
+ replies_has_more?: boolean; // 楼中楼分页:上次拉取是否满页
+ reply_to_user?: User; // @ 的目标用户
+};
+
+// 讨论区通用条目:评课评论与问答回答共用同一套 UI,
+// 这里取两者结构的交集做超类型,除 id/content/ctime 外均可选
+export interface DiscussionComment {
+ id: number;
+ content: string;
+ ctime: number;
+ utime?: number;
+ user?: User;
+ publisher?: User;
+ commentator_id?: number;
+ root_comment_id?: number;
+ parent_comment_id?: number;
+ reply_to_uid?: number;
+ reply_to_user?: User;
+ replies?: DiscussionComment[];
+ reply_count?: number;
+ has_replies?: boolean;
+ total_comment_count?: number;
+ replies_has_more?: boolean;
+}
+
+// 定义评论详情的类型
+export type CommentInfoType = {
+ nickname: string;
+ avatar: string;
+ id: number;
+ content: string;
+ class_name: string;
+ teacher: string;
+ star_rating: number;
+ total_support_count: number;
+ total_oppose_count: number;
+ total_comment_count: number;
+ utime: number;
+ ctime: number;
+};
+
+export type User = {
+ id: number;
+ avatar: string;
+ nickname: string;
+ using_title?: string;
+ level?: number;
+};
+
+/** 发布者信息(后端内嵌返回) */
+export interface PublisherInfo {
+ id: number;
+ avatar: string;
+ nickname: string;
+ using_title?: string;
+ level?: number;
+}
+
+/** 评论详情 */
+export interface CommentInfo {
+ /**
+ * 考核方式,支持多选
+ */
+ assessments?: string[];
+ content?: string;
+ course_id?: number;
+ /** 课程名(后端内嵌返回,替代 courseDetailSlice 查询) */
+ course_name?: string;
+ ctime?: number;
+ /**
+ * 课程特点,支持多选
+ */
+ features?: string[];
+ id?: number;
+ /** 发布者信息(后端内嵌返回,替代 publisherInfoSlice 查询) */
+ publisher?: PublisherInfo;
+ publisher_id?: number;
+ /**
+ * 1支持,0无,-1反对
+ */
+ stance?: number;
+ star_rating?: number;
+ status?: string;
+ /** 教师名(后端内嵌返回,替代 courseDetailSlice 查询) */
+ teacher_name?: string;
+ total_comment_count?: number;
+ total_oppose_count?: number;
+ total_support_count?: number;
+ utime?: number;
+ /** 是否匿名 */
+ is_anonymous?: boolean;
+}
+
+export type Course = {
+ id: number;
+ name: string;
+ teacher: string;
+ school: string;
+ type: string;
+ credit: number;
+ composite_score: number;
+ rater_count: number;
+ assessments: Record;
+ features: Record;
+ is_collected: boolean;
+ is_subscribed: boolean;
+};
diff --git a/src/common/types/courseType.ts b/src/common/types/courseType.ts
new file mode 100644
index 00000000..e179a909
--- /dev/null
+++ b/src/common/types/courseType.ts
@@ -0,0 +1,69 @@
+type CourseType = {
+ MAJOR: 'CoursePropertyMajorCore';
+ GENERAL_ELECT: 'CoursePropertyGeneralElective';
+ GENERAL_CORE: 'CoursePropertyGeneralCore';
+ GENERAL_REQUIRED: 'CoursePropertyGeneralRequired';
+ MAJOR_ELECTIVE: 'CoursePropertyMajorElective';
+ ANY: 'CoursePropertyAny';
+};
+
+export const COURSE_TYPE: CourseType = {
+ MAJOR: 'CoursePropertyMajorCore',
+ GENERAL_ELECT: 'CoursePropertyGeneralElective',
+ GENERAL_CORE: 'CoursePropertyGeneralCore',
+ GENERAL_REQUIRED: 'CoursePropertyGeneralRequired',
+ MAJOR_ELECTIVE: 'CoursePropertyMajorElective',
+ ANY: 'CoursePropertyAny',
+};
+/** 课程类别 */
+export type classType =
+ | CourseType['MAJOR']
+ | CourseType['GENERAL_CORE']
+ | CourseType['GENERAL_ELECT']
+ | CourseType['GENERAL_REQUIRED']
+ | CourseType['MAJOR_ELECTIVE']
+ | CourseType['ANY'];
+
+export type CourseDetailsType = {
+ /** 课程名 */
+ name: string;
+ /** 教师 */
+ teacher: string;
+ /** 学院 */
+ school: string;
+};
+export type PublisherDetailsType = {
+ /** 昵称 */
+ nickname: string;
+ /** 头像 */
+ avatar: string;
+ /** id */
+ id: number;
+ /** 称号 */
+ using_title?: string;
+ /** 等级 */
+ level?: number;
+};
+/* 对 comment 操作类型 */
+export enum COMMENT_ACTIONS {
+ LIKE = 1,
+ COMMENT = 2,
+ DISLIKE = 3,
+ REMOVE_COMMENT = 4,
+}
+/** 课程信息 */
+export interface CourseDetailSlice {
+ /** course信息 */
+ courseDetail: Record;
+
+ /** 批量缓存课程信息(从评价列表等接口内联数据提取) */
+ cacheCourseDetails: (courses: Record) => void;
+ /** 从服务端获取Course信息 */
+ fetchCourseDetail: (courseId: number) => Promise;
+ /** 获取Course信息 */
+ getCourseDetail: (courseId: number) => Promise;
+ /** 清空课程详情缓存 */
+ clear: () => void;
+}
+
+export type CourseInfoStore = CourseDetailSlice;
diff --git a/src/common/types/loadType.ts b/src/common/types/loadType.ts
new file mode 100644
index 00000000..614bdad6
--- /dev/null
+++ b/src/common/types/loadType.ts
@@ -0,0 +1,10 @@
+/** 数据加载策略 */
+export type LoadStrategy = 'cache-first' | 'network-first' | 'network-only';
+
+/** 本次数据的来源 */
+export type DataSource = 'network' | 'cache' | 'fallback';
+
+export interface LoadResult {
+ data: T;
+ source: DataSource;
+}
diff --git a/src/common/types/myCourseType.ts b/src/common/types/myCourseType.ts
new file mode 100644
index 00000000..929477bd
--- /dev/null
+++ b/src/common/types/myCourseType.ts
@@ -0,0 +1,8 @@
+export interface MyCourseProps {
+ name: string;
+ teacher: string;
+ evaluated: boolean;
+ year: string;
+ term: string;
+ id: number;
+}
diff --git a/src/common/types/requestType.ts b/src/common/types/requestType.ts
new file mode 100644
index 00000000..eeaa0f71
--- /dev/null
+++ b/src/common/types/requestType.ts
@@ -0,0 +1,43 @@
+export interface TokenConfig {
+ // token名称,用于区分不同 token
+ name: string;
+ // 如果已有 token,直接使用
+ token?: string;
+ // 重新刷新次数
+ maxRetry?: number;
+ // 自定义刷新逻辑
+ refresh: () => Promise;
+ // 刷新失败回调
+ onRefreshError?: (error: Error) => void;
+ // 刷新成功回调
+ onRefreshSuccess?: (newToken: string) => void;
+}
+
+export interface UploadConfig {
+ // 小程序临时文件路径
+ filePath: string;
+ // 文件对应的字段名
+ fileFieldName?: string;
+ // 额外的文件数据
+ formData?: Record;
+ // 是否自动将 body 合并到 formData 中
+ mergeBodyToFormData?: boolean;
+}
+
+export interface RequestConfig {
+ baseUrl?: string;
+ withToken?: boolean;
+ tokenConfig?: TokenConfig;
+ header?: Record;
+ upload?: UploadConfig;
+ returnFullResponse?: boolean;
+ timeout?: number;
+ errorConfig?: any;
+ onRequestError?: (error: unknown) => void;
+}
+
+export interface InterceptorContext {
+ requestConfig: Taro.request.Option | Taro.uploadFile.Option;
+ config: RequestConfig;
+ response: Taro.request.SuccessCallbackResult;
+}
diff --git a/src/common/types/tabBarType.ts b/src/common/types/tabBarType.ts
new file mode 100644
index 00000000..1a5414c3
--- /dev/null
+++ b/src/common/types/tabBarType.ts
@@ -0,0 +1,10 @@
+export type TabItemProps = {
+ name: string;
+ icon: string;
+ key: string;
+};
+
+export interface TabBarProps {
+ tabs: TabItemProps[];
+ onTabClick: (tabKey: string) => void;
+}
diff --git a/src/common/types/userTypes.d.ts b/src/common/types/userTypes.ts
similarity index 51%
rename from src/common/types/userTypes.d.ts
rename to src/common/types/userTypes.ts
index b82f3856..b6b2adff 100644
--- a/src/common/types/userTypes.d.ts
+++ b/src/common/types/userTypes.ts
@@ -1,34 +1,31 @@
-declare type UserInfo = {
- avatarUrl: string; // 用户头像的URL
- nickName: string; // 用户昵称
+export type UserInfo = {
+ avatarUrl: string;
+ nickName: string;
};
-declare type ResponseLevel = {
+export type ResponseLevel = {
code?: number;
data: WebPointInfoVo;
msg?: string;
};
-declare type WebPointInfoVo = {
+export type WebPointInfoVo = {
level: number;
next_level_points: number;
points: number;
};
-declare type ResponseUser = {
+export type ResponseUser = {
code?: number;
data: WebUserProfileVo;
msg?: string;
};
-declare type WebUserProfileVo = {
+export type WebUserProfileVo = {
avatar: string;
ctime: number;
grade_sharing_is_signed?: boolean;
id: number;
- /**
- * 是否为新用户,新用户尚未编辑过个人信息
- */
new: boolean;
nickname: string;
studentId: string;
@@ -36,73 +33,42 @@ declare type WebUserProfileVo = {
using_title: string;
utime?: number;
};
-/**
- * ginx.Result
- */
-declare interface GradeResponse {
- /**
- * 错误码,非 0 表示失败
- */
+
+export interface GradeResponse {
code?: number;
data?: GradeChart[];
- /**
- * 错误或成功 描述
- */
msg?: string;
}
-/**
- * web.GradeChartVo
- */
-declare interface GradeChart {
+export interface GradeChart {
avg: number;
grades: Grade[];
}
-declare interface Grade {
+export interface Grade {
percent?: number;
total_grades: number[];
}
-/**
- * ginx.Result
- */
-declare interface ResponseQuestion {
- /**
- * 错误码,非 0 表示失败
- */
+
+export interface ResponseQuestion {
code?: number;
data: WebQuestionVo[];
- /**
- * 错误或成功 描述
- */
msg?: string;
}
-/**
- * web.QuestionVo
- */
-declare interface WebQuestionVo {
+export interface WebQuestionVo {
answer_cnt?: number;
- /**
- * 具体针对那种业务的提问,如 Course
- */
biz?: string;
biz_id?: number;
content: string;
ctime?: number;
id?: number;
preview_answers: Answerv1Answer[];
- /**
- * 提问者用户id
- */
questioner_id?: number;
utime?: number;
}
-/**
- * answerv1.Answer
- */
-declare interface Answerv1Answer {
+export interface Answerv1Answer {
content?: string;
ctime?: number;
id?: number;
diff --git a/src/common/utils/Nav.ts b/src/common/utils/Nav.ts
deleted file mode 100644
index 69f546b0..00000000
--- a/src/common/utils/Nav.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import Taro from '@tarojs/taro';
-
-export const Nav = (navPath: string) => {
- return Taro.navigateTo({ url: navPath });
-};
-export const Back = () => {
- return Taro.navigateBack();
-};
-export const Redirect = (redirectPath: string) => {
- return Taro.reLaunch({ url: redirectPath });
-};
diff --git a/src/common/utils/avatarDecoration.ts b/src/common/utils/avatarDecoration.ts
new file mode 100644
index 00000000..ab1374a7
--- /dev/null
+++ b/src/common/utils/avatarDecoration.ts
@@ -0,0 +1,13 @@
+export type AvatarFrame = 'default' | 'colorful';
+
+// 以此致敬blg战队于2024季中邀请赛中对战gen.g战队,被gen.g peyz 打出 28/2/7,泉水五杀的精彩表现
+const EASTER_EGG_MAP: Record = {
+ '28275': 'colorful',
+};
+
+export function resolveAvatarFrame(nickname?: string): AvatarFrame {
+ if (nickname && EASTER_EGG_MAP[nickname]) {
+ return EASTER_EGG_MAP[nickname];
+ }
+ return 'default';
+}
diff --git a/src/common/utils/checkSum.ts b/src/common/utils/checkSum.ts
new file mode 100644
index 00000000..d705b7c9
--- /dev/null
+++ b/src/common/utils/checkSum.ts
@@ -0,0 +1,15 @@
+/**
+ * Adler32 校验和计算
+ */
+export function calculateAdler32(arrayBuffer: ArrayBuffer): string {
+ const view = new Uint8Array(arrayBuffer);
+ let a = 1;
+ let b = 0;
+
+ for (let i = 0; i < view.length; i++) {
+ a = (a + view[i]) % 65521;
+ b = (b + a) % 65521;
+ }
+
+ return (((b << 16) | a) >>> 0).toString();
+}
diff --git a/src/common/utils/checkToken.ts b/src/common/utils/checkToken.ts
index 50899cd2..895ccc22 100644
--- a/src/common/utils/checkToken.ts
+++ b/src/common/utils/checkToken.ts
@@ -1,16 +1,25 @@
import Taro from '@tarojs/taro';
+import { hasStoredSession, isColdStartOnLoginPage } from '@/common/auth/session';
+
+const LOGIN_URL = '/pages/login/index';
+const MAIN_TAB_URL = '/pages/main/index';
+
const checkToken = () => {
- const token: string = Taro.getStorageSync('shortToken');
const accountInfo = Taro.getAccountInfoSync();
+
void Taro.setStorage({
key: 'accountInfo',
data: accountInfo,
});
- if (token) {
- void Taro.switchTab({ url: '/pages/main/index' });
- } else {
- void Taro.redirectTo({ url: '/pages/login/index' });
+
+ if (!hasStoredSession()) {
+ void Taro.redirectTo({ url: LOGIN_URL });
+ return;
+ }
+
+ if (isColdStartOnLoginPage()) {
+ void Taro.switchTab({ url: MAIN_TAB_URL });
}
};
diff --git a/src/common/utils/clipboard.ts b/src/common/utils/clipboard.ts
new file mode 100644
index 00000000..2bdd05fb
--- /dev/null
+++ b/src/common/utils/clipboard.ts
@@ -0,0 +1,24 @@
+import Taro from '@tarojs/taro';
+
+interface CopyOptions {
+ // 复制成功后的轻提示文案,默认「复制成功」
+ successText?: string;
+ // 复制失败后的轻提示文案,不传则失败时不提示
+ failText?: string;
+}
+
+// 统一的复制到剪贴板逻辑,避免各页面重复书写 setClipboardData + showToast
+export function copyToClipboard(data: string, options?: CopyOptions): void {
+ const { successText = '复制成功', failText } = options || {};
+ void Taro.setClipboardData({
+ data,
+ success: () => {
+ void Taro.showToast({ title: successText, icon: 'success', duration: 1500 });
+ },
+ fail: () => {
+ if (failText) {
+ void Taro.showToast({ title: failText, icon: 'none', duration: 1500 });
+ }
+ },
+ });
+}
diff --git a/src/common/utils/comment.ts b/src/common/utils/comment.ts
new file mode 100644
index 00000000..33443980
--- /dev/null
+++ b/src/common/utils/comment.ts
@@ -0,0 +1,19 @@
+import type { CommentInfo } from '@/common/types/commentTypes';
+
+// 昵称兜底:空白昵称显示为匿名用户
+export function displayNickname(nickname?: string): string {
+ const trimmed = nickname?.trim();
+ return trimmed || '匿名用户';
+}
+
+// 切换单条评论/课评的点赞态与点赞数
+export function toggleCommentLike(item: CommentInfo, willLike: boolean): CommentInfo {
+ return {
+ ...item,
+ stance: willLike ? 1 : 0,
+ total_support_count: Math.max(
+ 0,
+ (item.total_support_count || 0) + (willLike ? 1 : -1)
+ ),
+ };
+}
diff --git a/src/common/utils/evaluation.ts b/src/common/utils/evaluation.ts
new file mode 100644
index 00000000..affdeafc
--- /dev/null
+++ b/src/common/utils/evaluation.ts
@@ -0,0 +1,22 @@
+import Taro from '@tarojs/taro';
+
+import { ROUTES } from '@/common/constants/routes';
+import type { CommentInfo } from '@/common/types/commentTypes';
+import { useEvaluationStore } from '@/store/evaluations';
+
+/** URL 驱动打开课评详情:实体仓 upsert → 带 bizId 跳转 */
+export function navigateToEvaluationDetail(item: CommentInfo): void {
+ const bizId = Number(item.id);
+ if (!bizId) return;
+ useEvaluationStore.getState().upsertOne(item);
+ void Taro.navigateTo({ url: `${ROUTES.course.evaluateInfo}?bizId=${bizId}` });
+}
+
+/** 仅知 bizId 时打开详情(通知等入口) */
+export function navigateToEvaluationDetailById(bizId: number | string): void {
+ const id = Number(bizId);
+ if (!id) return;
+ const existing = useEvaluationStore.getState().get(id);
+ if (existing) useEvaluationStore.getState().upsertOne(existing);
+ void Taro.navigateTo({ url: `${ROUTES.course.evaluateInfo}?bizId=${id}` });
+}
diff --git a/src/common/utils/eventBus.ts b/src/common/utils/eventBus.ts
new file mode 100644
index 00000000..6e5254d4
--- /dev/null
+++ b/src/common/utils/eventBus.ts
@@ -0,0 +1,106 @@
+import type { CommentInfo } from '@/common/types/commentTypes';
+
+type Handler = (payload: T) => void;
+
+type OffFn = () => void;
+
+interface SubscriberDebugInfo {
+ stack: string;
+}
+
+interface EventMap {
+ question: {
+ id?: number;
+ content: string;
+ biz: string;
+ biz_id: number;
+ answer_cnt: number;
+ preview_answers: Record[];
+ };
+ evaluation: CommentInfo & { type?: string };
+ eval_item_removed: { id: number };
+ notification_list: { data: unknown[]; type: string };
+ feedback_detail: Record;
+}
+
+class EventBus {
+ private channels = new Map>();
+ private stickyPayloads = new Map();
+ private handlerDebug = new Map();
+
+ private captureDebugInfo(): SubscriberDebugInfo {
+ const stack = new Error().stack || '';
+ return { stack };
+ }
+
+ on<
+ K extends keyof EventMap | string,
+ T = K extends keyof EventMap ? EventMap[K] : unknown,
+ >(event: K, handler: Handler): OffFn {
+ let handlers = this.channels.get(event);
+ if (!handlers) {
+ handlers = new Set();
+ this.channels.set(event, handlers);
+ }
+ handlers.add(handler as Handler);
+ this.handlerDebug.set(handler as Handler, this.captureDebugInfo());
+
+ return () => {
+ handlers?.delete(handler as Handler);
+ this.handlerDebug.delete(handler as Handler);
+ };
+ }
+
+ off<
+ K extends keyof EventMap | string,
+ T = K extends keyof EventMap ? EventMap[K] : unknown,
+ >(event: K, handler: Handler): void {
+ this.channels.get(event)?.delete(handler as Handler);
+ this.handlerDebug.delete(handler as Handler);
+ }
+
+ emit<
+ K extends keyof EventMap | string,
+ T = K extends keyof EventMap ? EventMap[K] : unknown,
+ >(event: K, payload: T): void {
+ this.channels.get(event)?.forEach((fn) => fn(payload));
+ }
+
+ stickyEmit<
+ K extends keyof EventMap | string,
+ T = K extends keyof EventMap ? EventMap[K] : unknown,
+ >(event: K, payload: T): void {
+ this.stickyPayloads.set(event, payload);
+ this.emit(event, payload);
+ }
+
+ onSticky<
+ K extends keyof EventMap | string,
+ T = K extends keyof EventMap ? EventMap[K] : unknown,
+ >(event: K, handler: Handler): OffFn {
+ const sticky = this.stickyPayloads.get(event);
+ if (sticky !== undefined) {
+ handler(sticky as T);
+ }
+ return this.on(event, handler);
+ }
+
+ getSticky(
+ event: K
+ ): K extends keyof EventMap ? EventMap[K] | undefined : unknown | undefined {
+ return this.stickyPayloads.get(event) as any;
+ }
+
+ clear(event?: string): void {
+ if (event) {
+ this.channels.delete(event);
+ this.stickyPayloads.delete(event);
+ } else {
+ this.channels.clear();
+ this.stickyPayloads.clear();
+ this.handlerDebug.clear();
+ }
+ }
+}
+
+export const bus = new EventBus();
diff --git a/src/common/utils/fetch.ts b/src/common/utils/fetch.ts
deleted file mode 100644
index 2fc0e7c3..00000000
--- a/src/common/utils/fetch.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-/* eslint-disable @typescript-eslint/no-unsafe-return */
-import Taro from '@tarojs/taro';
-
-import { LoginResponseHeaders } from '../api/handleLogin';
-
-const preUrl = 'https://kstack.muxixyz.com';
-
-const header = {
- 'Content-Type': 'application/json;charset=utf-8',
-};
-
-const getToken = async () => {
- const res = await Taro.getStorage({ key: 'shortToken' });
- if (res.data) return res.data;
- void Taro.reLaunch({ url: '/pages/login/index' });
- throw new Error(`没token: ${res.errMsg as unknown as string}`);
-};
-
-const refreshToken = async () => {
- try {
- const longToken = await Taro.getStorage({ key: 'longToken' });
- if (!longToken.data) {
- void Taro.reLaunch({ url: '/pages/login/index' });
- throw new Error('没longToken');
- }
-
- const response = await Taro.request({
- url: `${preUrl}/users/refresh_token`,
- method: 'GET',
- header: {
- ...header,
- Authorization: `Bearer ${longToken.data}`,
- },
- });
-
- if (response.statusCode.toString().startsWith('2')) {
- const headers: LoginResponseHeaders = response.header;
- const shortToken = headers['X-Jwt-Token'];
- if (shortToken) {
- await Taro.setStorage({ key: 'shortToken', data: shortToken.toString() });
- }
- }
- throw new Error('刷新token失败');
- } catch (error) {
- if (Taro.getStorageSync('visitor') === true) return;
- void Taro.reLaunch({ url: '/pages/login/index' }).then(() => {
- void Taro.showToast({
- title: '登录过期 请刷新小程序重新登录',
- icon: 'none',
- });
- });
- throw error;
- }
-};
-
-const request = async (
- url = '',
- method: 'GET' | 'POST' | 'PUT' = 'GET',
- data = {},
- isToken = true
-) => {
- let token = isToken ? `Bearer ${await getToken()}` : '';
- header['Authorization'] = token ? `${token}` : '';
-
- try {
- const response = await Taro.request({
- url: `${preUrl}${url}`,
- method,
- header,
- data: method === 'POST' ? JSON.stringify(data) : data,
- });
-
- if (response.statusCode.toString().startsWith('2')) {
- return response.data;
- } else if (response.statusCode === 401) {
- await refreshToken();
- const newToken = await Taro.getStorage({ key: 'shortToken' });
- token = `Bearer ${newToken.data}`;
- header['Authorization'] = token;
-
- // 使用新 token 重试请求
- const retryResponse = await Taro.request({
- url: `${preUrl}${url}`,
- method,
- header,
- data: method === 'POST' ? JSON.stringify(data) : data,
- });
-
- if (retryResponse.statusCode.toString().startsWith('2')) {
- return retryResponse.data;
- }
- throw new Error(retryResponse.statusCode.toString());
- } else {
- const errorData = response.data as { code: number; msg: string };
- throw new Error(errorData.code.toString());
- }
- } catch (error) {
- // eslint-disable-next-line no-console
- console.error('error', error);
- throw error;
- }
-};
-
-export const post = (url = '', data = {}) => request(url, 'POST', data, true);
-export const get = (url = '') => request(url, 'GET', {}, true);
-
-const preUrl1 = 'https://miniprograms.muxixyz.com';
-const preUrl2 = 'https://miniprogramschanger.muxixyz.com';
-const request1 = async (url = '', method: 'GET' | 'POST' = 'GET', data = {}) => {
- try {
- const response = await Taro.request({
- //审核之前换一下url
- url: `${preUrl1}${url}`,
- method,
- header,
- data: method === 'POST' ? JSON.stringify(data) : data,
- });
-
- if (response.statusCode.toString().startsWith('2')) {
- return response.data;
- } else {
- const errorData = response.data as { code: number; msg: string };
- throw new Error(response.statusCode === 401 ? '401' : `${errorData.code}`);
- }
- } catch (error) {
- // eslint-disable-next-line no-console
- console.error('error', error);
- throw error;
- }
-};
-//为了躲避审核
-export const postBool = (url = '', data = {}): Promise =>
- request1(url, 'POST', data);
-
-export const put = (url = '', data = {}) => request(url, 'PUT', data, true);
diff --git a/src/common/utils/formatDate.ts b/src/common/utils/formatDate.ts
new file mode 100644
index 00000000..1eee8f0d
--- /dev/null
+++ b/src/common/utils/formatDate.ts
@@ -0,0 +1,99 @@
+type DateInput = string | number | Date;
+
+const toDate = (input: DateInput = new Date().toISOString()): Date => {
+ if (input instanceof Date) return input;
+ return new Date(input);
+};
+
+export const formatDate = (
+ input?: DateInput,
+ format: string = 'yyyy年MM月dd日'
+): string => {
+ const date = toDate(input);
+
+ const map: Record = {
+ yyyy: date.getFullYear().toString(),
+ MM: (date.getMonth() + 1).toString().padStart(2, '0'),
+ M: (date.getMonth() + 1).toString(),
+ dd: date.getDate().toString().padStart(2, '0'),
+ d: date.getDate().toString(),
+ hh: date.getHours().toString().padStart(2, '0'),
+ h: date.getHours().toString(),
+ mm: date.getMinutes().toString().padStart(2, '0'),
+ m: date.getMinutes().toString(),
+ ss: date.getSeconds().toString().padStart(2, '0'),
+ s: date.getSeconds().toString(),
+ };
+
+ return format.replace(/yyyy|MM|M|dd|d|hh|h|mm|m|ss|s/g, (match) => {
+ return map[match] || match;
+ });
+};
+
+export const formatIMTime = (
+ input: DateInput | undefined,
+ mustIncludeTime: boolean = true
+): string => {
+ const src = toDate(input);
+ const now = new Date();
+ const timePart = `${formatDate(src, 'hh:mm')}`;
+
+ const oneDay = 24 * 60 * 60 * 1000;
+
+ const currYear = now.getFullYear();
+ const currMonth = now.getMonth() + 1;
+ const currDate = now.getDate();
+
+ const srcYear = src.getFullYear();
+ const srcMonth = src.getMonth() + 1;
+ const srcDate = src.getDate();
+
+ // 同年
+ if (currYear === srcYear) {
+ // 同月
+ if (currMonth === srcMonth) {
+ // 同日
+ if (currDate === srcDate) {
+ const diff = now.getTime() - src.getTime();
+
+ if (diff < 60 * 1000) {
+ return '刚刚';
+ }
+ return timePart;
+ }
+
+ // 昨天
+ const yesterday = new Date(now.getTime() - oneDay);
+ if (srcDate === yesterday.getDate()) {
+ return `昨天${mustIncludeTime ? ` ${timePart}` : ''}`;
+ }
+
+ // 前天
+ const beforeYesterday = new Date(now.getTime() - 2 * oneDay);
+ if (srcDate === beforeYesterday.getDate()) {
+ return `前天${mustIncludeTime ? ` ${timePart}` : ''}`;
+ }
+
+ // 七天内
+ const diff = now.getTime() - src.getTime();
+ if (diff <= 7 * oneDay) {
+ const weekdays = [
+ '星期日',
+ '星期一',
+ '星期二',
+ '星期三',
+ '星期四',
+ '星期五',
+ '星期六',
+ ];
+ return `${weekdays[src.getDay()]}${mustIncludeTime ? ` ${timePart}` : ''}`;
+ }
+ }
+
+ // 同年但非同月
+ return formatDate(src, `M月d日${mustIncludeTime ? ' hh:mm' : ''}`);
+ }
+
+ // 跨年
+ return formatDate(src, `yyyy年M月d日${mustIncludeTime ? ' hh:mm' : ''}`);
+};
diff --git a/src/common/utils/formatIsoDate.ts b/src/common/utils/formatIsoDate.ts
deleted file mode 100644
index 7eaa6506..00000000
--- a/src/common/utils/formatIsoDate.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-const formatIsoDate = (isoDateString: string = new Date().toISOString()) => {
- const date = new Date(isoDateString);
-
- const year = date.getFullYear();
- const month = (date.getMonth() + 1).toString().padStart(2, '0');
- const day = date.getDate().toString().padStart(2, '0');
-
- return `${year}年${month}月${day}日`;
-};
-
-export default formatIsoDate;
diff --git a/src/common/utils/generateSemesterOptions.ts b/src/common/utils/generateSemesterOptions.ts
deleted file mode 100644
index 378235f0..00000000
--- a/src/common/utils/generateSemesterOptions.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { get } from '../api/get';
-
-export interface semesterOptionsType {
- yearOptions: string[];
- // semesterOptions: string[];
- currentSemester: { year: string; sem: string };
-}
-
-export const generateSemesterOptions = async (): Promise => {
- const response = (await get('/users/profile')) as ResponseUser;
- const { studentId } = response.data;
-
- const currentYear = new Date().getFullYear();
- const currentMonth = new Date().getMonth() + 1;
- const firstOfUserYear = Number(studentId?.slice(0, 4) ?? new Date().getFullYear());
-
- const yearOptions: string[] = [];
- // const semesterOptions: string[] = [];
- const currentSemester: { year: string; sem: string } = {} as {
- year: string;
- sem: string;
- };
- const currentSemesterNumber = currentMonth >= 3 ? 1 : currentMonth >= 6 ? 2 : 3;
-
- for (let year = firstOfUserYear; year <= currentYear; year++) {
- if (year === currentYear) {
- if (currentMonth >= 6) yearOptions.push(`${year}-${year + 1}学年`);
- } else {
- yearOptions.push(`${year}-${year + 1}学年`);
- }
- }
-
- if (currentSemesterNumber === 1) currentSemester.sem = '第一学期';
- else if (currentSemesterNumber === 2) currentSemester.sem = '第二学期';
- else if (currentSemesterNumber === 3) currentSemester.sem = '第三学期';
-
- currentSemester.year = yearOptions[yearOptions.length - 1];
-
- return {
- yearOptions,
- // semesterOptions,
- currentSemester,
- };
-};
diff --git a/src/common/utils/getFileInfo.ts b/src/common/utils/getFileInfo.ts
new file mode 100644
index 00000000..53ae1a3b
--- /dev/null
+++ b/src/common/utils/getFileInfo.ts
@@ -0,0 +1,35 @@
+import Taro from '@tarojs/taro';
+
+// 文件信息接口
+export interface FileInfo {
+ size: number;
+ arrayBuffer: ArrayBuffer;
+ fileName: string;
+ filePath: string;
+}
+
+/**
+ * 获取小程序文件信息
+ */
+export async function getFileInfo(filePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ Taro.getFileSystemManager().getFileInfo({
+ filePath,
+ success: (fileRes) => {
+ Taro.getFileSystemManager().readFile({
+ filePath,
+ success: (readRes) => {
+ resolve({
+ size: fileRes.size,
+ arrayBuffer: readRes.data as ArrayBuffer,
+ fileName: filePath.split('/').pop() || 'file',
+ filePath,
+ });
+ },
+ fail: (readErr) => reject(new Error(`读取失败: ${readErr.errMsg}`)),
+ });
+ },
+ fail: (fileErr) => reject(new Error(`文件不存在: ${fileErr.errMsg}`)),
+ });
+ });
+}
diff --git a/src/common/utils/getUserInfo.ts b/src/common/utils/getUserInfo.ts
deleted file mode 100644
index ddfd3e62..00000000
--- a/src/common/utils/getUserInfo.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-/* eslint-disable @typescript-eslint/no-unsafe-return */
-/* eslint-disable @typescript-eslint/no-unsafe-member-access */
-import { get } from '@/common/utils';
-
-const getUserInfo = (userId: number): Promise => {
- return get(`/users/${userId}/profile`).then((res) => {
- if (res.code === 0 && res.data) {
- return res.data;
- }
- throw new Error('User not found');
- });
-};
-
-export default getUserInfo;
diff --git a/src/common/utils/hasAuthenticatedSession.ts b/src/common/utils/hasAuthenticatedSession.ts
new file mode 100644
index 00000000..fdfe15c5
--- /dev/null
+++ b/src/common/utils/hasAuthenticatedSession.ts
@@ -0,0 +1 @@
+export { hasAuthenticatedSession } from '@/common/auth/session';
diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts
index 81ed3a1e..d77157a8 100644
--- a/src/common/utils/index.ts
+++ b/src/common/utils/index.ts
@@ -1,5 +1,13 @@
+export { resolveAvatarFrame } from './avatarDecoration';
+export type { AvatarFrame } from './avatarDecoration';
+export { calculateAdler32 } from './checkSum';
export { default as checkToken } from './checkToken';
-export { get, post } from './fetch';
-export { default as formatIsoDate } from './formatIsoDate';
-export { default as getUserInfo } from './getUserInfo';
+export { copyToClipboard } from './clipboard';
+export { displayNickname, toggleCommentLike } from './comment';
+export { bus } from './eventBus';
+export { formatDate, formatIMTime } from './formatDate';
+export { getFileInfo } from './getFileInfo';
+export type { FileInfo } from './getFileInfo';
export { default as uniqueKey } from './keyGen';
+export { createTaroJSONStorage, taroStorageAdapter } from './storage';
+export { hideLoadingThenToast } from './toast';
diff --git a/src/common/utils/isVisitor.ts b/src/common/utils/isVisitor.ts
new file mode 100644
index 00000000..b67a3498
--- /dev/null
+++ b/src/common/utils/isVisitor.ts
@@ -0,0 +1,7 @@
+import Taro from '@tarojs/taro';
+
+import { VISITOR } from '@/common/constants/auth';
+
+export function isVisitorMode(): boolean {
+ return !!Taro.getStorageSync(VISITOR);
+}
diff --git a/src/common/utils/keyGen.ts b/src/common/utils/keyGen.ts
index 82a4c1aa..91d0f5d7 100644
--- a/src/common/utils/keyGen.ts
+++ b/src/common/utils/keyGen.ts
@@ -1,5 +1,6 @@
const keyGen = function* () {
let count = 0;
+ // eslint-disable-next-line no-constant-condition -- infinite key sequence for list identity
while (true) {
yield count++;
}
diff --git a/src/common/utils/loadData.ts b/src/common/utils/loadData.ts
new file mode 100644
index 00000000..2e349be0
--- /dev/null
+++ b/src/common/utils/loadData.ts
@@ -0,0 +1,60 @@
+import type { DataSource, LoadResult, LoadStrategy } from '@/common/types/loadType';
+
+interface LoadOptions {
+ strategy: LoadStrategy;
+ force?: boolean;
+ /** network-first 失败时是否允许回退到本地缓存,默认 true */
+ allowStaleFallback?: boolean;
+
+ getCache: () => T | null | undefined;
+ fetch: () => Promise;
+ setCache: (data: T) => void;
+}
+
+export async function loadData(options: LoadOptions): Promise> {
+ const {
+ strategy,
+ getCache,
+ fetch,
+ setCache,
+ force = false,
+ allowStaleFallback = true,
+ } = options;
+
+ if (strategy === 'cache-first') {
+ if (!force) {
+ const cached = getCache();
+ if (cached != null) {
+ return { data: cached, source: 'cache' };
+ }
+ }
+ const data = await fetch();
+ setCache(data);
+ return { data, source: 'network' };
+ }
+
+ if (strategy === 'network-first') {
+ try {
+ const data = await fetch();
+ setCache(data);
+ return { data, source: 'network' };
+ } catch {
+ if (!allowStaleFallback) {
+ throw new Error('网络异常');
+ }
+ const stale = getCache();
+ if (stale != null) {
+ return { data: stale, source: 'fallback' };
+ }
+ throw new Error('网络异常且无本地缓存');
+ }
+ }
+
+ const data = await fetch();
+ return { data, source: 'network' };
+}
+
+export function sourceLabel(source: DataSource | null): string | null {
+ if (source === 'fallback') return '当前为离线内容';
+ return null;
+}
diff --git a/src/common/utils/resetSession.ts b/src/common/utils/resetSession.ts
new file mode 100644
index 00000000..aa3e7ee0
--- /dev/null
+++ b/src/common/utils/resetSession.ts
@@ -0,0 +1,74 @@
+import Taro from '@tarojs/taro';
+
+import { LONG_TOKEN, SHORT_TOKEN, VISITOR } from '@/common/constants/auth';
+import { STUDENT_ID, USER_INFO } from '@/common/constants/user';
+import { invalidateGateGuardCache } from '@/common/hooks/useGateGuard';
+import { bus } from '@/common/utils/eventBus';
+
+export type SessionResetReason = 'logout' | 'visitor' | 'auth-expired' | 'login';
+
+type SessionResetHandler = () => void;
+
+const handlers = new Set();
+
+export function registerSessionReset(handler: SessionResetHandler): void {
+ handlers.add(handler);
+}
+
+function runSessionResetHandlers(): void {
+ for (const handler of handlers) {
+ handler();
+ }
+}
+
+const USER_PERSIST_KEYS = [
+ 'muxi-user-store',
+ 'notification-store',
+ 'user_myClass_storage',
+ 'user_collections_storage',
+ 'evaluation_history_cache',
+ 'research-store',
+ 'feedback-faq-cache',
+] as const;
+
+const USER_STORAGE_KEYS = [USER_INFO, 'UserSheetToken', 'FAQToken'] as const;
+
+/** 登出 / 游客 / 401 / 正式登录前:统一清空用户域缓存与内存态 */
+export function resetSession(reason: SessionResetReason): void {
+ if (reason !== 'login') {
+ try {
+ Taro.removeStorageSync(SHORT_TOKEN);
+ Taro.removeStorageSync(LONG_TOKEN);
+ Taro.removeStorageSync(VISITOR);
+ Taro.removeStorageSync(STUDENT_ID);
+ } catch {
+ //
+ }
+ } else {
+ try {
+ Taro.removeStorageSync(VISITOR);
+ } catch {
+ //
+ }
+ }
+
+ for (const key of USER_PERSIST_KEYS) {
+ try {
+ Taro.removeStorageSync(key);
+ } catch {
+ //
+ }
+ }
+
+ for (const key of USER_STORAGE_KEYS) {
+ try {
+ Taro.removeStorageSync(key);
+ } catch {
+ //
+ }
+ }
+
+ runSessionResetHandlers();
+ bus.clear();
+ invalidateGateGuardCache();
+}
diff --git a/src/common/utils/storage.ts b/src/common/utils/storage.ts
new file mode 100644
index 00000000..86c5a9a4
--- /dev/null
+++ b/src/common/utils/storage.ts
@@ -0,0 +1,14 @@
+import Taro from '@tarojs/taro';
+import { createJSONStorage, type StateStorage } from 'zustand/middleware';
+
+export const taroStorageAdapter: StateStorage = {
+ getItem: (name: string) =>
+ Taro.getStorage({ key: name })
+ .then((res) => (res.data as string) ?? null)
+ .catch(() => null),
+ setItem: (name: string, value: string) =>
+ Taro.setStorage({ key: name, data: value }).then(() => undefined),
+ removeItem: (name: string) => Taro.removeStorage({ key: name }).then(() => undefined),
+};
+
+export const createTaroJSONStorage = () => createJSONStorage(() => taroStorageAdapter);
diff --git a/src/common/utils/toast.ts b/src/common/utils/toast.ts
new file mode 100644
index 00000000..3d262ecd
--- /dev/null
+++ b/src/common/utils/toast.ts
@@ -0,0 +1,7 @@
+import Taro from '@tarojs/taro';
+
+/** 先关闭 Loading 再弹 Toast,避免微信小程序里 hideLoading 吞掉 Toast */
+export function hideLoadingThenToast(options: Taro.showToast.Option): void {
+ void Taro.hideLoading();
+ void Taro.showToast(options);
+}
diff --git a/src/custom-tab-bar/index.scss b/src/custom-tab-bar/index.scss
index 1cd673e9..e0de650c 100644
--- a/src/custom-tab-bar/index.scss
+++ b/src/custom-tab-bar/index.scss
@@ -1,31 +1,60 @@
@import '~taro-ui/dist/style/components/icon.scss';
-
.guild_line {
+ @include flex_align_center;
+
width: 100vw;
- height: 11.5vh;
+ height: 7vh;
position: fixed;
bottom: 0;
- background-color: #f9f9f2;
- box-shadow: 0 -1vh 1vh rgba(0, 0, 0, 0.1);
- display: flex;
+ background-color: $color_bg_page;
+ box-shadow: 0 -8rpx 12rpx rgba(0, 0, 0, 0.1);
justify-content: space-evenly;
- align-items: center;
-}
+ padding: $space_xs 0;
-.add_button {
- width: 8vh;
- height: 8vh;
- background-color: #f18900;
- border-radius: 50%;
- display: flex;
- justify-content: center;
- align-items: center;
- position: relative;
-}
+ .tab-item-wrapper {
+ @include flex_column_center;
+
+ width: 6vh;
+ height: 6vh;
+
+ .tab-text {
+ font-size: 1.5vh;
+ color: $color_text_deep;
+ font-weight: 400;
+ transition: color 0.3s ease;
+
+ &.active {
+ color: $color_brand_accent;
+ font-weight: 500;
+ }
+ }
+ }
+
+ .add_button {
+ @include flex_center;
+
+ width: 6vh;
+ height: 6vh;
+ background-color: $color_brand_accent;
+ border-radius: $radius_round;
+ position: relative;
+
+ &::before {
+ content: '';
+ position: absolute;
+ width: 3vh;
+ height: 0.6vh;
+ background-color: white;
+ border-radius: 0.2vh;
+ }
-.add_sign {
- color: white;
- font-size: 8vh;
- position: relative;
- bottom: 0.5vh;
+ &::after {
+ content: '';
+ position: absolute;
+ width: 0.6vh;
+ height: 3vh;
+ background-color: white;
+ border-radius: 0.2vh;
+ }
+ }
}
diff --git a/src/custom-tab-bar/index.tsx b/src/custom-tab-bar/index.tsx
index 63d4ac39..7f9feea7 100644
--- a/src/custom-tab-bar/index.tsx
+++ b/src/custom-tab-bar/index.tsx
@@ -1,52 +1,109 @@
-import { View } from '@tarojs/components';
-import Taro from '@tarojs/taro';
-import React, { memo } from 'react';
+import { Text, View } from '@tarojs/components';
+import Taro, { useDidShow } from '@tarojs/taro';
+import React, { memo, useRef } from 'react';
import { AtIcon } from 'taro-ui';
import './index.scss';
-import useActiveButtonStore, { ActiveButtonType } from '@/common/hooks/useActiveNav';
-import { uniqueKey } from '@/common/utils';
+import { ActiveButtonType, useActiveButtonStore } from '@/store/app';
-const TAB_LIST: Array<{ pagePath: string; name: string; icon?: string }> = [
- { pagePath: '/pages/main/index', name: 'Home', icon: 'streaming' },
- { pagePath: '/pages/guide/index', name: 'Guide', icon: 'download-cloud' },
- { pagePath: '/pages/evaluate/index', name: '+' },
- { pagePath: '/pages/notification/index', name: 'Massage', icon: 'message' },
- { pagePath: '/pages/profile/index', name: 'Profile', icon: 'user' },
+const TAB_LIST: Array<{
+ pagePath: string;
+ name: string;
+ icon?: string;
+ content?: string;
+ matchPath?: string;
+}> = [
+ {
+ pagePath: '/pages/main/index',
+ name: 'Home',
+ icon: 'streaming',
+ content: '广场',
+ matchPath: 'pages/main/index',
+ },
+ {
+ pagePath: '/pages/guide/index',
+ name: 'Guide',
+ icon: 'download-cloud',
+ content: '手册',
+ matchPath: 'pages/guide/index',
+ },
+ { pagePath: '/pages/myclass/index', name: '+' },
+ {
+ pagePath: '/pages/notification/main/index',
+ name: 'Message',
+ icon: 'message',
+ content: '消息',
+ matchPath: 'pages/notification/main/index',
+ },
+ {
+ pagePath: '/pages/profile/index',
+ name: 'Profile',
+ icon: 'user',
+ content: '我的',
+ matchPath: 'pages/profile/index',
+ },
];
const TabBar: React.FC = memo(() => {
const { activeButton, setActiveButton } = useActiveButtonStore();
+ const lastClickTime = useRef(0);
+
+ useDidShow(() => {
+ // 自定义 tabBar 里 getCurrentInstance().router 不可靠,改用页面栈读当前页,
+ // 保证重登后回到广场时高亮能正确归位
+ const pages = Taro.getCurrentPages();
+ const currentPage = pages[pages.length - 1]?.route || '';
+ const matched = TAB_LIST.find(
+ (item) => item.matchPath && currentPage.includes(item.matchPath)
+ );
+ if (matched?.name && matched.name !== '+') {
+ setActiveButton(matched.name as ActiveButtonType);
+ }
+ });
+
+ const handleTabClick = (item: (typeof TAB_LIST)[number]) => {
+ const now = Date.now();
+ if (now - lastClickTime.current < 300) return;
+ lastClickTime.current = now;
+
+ if (item.name === '+') {
+ void Taro.navigateTo({ url: '/pages/myclass/index' });
+ } else {
+ setActiveButton(item.name as ActiveButtonType);
+ void Taro.switchTab({ url: item.pagePath });
+ }
+ };
return (
{TAB_LIST.map((item) => (
- <>
+
{item.name === '+' ? (
-
- {
- void Taro.navigateTo({ url: '/pages/evaluate/index' });
- }}
- >
- +
-
-
+ {
+ handleTabClick(item);
+ }}
+ >
) : (
- {
- void Taro.switchTab({ url: item.pagePath });
- setActiveButton(item.name as ActiveButtonType);
+ handleTabClick(item);
}}
- >
+ >
+
+
+ {item.content}
+
+
)}
- >
+
))}
);
diff --git a/src/modules/guide/components/Contact.tsx b/src/modules/guide/components/Contact.tsx
index 335e2420..82e68a94 100644
--- a/src/modules/guide/components/Contact.tsx
+++ b/src/modules/guide/components/Contact.tsx
@@ -1,18 +1,13 @@
import { Text, View } from '@tarojs/components';
-import Taro from '@tarojs/taro';
import { memo } from 'react';
import { AtIcon } from 'taro-ui';
+import { copyToClipboard } from '@/common/utils';
+
const Contact: React.FC = memo(() => {
const handleCopy = () => {
- void Taro.setClipboardData({
- data: 'https://jwc.ccnu.edu.cn/info/1048/9828.htm',
- success: () => {
- void Taro.showToast({
- title: '已复制链接,请在浏览器中打开',
- icon: 'success',
- });
- },
+ copyToClipboard('https://jwc.ccnu.edu.cn/info/1048/9828.htm', {
+ successText: '已复制链接,请在浏览器中打开',
});
};
return (
diff --git a/src/modules/guide/components/Guide.tsx b/src/modules/guide/components/Guide.tsx
index f2f7cfcf..13f997c7 100644
--- a/src/modules/guide/components/Guide.tsx
+++ b/src/modules/guide/components/Guide.tsx
@@ -1,30 +1,15 @@
-import { View } from '@tarojs/components';
-import { memo, useState } from 'react';
+import { Text, View } from '@tarojs/components';
+import { memo } from 'react';
import { NavigationBar } from '@/modules/navigation';
-import Contact from './Contact';
-import Selector from './Selector';
-import Source from './Source';
-
const Guide: React.FC = memo(() => {
- const [selection, setSelection] = useState<{ year: string; term: string }>({
- year: '全部',
- term: '全部',
- });
- const [isOpen, setIsOpen] = useState(false);
return (
-
+
-
-
-
-
+
+ 敬请期待
+
);
});
diff --git a/src/modules/guide/components/Selector.tsx b/src/modules/guide/components/Selector.tsx
index bccbf2bb..03253683 100644
--- a/src/modules/guide/components/Selector.tsx
+++ b/src/modules/guide/components/Selector.tsx
@@ -10,16 +10,18 @@ type SelectType = '学年' | '学期';
interface SelectProps {
type: SelectType;
value: string;
- setIsOpen: React.Dispatch>;
style?: CSSProperties;
+
+ setIsOpen: React.Dispatch>;
}
interface SelectorProps {
selection: { year: string; term: string };
isOpen: boolean;
+ children: React.ReactNode;
+
setSelection: React.Dispatch>;
setIsOpen: React.Dispatch>;
- children: React.ReactNode;
}
const Times = () => {
@@ -65,7 +67,7 @@ const Selector: React.FC = memo(
({ selection, isOpen, setSelection, setIsOpen, children }) => (
<>
- {['学年', '学期'].map((item: SelectType) => (
+ {(['学年', '学期'] as SelectType[]).map((item) => (