├── .dockerignore ├── .eslintrc.json ├── .github └── workflows │ └── react.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── Dockerfile ├── LICENSE ├── README.md ├── jest.config.js ├── jest.setup.js ├── next-env.d.ts ├── next.config.js ├── package.json ├── public ├── favicon.ico └── react.svg ├── src ├── components │ ├── __tests__ │ │ ├── announcement-list.test.tsx │ │ ├── course-detail-card.test.tsx │ │ ├── course-item.test.tsx │ │ └── review-reaction-button.test.tsx │ ├── about-card.tsx │ ├── account-login-form.tsx │ ├── announcement-list.tsx │ ├── contact-email.tsx │ ├── course-detail-card.tsx │ ├── course-filter-card.tsx │ ├── course-item.tsx │ ├── course-list.tsx │ ├── email-login-form.tsx │ ├── email-password-login-form.tsx │ ├── icon-text.tsx │ ├── image-promotion.tsx │ ├── layouts.tsx │ ├── md-editor.tsx │ ├── md-preview.tsx │ ├── navbar.tsx │ ├── page-header.tsx │ ├── promotion-card.tsx │ ├── related-card.tsx │ ├── report-list.tsx │ ├── report-modal.tsx │ ├── reset-password-form.tsx │ ├── review-filter.tsx │ ├── review-item.tsx │ ├── review-list.tsx │ ├── review-rating-trend.tsx │ ├── review-reaction-button.tsx │ └── review-revision-modal.tsx ├── config │ ├── config.ts │ └── touchpoint.ts ├── lib │ ├── context.ts │ ├── models.ts │ └── utils.ts ├── pages │ ├── 404.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── about.tsx │ ├── activity.tsx │ ├── course │ │ └── [id].tsx │ ├── courses.tsx │ ├── faq.tsx │ ├── follow-course.tsx │ ├── follow-review.tsx │ ├── latest.tsx │ ├── login.tsx │ ├── point.tsx │ ├── preference.tsx │ ├── report.tsx │ ├── review │ │ └── [id].tsx │ ├── search.tsx │ ├── statistics.tsx │ ├── sync.tsx │ └── write-review.tsx ├── services │ ├── announcement.ts │ ├── common.ts │ ├── course.ts │ ├── promotion.ts │ ├── report.ts │ ├── request.ts │ ├── review.ts │ ├── semester.ts │ ├── statistic.ts │ ├── sync.ts │ └── user.ts └── styles │ ├── custom.ant.css │ └── global.css ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .next 7 | .git 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/react.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [ 18.x ] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'yarn' 25 | - name: Install Dependencies 26 | run: yarn install --frozen-lockfile 27 | - name: Run Tests 28 | run: yarn test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | /.swc/ 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | .pnpm-debug.log* 28 | 29 | # local env files 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | .next 5 | .swc 6 | styles -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false, 6 | "importOrder": ["^[./]", "^@/(.*)$"], 7 | "importOrderSeparation": true, 8 | "importOrderSortSpecifiers": true 9 | } 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Install dependencies only when needed 2 | FROM node:20-alpine AS deps 3 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 4 | RUN sed -i 's/dl-cdn.alpinelinux.org/mirror.sjtu.edu.cn/g' /etc/apk/repositories && apk add --no-cache libc6-compat 5 | WORKDIR /app 6 | COPY package.json yarn.lock ./ 7 | RUN yarn config set registry https://registry.npmmirror.com 8 | RUN yarn install --frozen-lockfile 9 | 10 | # If using npm with a `package-lock.json` comment out above and use below instead 11 | # COPY package.json package-lock.json ./ 12 | # RUN npm ci 13 | 14 | # Rebuild the source code only when needed 15 | FROM node:20-alpine AS builder 16 | WORKDIR /app 17 | COPY --from=deps /app/node_modules ./node_modules 18 | COPY . . 19 | 20 | # Next.js collects completely anonymous telemetry data about general usage. 21 | # Learn more here: https://nextjs.org/telemetry 22 | # Uncomment the following line in case you want to disable telemetry during the build. 23 | # ENV NEXT_TELEMETRY_DISABLED 1 24 | 25 | RUN yarn build 26 | 27 | # If using npm comment out above and use below instead 28 | # RUN npm run build 29 | 30 | # Production image, copy all the files and run next 31 | FROM node:20-alpine AS runner 32 | WORKDIR /app 33 | 34 | ENV NODE_ENV production 35 | # Uncomment the following line in case you want to disable telemetry during runtime. 36 | # ENV NEXT_TELEMETRY_DISABLED 1 37 | 38 | RUN addgroup --system --gid 1001 nodejs 39 | RUN adduser --system --uid 1001 nextjs 40 | 41 | # You only need to copy next.config.js if you are NOT using the default configuration 42 | COPY --from=builder /app/next.config.js ./ 43 | COPY --from=builder /app/public ./public 44 | COPY --from=builder /app/package.json ./package.json 45 | 46 | # Automatically leverage output traces to reduce image size 47 | # https://nextjs.org/docs/advanced-features/output-file-tracing 48 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 49 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 50 | 51 | USER nextjs 52 | 53 | EXPOSE 3000 54 | 55 | ENV PORT 3000 56 | 57 | CMD ["node", "server.js"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Du Jiajun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jCourse:SJTU 选课社区 2 | 3 | ## 开始使用 4 | 5 | 使用 [Next.js](https://nextjs.org/) 框架, UI 库选用 [Ant Design](https://github.com/ant-design/ant-design/)。 6 | 7 | 安装依赖 8 | 9 | ```bash 10 | $ yarn 11 | ``` 12 | 13 | 本地调试 14 | 15 | ```bash 16 | $ yarn dev 17 | ``` 18 | 19 | 构建 20 | 21 | ```bash 22 | $ yarn build 23 | ``` 24 | 25 | ## 后端服务 26 | 27 | 请见: https://github.com/dujiajun/jcourse_api 28 | 29 | ## Learn More 30 | 31 | To learn more about Next.js, take a look at the following resources: 32 | 33 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 34 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 35 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const nextJest = require('next/jest') 2 | 3 | const createJestConfig = nextJest({ 4 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 5 | dir: './', 6 | }) 7 | 8 | // Add any custom config to be passed to Jest 9 | /** @type {import('jest').Config} */ 10 | const customJestConfig = { 11 | // Add more setup options before each test is run 12 | // setupFilesAfterEnv: ['/jest.setup.js'], 13 | // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work 14 | moduleDirectories: ['node_modules', '/'], 15 | testEnvironment: 'jest-environment-jsdom', 16 | setupFiles: ['/jest.setup.js'], 17 | moduleNameMapper: { 18 | "^@/(.*)$": "/src/$1" 19 | } 20 | } 21 | 22 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 23 | module.exports = createJestConfig(customJestConfig) 24 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | // const React = require('react'); 2 | const util = require('util'); 3 | 4 | // eslint-disable-next-line no-console 5 | // console.log('Current React Version:', React.version); 6 | 7 | /* eslint-disable global-require */ 8 | if (typeof window !== 'undefined') { 9 | global.window.resizeTo = (width, height) => { 10 | global.window.innerWidth = width || global.window.innerWidth; 11 | global.window.innerHeight = height || global.window.innerHeight; 12 | global.window.dispatchEvent(new Event('resize')); 13 | }; 14 | global.window.scrollTo = () => {}; 15 | // ref: https://github.com/ant-design/ant-design/issues/18774 16 | if (!window.matchMedia) { 17 | Object.defineProperty(global.window, 'matchMedia', { 18 | writable: true, 19 | configurable: true, 20 | value: jest.fn(query => ({ 21 | matches: query.includes('max-width'), 22 | addListener: jest.fn(), 23 | removeListener: jest.fn(), 24 | })), 25 | }); 26 | } 27 | 28 | // Fix css-animation or rc-motion deps on these 29 | // https://github.com/react-component/motion/blob/9c04ef1a210a4f3246c9becba6e33ea945e00669/src/util/motion.ts#L27-L35 30 | // https://github.com/yiminghe/css-animation/blob/a5986d73fd7dfce75665337f39b91483d63a4c8c/src/Event.js#L44 31 | window.AnimationEvent = window.AnimationEvent || window.Event; 32 | window.TransitionEvent = window.TransitionEvent || window.Event; 33 | 34 | // ref: https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom 35 | // ref: https://github.com/jsdom/jsdom/issues/2524 36 | Object.defineProperty(window, 'TextEncoder', { writable: true, value: util.TextEncoder }); 37 | Object.defineProperty(window, 'TextDecoder', { writable: true, value: util.TextDecoder }); 38 | } 39 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | output: "standalone", 4 | basePath: process.env.BASE_PATH || undefined, 5 | reactStrictMode: true, 6 | async redirects() { 7 | return [ 8 | { 9 | source: "/", 10 | destination: "/latest", 11 | permanent: true, 12 | } 13 | ]; 14 | }, 15 | async rewrites() { 16 | if (process.env.REMOTE_URL) { 17 | return [ 18 | { 19 | source: "/api/:path*", 20 | destination: `${process.env.REMOTE_URL}/api/:path*/`, 21 | }, 22 | { 23 | source: "/oauth/:path*", 24 | destination: `${process.env.REMOTE_URL}/oauth/:path*/`, 25 | }, 26 | { 27 | source: "/upload/:path*", 28 | destination: `${process.env.REMOTE_URL}/upload/:path*/`, 29 | }, 30 | { 31 | source: "/static/:path*", 32 | destination: `${process.env.REMOTE_URL}/static/:path*/`, 33 | }, 34 | ]; 35 | } else return []; 36 | }, 37 | transpilePackages: ['ahooks'] 38 | }; 39 | 40 | module.exports = nextConfig; 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jcourse_nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "jest", 11 | "format": "prettier --write src" 12 | }, 13 | "dependencies": { 14 | "@ant-design/icons": "^5.3.6", 15 | "ahooks": "^3.7.11", 16 | "antd": "^5.16.1", 17 | "axios": "^1.6.8", 18 | "next": "^13.5.4", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "react-markdown": "^9.0.1", 22 | "react-responsive": "^10.0.0", 23 | "recharts": "^2.12.4", 24 | "rehype-sanitize": "^6.0.0", 25 | "remark-breaks": "^4.0.0", 26 | "remark-gfm": "^4.0.0", 27 | "swr": "^2.2.5" 28 | }, 29 | "devDependencies": { 30 | "@testing-library/jest-dom": "^6.4.2", 31 | "@testing-library/react": "^14.3.0", 32 | "@testing-library/user-event": "^14.5.2", 33 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 34 | "@types/node": "^20.12.7", 35 | "@types/react": "^18.2.75", 36 | "@types/react-dom": "^18.2.24", 37 | "eslint": "^9.0.0", 38 | "eslint-config-next": "^13.5.4", 39 | "eslint-config-prettier": "^9.1.0", 40 | "jest": "^29.7.0", 41 | "jest-environment-jsdom": "^29.7.0", 42 | "less": "^4.2.0", 43 | "prettier": "^3.2.5", 44 | "typescript": "^5.4.4" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SJTU-jCourse/jcourse/0004d9b6f34d39212e616ed256c9d8580713482b/public/favicon.ico -------------------------------------------------------------------------------- /public/react.svg: -------------------------------------------------------------------------------- 1 | 2 | React Logo 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/__tests__/announcement-list.test.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { render, screen } from "@testing-library/react"; 3 | 4 | import AnnouncementList from "@/components/announcement-list"; 5 | import { Announcement } from "@/lib/models"; 6 | 7 | describe("announcement list", () => { 8 | it("shows nothing to a empty list", () => { 9 | const announcements: Announcement[] = []; 10 | render(); 11 | expect(screen.getByText("No data")).toBeInTheDocument(); 12 | }); 13 | 14 | it("shows an item format without url", () => { 15 | const announcements: Announcement[] = [ 16 | { title: "title", message: "message", created_at: "2022", url: null }, 17 | ]; 18 | render(); 19 | expect(screen.queryByText("title")).not.toBeInTheDocument(); 20 | expect(screen.getByText("message")).toBeInTheDocument(); 21 | expect(screen.queryByText("2022")).not.toBeInTheDocument(); 22 | expect(screen.queryByText("相关链接")).not.toBeInTheDocument(); 23 | }); 24 | 25 | it("shows an item format with url", () => { 26 | const announcements: Announcement[] = [ 27 | { title: "title", message: "message", created_at: "2022", url: "url" }, 28 | ]; 29 | render(); 30 | expect(screen.getByText("相关链接")).toBeInTheDocument(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/components/__tests__/course-detail-card.test.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { render, screen } from "@testing-library/react"; 3 | import userEvent from "@testing-library/user-event"; 4 | 5 | import CourseDetailCard from "@/components/course-detail-card"; 6 | import { CourseDetail } from "@/lib/models"; 7 | 8 | describe("course detail card", () => { 9 | let course: CourseDetail; 10 | beforeEach(() => { 11 | course = { 12 | id: 1, 13 | code: "test001", 14 | categories: [], 15 | department: "测试单位", 16 | name: "测试课程", 17 | credit: 6, 18 | main_teacher: { 19 | tid: "TA001", 20 | name: "高女士", 21 | }, 22 | teacher_group: [ 23 | { 24 | tid: "TA001", 25 | name: "高女士", 26 | }, 27 | ], 28 | rating: { avg: 0, count: 0 }, 29 | moderator_remark: null, 30 | related_teachers: [], 31 | related_courses: [], 32 | notification_level: null, 33 | }; 34 | }); 35 | 36 | it("shows nothing to undefined course", () => { 37 | render(); 38 | expect(screen.getByText("课程信息")).toBeInTheDocument(); 39 | }); 40 | 41 | it("shows loading", () => { 42 | render(); 43 | expect(screen.queryByText("课号")).not.toBeInTheDocument(); 44 | }); 45 | 46 | it("shows course necessary fields", () => { 47 | render(); 48 | expect(screen.getByText("课号")).toBeInTheDocument(); 49 | expect(screen.getByText("课程学分")).toBeInTheDocument(); 50 | expect(screen.getByText("开课单位")).toBeInTheDocument(); 51 | expect(screen.queryByText("课程类别")).not.toBeInTheDocument(); 52 | expect(screen.queryByText("合上教师")).not.toBeInTheDocument(); 53 | expect(screen.getByText("信息有误?")).toBeInTheDocument(); 54 | expect(screen.queryByText("推荐指数")).not.toBeInTheDocument(); 55 | expect(screen.queryByText("通知级别")).toBeInTheDocument(); 56 | expect(screen.queryByText("正常")).toBeInTheDocument(); 57 | }); 58 | 59 | it("shows all fields", () => { 60 | course.teacher_group = [ 61 | { 62 | tid: "TA001", 63 | name: "高女士", 64 | }, 65 | { 66 | tid: "TA002", 67 | name: "梁女士", 68 | }, 69 | ]; 70 | 71 | course.moderator_remark = "remark"; 72 | course.rating = { avg: 1.567, count: 20 }; 73 | course.categories = ["通识", "通选"]; 74 | course.notification_level = 1; 75 | 76 | render( 77 | 82 | ); 83 | 84 | expect(screen.queryByText("课程类别")).toBeInTheDocument(); 85 | expect(screen.queryByText("通识,通选")).toBeInTheDocument(); 86 | expect(screen.queryByText("合上教师")).toBeInTheDocument(); 87 | expect(screen.queryByText("高女士,梁女士")).toBeInTheDocument(); 88 | expect(screen.queryByText("推荐指数")).toBeInTheDocument(); 89 | expect(screen.queryByText("1.6")).toBeInTheDocument(); 90 | expect(screen.queryByText("(20人评价)")).toBeInTheDocument(); 91 | expect(screen.queryByText("备注")).toBeInTheDocument(); 92 | expect(screen.queryByText("remark")).toBeInTheDocument(); 93 | expect(screen.queryByText("学过学期")).toBeInTheDocument(); 94 | expect(screen.queryByText("2020-2021-1")).toBeInTheDocument(); 95 | expect(screen.queryByText("关注")).toBeInTheDocument(); 96 | }); 97 | 98 | it("shows modal when clicks button", async () => { 99 | render(); 100 | const correctButton = screen.getByText("信息有误?"); 101 | await userEvent.click(correctButton); 102 | expect(screen.queryByText("课程信息反馈")).toBeInTheDocument(); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/components/__tests__/course-item.test.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { render, screen } from "@testing-library/react"; 3 | 4 | import CourseItem from "@/components/course-item"; 5 | import { CommonInfo, CourseListItem } from "@/lib/models"; 6 | import { CommonInfoContext } from "@/lib/context"; 7 | 8 | const getTestCommonInfo = () => { 9 | const commonInfo = { 10 | announcements: [], 11 | semesters: [], 12 | available_semesters: [], 13 | user: { 14 | id: 0, 15 | username: "", 16 | is_staff: false, 17 | account: null, 18 | }, 19 | semesterMap: new Map(), 20 | my_reviews: new Map(), 21 | enrolled_courses: new Map(), 22 | reviewed_courses: new Map(), 23 | }; 24 | return commonInfo; 25 | }; 26 | 27 | describe("course detail card", () => { 28 | let course: CourseListItem; 29 | let commonInfo: CommonInfo; 30 | beforeEach(() => { 31 | course = { 32 | id: 12345, 33 | code: "test001", 34 | categories: [], 35 | department: "测试单位", 36 | name: "测试课程", 37 | credit: 2, 38 | teacher: "高女士", 39 | rating: { count: 0, avg: 0 }, 40 | }; 41 | commonInfo = getTestCommonInfo(); 42 | }); 43 | it("shows minimal info", () => { 44 | render( 45 | 46 | 47 | 48 | ); 49 | expect( 50 | screen.queryByText("test001 测试课程(高女士)") 51 | ).toBeInTheDocument(); 52 | expect( 53 | screen.queryByText("test001 测试课程(高女士)")?.getAttribute("href") 54 | ).toBe("/course/12345"); 55 | expect(screen.queryByText("2学分 测试单位")).toBeInTheDocument(); 56 | expect(screen.queryByText("暂无点评")).toBeInTheDocument(); 57 | expect(screen.queryByText("学过")).not.toBeInTheDocument(); 58 | expect(screen.queryByText("已点评")).not.toBeInTheDocument(); 59 | }); 60 | it("shows rating", () => { 61 | course.rating = { count: 10, avg: 1.666 }; 62 | render( 63 | 64 | 65 | 66 | ); 67 | expect(screen.queryByText("1.7")).toBeInTheDocument(); 68 | expect(screen.queryByText("10人评价")).toBeInTheDocument(); 69 | expect(screen.queryByText("暂无点评")).not.toBeInTheDocument(); 70 | }); 71 | it("shows enroll tag", () => { 72 | commonInfo.enrolled_courses.set(course.id, { 73 | course_id: course.id, 74 | semester_id: 1, 75 | }); 76 | render( 77 | 78 | 79 | 80 | ); 81 | expect(screen.queryByText("学过")).not.toBeInTheDocument(); 82 | render( 83 | 84 | 85 | 86 | ); 87 | expect(screen.queryByText("学过")).toBeInTheDocument(); 88 | }); 89 | it("shows category tags", () => { 90 | course.categories = ["通识", "通选"]; 91 | render( 92 | 93 | 94 | 95 | ); 96 | expect(screen.queryByText("通识")).toBeInTheDocument(); 97 | expect(screen.queryByText("通选")).toBeInTheDocument(); 98 | }); 99 | it("shows category tags", () => { 100 | course.categories = ["通识", "通选"]; 101 | render(); 102 | expect(screen.queryByText("通识")).toBeInTheDocument(); 103 | expect(screen.queryByText("通选")).toBeInTheDocument(); 104 | }); 105 | it("shows review tags", () => { 106 | commonInfo.reviewed_courses.set(course.id, { 107 | course_id: course.id, 108 | semester_id: 1, 109 | id: 12345, 110 | }); 111 | render( 112 | 113 | 114 | 115 | ); 116 | expect(screen.queryByText("已点评")).toBeInTheDocument(); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /src/components/__tests__/review-reaction-button.test.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { render, screen } from "@testing-library/react"; 3 | import userEvent from "@testing-library/user-event"; 4 | 5 | import ReviewReactionButton from "@/components/review-reaction-button"; 6 | import { ReviewReaction } from "@/lib/models"; 7 | 8 | describe("review reaction button", () => { 9 | let approveButton: HTMLElement; 10 | let disapproveButton: HTMLElement; 11 | let onReaction: jest.Mock<(id: number, reaction: number) => void>; 12 | beforeEach(() => { 13 | onReaction = jest.fn(); 14 | const reaction: ReviewReaction = { id: 1, approves: 10, disapproves: 20 }; 15 | render( 16 | 17 | ); 18 | approveButton = screen.getByText("10"); 19 | disapproveButton = screen.getByText("20"); 20 | }); 21 | 22 | it("renders two buttons", () => { 23 | expect(approveButton).toBeInTheDocument(); 24 | expect(disapproveButton).toBeInTheDocument(); 25 | }); 26 | 27 | it("clicks approve and reset", async () => { 28 | await userEvent.click(approveButton); 29 | expect(approveButton).toHaveTextContent("11"); 30 | expect(disapproveButton).toHaveTextContent("20"); 31 | expect(onReaction).toHaveBeenCalledWith(1, 1); 32 | await userEvent.click(approveButton); 33 | 34 | expect(approveButton).toHaveTextContent("10"); 35 | expect(disapproveButton).toHaveTextContent("20"); 36 | expect(onReaction).toHaveBeenCalledWith(1, 0); 37 | expect(onReaction).toHaveBeenCalledTimes(2); 38 | }); 39 | 40 | it("clicks disapprove and reset", async () => { 41 | await userEvent.click(disapproveButton); 42 | expect(approveButton).toHaveTextContent("10"); 43 | expect(disapproveButton).toHaveTextContent("21"); 44 | expect(onReaction).toHaveBeenCalledTimes(1); 45 | expect(onReaction).toHaveBeenCalledWith(1, -1); 46 | 47 | await userEvent.click(disapproveButton); 48 | expect(approveButton).toHaveTextContent("10"); 49 | expect(disapproveButton).toHaveTextContent("20"); 50 | expect(onReaction).toHaveBeenCalledWith(1, 0); 51 | expect(onReaction).toHaveBeenCalledTimes(2); 52 | }); 53 | 54 | it("clicks and exchange", async () => { 55 | await userEvent.click(disapproveButton); 56 | expect(approveButton).toHaveTextContent("10"); 57 | expect(disapproveButton).toHaveTextContent("21"); 58 | expect(onReaction).toHaveBeenCalledWith(1, -1); 59 | expect(onReaction).toHaveBeenCalledTimes(1); 60 | 61 | await userEvent.click(approveButton); 62 | expect(approveButton).toHaveTextContent("11"); 63 | expect(disapproveButton).toHaveTextContent("20"); 64 | expect(onReaction).toHaveBeenCalledWith(1, 1); 65 | expect(onReaction).toHaveBeenCalledTimes(2); 66 | 67 | await userEvent.click(disapproveButton); 68 | expect(approveButton).toHaveTextContent("10"); 69 | expect(disapproveButton).toHaveTextContent("21"); 70 | expect(onReaction).toHaveBeenCalledWith(1, -1); 71 | expect(onReaction).toHaveBeenCalledTimes(3); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/components/about-card.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from "antd"; 2 | 3 | import ContactEmail from "@/components/contact-email"; 4 | 5 | const { Paragraph, Title } = Typography; 6 | 7 | const AboutCard = () => { 8 | return ( 9 | 10 | 11 | 简介 12 | 13 | 14 | SJTU选课社区为非官方网站。选课社区目的在于让同学们了解课程的更多情况,不想也不能代替教务处的课程评教。 15 | 16 | 机制 17 | 匿名身份 18 | 19 | 选课社区采用 jAccount 或者邮箱登录并作为身份标识。本站不明文存储您的 20 | jAccount 用户名,仅在数据库中存放其哈希值。 21 |
22 | 选课社区前台不显示每条点评的用户名,也不显示不同点评之间的用户关联。 23 |
24 | 点评管理 25 | 26 | 在符合社区规范的情况下,我们不修改选课社区的点评内容,也不评价内容的真实性。 27 | 如果您上过某一门课程并认为网站上的点评与事实不符,欢迎提交您的意见, 28 | 我们相信全面的信息会给大家最好的答案。 29 |
30 | 选课社区管理员的责任仅限于维护系统的稳定,删除非课程点评内容和重复发帖,并维护课程和教师信息格式, 31 | 方便进行数据的批量处理。 32 |
33 | 隐私 34 | 35 | 当您访问选课社区时,我们使用百度统计收集您的访问信息,便于统计用户使用情况。 36 |
37 | 当您登录选课社区时,我们会收集您的身份类型(在校生、教职工、校友等),但不收集除此以外的其他信息。 38 |
39 | 选课社区部分功能可能需要使用 jAccount 40 | 接口获取并存储选课等信息,我们将在您使用此类功能前予以提示。 41 |
42 | 联系方式 43 | 44 | 您可以通过邮件 45 | 46 | 联系我们。 47 | 48 |
49 | ); 50 | }; 51 | 52 | export default AboutCard; 53 | -------------------------------------------------------------------------------- /src/components/account-login-form.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Form, Input } from "antd"; 2 | 3 | import { AccountLoginRequest } from "@/lib/models"; 4 | 5 | const AccountLoginForm = ({ 6 | onFinish, 7 | }: { 8 | onFinish: (request: AccountLoginRequest) => void; 9 | }) => { 10 | const [form] = Form.useForm(); 11 | 12 | return ( 13 |
20 | 29 | 30 | 31 | 32 | 36 | 41 | 42 | 43 | 44 | 52 | 53 |
54 | ); 55 | }; 56 | 57 | export default AccountLoginForm; 58 | -------------------------------------------------------------------------------- /src/components/announcement-list.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, List } from "antd"; 2 | 3 | import { Announcement } from "@/lib/models"; 4 | 5 | const AnnouncementList = ({ 6 | announcements, 7 | }: { 8 | announcements: Announcement[]; 9 | }) => { 10 | return ( 11 | { 17 | return ( 18 | 22 | 26 |
{announcement.message}
27 | {announcement.url && 相关链接} 28 | 29 | } 30 | banner 31 | showIcon={false} 32 | type="info" 33 | /> 34 |
35 | ); 36 | }} 37 | /> 38 | ); 39 | }; 40 | export default AnnouncementList; 41 | -------------------------------------------------------------------------------- /src/components/contact-email.tsx: -------------------------------------------------------------------------------- 1 | import Config from "@/config/config"; 2 | 3 | const ContactEmail = () => { 4 | return ( 5 | {Config.CONTACT_EMAIL} 6 | ); 7 | }; 8 | 9 | export default ContactEmail; 10 | -------------------------------------------------------------------------------- /src/components/course-detail-card.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Card, 4 | Descriptions, 5 | Select, 6 | Space, 7 | Typography, 8 | message, 9 | } from "antd"; 10 | import { PropsWithChildren, useState } from "react"; 11 | 12 | import ReportModal from "@/components/report-modal"; 13 | import { CourseDetail, NotificationLevel, Teacher } from "@/lib/models"; 14 | import { changeCourseNotificationLevel } from "@/services/course"; 15 | 16 | const { Text } = Typography; 17 | 18 | const NotificationLevelSelect = ({ course }: { course: CourseDetail }) => { 19 | const onNotificationLevelChange = (value: NotificationLevel) => { 20 | changeCourseNotificationLevel(course.id, value).catch((error) => { 21 | message.error(error.response?.data); 22 | }); 23 | }; 24 | 25 | const options = [ 26 | { value: NotificationLevel.NORMAL, label: "正常" }, 27 | { value: NotificationLevel.FOLLOW, label: "关注" }, 28 | { value: NotificationLevel.IGNORE, label: "忽略" }, 29 | ]; 30 | return ( 31 | 32 | 通知级别 33 | 39 | 40 | ); 41 | }; 42 | 43 | const CourseDetailCard = ({ 44 | course, 45 | loading, 46 | enroll_semester, 47 | }: PropsWithChildren<{ 48 | course?: CourseDetail; 49 | loading?: boolean; 50 | enroll_semester?: string | null; 51 | }>) => { 52 | const [isModalOpen, setIsModalOpen] = useState(false); 53 | return ( 54 | setIsModalOpen(true)}> 60 | 信息有误? 61 | , 62 | , 66 | ] 67 | } 68 | > 69 | {course && ( 70 | <> 71 | 72 | {course.code} 73 | 74 | {course.credit} 75 | 76 | 77 | {course.department} 78 | 79 | {course.teacher_group.length > 1 && ( 80 | 81 | {course.teacher_group 82 | .map((item: Teacher) => { 83 | return item.name; 84 | }) 85 | .join(",")} 86 | 87 | )} 88 | {course.categories?.length > 0 && ( 89 | 90 | {course.categories.join(",")} 91 | 92 | )} 93 | {course.moderator_remark && ( 94 | 95 | {course.moderator_remark} 96 | 97 | )} 98 | {enroll_semester && ( 99 | 100 | {enroll_semester} 101 | 102 | )} 103 | {course.rating.count > 0 && ( 104 | 105 | {course.rating.avg?.toFixed(1)} 106 | ({course.rating.count}人评价) 107 | 108 | )} 109 | 110 | setIsModalOpen(false)} 115 | onCancel={() => setIsModalOpen(false)} 116 | /> 117 | 118 | )} 119 | 120 | ); 121 | }; 122 | 123 | export default CourseDetailCard; 124 | -------------------------------------------------------------------------------- /src/components/course-filter-card.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Card, 4 | Checkbox, 5 | Col, 6 | Collapse, 7 | CollapseProps, 8 | Grid, 9 | Row, 10 | Tag, 11 | } from "antd"; 12 | import { useState } from "react"; 13 | 14 | import { CourseFilterItem, CourseFilters } from "@/lib/models"; 15 | 16 | const CourseFilterCard = ({ 17 | filters, 18 | selectedCategories, 19 | selectedDepartments, 20 | defaultOnlyHasReviews, 21 | onClick, 22 | loading, 23 | }: { 24 | filters: CourseFilters | undefined; 25 | selectedCategories?: string; 26 | selectedDepartments?: string; 27 | defaultOnlyHasReviews?: boolean; 28 | onClick: ( 29 | onlyHasReviews: boolean, 30 | checkedCategories: number[], 31 | checkedDepartments: number[] 32 | ) => void; 33 | loading: boolean; 34 | }) => { 35 | const [checkedCategories, setCheckedCategories] = useState( 36 | selectedCategories 37 | ? selectedCategories.split(",").map((item) => parseInt(item)) 38 | : [] 39 | ); 40 | const [checkedDepartments, setCheckedDepartments] = useState( 41 | selectedDepartments 42 | ? selectedDepartments.split(",").map((item) => parseInt(item)) 43 | : [] 44 | ); 45 | const [onlyHasReviews, setOnlyHasReviews] = useState(false); 46 | const screens = Grid.useBreakpoint(); 47 | 48 | const items: CollapseProps["items"] = [ 49 | { 50 | key: "reviews", 51 | label: "点评", 52 | children: ( 53 | setOnlyHasReviews(e.target.checked)} 56 | > 57 | 仅显示有点评的课程 58 | 59 | ), 60 | }, 61 | { 62 | key: "categories", 63 | label: "课程类别", 64 | children: ( 65 | { 68 | setCheckedCategories(e as number[]); 69 | }} 70 | > 71 | 72 | {filters?.categories.map((item: CourseFilterItem) => ( 73 | 74 | {item.name} 75 | {item.count} 76 | 77 | ))} 78 | 79 | 80 | ), 81 | }, 82 | { 83 | key: "departments", 84 | label: "开课单位", 85 | children: ( 86 | { 89 | setCheckedDepartments(e as number[]); 90 | }} 91 | > 92 | 93 | {filters?.departments.map((item: CourseFilterItem) => ( 94 | 95 | {item.name} 96 | {item.count} 97 | 98 | ))} 99 | 100 | 101 | ), 102 | }, 103 | ]; 104 | 105 | return ( 106 | 111 | onClick(onlyHasReviews, checkedCategories, checkedDepartments) 112 | } 113 | > 114 | 确认 115 | 116 | } 117 | className={filters && "filter-card"} 118 | loading={loading} 119 | > 120 | {filters && ( 121 | 128 | )} 129 | 130 | ); 131 | }; 132 | 133 | export default CourseFilterCard; 134 | -------------------------------------------------------------------------------- /src/components/course-item.tsx: -------------------------------------------------------------------------------- 1 | import { List, Space, Tag, Typography } from "antd"; 2 | import Link from "next/link"; 3 | 4 | import Config from "@/config/config"; 5 | import { CourseListItem } from "@/lib/models"; 6 | import { CommonInfoContext } from "@/lib/context"; 7 | 8 | const { Text } = Typography; 9 | 10 | const CourseItem = ({ 11 | course, 12 | showEnroll, 13 | }: { 14 | course: CourseListItem; 15 | showEnroll?: boolean; 16 | }) => { 17 | return ( 18 | 19 | {(commonInfo) => { 20 | return ( 21 | 0 ? ( 25 | 26 | 27 | {course.rating.avg.toFixed(1)} 28 | 29 | {course.rating.count}人评价 30 | 31 | ) : ( 32 | 暂无点评 33 | ) 34 | } 35 | > 36 | 37 | 38 | {course.code + " "} 39 | {course.name}({course.teacher}) 40 | 41 | 42 | {showEnroll && commonInfo?.enrolled_courses.has(course.id) && ( 43 | 学过 44 | )} 45 | {course.categories && 46 | course.categories.map((tag: string) => ( 47 | 48 | {tag} 49 | 50 | ))} 51 | {commonInfo?.reviewed_courses.has(course.id) && ( 52 | 已点评 53 | )} 54 | 55 | {course.credit}学分 {course.department} 56 | 57 | 58 | 59 | 60 | ); 61 | }} 62 | 63 | ); 64 | }; 65 | 66 | export default CourseItem; 67 | -------------------------------------------------------------------------------- /src/components/course-list.tsx: -------------------------------------------------------------------------------- 1 | import { List } from "antd"; 2 | 3 | import CourseItem from "@/components/course-item"; 4 | import { CourseListItem, Pagination } from "@/lib/models"; 5 | 6 | const CourseList = ({ 7 | loading, 8 | count, 9 | courses, 10 | onPageChange, 11 | pagination, 12 | showEnroll, 13 | }: { 14 | loading: boolean; 15 | count: number | undefined; 16 | courses: CourseListItem[] | undefined; 17 | onPageChange?: Function; 18 | pagination?: Pagination; 19 | showEnroll?: boolean; 20 | }) => { 21 | return ( 22 | { 30 | onPageChange && onPageChange(page, pageSize); 31 | }, 32 | total: count, 33 | current: pagination.page, 34 | defaultCurrent: pagination.page, 35 | pageSize: pagination.pageSize, 36 | } 37 | : false 38 | } 39 | dataSource={courses} 40 | renderItem={(course) => ( 41 | 42 | )} 43 | /> 44 | ); 45 | }; 46 | export default CourseList; 47 | -------------------------------------------------------------------------------- /src/components/email-login-form.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Form, Input, message } from "antd"; 2 | import { useEffect, useRef, useState } from "react"; 3 | 4 | import { EmailLoginRequest } from "@/lib/models"; 5 | import { AccountRule, CodeRule } from "@/lib/utils"; 6 | import { authEmailSendCode } from "@/services/user"; 7 | 8 | const EmailLoginForm = ({ 9 | onFinish, 10 | }: { 11 | onFinish: (request: EmailLoginRequest) => void; 12 | }) => { 13 | const [form] = Form.useForm(); 14 | const [time, setTime] = useState(0); 15 | const timeRef = useRef(); 16 | const inCounter = time != 0; 17 | 18 | const onClick = () => { 19 | const account: string = form.getFieldValue("account"); 20 | if (account == undefined || !AccountRule.pattern.test(account)) { 21 | message.error("请输入正确的 jAccount 用户名。"); 22 | return; 23 | } 24 | authEmailSendCode(account) 25 | .then((data) => { 26 | setTime(60); 27 | message.success(data.detail); 28 | }) 29 | .catch((error) => { 30 | message.error(error.response.data.detail); 31 | }); 32 | }; 33 | 34 | useEffect(() => { 35 | if (inCounter) { 36 | timeRef.current = setTimeout(() => { 37 | setTime(time - 1); 38 | }, 1000); 39 | } 40 | return () => { 41 | clearTimeout(timeRef.current); 42 | }; 43 | }, [time]); 44 | return ( 45 |
52 | 53 | 58 | 59 | 60 | 61 | 66 | {inCounter ? `${time}秒后` : "获取验证码"} 67 | 68 | } 69 | size="large" 70 | /> 71 | 72 | 73 | 74 | 82 | 83 |
84 | ); 85 | }; 86 | 87 | export default EmailLoginForm; 88 | -------------------------------------------------------------------------------- /src/components/email-password-login-form.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Form, Input, Typography } from "antd"; 2 | import Link from "next/link"; 3 | 4 | import { EmailPasswordLoginRequest } from "@/lib/models"; 5 | import { AccountRule } from "@/lib/utils"; 6 | 7 | const EmailPasswordLoginForm = ({ 8 | onFinish, 9 | }: { 10 | onFinish: (request: EmailPasswordLoginRequest) => void; 11 | }) => { 12 | const [form] = Form.useForm(); 13 | 14 | return ( 15 |
22 | 23 | 28 | 29 | 30 | 34 | 39 | 40 | 41 | 44 | 在偏好设置 45 | 中设定密码后可使用密码登录 46 | 47 | } 48 | > 49 | 57 | 58 |
59 | ); 60 | }; 61 | 62 | export default EmailPasswordLoginForm; 63 | -------------------------------------------------------------------------------- /src/components/icon-text.tsx: -------------------------------------------------------------------------------- 1 | import { Space } from "antd"; 2 | import { FC, createElement } from "react"; 3 | 4 | export const LeftIconText = ({ 5 | icon, 6 | text, 7 | }: { 8 | icon: FC; 9 | text: string | number; 10 | }) => ( 11 | 12 | {createElement(icon)} 13 | {text} 14 | 15 | ); 16 | export const RightIconText = ({ 17 | icon, 18 | text, 19 | }: { 20 | icon: FC; 21 | text: string | number; 22 | }) => ( 23 | 24 | {text} 25 | {createElement(icon)} 26 | 27 | ); 28 | -------------------------------------------------------------------------------- /src/components/image-promotion.tsx: -------------------------------------------------------------------------------- 1 | import { Promotion } from "@/lib/models"; 2 | import { clickPromotion } from "@/services/promotion"; 3 | import { useDebounceFn } from "ahooks"; 4 | import { Image } from "antd"; 5 | import Link from "next/link"; 6 | import { NextRouter, useRouter } from "next/router"; 7 | 8 | const convertImageSrc = (src: string | null) => { 9 | if (!src) return ""; 10 | return src; 11 | }; 12 | 13 | const convertJumpLink = ( 14 | jump_link: string | null, 15 | router: NextRouter 16 | ): string => { 17 | return jump_link || router.asPath; 18 | }; 19 | 20 | const ImagePromotion = ({ promotion }: { promotion?: Promotion }) => { 21 | const router = useRouter(); 22 | const { run: onClick } = useDebounceFn( 23 | () => { 24 | if (promotion) clickPromotion(promotion.id); 25 | }, 26 | { wait: 5000 } 27 | ); 28 | 29 | if (!promotion) return <>; 30 | 31 | return ( 32 | 37 | {promotion.text 43 | 44 | ); 45 | }; 46 | 47 | export default ImagePromotion; 48 | -------------------------------------------------------------------------------- /src/components/layouts.tsx: -------------------------------------------------------------------------------- 1 | import { Layout, Space } from "antd"; 2 | import Link from "next/link"; 3 | import { useRouter } from "next/router"; 4 | import React, { useEffect, useState } from "react"; 5 | 6 | import AnnouncementList from "@/components/announcement-list"; 7 | import NavBar from "@/components/navbar"; 8 | import { CommonInfoContext } from "@/lib/context"; 9 | import { useCommonInfo } from "@/services/common"; 10 | 11 | const { Header, Content, Footer } = Layout; 12 | 13 | export const BasicLayout = ({ children }: React.PropsWithChildren<{}>) => { 14 | const [mounted, setMounted] = useState(false); 15 | 16 | const { commonInfo, error } = useCommonInfo(); 17 | const router = useRouter(); 18 | useEffect(() => { 19 | if (error?.response?.status == 403 && mounted) { 20 | const pathname = window.location.pathname; 21 | router.replace({ pathname: "/login", query: { next: pathname } }); 22 | } 23 | }, [error]); 24 | 25 | useEffect(() => { 26 | setMounted(true); 27 | }, []); 28 | 29 | if (!mounted) return <>; 30 | 31 | return ( 32 | 33 | 34 |
35 | 36 |
37 | 38 | 42 | {commonInfo?.announcements && commonInfo.announcements.length > 0 && ( 43 | 44 | )} 45 | {children} 46 | 47 |
48 | 49 | 关于 50 | 常见问题 51 | 反馈 52 | 53 |
©2024 SJTU选课社区
54 |
55 |
56 |
57 | ); 58 | }; 59 | 60 | export const LoginLayout = ({ children }: React.PropsWithChildren<{}>) => ( 61 | 62 |
63 |
SJTU选课社区
64 |
65 | {children} 66 |
67 | ); 68 | -------------------------------------------------------------------------------- /src/components/md-editor.tsx: -------------------------------------------------------------------------------- 1 | import { Col, Grid, Input, Row, Space, Switch } from "antd"; 2 | import { useState } from "react"; 3 | 4 | import MDPreview from "@/components/md-preview"; 5 | 6 | const MDEditor = ({ 7 | value, 8 | onChange, 9 | }: { 10 | value?: string; 11 | onChange?: Function; 12 | }) => { 13 | const onTextChange = (e: any) => { 14 | onChange?.(e); 15 | }; 16 | const [preview, setPreview] = useState(true); 17 | const screens = Grid.useBreakpoint(); 18 | const span = preview && screens.sm ? 12 : 24; 19 | return ( 20 | 21 | 22 | 29 | 30 | setPreview(!preview)} 34 | /> 35 | 预览 36 | 37 | 38 | {preview && ( 39 | 40 | 41 | 42 | )} 43 | 44 | ); 45 | }; 46 | 47 | export default MDEditor; 48 | -------------------------------------------------------------------------------- /src/components/md-preview.tsx: -------------------------------------------------------------------------------- 1 | import ReactMarkdown from "react-markdown"; 2 | import rehypeSanitize from "rehype-sanitize"; 3 | import remarkBreaks from "remark-breaks"; 4 | import remarkGfm from "remark-gfm"; 5 | 6 | const MDPreview = ({ src, ...props }: any) => { 7 | return ( 8 |
9 | 14 | {src} 15 | 16 |
17 | ); 18 | }; 19 | 20 | export default MDPreview; 21 | -------------------------------------------------------------------------------- /src/components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DollarOutlined, 3 | LogoutOutlined, 4 | ProfileOutlined, 5 | SearchOutlined, 6 | SettingOutlined, 7 | SyncOutlined, 8 | UserOutlined, 9 | } from "@ant-design/icons"; 10 | import { Button, Col, Dropdown, Menu, Row } from "antd"; 11 | import type { MenuProps } from "antd"; 12 | import Link from "next/link"; 13 | import { useRouter } from "next/router"; 14 | 15 | import { User } from "@/lib/models"; 16 | import { logout, toAdmin } from "@/services/user"; 17 | 18 | const NavBar = ({ user }: { user?: User }) => { 19 | const router = useRouter(); 20 | 21 | const handleMenuClick = (e: { key: string }) => { 22 | if (e.key == "logout") { 23 | logout(router.basePath, router); 24 | } else if (e.key == "account") { 25 | if (user?.is_staff) toAdmin(); 26 | } else { 27 | router.push(e.key); 28 | } 29 | }; 30 | const dropMenuItems: MenuProps["items"] = [ 31 | { 32 | key: "account", 33 | label: user?.account, 34 | icon: , 35 | }, 36 | { key: "/point", label: "社区积分", icon: }, 37 | { key: "/activity", label: "我的点评", icon: }, 38 | { key: "/sync", label: "同步课表", icon: }, 39 | { key: "/preference", label: "偏好设置", icon: }, 40 | { type: "divider", key: "divider" }, 41 | { key: "logout", label: "登出", icon: , danger: true }, 42 | ]; 43 | 44 | const navItems = [ 45 | { label: "最新", value: "/latest" }, 46 | { label: "关注", value: "/follow-review" }, 47 | { label: "课程", value: "/courses" }, 48 | ]; 49 | 50 | const navMenuItems = navItems.map((item) => { 51 | return { 52 | key: item.value, 53 | label: {item.label}, 54 | }; 55 | }); 56 | 57 | return ( 58 | 59 | 60 | 61 | SJTU选课社区 62 | 63 | 64 | 65 | 66 | 72 | 73 | 74 | 87 | 88 | 89 | 90 | ); 91 | }; 92 | 93 | export default NavBar; 94 | -------------------------------------------------------------------------------- /src/components/page-header.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLeftOutlined } from "@ant-design/icons"; 2 | import { Button, Col, Row, Space, Typography } from "antd"; 3 | 4 | const { Text } = Typography; 5 | const PageHeader = ({ 6 | title, 7 | subTitle, 8 | onBack, 9 | extra, 10 | }: { 11 | title: string | JSX.Element; 12 | subTitle?: string; 13 | onBack?: () => void; 14 | extra?: JSX.Element; 15 | }) => { 16 | return ( 17 | 21 | {onBack && ( 22 | 23 | 29 | 30 | )} 31 | {title && ( 32 | 33 | {title} 34 | 35 | )} 36 | {subTitle && ( 37 | 38 | {subTitle} 39 | 40 | )} 41 | {extra && {extra}} 42 | 43 | ); 44 | }; 45 | 46 | export default PageHeader; 47 | -------------------------------------------------------------------------------- /src/components/promotion-card.tsx: -------------------------------------------------------------------------------- 1 | import { Promotion } from "@/lib/models"; 2 | import { Card } from "antd"; 3 | import ImagePromotion from "./image-promotion"; 4 | 5 | const PromotionCard = ({ promotion }: { promotion?: Promotion }) => { 6 | if (!promotion) return <>; 7 | return ( 8 | 推广}> 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default PromotionCard; 15 | -------------------------------------------------------------------------------- /src/components/related-card.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Col, List, Space, Typography } from "antd"; 2 | import Link from "next/link"; 3 | import { PropsWithChildren } from "react"; 4 | 5 | import { CourseDetail } from "@/lib/models"; 6 | import { CommonInfoContext } from "@/lib/context"; 7 | import PromotionCard from "./promotion-card"; 8 | import Touchpoint from "@/config/touchpoint"; 9 | 10 | const { Text } = Typography; 11 | 12 | export const RelatedTeacher = ({ 13 | course, 14 | loading, 15 | }: PropsWithChildren<{ 16 | course: CourseDetail; 17 | loading: boolean; 18 | }>) => { 19 | const { related_teachers } = course; 20 | return ( 21 | 其他老师的{course.name}} 23 | loading={loading} 24 | > 25 | ( 29 | 30 | 31 | {item.tname} 32 | {item.count > 0 && ( 33 | 34 | {item.avg?.toFixed(1)} 35 | ({item.count}人) 36 | 37 | )} 38 | 39 | 40 | )} 41 | /> 42 | 43 | ); 44 | }; 45 | export const RelatedCourse = ({ 46 | course, 47 | loading, 48 | }: PropsWithChildren<{ 49 | course: CourseDetail; 50 | loading: boolean; 51 | }>) => { 52 | const { main_teacher, related_courses } = course; 53 | 54 | return ( 55 | {main_teacher.name}的其他课} 57 | loading={loading} 58 | > 59 | ( 63 | 64 | 65 | 66 | {item.code} {item.name} 67 | 68 | {item.count > 0 && ( 69 | 70 | {item.avg?.toFixed(1)} 71 | ({item.count}人) 72 | 73 | )} 74 | 75 | 76 | )} 77 | /> 78 | 79 | ); 80 | }; 81 | 82 | const RelatedCard = ({ 83 | course, 84 | loading, 85 | }: PropsWithChildren<{ 86 | course: CourseDetail | undefined; 87 | loading: boolean; 88 | }>) => { 89 | if (!course) return <>; 90 | return ( 91 | <> 92 | {course.related_teachers.length > 0 && ( 93 | 94 | 95 | 96 | )} 97 | {course.related_courses.length > 0 && ( 98 | 99 | 100 | 101 | )} 102 | 103 | {(commonInfo) => { 104 | return ( 105 | 106 | 111 | 112 | ); 113 | }} 114 | 115 | 116 | ); 117 | }; 118 | export default RelatedCard; 119 | -------------------------------------------------------------------------------- /src/components/report-list.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, List } from "antd"; 2 | 3 | import { Pagination, Report } from "@/lib/models"; 4 | 5 | const ReportList = ({ 6 | loading, 7 | count, 8 | reports, 9 | onPageChange, 10 | pagination, 11 | }: { 12 | loading: boolean; 13 | count: number | undefined; 14 | reports: Report[] | undefined; 15 | onPageChange?: Function; 16 | pagination?: Pagination; 17 | }) => { 18 | return ( 19 | { 27 | onPageChange && onPageChange(page, pageSize); 28 | }, 29 | total: count, 30 | current: pagination.page, 31 | defaultCurrent: pagination.page, 32 | pageSize: pagination.pageSize, 33 | } 34 | : false 35 | } 36 | dataSource={reports} 37 | renderItem={(item) => ( 38 | {item.created_at}, 42 |
{"#" + item.id}
, 43 | ]} 44 | className="comment" 45 | > 46 |

