├── .eslintrc.json ├── .prettierignore ├── public ├── favicon.ico └── react.svg ├── .dockerignore ├── src ├── config │ ├── touchpoint.ts │ └── config.ts ├── lib │ ├── context.ts │ ├── utils.ts │ └── models.ts ├── components │ ├── contact-email.tsx │ ├── promotion-card.tsx │ ├── icon-text.tsx │ ├── md-preview.tsx │ ├── announcement-list.tsx │ ├── review-list.tsx │ ├── page-header.tsx │ ├── md-editor.tsx │ ├── image-promotion.tsx │ ├── course-list.tsx │ ├── review-rating-trend.tsx │ ├── account-login-form.tsx │ ├── __tests__ │ │ ├── announcement-list.test.tsx │ │ ├── review-reaction-button.test.tsx │ │ ├── course-detail-card.test.tsx │ │ └── course-item.test.tsx │ ├── report-list.tsx │ ├── about-card.tsx │ ├── email-password-login-form.tsx │ ├── report-modal.tsx │ ├── course-item.tsx │ ├── layouts.tsx │ ├── email-login-form.tsx │ ├── review-reaction-button.tsx │ ├── review-revision-modal.tsx │ ├── navbar.tsx │ ├── review-filter.tsx │ ├── related-card.tsx │ ├── course-filter-card.tsx │ ├── course-detail-card.tsx │ ├── reset-password-form.tsx │ └── review-item.tsx ├── services │ ├── request.ts │ ├── announcement.ts │ ├── promotion.ts │ ├── report.ts │ ├── semester.ts │ ├── statistic.ts │ ├── sync.ts │ ├── common.ts │ ├── user.ts │ ├── review.ts │ └── course.ts ├── styles │ ├── custom.ant.css │ └── global.css └── pages │ ├── about.tsx │ ├── 404.tsx │ ├── _document.tsx │ ├── activity.tsx │ ├── review │ └── [id].tsx │ ├── preference.tsx │ ├── report.tsx │ ├── _app.tsx │ ├── follow-course.tsx │ ├── follow-review.tsx │ ├── latest.tsx │ ├── point.tsx │ ├── search.tsx │ ├── courses.tsx │ ├── sync.tsx │ ├── login.tsx │ ├── faq.tsx │ ├── statistics.tsx │ ├── course │ └── [id].tsx │ └── write-review.tsx ├── next-env.d.ts ├── .prettierrc.json ├── .gitignore ├── .github └── workflows │ └── react.yml ├── README.md ├── tsconfig.json ├── jest.config.js ├── next.config.js ├── LICENSE ├── package.json ├── jest.setup.js └── Dockerfile /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | .next 5 | .swc 6 | styles -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SJTU-jCourse/jcourse/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .next 7 | .git 8 | -------------------------------------------------------------------------------- /src/config/touchpoint.ts: -------------------------------------------------------------------------------- 1 | const Touchpoint = { 2 | BELOW_RELATED_COURSE: 1, 3 | }; 4 | 5 | export default Touchpoint; 6 | -------------------------------------------------------------------------------- /src/lib/context.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { CommonInfo } from "@/lib/models"; 4 | 5 | export const CommonInfoContext = React.createContext( 6 | undefined 7 | ); 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/lib/utils.ts: -------------------------------------------------------------------------------- 1 | export const AccountRule = { 2 | max: 50, 3 | required: true, 4 | pattern: /^([a-zA-Z0-9-_\.]+)$/, 5 | message: "请正确输入 jAccount 用户名", 6 | }; 7 | 8 | export const CodeRule = { 9 | required: true, 10 | len: 6, 11 | message: "请输入正确的验证码", 12 | }; 13 | -------------------------------------------------------------------------------- /src/services/request.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export const request = axios.create({ 4 | xsrfCookieName: "csrftoken", 5 | xsrfHeaderName: "X-CSRFToken", 6 | withCredentials: true, 7 | }); 8 | 9 | export const fetcher = (url: string) => 10 | request.get(url).then((res) => res.data); 11 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | const Config = { 2 | PAGE_SIZE: 20, 3 | TAG_COLOR_REVIEW: "blue", 4 | TAG_COLOR_ENROLL: "cyan", 5 | TAG_COLOR_CATEGORY: "green", 6 | JACCOUNT_CLIENT_ID: "", 7 | JACCOUNT_LOGIN_RETURI: "/login", 8 | JACCOUNT_SYNC_RETURI: "/sync", 9 | BAIDU_TONGJI_CODE: "bffe2d130d940fce5a0876ee2dc36b92", 10 | CONTACT_EMAIL: "course@sjtu.plus", 11 | }; 12 | 13 | export default Config; 14 | -------------------------------------------------------------------------------- /public/react.svg: -------------------------------------------------------------------------------- 1 | 2 | React Logo 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/services/announcement.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | 3 | import { Announcement } from "@/lib/models"; 4 | import { fetcher } from "@/services/request"; 5 | 6 | export function useAnnouncements() { 7 | const { data, error } = useSWR("/api/announcement/", fetcher); 8 | return { 9 | announcements: data, 10 | loading: !error && !data, 11 | isError: error, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/services/promotion.ts: -------------------------------------------------------------------------------- 1 | import { request } from "@/services/request"; 2 | 3 | export async function clickPromotion(id: number) { 4 | const resp = await request(`/api/promotion/${id}/click/`, { 5 | method: "post", 6 | }); 7 | return resp.data; 8 | } 9 | 10 | export async function showPromotion(id: number) { 11 | const resp = await request(`/api/promotion/${id}/show/`, { 12 | method: "post", 13 | }); 14 | return resp.data; 15 | } 16 | -------------------------------------------------------------------------------- /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/styles/custom.ant.css: -------------------------------------------------------------------------------- 1 | @media (max-width: 576px) { 2 | .ant-menu-horizontal:not(.ant-menu-dark) > .ant-menu-item { 3 | padding-left: 8px !important; 4 | padding-right: 8px !important; 5 | } 6 | 7 | .ant-menu-horizontal > .ant-menu-item::after, 8 | .ant-menu-horizontal > .ant-menu-submenu::after { 9 | left: 8px !important; 10 | right: 8px !important; 11 | } 12 | } 13 | 14 | .ant-list-item { 15 | padding-left: 4px !important; 16 | padding-right: 4px !important; 17 | } 18 | -------------------------------------------------------------------------------- /src/pages/about.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from "antd"; 2 | import Head from "next/head"; 3 | 4 | import AboutCard from "@/components/about-card"; 5 | import PageHeader from "@/components/page-header"; 6 | 7 | const AboutPage = () => { 8 | return ( 9 | <> 10 | history.back()} /> 11 | 12 | 关于 - SJTU选课社区 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | }; 20 | export default AboutPage; 21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/services/report.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | 3 | import { Report } from "@/lib/models"; 4 | import { fetcher, request } from "@/services/request"; 5 | 6 | export async function writeReport(comment: string) { 7 | return await request("/api/report/", { method: "post", data: { comment } }); 8 | } 9 | 10 | export function useReports() { 11 | const { data, error, mutate } = useSWR("/api/report/", fetcher); 12 | return { 13 | reports: data, 14 | loading: !error && !data, 15 | isError: error, 16 | mutate, 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /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/services/semester.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | 3 | import { Semester } from "@/lib/models"; 4 | import { fetcher } from "@/services/request"; 5 | 6 | export function useSemesters() { 7 | const { data, error } = useSWR("/api/semester/", fetcher); 8 | let semesterMap = undefined; 9 | if (data) { 10 | semesterMap = new Map(data.map((item: Semester) => [item.id, item.name])); 11 | } 12 | 13 | const availableSemesters = data?.filter((item) => item.available); 14 | 15 | return { 16 | semesters: data, 17 | availableSemesters, 18 | semesterMap, 19 | loading: !error && !data, 20 | error: error, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { Result } from "antd"; 2 | import Head from "next/head"; 3 | import Link from "next/link"; 4 | 5 | import ContactEmail from "@/components/contact-email"; 6 | 7 | const NotFoundPage = () => { 8 | return ( 9 | <> 10 | 11 | 404 - SJTU选课社区 12 | 13 | 18 | 如果这是网站的bug,或者要找的页面对你很重要,请通过 19 | 反馈或者邮件 20 | 21 | 联系我们 22 | 23 | } 24 | /> 25 | 26 | ); 27 | }; 28 | export default NotFoundPage; 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jCourse:SJTU 选课社区 2 | 3 | ## 迁移 4 | 5 | 本仓库已放弃维护。jCourse 前端迁移到新的仓库 [jcourse_vite](https://github.com/SJTU-jCourse/jcourse_vite)。 6 | 7 | ## 开始使用 8 | 9 | 使用 [Next.js](https://nextjs.org/) 框架, UI 库选用 [Ant Design](https://github.com/ant-design/ant-design/)。 10 | 11 | 安装依赖 12 | 13 | ```bash 14 | $ yarn 15 | ``` 16 | 17 | 本地调试 18 | 19 | ```bash 20 | $ yarn dev 21 | ``` 22 | 23 | 构建 24 | 25 | ```bash 26 | $ yarn build 27 | ``` 28 | 29 | ## 后端服务 30 | 31 | 请见: https://github.com/dujiajun/jcourse_api 32 | 33 | ## Learn More 34 | 35 | To learn more about Next.js, take a look at the following resources: 36 | 37 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 38 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 39 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Head, Html, Main, NextScript } from "next/document"; 2 | 3 | import Config from "@/config/config"; 4 | 5 | export default function Document() { 6 | return ( 7 | 8 | 9 | 10 | 14 | 18 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/pages/activity.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from "antd"; 2 | import Head from "next/head"; 3 | 4 | import PageHeader from "@/components/page-header"; 5 | import ReviewList from "@/components/review-list"; 6 | import { useMyReviews } from "@/services/review"; 7 | 8 | const ActivityPage = () => { 9 | const { reviews, loading } = useMyReviews(); 10 | 11 | return ( 12 | <> 13 | 17 | 18 | 我的点评 - SJTU选课社区 19 | 20 | 21 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default ActivityPage; 32 | -------------------------------------------------------------------------------- /src/pages/review/[id].tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "antd"; 2 | import Head from "next/head"; 3 | import { useRouter } from "next/router"; 4 | import { useEffect } from "react"; 5 | 6 | import Config from "@/config/config"; 7 | import { useReviewLocationInCourse } from "@/services/review"; 8 | 9 | const ReviewLocationPage = () => { 10 | const router = useRouter(); 11 | const { id } = router.query; 12 | 13 | const { data } = useReviewLocationInCourse(id as string); 14 | 15 | useEffect(() => { 16 | if (data) 17 | router.replace( 18 | `/course/${data.course}?page=${ 19 | Math.floor(data.location / Config.PAGE_SIZE) + 1 20 | }#review-${id}` 21 | ); 22 | }, [data]); 23 | return ( 24 | <> 25 | 26 | 跳转中…… 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default ReviewLocationPage; 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | "baseUrl": ".", 22 | "paths": { 23 | "@/*": [ 24 | "src/*" 25 | ] 26 | }, 27 | "plugins": [ 28 | { 29 | "name": "next" 30 | } 31 | ] 32 | }, 33 | "include": [ 34 | "next-env.d.ts", 35 | "**/*.ts", 36 | "**/*.tsx", 37 | ".next/types/**/*.ts" 38 | ], 39 | "exclude": [ 40 | "node_modules" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/services/statistic.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import dayjs from "dayjs"; 3 | import { StatisticInfo } from "@/lib/models"; 4 | import { fetcher } from "@/services/request"; 5 | 6 | export function useStatistic() { 7 | const { data, error } = useSWR("/api/statistic/", fetcher); 8 | 9 | if (data) { 10 | const currentDay = dayjs().subtract(1, "day").format("YYYY-MM-DD"); 11 | 12 | const new_user_map = new Map(); 13 | const new_review_map = new Map(); 14 | 15 | data.user_join_time.forEach((item) => { 16 | new_user_map.set(item.date, item.count); 17 | }); 18 | 19 | data.review_create_time.forEach((item) => { 20 | new_review_map.set(item.date, item.count); 21 | }); 22 | 23 | data.daily_new_users = new_user_map.get(currentDay) || 0; 24 | data.daily_new_reviews = new_review_map.get(currentDay) || 0; 25 | } 26 | 27 | return { 28 | indexState: data, 29 | loading: !error && !data, 30 | error: error, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/pages/preference.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, Modal } from "antd"; 2 | import Head from "next/head"; 3 | import { useState } from "react"; 4 | 5 | import PageHeader from "@/components/page-header"; 6 | import ResetPasswordForm from "@/components/reset-password-form"; 7 | 8 | const PreferencePage = () => { 9 | const [resetModalOpen, setResetModalOpen] = useState(false); 10 | return ( 11 | <> 12 | history.back()} /> 13 | 14 | 偏好设置 - SJTU选课社区 15 | 16 | 17 | 18 | setResetModalOpen(false)} 23 | > 24 | setResetModalOpen(false)} 26 | > 27 | 28 | 29 | 30 | ); 31 | }; 32 | export default PreferencePage; 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/review-list.tsx: -------------------------------------------------------------------------------- 1 | import { List } from "antd"; 2 | 3 | import ReviewItem from "@/components/review-item"; 4 | import { Pagination, Review } from "@/lib/models"; 5 | 6 | const ReviewList = ({ 7 | loading, 8 | count, 9 | reviews, 10 | onPageChange, 11 | pagination, 12 | }: { 13 | loading: boolean; 14 | count: number | undefined; 15 | reviews: Review[] | undefined; 16 | onPageChange?: Function; 17 | pagination?: Pagination; 18 | }) => { 19 | return ( 20 | { 28 | onPageChange && onPageChange(page, pageSize); 29 | }, 30 | total: count, 31 | current: pagination.page, 32 | defaultCurrent: pagination.page, 33 | pageSize: pagination.pageSize, 34 | } 35 | : false 36 | } 37 | dataSource={reviews} 38 | renderItem={(item) => } 39 | /> 40 | ); 41 | }; 42 | export default ReviewList; 43 | -------------------------------------------------------------------------------- /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/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/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/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/review-rating-trend.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Bar, 3 | ComposedChart, 4 | LabelList, 5 | Legend, 6 | Line, 7 | ResponsiveContainer, 8 | Tooltip, 9 | XAxis, 10 | YAxis, 11 | } from "recharts"; 12 | 13 | import { ReviewFilterSemesterItem } from "@/lib/models"; 14 | 15 | const ReviewRatingTrend = ({ data }: { data?: ReviewFilterSemesterItem[] }) => { 16 | return ( 17 | 18 | 22 | 23 | 24 | 30 | 31 | 32 | 33 | 34 | 35 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | export default ReviewRatingTrend; 49 | -------------------------------------------------------------------------------- /src/services/sync.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | 3 | import Config from "@/config/config"; 4 | import { CourseListItem, SyncCourseItem } from "@/lib/models"; 5 | import { fetcher, request } from "@/services/request"; 6 | 7 | export function loginSync(basePath: string) { 8 | const rediretUrl = 9 | window.location.origin + basePath + Config.JACCOUNT_SYNC_RETURI; 10 | window.location.href = `/oauth/sync-lessons/login?redirect_uri=${rediretUrl}`; 11 | } 12 | 13 | export async function authSync(code: string, state: string, basePath: string) { 14 | const rediretUrl = 15 | window.location.origin + basePath + Config.JACCOUNT_SYNC_RETURI; 16 | const resp = await request("/oauth/sync-lessons/auth/", { 17 | params: { 18 | code, 19 | state, 20 | redirect_uri: rediretUrl, 21 | }, 22 | }); 23 | return resp.data; 24 | } 25 | 26 | export async function syncLessons(courses: SyncCourseItem[]) { 27 | const resp = await request(`/api/sync-lessons-v2/`, { 28 | method: "POST", 29 | data: courses, 30 | }); 31 | return resp.data; 32 | } 33 | 34 | export function useLessons() { 35 | const { data, error, mutate } = useSWR( 36 | "/api/lesson/", 37 | fetcher 38 | ); 39 | return { 40 | courses: data, 41 | loading: !error && !data, 42 | isError: error, 43 | mutate, 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/pages/report.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card } from "antd"; 2 | import Head from "next/head"; 3 | import { useState } from "react"; 4 | 5 | import PageHeader from "@/components/page-header"; 6 | import ReportList from "@/components/report-list"; 7 | import ReportModal from "@/components/report-modal"; 8 | import { useReports } from "@/services/report"; 9 | 10 | const ReportPage = () => { 11 | const { reports, loading, mutate } = useReports(); 12 | 13 | const [isModalOpen, setIsModalOpen] = useState(false); 14 | 15 | return ( 16 | <> 17 | history.back()} /> 18 | 19 | 反馈 - SJTU选课社区 20 | 21 | setIsModalOpen(true)}> 25 | 提交反馈 26 | 27 | } 28 | > 29 | 34 | 35 | { 38 | setIsModalOpen(false); 39 | mutate(); 40 | }} 41 | onCancel={() => setIsModalOpen(false)} 42 | /> 43 | 44 | ); 45 | }; 46 | export default ReportPage; 47 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/__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/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { ConfigProvider, theme } from "antd"; 2 | import zhCN from "antd/locale/zh_CN"; 3 | import type { AppProps } from "next/app"; 4 | import { useEffect, useState } from "react"; 5 | import { useMediaQuery } from "react-responsive"; 6 | import { SWRConfig } from "swr"; 7 | 8 | import { BasicLayout, LoginLayout } from "@/components/layouts"; 9 | import "@/styles/global.css"; 10 | 11 | function MyApp({ Component, pageProps, router }: AppProps) { 12 | const [mounted, setMounted] = useState(false); 13 | 14 | const isDark = useMediaQuery({ 15 | query: "(prefers-color-scheme: dark)", 16 | }); 17 | 18 | useEffect(() => { 19 | setMounted(true); 20 | }, []); 21 | 22 | if (!mounted) return <>; 23 | 24 | return ( 25 | 26 | 33 | {router.pathname == "/login" ? ( 34 | 35 | 36 | 37 | ) : ( 38 | 39 | 40 | 41 | )} 42 | 43 | 44 | ); 45 | } 46 | 47 | export default MyApp; 48 | -------------------------------------------------------------------------------- /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/pages/follow-course.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from "antd"; 2 | import Head from "next/head"; 3 | import { useRouter } from "next/router"; 4 | 5 | import CourseList from "@/components/course-list"; 6 | import PageHeader from "@/components/page-header"; 7 | import Config from "@/config/config"; 8 | import { NotificationLevel, Pagination } from "@/lib/models"; 9 | import { useFollowingCourseList } from "@/services/course"; 10 | 11 | const FollowCoursePage = () => { 12 | const router = useRouter(); 13 | const { page, size } = router.query; 14 | 15 | const pagination: Pagination = { 16 | page: page ? parseInt(page as string) : 1, 17 | pageSize: size ? parseInt(size as string) : Config.PAGE_SIZE, 18 | }; 19 | 20 | const { courses, loading } = useFollowingCourseList( 21 | NotificationLevel.FOLLOW, 22 | pagination 23 | ); 24 | 25 | const onPageChange = (page: number, pageSize: number) => { 26 | router.push({ query: { page: page, size: pageSize } }); 27 | }; 28 | 29 | return ( 30 | <> 31 | 32 | 33 | 关注的课程 - SJTU选课社区 34 | 35 | 36 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | export default FollowCoursePage; 49 | -------------------------------------------------------------------------------- /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/services/common.ts: -------------------------------------------------------------------------------- 1 | import { CommonInfo, CommonInfoDTO } from "@/lib/models"; 2 | import { fetcher } from "@/services/request"; 3 | import useSWR from "swr"; 4 | 5 | export function useCommonInfo() { 6 | const { data, error } = useSWR("/api/common/", fetcher); 7 | 8 | let commonInfo: CommonInfo = { 9 | announcements: [], 10 | semesters: [], 11 | available_semesters: [], 12 | user: { 13 | id: 0, 14 | username: "", 15 | is_staff: false, 16 | account: null, 17 | }, 18 | semesterMap: new Map(), 19 | my_reviews: new Map(), 20 | enrolled_courses: new Map(), 21 | reviewed_courses: new Map(), 22 | promotions: new Map(), 23 | }; 24 | if (data) { 25 | commonInfo.announcements = data.announcements; 26 | commonInfo.user = data.user; 27 | commonInfo.user.account = localStorage.getItem("account"); 28 | commonInfo.semesters = data.semesters; 29 | commonInfo.available_semesters = data.semesters.filter( 30 | (item) => item.available 31 | ); 32 | data.semesters.forEach((item) => { 33 | commonInfo.semesterMap.set(item.id, item.name); 34 | }); 35 | data.my_reviews.forEach((item) => { 36 | commonInfo.my_reviews.set(item.id, item); 37 | commonInfo.reviewed_courses.set(item.course_id, item); 38 | }); 39 | data.enrolled_courses.forEach((item) => { 40 | commonInfo.enrolled_courses.set(item.course_id, item); 41 | }); 42 | data.promotions.forEach((item) => { 43 | commonInfo.promotions.set(item.touchpoint, item); 44 | }); 45 | } 46 | return { 47 | commonInfo, 48 | loading: !error && !data, 49 | error: error, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /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/pages/follow-review.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card } from "antd"; 2 | import Head from "next/head"; 3 | import { useRouter } from "next/router"; 4 | 5 | import PageHeader from "@/components/page-header"; 6 | import ReviewList from "@/components/review-list"; 7 | import Config from "@/config/config"; 8 | import { Pagination } from "@/lib/models"; 9 | import { useFollowedReviews } from "@/services/review"; 10 | 11 | const FollowReviewPage = () => { 12 | const router = useRouter(); 13 | const { page, size } = router.query; 14 | 15 | const pagination: Pagination = { 16 | page: page ? parseInt(page as string) : 1, 17 | pageSize: size ? parseInt(size as string) : Config.PAGE_SIZE, 18 | }; 19 | 20 | const { reviews, loading } = useFollowedReviews(pagination); 21 | 22 | const onPageChange = (page: number, pageSize: number) => { 23 | router.push({ query: { page: page, size: pageSize } }); 24 | }; 25 | 26 | return ( 27 | <> 28 | { 35 | router.push("/follow-course"); 36 | }} 37 | > 38 | 关注的课程 39 | 40 | } 41 | > 42 | 43 | 关注 - SJTU选课社区 44 | 45 | 46 | 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default FollowReviewPage; 59 | -------------------------------------------------------------------------------- /src/pages/latest.tsx: -------------------------------------------------------------------------------- 1 | import { EditOutlined } from "@ant-design/icons"; 2 | import { Button, Card } from "antd"; 3 | import Head from "next/head"; 4 | import Link from "next/link"; 5 | import { useRouter } from "next/router"; 6 | 7 | import PageHeader from "@/components/page-header"; 8 | import ReviewList from "@/components/review-list"; 9 | import Config from "@/config/config"; 10 | import { Pagination } from "@/lib/models"; 11 | import { useReviews } from "@/services/review"; 12 | 13 | const LatestPage = () => { 14 | const router = useRouter(); 15 | const { page, size } = router.query; 16 | 17 | const pagination: Pagination = { 18 | page: page ? parseInt(page as string) : 1, 19 | pageSize: size ? parseInt(size as string) : Config.PAGE_SIZE, 20 | }; 21 | 22 | const { reviews, loading } = useReviews(pagination); 23 | 24 | const onPageChange = (page: number, pageSize: number) => { 25 | router.push({ query: { page: page, size: pageSize } }); 26 | }; 27 | 28 | return ( 29 | <> 30 | 35 | 39 | 40 | } 41 | /> 42 | 43 | 最新点评 - SJTU选课社区 44 | 45 | 46 | 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default LatestPage; 59 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/pages/point.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Descriptions, Empty, Skeleton, Table, Typography } from "antd"; 2 | import Head from "next/head"; 3 | 4 | import PageHeader from "@/components/page-header"; 5 | import { useUserPoint } from "@/services/user"; 6 | 7 | const { Title, Paragraph } = Typography; 8 | 9 | const PointPage = () => { 10 | const { points, loading } = useUserPoint(); 11 | 12 | const columns = [ 13 | { 14 | title: "时间", 15 | dataIndex: "time", 16 | key: "time", 17 | }, 18 | { 19 | title: "变动值", 20 | dataIndex: "value", 21 | key: "value", 22 | }, 23 | { title: "描述", dataIndex: "description", key: "description" }, 24 | ]; 25 | return ( 26 | <> 27 | 28 | 29 | 社区积分 - SJTU选课社区 30 | 31 | 32 | 33 | 概览 34 | 35 | {points ? ( 36 | 37 | 38 | {points.points} 39 | 40 | 41 | ) : ( 42 | 43 | )} 44 | 45 | 46 | 说明 47 | 48 | 49 | 您可以前往 50 | 55 | 传承·交大 56 | 57 | 将选课社区积分兑换为传承积分。 58 | 59 | 积分详情 60 | 61 |
67 |
68 |
69 |
70 | 71 | ); 72 | }; 73 | export default PointPage; 74 | -------------------------------------------------------------------------------- /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"] -------------------------------------------------------------------------------- /src/pages/search.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Input, message } from "antd"; 2 | import Head from "next/head"; 3 | import { useRouter } from "next/router"; 4 | import { useEffect, useRef } from "react"; 5 | 6 | import CourseList from "@/components/course-list"; 7 | import PageHeader from "@/components/page-header"; 8 | import Config from "@/config/config"; 9 | import { Pagination } from "@/lib/models"; 10 | import { useSearchCourse } from "@/services/course"; 11 | 12 | const { Search } = Input; 13 | 14 | const SearchPage = () => { 15 | const router = useRouter(); 16 | const { page, size, q } = router.query; 17 | const show_q = q ? (q as string) : ""; 18 | 19 | const pagination: Pagination = { 20 | page: page ? parseInt(page as string) : 1, 21 | pageSize: size ? parseInt(size as string) : Config.PAGE_SIZE, 22 | }; 23 | const inputRef = useRef(null); 24 | 25 | const { courses, loading, mutate } = useSearchCourse(q as string, pagination); 26 | 27 | useEffect(() => { 28 | inputRef.current?.focus({ cursor: "end" }); 29 | if (show_q == "") return; 30 | mutate(); 31 | }, []); 32 | 33 | const onSearch = (value: string) => { 34 | if (value.trim() == "") { 35 | message.info("请输入搜索内容"); 36 | return; 37 | } 38 | router.push({ query: { q: value.trim() } }); 39 | }; 40 | 41 | const onPageChange = (page: number, pageSize: number) => { 42 | router.push({ query: { q, page, size: pageSize } }); 43 | }; 44 | 45 | return ( 46 | <> 47 | history.back()}> 48 | 49 | {"搜索 " + show_q + " - SJTU选课社区"} 50 | 51 | 59 | 60 | 68 | 69 | 70 | ); 71 | }; 72 | 73 | export default SearchPage; 74 | -------------------------------------------------------------------------------- /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 |