{item.comment}

47 | {item.reply && } 48 |
49 | )} 50 | /> 51 | ); 52 | }; 53 | export default ReportList; 54 | -------------------------------------------------------------------------------- /src/components/report-modal.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Form, Input, Modal, Typography, message } from "antd"; 2 | import Link from "next/link"; 3 | 4 | import { writeReport } from "@/services/report"; 5 | 6 | const { TextArea } = Input; 7 | const { Text } = Typography; 8 | const ReportModal = ({ 9 | open, 10 | defaultComment, 11 | title, 12 | onOk, 13 | onCancel, 14 | }: { 15 | open: boolean; 16 | defaultComment?: string; 17 | title?: string; 18 | onOk?: () => void; 19 | onCancel?: () => void; 20 | }) => { 21 | const [form] = Form.useForm(); 22 | 23 | const handleSubmit = (value: { comment: string }) => { 24 | writeReport(value.comment).then((resp) => { 25 | if (resp.status == 201) { 26 | message.success("提交成功,请等候管理员回复!"); 27 | if (onOk) onOk(); 28 | } 29 | }); 30 | }; 31 | 32 | return ( 33 | 40 |
46 | { 54 | const trimed = value.trim(); 55 | return trimed != "" && trimed != defaultComment 56 | ? Promise.resolve() 57 | : Promise.reject(); 58 | }, 59 | }, 60 | ]} 61 | initialValue={defaultComment} 62 | help={ 63 | 64 | 您可以在页面底部 65 | 66 | 反馈 67 | 68 | 查看反馈记录和管理员回复。 69 | 70 | } 71 | > 72 |