├── .gitignore ├── LICENSE ├── README.md ├── admin ├── .dockerignore ├── .env ├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── docker-compose.test.yaml ├── docker-compose.yaml ├── eslint.config.js ├── index.html ├── nginx.conf ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public │ └── vite.svg ├── src │ ├── app │ │ ├── app.tsx │ │ ├── components │ │ │ ├── markdown-editor │ │ │ │ └── index.tsx │ │ │ ├── markdown-viewer │ │ │ │ ├── index.tsx │ │ │ │ └── markdown-viewer.module.scss │ │ │ └── pagination │ │ │ │ └── index.ts │ │ ├── modules │ │ │ ├── article │ │ │ │ ├── article-content.tsx │ │ │ │ ├── article-form.tsx │ │ │ │ ├── article-modal.tsx │ │ │ │ ├── article.service.ts │ │ │ │ ├── index.tsx │ │ │ │ ├── style.scss │ │ │ │ ├── useArticle.ts │ │ │ │ └── useModal.ts │ │ │ ├── base-layout │ │ │ │ ├── base-layout.service.ts │ │ │ │ ├── index.tsx │ │ │ │ └── useBaseLayout.ts │ │ │ ├── comment │ │ │ │ ├── comment.service.ts │ │ │ │ ├── index.tsx │ │ │ │ └── useComment.ts │ │ │ ├── dashboard │ │ │ │ └── index.tsx │ │ │ ├── role │ │ │ │ ├── index.tsx │ │ │ │ ├── role.service.ts │ │ │ │ └── useRole.ts │ │ │ ├── topic │ │ │ │ ├── index.tsx │ │ │ │ ├── topic-form.tsx │ │ │ │ ├── topic-modal.tsx │ │ │ │ ├── topic.service.ts │ │ │ │ ├── useModal.ts │ │ │ │ └── useTopic.ts │ │ │ └── user │ │ │ │ ├── index.tsx │ │ │ │ └── user.service.ts │ │ └── utils │ │ │ └── axios.ts │ ├── assets │ │ └── .gitkeep │ ├── main.tsx │ ├── styles.scss │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── screenshots ├── admin.jpg └── web.jpg ├── server ├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── Dockerfile ├── Makefile ├── README.md ├── docker-compose.test.yaml ├── docker-compose.yaml ├── nest-cli.json ├── package.json ├── pnpm-lock.yaml ├── prisma │ ├── migrations │ │ ├── 20231124091041_init │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma ├── src │ ├── app.module.ts │ ├── assets │ │ └── .gitkeep │ ├── decorators │ │ ├── jwt.decorator.ts │ │ └── roles.decorator.ts │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── filters │ │ └── http-exception.filter.ts │ ├── main.ts │ ├── modules │ │ ├── article │ │ │ ├── admin.controller.ts │ │ │ ├── article.controller.ts │ │ │ ├── article.module.ts │ │ │ ├── article.service.ts │ │ │ └── create-article.dto.ts │ │ ├── auth │ │ │ ├── auth.controller.ts │ │ │ ├── auth.module.ts │ │ │ ├── auth.service.ts │ │ │ ├── jwt-auth.guard.ts │ │ │ ├── jwt.strategy.ts │ │ │ ├── local-auth.guard.ts │ │ │ └── local.strategy.ts │ │ ├── comment │ │ │ ├── admin.controller.ts │ │ │ ├── comment.controller.ts │ │ │ ├── comment.module.ts │ │ │ ├── comment.service.ts │ │ │ └── create-comment.dto.ts │ │ ├── prisma │ │ │ ├── prisma.module.ts │ │ │ └── prisma.service.ts │ │ ├── role │ │ │ ├── admin.controller.ts │ │ │ ├── create-role.dto.ts │ │ │ ├── role.module.ts │ │ │ ├── role.service.ts │ │ │ └── roles.guard.ts │ │ ├── shared │ │ │ └── shared.module.ts │ │ ├── tag │ │ │ ├── admin.controller.ts │ │ │ ├── create-tag.dto.ts │ │ │ ├── tag.module.ts │ │ │ └── tag.service.ts │ │ ├── topic │ │ │ ├── admin.controller.ts │ │ │ ├── create-topic.dto.ts │ │ │ ├── topic.controller.ts │ │ │ ├── topic.module.ts │ │ │ └── topic.service.ts │ │ └── user │ │ │ ├── admin.controller.ts │ │ │ ├── create-user.dto.ts │ │ │ ├── user.controller.ts │ │ │ ├── user.module.ts │ │ │ └── user.service.ts │ ├── types │ │ └── pagination.ts │ └── utils │ │ ├── get-hash.ts │ │ └── prisma-errors.ts ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json └── web ├── .dockerignore ├── .env ├── .eslintrc.json ├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── docker-compose.test.yaml ├── docker-compose.yaml ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── next.svg └── vercel.svg ├── src ├── app │ ├── article │ │ └── [id] │ │ │ └── page.tsx │ ├── global.css │ ├── layout.tsx │ ├── login │ │ └── page.tsx │ ├── page.tsx │ ├── reset-password │ │ └── page.tsx │ └── topic │ │ └── [code] │ │ └── page.tsx ├── components │ └── markdown-viewer │ │ ├── index.tsx │ │ └── markdown-viewer.module.scss ├── hooks │ ├── useArticle.ts │ ├── useAuth.ts │ ├── useResetPassword.ts │ └── useTopic.ts ├── themes │ ├── default │ │ ├── components │ │ │ ├── article-item │ │ │ │ ├── article-content.tsx │ │ │ │ └── index.tsx │ │ │ ├── article-list │ │ │ │ ├── divider.tsx │ │ │ │ └── index.tsx │ │ │ ├── article-meta │ │ │ │ ├── article-meta-date.tsx │ │ │ │ ├── article-meta-topic.tsx │ │ │ │ └── index.tsx │ │ │ └── article │ │ │ │ └── article-footer.tsx │ │ ├── index.ts │ │ ├── layout │ │ │ ├── base-footer.tsx │ │ │ ├── base-header.tsx │ │ │ ├── base-layout.tsx │ │ │ └── index.ts │ │ ├── pages │ │ │ ├── article.tsx │ │ │ ├── home.tsx │ │ │ ├── login.tsx │ │ │ ├── reset-password.tsx │ │ │ └── topic.tsx │ │ └── utils │ │ │ └── addClassName.ts │ └── index.ts ├── types │ └── index.d.ts └── utils │ └── request.ts ├── tailwind.config.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 hojas 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 | # nest-react-full-stack 2 | 3 | Nestjs and React full-stack application. 4 | 5 | ## Screenshots 6 | 7 | ### Admin 8 | 9 | ![admin](./screenshots/admin.jpg) 10 | 11 | ### Web 12 | 13 | ![web](./screenshots/web.jpg) 14 | 15 | ## Tech Stack 16 | 17 | 1. Nest 18 | 2. React 19 | 3. Next 20 | 21 | ## Features 22 | 23 | 1. User management 24 | 2. Role management 25 | 3. Topic management 26 | 4. Article management 27 | 5. Comment management 28 | -------------------------------------------------------------------------------- /admin/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /admin/.env: -------------------------------------------------------------------------------- 1 | DOMAIN=localhost 2 | VITE_API_BASE_URL=http://localhost:4200/api 3 | -------------------------------------------------------------------------------- /admin/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /admin/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.15.0-alpine AS builder 2 | 3 | WORKDIR /opt/app 4 | COPY package.json pnpm-lock.yaml /opt/app/ 5 | RUN corepack enable && pnpm i 6 | 7 | COPY . . 8 | RUN pnpm build 9 | 10 | 11 | FROM nginx:1.27.0-alpine 12 | 13 | WORKDIR /usr/share/nginx/html 14 | COPY --from=builder /opt/app/dist ./admin 15 | COPY nginx.conf /etc/nginx/conf.d/default.conf 16 | -------------------------------------------------------------------------------- /admin/Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | docker compose -f docker-compose.test.yaml up admin -d 3 | test-down: 4 | docker compose -f docker-compose.test.yaml down 5 | prod: 6 | docker compose up admin -d 7 | -------------------------------------------------------------------------------- /admin/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | sourceType: 'module', 22 | project: ['./tsconfig.json', './tsconfig.node.json'], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | } 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /admin/docker-compose.test.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | admin: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | image: nest-react-full-stack-admin 7 | container_name: nest-react-full-stack-admin 8 | restart: always 9 | ports: 10 | - 127.0.0.1:4200:4200 11 | env_file: 12 | - .env 13 | environment: 14 | VIRTUAL_PORT: 4200 15 | VIRTUAL_PATH: /admin 16 | -------------------------------------------------------------------------------- /admin/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | admin: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | image: nest-react-full-stack-admin 7 | container_name: nest-react-full-stack-admin 8 | restart: always 9 | ports: 10 | - 127.0.0.1:4200:4200 11 | env_file: 12 | - .env 13 | environment: 14 | LETSENCRYPT_HOST: ${DOMAIN} 15 | VIRTUAL_HOST: ${DOMAIN} 16 | VIRTUAL_PORT: 4200 17 | VIRTUAL_PATH: /admin 18 | networks: 19 | - nginx-proxy 20 | 21 | networks: 22 | nginx-proxy: 23 | external: true 24 | -------------------------------------------------------------------------------- /admin/eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | react: true, 5 | }) 6 | -------------------------------------------------------------------------------- /admin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /admin/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 4200; 3 | listen [::]:4200; 4 | server_name localhost; 5 | 6 | location / { 7 | root /usr/share/nginx/html; 8 | index /admin/index.html; 9 | try_files $uri $uri/ /admin/index.html; 10 | } 11 | 12 | error_page 404 /admin/index.html; 13 | } 14 | -------------------------------------------------------------------------------- /admin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "admin", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "private": true, 6 | "packageManager": "pnpm@9.4.0", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "lint": "eslint .", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@ant-design/icons": "^5.3.7", 15 | "@codemirror/lang-markdown": "^6.2.5", 16 | "@codemirror/language-data": "^6.5.1", 17 | "@uiw/codemirror-theme-dracula": "^4.22.2", 18 | "@uiw/react-codemirror": "^4.22.2", 19 | "antd": "^5.18.3", 20 | "axios": "^1.7.2", 21 | "date-fns": "^3.6.0", 22 | "github-markdown-css": "^5.6.1", 23 | "react": "^18.3.1", 24 | "react-dom": "^18.3.1", 25 | "react-markdown": "^9.0.1", 26 | "react-router-dom": "^6.23.1", 27 | "react-syntax-highlighter": "^15.5.0", 28 | "remark-gfm": "^4.0.0" 29 | }, 30 | "devDependencies": { 31 | "@antfu/eslint-config": "^2.21.1", 32 | "@eslint-react/eslint-plugin": "^1.5.16", 33 | "@types/node": "^20.14.8", 34 | "@types/react": "^18.3.3", 35 | "@types/react-dom": "^18.3.0", 36 | "@types/react-syntax-highlighter": "^15.5.13", 37 | "@vitejs/plugin-react": "^4.3.1", 38 | "autoprefixer": "^10.4.19", 39 | "eslint": "^8.57.0", 40 | "eslint-plugin-react-hooks": "^4.6.2", 41 | "eslint-plugin-react-refresh": "^0.4.7", 42 | "postcss": "^8.4.38", 43 | "sass": "^1.77.6", 44 | "tailwindcss": "^3.4.4", 45 | "typescript": "^5.5.2", 46 | "vite": "^5.3.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /admin/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /admin/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /admin/src/app/app.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom' 2 | import { BaseLayout } from './modules/base-layout' 3 | import { AdminDashboard } from './modules/dashboard' 4 | import { AdminUser } from './modules/user' 5 | import { AdminTopic } from './modules/topic' 6 | import { AdminArticle } from './modules/article' 7 | import { AdminComment } from './modules/comment' 8 | import { AdminRole } from './modules/role' 9 | 10 | function App() { 11 | return ( 12 | 13 | 14 | } /> 15 | } /> 16 | } /> 17 | } /> 18 | } /> 19 | } /> 20 | 21 | 22 | ) 23 | } 24 | 25 | export default App 26 | -------------------------------------------------------------------------------- /admin/src/app/components/markdown-editor/index.tsx: -------------------------------------------------------------------------------- 1 | import CodeMirror from '@uiw/react-codemirror' 2 | import { dracula } from '@uiw/codemirror-theme-dracula' 3 | import { markdown, markdownLanguage } from '@codemirror/lang-markdown' 4 | import { languages } from '@codemirror/language-data' 5 | 6 | interface Props { 7 | className?: string 8 | content: string 9 | onChange: (content: string) => void 10 | } 11 | 12 | export function MarkdownEditor({ className, content, onChange }: Props) { 13 | return ( 14 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /admin/src/app/components/markdown-viewer/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import type { ExtraProps } from 'react-markdown' 3 | import ReactMarkdown from 'react-markdown' 4 | import remarkGfm from 'remark-gfm' 5 | import SyntaxHighlighter from 'react-syntax-highlighter' 6 | import { nightOwl } from 'react-syntax-highlighter/dist/cjs/styles/prism' 7 | 8 | import 'github-markdown-css/github-markdown-light.css' 9 | 10 | interface Props { 11 | className?: string 12 | content: string 13 | } 14 | 15 | export function MarkdownViewer({ className, content }: Props) { 16 | className = className ? ` ${className}` : '' 17 | 18 | return ( 19 | & 24 | React.HTMLAttributes & 25 | ExtraProps, 26 | ) { 27 | const { children, className } = props 28 | const match = /language-(\w+)/.exec(className || '') 29 | return match 30 | ? ( 31 | 39 | {String(children).replace(/\n$/, '')} 40 | 41 | ) 42 | : ( 43 | 44 | {children} 45 | 46 | ) 47 | }, 48 | }} 49 | remarkPlugins={[remarkGfm]} 50 | skipHtml={false} 51 | > 52 | {content} 53 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /admin/src/app/components/markdown-viewer/markdown-viewer.module.scss: -------------------------------------------------------------------------------- 1 | .markdown-viewer { 2 | > :global(pre) { 3 | padding: 0; 4 | border-radius: 0; 5 | } 6 | 7 | > :global(pre > div) { 8 | margin: 0 !important; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /admin/src/app/components/pagination/index.ts: -------------------------------------------------------------------------------- 1 | export interface Pagination { 2 | page: number 3 | pageSize: number 4 | total: number 5 | results: T[] 6 | } 7 | -------------------------------------------------------------------------------- /admin/src/app/modules/article/article-content.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useEffect } from 'react' 2 | import { MarkdownEditor } from '~/app/components/markdown-editor' 3 | import { MarkdownViewer } from '~/app/components/markdown-viewer' 4 | 5 | interface Props { 6 | content: string 7 | onChange(content: string): void 8 | } 9 | 10 | export const ArticleContent = ({ content, onChange }: Props) => { 11 | const [str, setStr] = useState(content) 12 | 13 | useEffect(() => { 14 | setStr(content) 15 | }, [content]) 16 | 17 | const handleChange = useCallback( 18 | (content: string) => { 19 | setStr(content) 20 | onChange(content) 21 | }, 22 | [onChange] 23 | ) 24 | 25 | return ( 26 |
27 | 32 | 36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /admin/src/app/modules/article/article-form.tsx: -------------------------------------------------------------------------------- 1 | // import { useEffect } from 'react' 2 | import { Form, Input, Select } from 'antd' 3 | import { Topic } from '../topic/topic.service' 4 | import { Article, CreateArticleDto } from './article.service' 5 | 6 | interface Props { 7 | topicList: Topic[] 8 | article?: Article 9 | onFinish: (article: CreateArticleDto) => void 10 | } 11 | 12 | export default ({ topicList, article, onFinish }: Props) => { 13 | const [form] = Form.useForm() 14 | 15 | // useEffect(() => { 16 | // form && form.resetFields && form.resetFields(['title', 'topicId']) 17 | // }, [form, article]) 18 | 19 | return ( 20 |
29 | 34 | 35 | 36 | 41 | 48 | 49 |
50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /admin/src/app/modules/article/article-modal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Button, Modal } from 'antd' 3 | 4 | interface Props { 5 | children: React.ReactNode 6 | visible: boolean 7 | title: string 8 | hideModal: () => void 9 | } 10 | 11 | export default ({ children, visible, title, hideModal }: Props) => { 12 | return ( 13 | 19 | 取消 20 | , 21 | , 24 | ]} 25 | > 26 | {children} 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /admin/src/app/modules/article/article.service.ts: -------------------------------------------------------------------------------- 1 | import { $axios } from '~/app/utils/axios' 2 | import type { Pagination } from '~/app/components/pagination' 3 | 4 | const api = { 5 | list: '/admin/article/', 6 | detail: (id: number) => `/admin/article/${id}/`, 7 | } 8 | 9 | export interface CreateArticleDto { 10 | title: string 11 | content: string 12 | topicId: number 13 | } 14 | 15 | export interface Article extends CreateArticleDto { 16 | id: number 17 | authorId: number 18 | createdAt: string 19 | } 20 | 21 | export class ArticleService { 22 | static getArticleList() { 23 | return $axios.get>(api.list) 24 | } 25 | 26 | static createArticle(article: CreateArticleDto) { 27 | return $axios.post
(api.list, { article }) 28 | } 29 | 30 | static updateArticle(id: number, article: CreateArticleDto) { 31 | return $axios.put
(api.detail(id), { article }) 32 | } 33 | 34 | static removeArticle(id: number) { 35 | return $axios.delete
(api.detail(id)) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /admin/src/app/modules/article/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Button, Table, Space, Popconfirm, Drawer } from 'antd' 3 | import type { DrawerProps } from 'antd' 4 | import { format } from 'date-fns' 5 | import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons' 6 | import { Topic } from '../topic/topic.service' 7 | import { Article, CreateArticleDto } from './article.service' 8 | import ArticleModal from './article-modal' 9 | import ArticleForm from './article-form' 10 | import { useArticle } from './useArticle' 11 | import { useModal } from './useModal' 12 | import { ArticleContent } from './article-content' 13 | import './style.scss' 14 | 15 | type ModalType = 'create' | 'update' 16 | 17 | const columns = ( 18 | topicList: Topic[], 19 | showModal: (type: ModalType) => void, 20 | setActiveArticle: (article: Article) => void, 21 | handleDelete: (id: number) => void, 22 | showDrawer: () => void 23 | ) => [ 24 | { 25 | title: 'ID', 26 | dataIndex: 'id', 27 | }, 28 | { 29 | title: '名称', 30 | dataIndex: 'title', 31 | }, 32 | { 33 | title: '分类', 34 | render: (_: unknown, record: Article) => ( 35 |
{topicList.find(c => c.id === record.topicId)?.name}
36 | ), 37 | }, 38 | { 39 | title: '创建时间', 40 | render: (_: unknown, record: Article) => ( 41 |
{format(new Date(record.createdAt), 'yyyy年MM月dd日 HH:mm:ss')}
42 | ), 43 | }, 44 | { 45 | title: '操作', 46 | render: (_: unknown, record: Article) => ( 47 | 48 | 58 | 68 | handleDelete(record.id)} 73 | > 74 | 78 | 79 | 80 | ), 81 | }, 82 | ] 83 | 84 | export const AdminArticle = () => { 85 | const [activeArticle, setActiveArticle] = useState
() 86 | const { 87 | topicList, 88 | articleList, 89 | addArticle, 90 | updateArticle, 91 | removeArticle, 92 | } = useArticle() 93 | 94 | const { 95 | modalVisible, 96 | setModalVisible, 97 | modalType, 98 | modalTitle, 99 | showModal, 100 | hideModal, 101 | } = useModal() 102 | 103 | const handleShowAddModal = () => { 104 | setActiveArticle(undefined) 105 | showModal('create') 106 | } 107 | 108 | const onFinish = (article: CreateArticleDto) => { 109 | modalType === 'create' 110 | ? addArticle(article) 111 | : activeArticle && updateArticle(activeArticle.id, article) 112 | 113 | setModalVisible(false) 114 | } 115 | 116 | const [drawerVisible, setDrawerVisible] = useState(false) 117 | const showDrawer = () => setDrawerVisible(true) 118 | const handleCloseDrawer = () => setDrawerVisible(false) 119 | const handleContentChange = (content: string) => { 120 | activeArticle && setActiveArticle({ ...activeArticle, content }) 121 | } 122 | const handleSaveArticleContent = () => { 123 | activeArticle && updateArticle(activeArticle.id, activeArticle) 124 | handleCloseDrawer() 125 | } 126 | 127 | const drawerProps = { 128 | className: 'article-content-drawer', 129 | title: '编辑文章内容', 130 | placement: 'right', 131 | width: '90%', 132 | visible: drawerVisible, 133 | onClose: handleCloseDrawer, 134 | extra: ( 135 | 136 | 137 | 140 | 141 | ), 142 | } as DrawerProps 143 | 144 | return ( 145 | <> 146 | 150 | record.id} 160 | pagination={false} 161 | /> 162 | 167 | 172 | 173 | 174 | 178 | 179 | 180 | ) 181 | } 182 | -------------------------------------------------------------------------------- /admin/src/app/modules/article/style.scss: -------------------------------------------------------------------------------- 1 | .article-content-drawer .ant-drawer-content { 2 | overflow: hidden; 3 | } 4 | -------------------------------------------------------------------------------- /admin/src/app/modules/article/useArticle.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { message } from 'antd' 3 | import { Pagination } from '~/app/components/pagination' 4 | import { ArticleService, Article, CreateArticleDto } from './article.service' 5 | import { TopicService, Topic } from '../topic/topic.service' 6 | 7 | export const useArticle = () => { 8 | const [topicList, setTopicList] = useState([]) 9 | 10 | useEffect(() => { 11 | TopicService.getTopicList().then(({ ok, data }) => { 12 | ok && setTopicList(data) 13 | }) 14 | }, []) 15 | 16 | const [articleList, setArticleList] = useState>({ 17 | page: 1, 18 | pageSize: 20, 19 | total: 0, 20 | results: [], 21 | }) 22 | 23 | const getArticleList = async () => { 24 | const { ok, data } = await ArticleService.getArticleList() 25 | ok && setArticleList(data) 26 | } 27 | 28 | useEffect(() => { 29 | getArticleList() 30 | }, []) 31 | 32 | const addArticle = async (article: CreateArticleDto) => { 33 | const { ok, message: msg } = await ArticleService.createArticle(article) 34 | 35 | if (ok) { 36 | getArticleList() 37 | message.success('添加文章成功') 38 | } else { 39 | message.error(msg) 40 | } 41 | 42 | return ok 43 | } 44 | 45 | const updateArticle = async (id: number, article: CreateArticleDto) => { 46 | const { ok, message: msg } = await ArticleService.updateArticle(id, article) 47 | 48 | if (ok) { 49 | getArticleList() 50 | message.success('更新文章成功') 51 | } else { 52 | message.error(msg) 53 | } 54 | 55 | return ok 56 | } 57 | 58 | const removeArticle = async (id: number) => { 59 | await ArticleService.removeArticle(id) 60 | getArticleList() 61 | } 62 | 63 | return { 64 | topicList, 65 | articleList, 66 | addArticle, 67 | updateArticle, 68 | removeArticle, 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /admin/src/app/modules/article/useModal.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Article } from './article.service' 3 | 4 | type ModalType = 'create' | 'update' 5 | 6 | const modalTitleMap = { 7 | create: '添加文章', 8 | update: '更新文章', 9 | } 10 | 11 | export const useModal = () => { 12 | const [modalVisible, setModalVisible] = useState(false) 13 | const [modalType, setModalType] = useState('create') 14 | const [modalTitle, setModalTitle] = useState(modalTitleMap[modalType]) 15 | const [activeArticle, setActiveArticle] = useState
() 16 | 17 | useEffect(() => { 18 | modalType === 'create' && setActiveArticle(undefined) 19 | setModalTitle(modalTitleMap[modalType]) 20 | }, [modalType]) 21 | 22 | const showModal = (type: ModalType) => { 23 | setModalType(type) 24 | setModalVisible(true) 25 | } 26 | 27 | const hideModal = () => { 28 | setModalVisible(false) 29 | } 30 | 31 | return { 32 | modalVisible, 33 | setModalVisible, 34 | modalType, 35 | modalTitle, 36 | activeArticle, 37 | showModal, 38 | hideModal, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /admin/src/app/modules/base-layout/base-layout.service.ts: -------------------------------------------------------------------------------- 1 | import { $axios } from '~/app/utils/axios' 2 | 3 | const api = { 4 | user: '/auth/user/', 5 | } 6 | 7 | export class BaseLayoutService { 8 | static getUser() { 9 | return $axios.get(api.user) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /admin/src/app/modules/base-layout/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Layout, Menu } from 'antd' 3 | import type { ItemType } from 'antd/es/menu/hooks/useItems' 4 | import { 5 | CommentOutlined, 6 | DashboardOutlined, 7 | FileTextOutlined, 8 | UnorderedListOutlined, 9 | UserOutlined, 10 | UserSwitchOutlined, 11 | } from '@ant-design/icons' 12 | import { useBaseLayout } from './useBaseLayout' 13 | 14 | const { Header, Sider, Content, Footer } = Layout 15 | 16 | const menuItems: ItemType[] = [ 17 | { 18 | label: 'Dashboard', 19 | icon: , 20 | key: '/', 21 | }, 22 | { 23 | label: '用户管理', 24 | icon: , 25 | key: '/user', 26 | }, 27 | { 28 | label: '分类管理', 29 | icon: , 30 | key: '/topic', 31 | }, 32 | { 33 | label: '文章管理', 34 | icon: , 35 | key: '/article', 36 | }, 37 | { 38 | label: '评论管理', 39 | icon: , 40 | key: '/comment', 41 | }, 42 | { 43 | label: '角色管理', 44 | icon: , 45 | key: '/role', 46 | }, 47 | ] 48 | 49 | interface LayoutProps { 50 | children: React.ReactNode 51 | } 52 | 53 | export const BaseLayout: React.FC = ({ children }) => { 54 | const [collapsed, setCollapsed] = useState(false) 55 | const { selectedKeys, onClick } = useBaseLayout() 56 | 57 | return ( 58 | 59 | setCollapsed(value)} 63 | > 64 |
65 | Admin 66 |
67 | 74 | 75 | 76 |
77 | 78 |
{children}
79 |
80 |
81 | nest-react-full-stack ©2023 Created by hojas 82 |
83 | 84 | 85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /admin/src/app/modules/base-layout/useBaseLayout.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import type { To } from 'react-router-dom' 3 | import { useLocation, useNavigate } from 'react-router-dom' 4 | import { BaseLayoutService } from './base-layout.service' 5 | 6 | export function useBaseLayout() { 7 | const location = useLocation() 8 | const [selectedKeys, setSelectedKeys] = useState([location.pathname]) 9 | 10 | const getUser = async () => { 11 | const { ok } = await BaseLayoutService.getUser() 12 | if (!ok) 13 | window.location.href = '/' 14 | } 15 | 16 | useEffect(() => { 17 | getUser() 18 | }, []) 19 | 20 | useEffect(() => { 21 | setSelectedKeys([location.pathname]) 22 | }, [location]) 23 | 24 | const navigate = useNavigate() 25 | const onClick = (e: { key: To }) => { 26 | navigate(e.key) 27 | } 28 | 29 | return { 30 | selectedKeys, 31 | onClick, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /admin/src/app/modules/comment/comment.service.ts: -------------------------------------------------------------------------------- 1 | import { $axios } from '~/app/utils/axios' 2 | import { Pagination } from '~/app/components/pagination' 3 | 4 | const api = { 5 | list: '/admin/comment/', 6 | detail: (id: number) => `/admin/comment/${id}/`, 7 | } 8 | 9 | export interface Comment { 10 | id: number 11 | content: string 12 | createdAt: string 13 | author: { 14 | id: number 15 | username: string 16 | } 17 | article: { 18 | id: number 19 | title: string 20 | } 21 | } 22 | 23 | export class CommentService { 24 | static getCommentList() { 25 | return $axios.get>(api.list) 26 | } 27 | 28 | static removeComment(id: number) { 29 | return $axios.delete(api.detail(id)) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /admin/src/app/modules/comment/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Popconfirm, Space, Table } from 'antd' 2 | import { DeleteOutlined } from '@ant-design/icons' 3 | import type { Comment } from './comment.service' 4 | import { useComment } from './useComment' 5 | 6 | function columns(handleDelete: (id: number) => void) { 7 | return [ 8 | { 9 | title: 'ID', 10 | dataIndex: 'id', 11 | }, 12 | { 13 | title: '内容', 14 | dataIndex: 'content', 15 | }, 16 | { 17 | title: '作者', 18 | render: (_: any, record: Comment) =>
{record.author.username}
, 19 | }, 20 | { 21 | title: '文章', 22 | render: (_: any, record: Comment) =>
{record.article.title}
, 23 | }, 24 | { 25 | title: '时间', 26 | dataIndex: 'createdAt', 27 | }, 28 | { 29 | title: '操作', 30 | key: 'action', 31 | render: (_: any, record: Comment) => ( 32 | 33 | handleDelete(record.id)} 38 | > 39 | 43 | 44 | 45 | ), 46 | }, 47 | ] 48 | } 49 | 50 | export function AdminComment() { 51 | const { commentList, getCommentList, removeComment } = useComment() 52 | 53 | getCommentList() 54 | 55 | return ( 56 |
record.id} 60 | /> 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /admin/src/app/modules/comment/useComment.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import type { Comment } from './comment.service' 3 | import { CommentService } from './comment.service' 4 | import type { Pagination } from '~/app/components/pagination' 5 | 6 | export function useComment() { 7 | const [commentList, setCommentList] = useState>({ 8 | page: 1, 9 | pageSize: 20, 10 | total: 0, 11 | results: [], 12 | }) 13 | 14 | const getCommentList = async () => { 15 | const { ok, data } = await CommentService.getCommentList() 16 | ok && setCommentList(data) 17 | } 18 | 19 | useEffect(() => { 20 | getCommentList() 21 | }, []) 22 | 23 | const removeComment = async (id: number) => { 24 | await CommentService.removeComment(id) 25 | getCommentList() 26 | } 27 | 28 | return { 29 | commentList, 30 | getCommentList, 31 | removeComment, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /admin/src/app/modules/dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | export const AdminDashboard = () => ( 2 |
3 |

Dashboard

4 |
5 | ) 6 | -------------------------------------------------------------------------------- /admin/src/app/modules/role/index.tsx: -------------------------------------------------------------------------------- 1 | import { Table } from 'antd' 2 | import { useRole } from './useRole' 3 | 4 | const columns = [ 5 | { 6 | title: 'ID', 7 | dataIndex: 'id', 8 | }, 9 | { 10 | title: '名称', 11 | dataIndex: 'name', 12 | }, 13 | { 14 | title: 'CODE', 15 | dataIndex: 'code', 16 | }, 17 | ] 18 | 19 | export const AdminRole = () => { 20 | const { roleList } = useRole() 21 | 22 | return ( 23 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /admin/src/app/modules/role/role.service.ts: -------------------------------------------------------------------------------- 1 | import { $axios } from '~/app/utils/axios' 2 | 3 | const api = { 4 | list: '/admin/role/', 5 | detail: (id: number) => `/admin/role/${id}/`, 6 | } 7 | 8 | export interface CreateRoleDto { 9 | code: string 10 | name: string 11 | } 12 | 13 | export interface Role extends CreateRoleDto { 14 | id: number 15 | } 16 | 17 | export class RoleService { 18 | static getRoleList() { 19 | return $axios.get(api.list) 20 | } 21 | 22 | static createRole(role: CreateRoleDto) { 23 | return $axios.post(api.list, { role }) 24 | } 25 | 26 | static updateRole(id: number, role: CreateRoleDto) { 27 | return $axios.put(api.detail(id), { role }) 28 | } 29 | 30 | static removeRole(id: number) { 31 | return $axios.delete(api.detail(id)) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /admin/src/app/modules/role/useRole.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Role, RoleService } from './role.service' 3 | 4 | export const useRole = () => { 5 | const [roleList, setRoleList] = useState([]) 6 | 7 | const getRoleList = async () => { 8 | const { ok, data } = await RoleService.getRoleList() 9 | ok && setRoleList(data) 10 | } 11 | 12 | useEffect(() => { 13 | getRoleList() 14 | }, []) 15 | 16 | return { 17 | roleList, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /admin/src/app/modules/topic/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Button, Table, Space, Popconfirm } from 'antd' 3 | import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons' 4 | import { Topic, CreateTopicDto } from './topic.service' 5 | import TopicModal from './topic-modal' 6 | import TopicForm from './topic-form' 7 | import { useTopic } from './useTopic' 8 | import { useModal } from './useModal' 9 | 10 | type ModalType = 'create' | 'update' 11 | 12 | const columns = ( 13 | showModal: (type: ModalType) => void, 14 | setActiveTopic: (topic: Topic) => void, 15 | handleDelete: (id: number) => void 16 | ) => [ 17 | { 18 | title: 'ID', 19 | dataIndex: 'id', 20 | }, 21 | { 22 | title: '名称', 23 | dataIndex: 'name', 24 | }, 25 | { 26 | title: 'CODE', 27 | dataIndex: 'code', 28 | }, 29 | { 30 | title: '排序码', 31 | dataIndex: 'orderIndex', 32 | }, 33 | { 34 | title: '操作', 35 | key: 'action', 36 | render: (_: any, record: Topic) => ( 37 | 38 | 48 | handleDelete(record.id)} 53 | > 54 | 58 | 59 | 60 | ), 61 | }, 62 | ] 63 | 64 | export const AdminTopic = () => { 65 | const [activeTopic, setActiveTopic] = useState() 66 | const { topicList, addTopic, updateTopic, removeTopic } = 67 | useTopic() 68 | 69 | const { 70 | modalVisible, 71 | setModalVisible, 72 | modalType, 73 | modalTitle, 74 | showModal, 75 | hideModal, 76 | } = useModal() 77 | 78 | const onShowAddModal = () => { 79 | setActiveTopic(undefined) 80 | showModal('create') 81 | } 82 | 83 | const onFinish = (topic: CreateTopicDto) => { 84 | modalType === 'create' 85 | ? addTopic(topic) 86 | : activeTopic && updateTopic(activeTopic.id, topic) 87 | 88 | setModalVisible(false) 89 | } 90 | 91 | return ( 92 | <> 93 | 97 |
record.id} 101 | pagination={false} 102 | /> 103 | 108 | 109 | 110 | 111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /admin/src/app/modules/topic/topic-form.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Input, InputNumber } from 'antd' 2 | import { Topic, CreateTopicDto } from './topic.service' 3 | 4 | interface Props { 5 | topic?: Topic 6 | onFinish: (topic: CreateTopicDto) => void 7 | } 8 | 9 | export default ({ topic, onFinish }: Props) => { 10 | const [form] = Form.useForm() 11 | 12 | return ( 13 |
22 | 27 | 28 | 29 | 34 | 35 | 36 | 41 | 42 | 43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /admin/src/app/modules/topic/topic-modal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Button, Modal } from 'antd' 3 | 4 | interface Props { 5 | children: React.ReactNode 6 | visible: boolean 7 | title: string 8 | hideModal: () => void 9 | } 10 | 11 | export default ({ children, visible, title, hideModal }: Props) => { 12 | return ( 13 | 19 | 取消 20 | , 21 | , 24 | ]} 25 | > 26 | {children} 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /admin/src/app/modules/topic/topic.service.ts: -------------------------------------------------------------------------------- 1 | import { $axios } from '~/app/utils/axios' 2 | 3 | const api = { 4 | list: '/admin/topic/', 5 | detail: (id: number) => `/admin/topic/${id}/`, 6 | } 7 | 8 | export interface CreateTopicDto { 9 | code: string 10 | name: string 11 | orderIndex: number 12 | } 13 | 14 | export interface Topic extends CreateTopicDto { 15 | id: number 16 | } 17 | 18 | export class TopicService { 19 | static getTopicList() { 20 | return $axios.get(api.list) 21 | } 22 | 23 | static createTopic(topic: CreateTopicDto) { 24 | return $axios.post(api.list, { topic }) 25 | } 26 | 27 | static updateTopic(id: number, topic: CreateTopicDto) { 28 | return $axios.put(api.detail(id), { topic }) 29 | } 30 | 31 | static removeTopic(id: number) { 32 | return $axios.delete(api.detail(id)) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /admin/src/app/modules/topic/useModal.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Topic } from './topic.service' 3 | 4 | type ModalType = 'create' | 'update' 5 | 6 | const modalTitleMap = { 7 | create: '添加分类', 8 | update: '更新分类', 9 | } 10 | 11 | export const useModal = () => { 12 | const [modalVisible, setModalVisible] = useState(false) 13 | const [modalType, setModalType] = useState('create') 14 | const [modalTitle, setModalTitle] = useState(modalTitleMap[modalType]) 15 | const [activeTopic, setActiveTopic] = useState() 16 | 17 | useEffect(() => { 18 | modalType === 'create' && setActiveTopic(undefined) 19 | setModalTitle(modalTitleMap[modalType]) 20 | }, [modalType]) 21 | 22 | const showModal = (type: ModalType) => { 23 | setModalType(type) 24 | setModalVisible(true) 25 | } 26 | 27 | const hideModal = () => { 28 | setModalVisible(false) 29 | } 30 | 31 | return { 32 | modalVisible, 33 | setModalVisible, 34 | modalType, 35 | modalTitle, 36 | activeTopic, 37 | showModal, 38 | hideModal, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /admin/src/app/modules/topic/useTopic.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { message } from 'antd' 3 | import { 4 | Topic, 5 | CreateTopicDto, 6 | TopicService, 7 | } from './topic.service' 8 | 9 | export const useTopic = () => { 10 | const [topicList, setTopicList] = useState([]) 11 | 12 | const getTopicList = async () => { 13 | const { ok, data } = await TopicService.getTopicList() 14 | ok && setTopicList(data) 15 | } 16 | 17 | useEffect(() => { 18 | getTopicList() 19 | }, []) 20 | 21 | const addTopic = async (topic: CreateTopicDto) => { 22 | const { ok, message: msg } = await TopicService.createTopic(topic) 23 | 24 | if (ok) { 25 | getTopicList() 26 | message.success('添加分类成功') 27 | } else { 28 | message.error(msg) 29 | } 30 | 31 | return ok 32 | } 33 | 34 | const updateTopic = async (id: number, topic: CreateTopicDto) => { 35 | const { ok, message: msg } = await TopicService.updateTopic( 36 | id, 37 | topic 38 | ) 39 | 40 | if (ok) { 41 | getTopicList() 42 | message.success('更新分类成功') 43 | } else { 44 | message.error(msg) 45 | } 46 | 47 | return ok 48 | } 49 | 50 | const removeTopic = async (id: number) => { 51 | await TopicService.removeTopic(id) 52 | getTopicList() 53 | } 54 | 55 | return { 56 | topicList, 57 | addTopic, 58 | updateTopic, 59 | removeTopic, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /admin/src/app/modules/user/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Table } from 'antd' 3 | import { User, UserService } from './user.service' 4 | 5 | const columns = [ 6 | { 7 | title: 'ID', 8 | dataIndex: 'id', 9 | key: 'id', 10 | }, 11 | { 12 | title: '用户名', 13 | dataIndex: 'username', 14 | key: 'username', 15 | }, 16 | ] 17 | 18 | export const AdminUser = () => { 19 | const [page, setPage] = useState(1) 20 | const [dataSource, setDataSource] = useState([]) 21 | 22 | useEffect(() => { 23 | UserService.getUserList().then(({ ok, data }) => { 24 | if (ok) { 25 | setPage(data.page) 26 | setDataSource(data.results) 27 | } 28 | }) 29 | }, [page]) 30 | 31 | return
32 | } 33 | -------------------------------------------------------------------------------- /admin/src/app/modules/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { $axios } from '~/app/utils/axios' 2 | import { Pagination } from '~/app/components/pagination' 3 | 4 | const api = { 5 | user: '/admin/user/', 6 | } 7 | 8 | export interface User { 9 | id: number 10 | username: string 11 | phone: string 12 | } 13 | 14 | export class UserService { 15 | static getUserList() { 16 | return $axios.get>(api.user) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /admin/src/app/utils/axios.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios' 2 | import axios from 'axios' 3 | 4 | export interface CustomResponse 5 | extends AxiosResponse { 6 | ok: boolean 7 | message?: string 8 | } 9 | 10 | export function initAxios(baseURL = '/api') { 11 | const instance = axios.create({ 12 | baseURL, 13 | timeout: 3000, 14 | withCredentials: true, 15 | }) 16 | 17 | instance.interceptors.response.use( 18 | (response: AxiosResponse): CustomResponse => ({ 19 | ...response, 20 | ok: true, 21 | }), 22 | async (error: AxiosError>) => { 23 | const defaultMsg = '请求失败,请稍后再试' 24 | const data = error.response?.data || {} 25 | const message = data.message || defaultMsg 26 | 27 | return { 28 | ...error.response, 29 | ok: false, 30 | message, 31 | } 32 | }, 33 | ) 34 | 35 | return { 36 | get: , D = unknown>( 37 | url: string, 38 | config?: AxiosRequestConfig, 39 | ): Promise => instance.get(url, config), 40 | 41 | post: , D = unknown>( 42 | url: string, 43 | data?: D, 44 | config?: AxiosRequestConfig, 45 | ): Promise => instance.post(url, data, config), 46 | 47 | put: , D = unknown>( 48 | url: string, 49 | data?: D, 50 | config?: AxiosRequestConfig, 51 | ): Promise => instance.put(url, data, config), 52 | 53 | delete: , D = unknown>( 54 | url: string, 55 | config?: AxiosRequestConfig, 56 | ): Promise => instance.delete(url, config), 57 | } 58 | } 59 | 60 | export const $axios = initAxios(import.meta.env.VITE_API_BASE_URL) 61 | -------------------------------------------------------------------------------- /admin/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hojas/nest-react-full-stack/ce2e64eb9d13552fdd1ff9ecfa1acdecaa6ebebb/admin/src/assets/.gitkeep -------------------------------------------------------------------------------- /admin/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | import { BrowserRouter } from 'react-router-dom' 4 | 5 | import App from './app/app' 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement, 9 | ) 10 | root.render( 11 | 12 | 13 | 14 | 15 | , 16 | ) 17 | -------------------------------------------------------------------------------- /admin/src/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'antd/dist/reset.css'; 2 | 3 | // @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | -------------------------------------------------------------------------------- /admin/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /admin/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | './index.html', 5 | './src/**/*.{js,ts,jsx,tsx}', 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | -------------------------------------------------------------------------------- /admin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "jsx": "react-jsx", 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "useDefineForClassFields": true, 7 | "module": "ESNext", 8 | "moduleResolution": "bundler", 9 | "paths": { 10 | "~/*": ["./src/*"] 11 | }, 12 | "resolveJsonModule": true, 13 | "allowImportingTsExtensions": true, 14 | "strict": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noEmit": true, 19 | "isolatedModules": true, 20 | "skipLibCheck": true 21 | }, 22 | "references": [{ "path": "./tsconfig.node.json" }], 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /admin/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "strict": true, 7 | "allowSyntheticDefaultImports": true, 8 | "skipLibCheck": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /admin/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { defineConfig } from 'vite' 3 | import react from '@vitejs/plugin-react' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | base: '/admin', 8 | resolve: { 9 | alias: { 10 | '~': path.resolve(__dirname, 'src'), 11 | }, 12 | }, 13 | plugins: [react()], 14 | }) 15 | -------------------------------------------------------------------------------- /screenshots/admin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hojas/nest-react-full-stack/ce2e64eb9d13552fdd1ff9ecfa1acdecaa6ebebb/screenshots/admin.jpg -------------------------------------------------------------------------------- /screenshots/web.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hojas/nest-react-full-stack/ce2e64eb9d13552fdd1ff9ecfa1acdecaa6ebebb/screenshots/web.jpg -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.15.0-alpine 2 | 3 | WORKDIR /opt/app 4 | 5 | COPY package.json pnpm-lock.yaml ./ 6 | RUN corepack enable && pnpm i 7 | 8 | COPY . . 9 | RUN pnpm prisma:generate && pnpm build 10 | 11 | CMD pnpm prisma:migrate && pnpm start 12 | -------------------------------------------------------------------------------- /server/Makefile: -------------------------------------------------------------------------------- 1 | dev: 2 | docker compose -f docker-compose.test.yaml up -d 3 | dev-down: 4 | docker compose -f docker-compose.test.yaml down 5 | prod: 6 | docker compose up -d 7 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ pnpm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ pnpm run start 40 | 41 | # watch mode 42 | $ pnpm run start:dev 43 | 44 | # production mode 45 | $ pnpm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ pnpm run test 53 | 54 | # e2e tests 55 | $ pnpm run test:e2e 56 | 57 | # test coverage 58 | $ pnpm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /server/docker-compose.test.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | container_name: nest-react-full-stack-postgres 4 | image: postgres:16.3-alpine 5 | restart: always 6 | ports: 7 | - 127.0.0.1:5432:5432 8 | environment: 9 | POSTGRES_USER: nest_blog 10 | POSTGRES_PASSWORD: nest_blog 11 | POSTGRES_DB: nest_blog 12 | volumes: 13 | - postgres-data:/var/lib/postgresql/data 14 | server: 15 | build: 16 | context: . 17 | dockerfile: Dockerfile 18 | image: nest-react-full-stack-server 19 | container_name: nest-react-full-stack-server 20 | restart: always 21 | ports: 22 | - 127.0.0.1:8080:8080 23 | env_file: 24 | - .env 25 | environment: 26 | VIRTUAL_PORT: 8081 27 | VIRTUAL_PATH: /api 28 | depends_on: 29 | - postgres 30 | links: 31 | - postgres 32 | 33 | volumes: 34 | postgres-data: 35 | -------------------------------------------------------------------------------- /server/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | container_name: nest-react-full-stack-postgres 4 | image: postgres:16.3-alpine 5 | restart: always 6 | ports: 7 | - 127.0.0.1:5432:5432 8 | environment: 9 | POSTGRES_USER: nest_blog 10 | POSTGRES_PASSWORD: nest_blog 11 | POSTGRES_DB: nest_blog 12 | volumes: 13 | - postgres-data:/var/lib/postgresql/data 14 | networks: 15 | - nginx-proxy 16 | server: 17 | build: 18 | context: . 19 | dockerfile: Dockerfile 20 | image: nest-react-full-stack-server 21 | container_name: nest-react-full-stack-server 22 | restart: always 23 | ports: 24 | - 127.0.0.1:8081:8081 25 | env_file: 26 | - .env.production.local 27 | environment: 28 | LETSENCRYPT_HOST: ${DOMAIN} 29 | VIRTUAL_HOST: ${DOMAIN} 30 | VIRTUAL_PORT: 8081 31 | VIRTUAL_PATH: /api 32 | depends_on: 33 | - postgres 34 | links: 35 | - postgres 36 | networks: 37 | - nginx-proxy 38 | 39 | volumes: 40 | postgres-data: 41 | 42 | networks: 43 | nginx-proxy: 44 | external: true 45 | -------------------------------------------------------------------------------- /server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.0.1", 4 | "packageManager": "pnpm@9.4.0", 5 | "description": "nest-react-full-stack server.", 6 | "author": "hojas", 7 | "private": true, 8 | "license": "MIT", 9 | "scripts": { 10 | "prisma:generate": "prisma generate", 11 | "prisma:migrate-dev": "prisma migrate dev --name", 12 | "prisma:migrate": "prisma migrate deploy", 13 | "build": "nest build", 14 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 15 | "start": "nest start", 16 | "start:dev": "nest start --watch", 17 | "start:debug": "nest start --debug --watch", 18 | "start:prod": "node dist/main", 19 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 20 | "test": "jest", 21 | "test:watch": "jest --watch", 22 | "test:cov": "jest --coverage", 23 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 24 | "test:e2e": "jest --config ./test/jest-e2e.json" 25 | }, 26 | "dependencies": { 27 | "@nestjs/common": "^10.3.9", 28 | "@nestjs/config": "^3.2.2", 29 | "@nestjs/core": "^10.3.9", 30 | "@nestjs/jwt": "^10.2.0", 31 | "@nestjs/passport": "^10.0.3", 32 | "@nestjs/platform-express": "^10.3.9", 33 | "@prisma/client": "^5.15.1", 34 | "cookie-parser": "^1.4.6", 35 | "helmet": "^7.1.0", 36 | "passport-jwt": "^4.0.1", 37 | "passport-local": "^1.0.0", 38 | "reflect-metadata": "^0.2.2", 39 | "rxjs": "^7.8.1" 40 | }, 41 | "devDependencies": { 42 | "@nestjs/cli": "^10.3.2", 43 | "@nestjs/schematics": "^10.1.1", 44 | "@nestjs/testing": "^10.3.9", 45 | "@types/cookie-parser": "^1.4.7", 46 | "@types/express": "^4.17.21", 47 | "@types/jest": "^29.5.12", 48 | "@types/node": "^20.14.8", 49 | "@types/supertest": "^6.0.2", 50 | "@typescript-eslint/eslint-plugin": "^6.0.0", 51 | "@typescript-eslint/parser": "^6.0.0", 52 | "eslint": "^8.42.0", 53 | "eslint-config-prettier": "^9.1.0", 54 | "eslint-plugin-prettier": "^5.1.3", 55 | "jest": "^29.7.0", 56 | "prettier": "^3.3.2", 57 | "prisma": "^5.15.1", 58 | "source-map-support": "^0.5.21", 59 | "supertest": "^7.0.0", 60 | "ts-jest": "^29.1.5", 61 | "ts-loader": "^9.5.1", 62 | "ts-node": "^10.9.2", 63 | "tsconfig-paths": "^4.2.0", 64 | "typescript": "^5.5.2" 65 | }, 66 | "jest": { 67 | "moduleFileExtensions": [ 68 | "js", 69 | "json", 70 | "ts" 71 | ], 72 | "rootDir": "src", 73 | "testRegex": ".*\\.spec\\.ts$", 74 | "transform": { 75 | "^.+\\.(t|j)s$": "ts-jest" 76 | }, 77 | "collectCoverageFrom": [ 78 | "**/*.(t|j)s" 79 | ], 80 | "coverageDirectory": "../coverage", 81 | "testEnvironment": "node" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /server/prisma/migrations/20231124091041_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "CommentStatus" AS ENUM ('PENDING', 'RESOLVED', 'REJECTED'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "Role" ( 6 | "id" SERIAL NOT NULL, 7 | "name" VARCHAR NOT NULL, 8 | "code" VARCHAR NOT NULL, 9 | "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, 11 | 12 | CONSTRAINT "Role_pkey" PRIMARY KEY ("id") 13 | ); 14 | 15 | -- CreateTable 16 | CREATE TABLE "User" ( 17 | "id" SERIAL NOT NULL, 18 | "username" VARCHAR NOT NULL, 19 | "password" VARCHAR NOT NULL, 20 | "phone" VARCHAR NOT NULL DEFAULT '', 21 | "nickname" VARCHAR NOT NULL DEFAULT '', 22 | "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, 23 | "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, 24 | 25 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 26 | ); 27 | 28 | -- CreateTable 29 | CREATE TABLE "Topic" ( 30 | "id" SERIAL NOT NULL, 31 | "name" VARCHAR NOT NULL, 32 | "code" VARCHAR NOT NULL, 33 | "orderIndex" INTEGER NOT NULL DEFAULT 0, 34 | "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, 35 | "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, 36 | 37 | CONSTRAINT "Topic_pkey" PRIMARY KEY ("id") 38 | ); 39 | 40 | -- CreateTable 41 | CREATE TABLE "Article" ( 42 | "id" SERIAL NOT NULL, 43 | "title" VARCHAR NOT NULL, 44 | "content" TEXT NOT NULL, 45 | "topicId" INTEGER NOT NULL, 46 | "authorId" INTEGER NOT NULL, 47 | "likeCount" INTEGER NOT NULL DEFAULT 0, 48 | "collectCount" INTEGER NOT NULL DEFAULT 0, 49 | "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, 50 | "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, 51 | 52 | CONSTRAINT "Article_pkey" PRIMARY KEY ("id") 53 | ); 54 | 55 | -- CreateTable 56 | CREATE TABLE "Tag" ( 57 | "id" SERIAL NOT NULL, 58 | "name" VARCHAR NOT NULL, 59 | "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, 60 | "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, 61 | 62 | CONSTRAINT "Tag_pkey" PRIMARY KEY ("id") 63 | ); 64 | 65 | -- CreateTable 66 | CREATE TABLE "Comment" ( 67 | "id" SERIAL NOT NULL, 68 | "content" VARCHAR NOT NULL, 69 | "authorId" INTEGER NOT NULL, 70 | "articleId" INTEGER NOT NULL, 71 | "status" "CommentStatus" NOT NULL DEFAULT 'PENDING', 72 | "parentId" INTEGER, 73 | "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, 74 | "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, 75 | 76 | CONSTRAINT "Comment_pkey" PRIMARY KEY ("id") 77 | ); 78 | 79 | -- CreateTable 80 | CREATE TABLE "_RoleToUser" ( 81 | "A" INTEGER NOT NULL, 82 | "B" INTEGER NOT NULL 83 | ); 84 | 85 | -- CreateTable 86 | CREATE TABLE "_ArticleToTag" ( 87 | "A" INTEGER NOT NULL, 88 | "B" INTEGER NOT NULL 89 | ); 90 | 91 | -- CreateIndex 92 | CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name"); 93 | 94 | -- CreateIndex 95 | CREATE UNIQUE INDEX "Role_code_key" ON "Role"("code"); 96 | 97 | -- CreateIndex 98 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); 99 | 100 | -- CreateIndex 101 | CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone"); 102 | 103 | -- CreateIndex 104 | CREATE UNIQUE INDEX "User_nickname_key" ON "User"("nickname"); 105 | 106 | -- CreateIndex 107 | CREATE UNIQUE INDEX "Topic_name_key" ON "Topic"("name"); 108 | 109 | -- CreateIndex 110 | CREATE UNIQUE INDEX "Topic_code_key" ON "Topic"("code"); 111 | 112 | -- CreateIndex 113 | CREATE UNIQUE INDEX "_RoleToUser_AB_unique" ON "_RoleToUser"("A", "B"); 114 | 115 | -- CreateIndex 116 | CREATE INDEX "_RoleToUser_B_index" ON "_RoleToUser"("B"); 117 | 118 | -- CreateIndex 119 | CREATE UNIQUE INDEX "_ArticleToTag_AB_unique" ON "_ArticleToTag"("A", "B"); 120 | 121 | -- CreateIndex 122 | CREATE INDEX "_ArticleToTag_B_index" ON "_ArticleToTag"("B"); 123 | 124 | -- AddForeignKey 125 | ALTER TABLE "Article" ADD CONSTRAINT "Article_topicId_fkey" FOREIGN KEY ("topicId") REFERENCES "Topic"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 126 | 127 | -- AddForeignKey 128 | ALTER TABLE "Article" ADD CONSTRAINT "Article_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 129 | 130 | -- AddForeignKey 131 | ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 132 | 133 | -- AddForeignKey 134 | ALTER TABLE "Comment" ADD CONSTRAINT "Comment_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 135 | 136 | -- AddForeignKey 137 | ALTER TABLE "_RoleToUser" ADD CONSTRAINT "_RoleToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE; 138 | 139 | -- AddForeignKey 140 | ALTER TABLE "_RoleToUser" ADD CONSTRAINT "_RoleToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 141 | 142 | -- AddForeignKey 143 | ALTER TABLE "_ArticleToTag" ADD CONSTRAINT "_ArticleToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE; 144 | 145 | -- AddForeignKey 146 | ALTER TABLE "_ArticleToTag" ADD CONSTRAINT "_ArticleToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; 147 | -------------------------------------------------------------------------------- /server/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /server/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | enum CommentStatus { 11 | PENDING 12 | RESOLVED 13 | REJECTED 14 | } 15 | 16 | model Role { 17 | id Int @id @default(autoincrement()) 18 | name String @unique @db.VarChar 19 | code String @unique @db.VarChar 20 | createdAt DateTime @default(now()) @db.Timestamp(6) 21 | updatedAt DateTime @default(now()) @updatedAt @db.Timestamp(6) 22 | users User[] 23 | } 24 | 25 | model User { 26 | id Int @id @default(autoincrement()) 27 | username String @unique @db.VarChar // email as username 28 | password String @db.VarChar 29 | phone String @unique @default("") @db.VarChar 30 | nickname String @unique @default("") @db.VarChar 31 | createdAt DateTime @default(now()) @db.Timestamp(6) 32 | updatedAt DateTime @default(now()) @updatedAt @db.Timestamp(6) 33 | roles Role[] 34 | articles Article[] 35 | comments Comment[] 36 | } 37 | 38 | model Topic { 39 | id Int @id @default(autoincrement()) 40 | name String @unique @db.VarChar 41 | code String @unique @db.VarChar 42 | orderIndex Int @default(0) 43 | createdAt DateTime @default(now()) @db.Timestamp(6) 44 | updatedAt DateTime @default(now()) @updatedAt @db.Timestamp(6) 45 | articles Article[] 46 | } 47 | 48 | model Article { 49 | id Int @id @default(autoincrement()) 50 | title String @db.VarChar 51 | content String 52 | topicId Int 53 | authorId Int 54 | likeCount Int @default(0) 55 | collectCount Int @default(0) 56 | createdAt DateTime @default(now()) @db.Timestamp(6) 57 | updatedAt DateTime @default(now()) @updatedAt @db.Timestamp(6) 58 | topic Topic @relation(fields: [topicId], references: [id]) 59 | author User @relation(fields: [authorId], references: [id]) 60 | tags Tag[] 61 | comments Comment[] 62 | } 63 | 64 | model Tag { 65 | id Int @id @default(autoincrement()) 66 | name String @db.VarChar 67 | createdAt DateTime @default(now()) @db.Timestamp(6) 68 | updatedAt DateTime @default(now()) @updatedAt @db.Timestamp(6) 69 | articles Article[] 70 | } 71 | 72 | model Comment { 73 | id Int @id @default(autoincrement()) 74 | content String @db.VarChar 75 | authorId Int 76 | articleId Int 77 | status CommentStatus @default(PENDING) 78 | parentId Int? 79 | createdAt DateTime @default(now()) @db.Timestamp(6) 80 | updatedAt DateTime @default(now()) @updatedAt @db.Timestamp(6) 81 | author User @relation(fields: [authorId], references: [id]) 82 | article Article @relation(fields: [articleId], references: [id]) 83 | } 84 | -------------------------------------------------------------------------------- /server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { APP_GUARD } from '@nestjs/core'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | 5 | import { RoleModule } from './modules/role/role.module'; 6 | import { RolesGuard } from './modules/role/roles.guard'; 7 | import { AuthModule } from './modules/auth/auth.module'; 8 | import { JwtAuthGuard } from './modules/auth/jwt-auth.guard'; 9 | import { UserModule } from './modules/user/user.module'; 10 | import { TopicModule } from './modules/topic/topic.module'; 11 | import { ArticleModule } from './modules/article/article.module'; 12 | import { CommentModule } from './modules/comment/comment.module'; 13 | import { TagModule } from './modules/tag/tag.module'; 14 | 15 | @Module({ 16 | imports: [ 17 | ConfigModule.forRoot(), 18 | RoleModule, 19 | AuthModule, 20 | UserModule, 21 | TopicModule, 22 | ArticleModule, 23 | CommentModule, 24 | TagModule, 25 | ], 26 | providers: [ 27 | { 28 | provide: APP_GUARD, 29 | useClass: JwtAuthGuard, 30 | }, 31 | { 32 | provide: APP_GUARD, 33 | useClass: RolesGuard, 34 | }, 35 | ], 36 | }) 37 | export class AppModule {} 38 | -------------------------------------------------------------------------------- /server/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hojas/nest-react-full-stack/ce2e64eb9d13552fdd1ff9ecfa1acdecaa6ebebb/server/src/assets/.gitkeep -------------------------------------------------------------------------------- /server/src/decorators/jwt.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const IS_PUBLIC_KEY = 'isPublic'; 4 | export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); 5 | -------------------------------------------------------------------------------- /server/src/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata, CustomDecorator } from '@nestjs/common' 2 | 3 | export const Roles = (...roles: string[]): CustomDecorator => 4 | SetMetadata('roles', roles) 5 | -------------------------------------------------------------------------------- /server/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | } 4 | -------------------------------------------------------------------------------- /server/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | } 4 | -------------------------------------------------------------------------------- /server/src/filters/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import { 3 | Catch, 4 | ExceptionFilter, 5 | ArgumentsHost, 6 | HttpException, 7 | } from '@nestjs/common' 8 | 9 | @Catch(HttpException) 10 | export class HttpExceptionFilter implements ExceptionFilter { 11 | catch(exception: HttpException, host: ArgumentsHost): void { 12 | const ctx = host.switchToHttp() 13 | const response = ctx.getResponse() 14 | const request = ctx.getRequest() 15 | const status = exception.getStatus() 16 | 17 | response.status(status).json({ 18 | statusCode: status, 19 | timestamp: new Date().toISOString(), 20 | path: request.url, 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import helmet from 'helmet'; 4 | import * as cookieParser from 'cookie-parser'; 5 | 6 | import { AppModule } from './app.module'; 7 | 8 | async function bootstrap() { 9 | const app = await NestFactory.create(AppModule); 10 | 11 | app.use(helmet()); 12 | app.use(cookieParser()); 13 | app.setGlobalPrefix('api'); 14 | 15 | const port = process.env.API_PORT || 8080; 16 | await app.listen(port); 17 | 18 | Logger.log(`🚀 Application is running on: http://localhost:${port}/api`); 19 | } 20 | 21 | bootstrap(); 22 | -------------------------------------------------------------------------------- /server/src/modules/article/admin.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Query, 4 | Param, 5 | Body, 6 | HttpCode, 7 | Get, 8 | Post, 9 | Req, 10 | Put, 11 | Delete, 12 | UseFilters, 13 | NotFoundException, 14 | DefaultValuePipe, 15 | ParseIntPipe, 16 | } from '@nestjs/common' 17 | 18 | import { Roles } from '../../decorators/roles.decorator' 19 | import { HttpExceptionFilter } from '../../filters/http-exception.filter' 20 | import { Pagination, PAGE_SIZE } from '../../types/pagination' 21 | 22 | import { Article } from '@prisma/client' 23 | import { ArticleService } from './article.service' 24 | import { CreateArticleDto } from './create-article.dto' 25 | 26 | @Controller('admin/article') 27 | @Roles('admin') 28 | export class AdminController { 29 | constructor(private articleService: ArticleService) {} 30 | 31 | @Get() 32 | findAll( 33 | @Query('page', new DefaultValuePipe(1), ParseIntPipe) 34 | page: number, 35 | @Query('page_size', new DefaultValuePipe(PAGE_SIZE), ParseIntPipe) 36 | pageSize: number, 37 | ): Promise> { 38 | return this.articleService.findAll({ page, pageSize }) 39 | } 40 | 41 | @Post() 42 | create( 43 | @Req() req, 44 | @Body('article') article: CreateArticleDto, 45 | ): Promise
{ 46 | article.authorId = req.user.id 47 | return this.articleService.create(article) 48 | } 49 | 50 | @Get(':id') 51 | @UseFilters(HttpExceptionFilter) 52 | async findById(@Param('id', ParseIntPipe) id: number): Promise
{ 53 | const article = await this.articleService.findById(id) 54 | if (article) { 55 | return article 56 | } 57 | 58 | throw new NotFoundException() 59 | } 60 | 61 | @Put(':id') 62 | @UseFilters(HttpExceptionFilter) 63 | async update( 64 | @Param('id', ParseIntPipe) id: number, 65 | @Body('article') article: CreateArticleDto, 66 | ): Promise
{ 67 | const res = await this.articleService.update(id, article) 68 | if (res) { 69 | return res 70 | } 71 | 72 | throw new NotFoundException() 73 | } 74 | 75 | @Delete(':id') 76 | @HttpCode(204) 77 | @UseFilters(HttpExceptionFilter) 78 | async remove(@Param('id', ParseIntPipe) id: number): Promise { 79 | const res = await this.articleService.remove(id) 80 | 81 | if (!res) { 82 | throw new NotFoundException() 83 | } 84 | 85 | return null 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /server/src/modules/article/article.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Query, 4 | Param, 5 | Body, 6 | HttpCode, 7 | Get, 8 | Post, 9 | Put, 10 | Delete, 11 | Req, 12 | UseFilters, 13 | NotFoundException, 14 | DefaultValuePipe, 15 | ParseIntPipe, 16 | } from '@nestjs/common'; 17 | 18 | import { Public } from '../../decorators/jwt.decorator'; 19 | import { HttpExceptionFilter } from '../../filters/http-exception.filter'; 20 | import { Pagination, PAGE_SIZE } from '../../types/pagination'; 21 | 22 | import { Article } from '@prisma/client'; 23 | import { ArticleService } from './article.service'; 24 | import { CreateArticleDto } from './create-article.dto'; 25 | 26 | @Controller('article') 27 | export class ArticleController { 28 | constructor(private articleService: ArticleService) {} 29 | 30 | @Public() 31 | @Get() 32 | @UseFilters(HttpExceptionFilter) 33 | async findAll( 34 | @Query('topic_code') 35 | topicCode: string, 36 | @Query('page', new DefaultValuePipe(1), ParseIntPipe) 37 | page: number, 38 | @Query('page_size', new DefaultValuePipe(PAGE_SIZE), ParseIntPipe) 39 | pageSize: number, 40 | ): Promise> { 41 | return this.articleService.findAll({ page, pageSize }, { topicCode }); 42 | } 43 | 44 | @Public() 45 | @Get(':id') 46 | @UseFilters(HttpExceptionFilter) 47 | async findById(@Param('id', ParseIntPipe) id: number): Promise
{ 48 | const article = await this.articleService.findById(id); 49 | if (article) { 50 | return article; 51 | } 52 | 53 | throw new NotFoundException(); 54 | } 55 | 56 | @Post() 57 | create( 58 | @Req() req, 59 | @Body('article') article: CreateArticleDto, 60 | ): Promise
{ 61 | article.authorId = req.user.id; 62 | return this.articleService.create(article); 63 | } 64 | 65 | @Put(':id') 66 | @UseFilters(HttpExceptionFilter) 67 | async update( 68 | @Param('id', ParseIntPipe) id: number, 69 | @Body('article') article: CreateArticleDto, 70 | ): Promise
{ 71 | const res = await this.articleService.update(id, article); 72 | if (res) { 73 | return res; 74 | } 75 | 76 | throw new NotFoundException(); 77 | } 78 | 79 | @Delete(':id') 80 | @HttpCode(204) 81 | @UseFilters(HttpExceptionFilter) 82 | async remove(@Param('id', ParseIntPipe) id: number): Promise { 83 | const res = await this.articleService.remove(id); 84 | 85 | if (!res) { 86 | throw new NotFoundException(); 87 | } 88 | 89 | return null; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /server/src/modules/article/article.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { SharedModule } from '../shared/shared.module' 4 | import { TopicModule } from '../topic/topic.module' 5 | 6 | import { ArticleController } from './article.controller' 7 | import { AdminController } from './admin.controller' 8 | import { ArticleService } from './article.service' 9 | 10 | @Module({ 11 | imports: [SharedModule, TopicModule], 12 | controllers: [ArticleController, AdminController], 13 | providers: [ArticleService], 14 | }) 15 | export class ArticleModule {} 16 | -------------------------------------------------------------------------------- /server/src/modules/article/article.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { Article } from '@prisma/client' 3 | import { Pagination } from '../../types/pagination' 4 | import { PrismaService } from '../prisma/prisma.service' 5 | import { TopicService } from '../topic/topic.service' 6 | import { CreateArticleDto } from './create-article.dto' 7 | 8 | type QueryType = { 9 | topicCode?: string 10 | tags?: string[] 11 | } 12 | 13 | type WhereType = { 14 | topicId?: number 15 | } 16 | 17 | @Injectable() 18 | export class ArticleService { 19 | constructor( 20 | private prisma: PrismaService, 21 | private topicService: TopicService 22 | ) {} 23 | 24 | async findAll( 25 | { page, pageSize }: Pagination
, 26 | query?: QueryType 27 | ): Promise> { 28 | const where: WhereType = {} 29 | 30 | if (query && query.topicCode) { 31 | const topic = await this.topicService.findByCode(query.topicCode) 32 | where.topicId = topic ? topic.id : 0 33 | } 34 | 35 | const articles = await this.prisma.article.findMany({ 36 | where, 37 | skip: (page - 1) * pageSize, 38 | take: pageSize, 39 | include: { 40 | topic: true, 41 | author: true, 42 | }, 43 | orderBy: { createdAt: 'desc' }, 44 | }) 45 | 46 | return { 47 | page, 48 | pageSize, 49 | count: articles.length, 50 | results: articles, 51 | } 52 | } 53 | 54 | findById(id: number): Promise
{ 55 | return this.prisma.article.findUnique({ 56 | where: { id }, 57 | include: { 58 | topic: true, 59 | author: true, 60 | }, 61 | }) 62 | } 63 | 64 | create(article: CreateArticleDto): Promise
{ 65 | article.tagIds = article.tagIds || [] 66 | 67 | const data = { 68 | title: article.title, 69 | content: article.content || '', 70 | topic: { 71 | connect: { 72 | id: article.topicId, 73 | }, 74 | }, 75 | author: { 76 | connect: { 77 | id: article.authorId, 78 | }, 79 | }, 80 | tags: { 81 | connect: article.tagIds.map(id => ({ 82 | id, 83 | })), 84 | }, 85 | } 86 | 87 | return this.prisma.article.create({ data }) 88 | } 89 | 90 | update(id: number, article: CreateArticleDto): Promise
{ 91 | article.tagIds = article.tagIds || [] 92 | 93 | const data = { 94 | title: article.title, 95 | content: article.content, 96 | topic: { 97 | connect: { 98 | id: article.topicId, 99 | }, 100 | }, 101 | tags: { 102 | connect: article.tagIds.map(id => ({ 103 | id, 104 | })), 105 | }, 106 | } 107 | 108 | return this.prisma.article.update({ where: { id }, data }) 109 | } 110 | 111 | remove(id: number): Promise
{ 112 | return this.prisma.article.delete({ where: { id } }) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /server/src/modules/article/create-article.dto.ts: -------------------------------------------------------------------------------- 1 | export class CreateArticleDto { 2 | title: string 3 | content: string 4 | topicId: number 5 | authorId: number 6 | tagIds: number[] 7 | } 8 | -------------------------------------------------------------------------------- /server/src/modules/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { 3 | Controller, 4 | Get, 5 | Post, 6 | Req, 7 | Res, 8 | Body, 9 | UseGuards, 10 | } from '@nestjs/common'; 11 | import { User } from '@prisma/client'; 12 | import { Public } from '../../decorators/jwt.decorator'; 13 | import { CreateUserDto } from '../user/create-user.dto'; 14 | import { UserService } from '../user/user.service'; 15 | 16 | import { AuthService } from './auth.service'; 17 | import { LocalAuthGuard } from './local-auth.guard'; 18 | 19 | interface MyRequest extends Request { 20 | user?: User; 21 | } 22 | 23 | @Controller('auth') 24 | export class AuthController { 25 | constructor( 26 | private readonly authService: AuthService, 27 | private readonly userService: UserService, 28 | ) {} 29 | 30 | @Public() 31 | @UseGuards(LocalAuthGuard) 32 | @Post('login') 33 | async login( 34 | @Req() req: MyRequest, 35 | @Res() res: Response, 36 | ): Promise> { 37 | const { access_token: token } = this.authService.login(req.user as User); 38 | 39 | if (token) { 40 | this.authService.setToken(res, token); 41 | return res.json({ ok: true }); 42 | } 43 | return res.json({ ok: false }); 44 | } 45 | 46 | @Public() 47 | @Post('register') 48 | async register(@Body('user') user: CreateUserDto): Promise { 49 | return this.userService.create(user); 50 | } 51 | 52 | @Get('user') 53 | getProfile(@Req() req: MyRequest) { 54 | return req.user; 55 | } 56 | 57 | @Post('reset-password') 58 | resetPassword( 59 | @Req() req: MyRequest, 60 | @Body() 61 | user: { 62 | oldPassword: string; 63 | newPassword: string; 64 | }, 65 | ) { 66 | const currentUser = req.user as User; 67 | return this.userService.resetPassword( 68 | currentUser.username, 69 | user.oldPassword, 70 | user.newPassword, 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /server/src/modules/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import { PassportModule } from '@nestjs/passport'; 4 | import { UserModule } from '../user/user.module'; 5 | 6 | import { AuthController } from './auth.controller'; 7 | import { AuthService } from './auth.service'; 8 | import { JwtStrategy } from './jwt.strategy'; 9 | import { LocalStrategy } from './local.strategy'; 10 | 11 | @Module({ 12 | imports: [ 13 | UserModule, 14 | PassportModule, 15 | JwtModule.register({ 16 | secret: process.env.JWT_SECRET, 17 | signOptions: { expiresIn: process.env.JWT_EXPIRES }, 18 | }), 19 | ], 20 | controllers: [AuthController], 21 | providers: [AuthService, LocalStrategy, JwtStrategy], 22 | exports: [AuthService, JwtModule], 23 | }) 24 | export class AuthModule {} 25 | -------------------------------------------------------------------------------- /server/src/modules/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { JwtService } from '@nestjs/jwt'; 4 | import { User } from '@prisma/client'; 5 | import { UserService } from '../user/user.service'; 6 | import { getHash } from '../../utils/get-hash'; 7 | 8 | @Injectable() 9 | export class AuthService { 10 | constructor( 11 | private userService: UserService, 12 | private jwtService: JwtService, 13 | ) {} 14 | 15 | async validateUser( 16 | username: string, 17 | password: string, 18 | ): Promise | undefined> { 19 | const user = await this.userService.findByEmail(username); 20 | 21 | if (user && user.password === getHash(password)) { 22 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 23 | const { password, ...result } = user; 24 | 25 | return result; 26 | } 27 | } 28 | 29 | login(user: User) { 30 | return { 31 | access_token: this.jwtService.sign(user), 32 | }; 33 | } 34 | 35 | setToken(res: Response, token: string): void { 36 | res.cookie('auth_token', token, { 37 | path: '/', 38 | httpOnly: true, 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /server/src/modules/auth/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | ExecutionContext, 4 | UnauthorizedException, 5 | } from '@nestjs/common'; 6 | import { Reflector } from '@nestjs/core'; 7 | import { AuthGuard } from '@nestjs/passport'; 8 | import { IS_PUBLIC_KEY } from '../../decorators/jwt.decorator'; 9 | import { Observable } from 'rxjs'; 10 | 11 | @Injectable() 12 | export class JwtAuthGuard extends AuthGuard('jwt') { 13 | constructor(private reflector: Reflector) { 14 | super(); 15 | } 16 | 17 | canActivate( 18 | context: ExecutionContext, 19 | ): boolean | Promise | Observable { 20 | const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ 21 | context.getHandler(), 22 | context.getClass(), 23 | ]); 24 | 25 | if (isPublic) { 26 | return true; 27 | } 28 | 29 | return super.canActivate(context); 30 | } 31 | 32 | handleRequest(err: any, user: any) { 33 | if (err || !user) { 34 | throw err || new UnauthorizedException({ message: '未登录' }); 35 | } 36 | 37 | return user; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/src/modules/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { Strategy } from 'passport-jwt'; 5 | import { User } from '@prisma/client'; 6 | 7 | const cookieExtractor = (req: Request): string => 8 | req && req.cookies ? req.cookies['auth_token'] : null; 9 | 10 | @Injectable() 11 | export class JwtStrategy extends PassportStrategy(Strategy) { 12 | constructor() { 13 | super({ 14 | jwtFromRequest: cookieExtractor, 15 | ignoreExpiration: false, 16 | secretOrKey: process.env.JWT_SECRET, 17 | }); 18 | } 19 | 20 | handleRequest(err: Error, user: User): User | never { 21 | if (err || !user) { 22 | throw err || new UnauthorizedException({ message: '未登录' }); 23 | } 24 | 25 | return user; 26 | } 27 | 28 | async validate(payload: any) { 29 | return payload; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /server/src/modules/auth/local-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class LocalAuthGuard extends AuthGuard('local') { 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | handleRequest(err: any, user: any) { 8 | if (err || !user) { 9 | throw err || new UnauthorizedException({ message: '登录失败' }); 10 | } 11 | 12 | return user; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/src/modules/auth/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-local'; 4 | 5 | import { User } from '@prisma/client'; 6 | import { AuthService } from './auth.service'; 7 | 8 | @Injectable() 9 | export class LocalStrategy extends PassportStrategy(Strategy) { 10 | constructor(private authService: AuthService) { 11 | super(); 12 | } 13 | 14 | // login with email 15 | async validate( 16 | username: string, 17 | password: string, 18 | ): Promise | never> { 19 | const user = await this.authService.validateUser(username, password); 20 | 21 | if (!user) { 22 | throw new UnauthorizedException({ message: '未登录' }); 23 | } 24 | return user; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/src/modules/comment/admin.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Query, 4 | Param, 5 | HttpCode, 6 | Get, 7 | Post, 8 | Delete, 9 | UseFilters, 10 | NotFoundException, 11 | DefaultValuePipe, 12 | ParseIntPipe, 13 | } from '@nestjs/common' 14 | 15 | import { Roles } from '../../decorators/roles.decorator' 16 | import { HttpExceptionFilter } from '../../filters/http-exception.filter' 17 | import { Pagination, PAGE_SIZE } from '../../types/pagination' 18 | 19 | import { Comment, CommentStatus } from '@prisma/client' 20 | import { CommentService } from './comment.service' 21 | 22 | @Roles('admin') 23 | @Controller('admin/comment') 24 | export class AdminController { 25 | constructor(private commentService: CommentService) {} 26 | 27 | @Get() 28 | findAll( 29 | @Query('page', new DefaultValuePipe(1), ParseIntPipe) 30 | page: number, 31 | @Query('page_size', new DefaultValuePipe(PAGE_SIZE), ParseIntPipe) 32 | pageSize: number 33 | ): Promise> { 34 | return this.commentService.findAll({ page, pageSize }) 35 | } 36 | 37 | @Get(':id') 38 | @UseFilters(HttpExceptionFilter) 39 | async findById(@Param('id', ParseIntPipe) id: number): Promise { 40 | const comment = await this.commentService.findById(id) 41 | if (comment) { 42 | return comment 43 | } 44 | 45 | throw new NotFoundException() 46 | } 47 | 48 | @Post(':id/resolve') 49 | @UseFilters(HttpExceptionFilter) 50 | async resolve(@Param('id', ParseIntPipe) id: number): Promise { 51 | const comment = await this.commentService.findById(id) 52 | 53 | if (comment) { 54 | comment.status = CommentStatus.RESOLVED 55 | return this.commentService.update(id, comment) 56 | } 57 | 58 | throw new NotFoundException() 59 | } 60 | 61 | @Post(':id/reject') 62 | @UseFilters(HttpExceptionFilter) 63 | async reject(@Param('id', ParseIntPipe) id: number): Promise { 64 | const comment = await this.commentService.findById(id) 65 | 66 | if (comment) { 67 | comment.status = CommentStatus.REJECTED 68 | return this.commentService.update(id, comment) 69 | } 70 | 71 | throw new NotFoundException() 72 | } 73 | 74 | @Delete(':id') 75 | @HttpCode(204) 76 | @UseFilters(HttpExceptionFilter) 77 | async remove(@Param('id', ParseIntPipe) id: number): Promise { 78 | const res = await this.commentService.remove(id) 79 | 80 | if (!res) { 81 | throw new NotFoundException() 82 | } 83 | 84 | return '' 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /server/src/modules/comment/comment.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Query, 4 | Param, 5 | Body, 6 | HttpCode, 7 | Get, 8 | Post, 9 | Put, 10 | Delete, 11 | Req, 12 | UseFilters, 13 | NotFoundException, 14 | DefaultValuePipe, 15 | ParseIntPipe, 16 | } from '@nestjs/common' 17 | 18 | import { Public } from '../../decorators/jwt.decorator' 19 | import { Roles } from '../../decorators/roles.decorator' 20 | import { HttpExceptionFilter } from '../../filters/http-exception.filter' 21 | import { Pagination, PAGE_SIZE } from '../../types/pagination' 22 | 23 | import { Comment } from '@prisma/client' 24 | import { CreateCommentDto } from './create-comment.dto' 25 | import { CommentService } from './comment.service' 26 | 27 | @Public() 28 | @Controller('comment') 29 | export class CommentController { 30 | constructor(private commentService: CommentService) {} 31 | 32 | @Get() 33 | findAll( 34 | @Query('article_id', ParseIntPipe) articleId: number, 35 | @Query('page', new DefaultValuePipe(1), ParseIntPipe) 36 | page: number, 37 | @Query('page_size', new DefaultValuePipe(PAGE_SIZE), ParseIntPipe) 38 | pageSize: number 39 | ): Promise> { 40 | return this.commentService.findAll({ page, pageSize }, { articleId }) 41 | } 42 | 43 | @Get(':id') 44 | @UseFilters(HttpExceptionFilter) 45 | async findById(@Param('id', ParseIntPipe) id: number): Promise { 46 | const comment = await this.commentService.findById(id) 47 | if (comment) { 48 | return comment 49 | } 50 | 51 | throw new NotFoundException() 52 | } 53 | 54 | @Roles('user') 55 | @Post() 56 | create( 57 | @Req() req, 58 | @Body('comment') comment: CreateCommentDto 59 | ): Promise { 60 | comment.authorId = req.user.id 61 | return this.commentService.create(comment) 62 | } 63 | 64 | @Roles('user') 65 | @Put(':id') 66 | @UseFilters(HttpExceptionFilter) 67 | async update( 68 | @Param('id', ParseIntPipe) id: number, 69 | @Body('comment') comment: Comment 70 | ): Promise { 71 | const res = await this.commentService.update(id, comment) 72 | if (res) { 73 | return res 74 | } 75 | 76 | throw new NotFoundException() 77 | } 78 | 79 | @Roles('user') 80 | @Delete(':id') 81 | @HttpCode(204) 82 | @UseFilters(HttpExceptionFilter) 83 | async remove(@Param('id', ParseIntPipe) id: number): Promise { 84 | const res = await this.commentService.remove(id) 85 | 86 | if (!res) { 87 | throw new NotFoundException() 88 | } 89 | 90 | return null 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /server/src/modules/comment/comment.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { SharedModule } from '../shared/shared.module' 4 | import { CommentController } from './comment.controller' 5 | import { AdminController } from './admin.controller' 6 | import { CommentService } from './comment.service' 7 | 8 | @Module({ 9 | imports: [SharedModule], 10 | controllers: [CommentController, AdminController], 11 | providers: [CommentService], 12 | }) 13 | export class CommentModule {} 14 | -------------------------------------------------------------------------------- /server/src/modules/comment/comment.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | import { Comment } from '@prisma/client' 4 | import { PrismaService } from '../prisma/prisma.service' 5 | import { Pagination } from '../../types/pagination' 6 | import { CreateCommentDto } from './create-comment.dto' 7 | 8 | type QueryType = { 9 | articleId?: number 10 | } 11 | 12 | @Injectable() 13 | export class CommentService { 14 | constructor(private prisma: PrismaService) {} 15 | 16 | async findAll( 17 | { page, pageSize }: Pagination, 18 | query?: QueryType 19 | ): Promise> { 20 | const where = query 21 | 22 | const comments = await this.prisma.comment.findMany({ 23 | where, 24 | skip: (page - 1) * pageSize, 25 | take: pageSize, 26 | orderBy: { createdAt: 'desc' }, 27 | }) 28 | 29 | return { 30 | page, 31 | pageSize, 32 | count: comments.length, 33 | results: comments, 34 | } 35 | } 36 | 37 | findById(id: number): Promise { 38 | return this.prisma.comment.findUnique({ where: { id } }) 39 | } 40 | 41 | create(comment: CreateCommentDto): Promise { 42 | const data = { 43 | content: comment.content, 44 | author: { 45 | connect: { id: comment.authorId }, 46 | }, 47 | article: { 48 | connect: { id: comment.articleId }, 49 | }, 50 | } 51 | 52 | return this.prisma.comment.create({ data }) 53 | } 54 | 55 | async update(id: number, comment: Comment): Promise { 56 | const data = { 57 | content: comment.content, 58 | } 59 | 60 | return this.prisma.comment.update({ where: { id }, data }) 61 | } 62 | 63 | async remove(id: number): Promise { 64 | return this.prisma.comment.delete({ where: { id } }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /server/src/modules/comment/create-comment.dto.ts: -------------------------------------------------------------------------------- 1 | export class CreateCommentDto { 2 | content: string 3 | authorId: number 4 | articleId: number 5 | } 6 | -------------------------------------------------------------------------------- /server/src/modules/prisma/prisma.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { PrismaService } from './prisma.service' 3 | 4 | @Module({ 5 | providers: [PrismaService], 6 | exports: [PrismaService], 7 | }) 8 | export class PrismaModule {} 9 | -------------------------------------------------------------------------------- /server/src/modules/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit } from '@nestjs/common' 2 | import { PrismaClient } from '@prisma/client' 3 | 4 | @Injectable() 5 | export class PrismaService extends PrismaClient implements OnModuleInit { 6 | async onModuleInit() { 7 | await this.$connect() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/src/modules/role/admin.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Put, 6 | Delete, 7 | Body, 8 | Param, 9 | ParseIntPipe, 10 | NotFoundException, 11 | } from '@nestjs/common' 12 | 13 | import { Roles } from '../../decorators/roles.decorator' 14 | import { Role } from '@prisma/client' 15 | import { CreateRoleDto } from './create-role.dto' 16 | import { RoleService } from './role.service' 17 | 18 | @Roles('admin') 19 | @Controller('admin/role') 20 | export class AdminController { 21 | constructor(private roleService: RoleService) {} 22 | 23 | @Get() 24 | findAll(): Promise { 25 | return this.roleService.findAll() 26 | } 27 | 28 | @Get(':id') 29 | async getById(@Param('id', ParseIntPipe) id: number): Promise { 30 | return this.roleService.findById(id) 31 | } 32 | 33 | @Post() 34 | create(@Body('role') role: CreateRoleDto): Promise { 35 | return this.roleService.create(role) 36 | } 37 | 38 | @Put(':id') 39 | async update( 40 | @Param('id', ParseIntPipe) id: number, 41 | @Body('role') role: Role 42 | ): Promise { 43 | const res = await this.roleService.update(id, role) 44 | if (res) { 45 | return res 46 | } 47 | 48 | throw new NotFoundException() 49 | } 50 | 51 | @Delete(':id') 52 | async remove(@Param('id', ParseIntPipe) id: number): Promise { 53 | const res = await this.roleService.remove(id) 54 | if (res) { 55 | return null 56 | } 57 | 58 | throw new NotFoundException() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /server/src/modules/role/create-role.dto.ts: -------------------------------------------------------------------------------- 1 | export class CreateRoleDto { 2 | name: string 3 | code: string 4 | } 5 | -------------------------------------------------------------------------------- /server/src/modules/role/role.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { SharedModule } from '../shared/shared.module' 4 | import { AdminController } from './admin.controller' 5 | import { RoleService } from './role.service' 6 | 7 | @Module({ 8 | imports: [SharedModule], 9 | controllers: [AdminController], 10 | providers: [RoleService], 11 | }) 12 | export class RoleModule {} 13 | -------------------------------------------------------------------------------- /server/src/modules/role/role.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | import { Role } from '@prisma/client' 4 | import { PrismaService } from '../prisma/prisma.service' 5 | import { CreateRoleDto } from './create-role.dto' 6 | 7 | @Injectable() 8 | export class RoleService { 9 | constructor(private prisma: PrismaService) {} 10 | 11 | async onApplicationBootstrap() { 12 | await this.initRoles() 13 | } 14 | 15 | async initRoles(): Promise { 16 | await this.addRole('管理员', 'admin') 17 | await this.addRole('用户', 'user') 18 | } 19 | 20 | private async addRole(name: string, code: string): Promise { 21 | const role = new CreateRoleDto() 22 | role.name = name 23 | role.code = code 24 | 25 | try { 26 | const r = await this.findByCode(role.code) 27 | 28 | if (!r) { 29 | const newRole = await this.create(role) 30 | console.log(`Created role ${newRole.name}`) 31 | } 32 | } catch { 33 | const newRole = await this.create(role) 34 | console.log(`Created role ${newRole.name}`) 35 | } 36 | } 37 | 38 | findAll(): Promise { 39 | return this.prisma.role.findMany({ orderBy: { createdAt: 'desc' } }) 40 | } 41 | 42 | findById(id: number): Promise { 43 | return this.prisma.role.findUnique({ where: { id } }) 44 | } 45 | 46 | findByCode(code: string): Promise { 47 | return this.prisma.role.findUnique({ where: { code } }) 48 | } 49 | 50 | create(role: CreateRoleDto): Promise { 51 | return this.prisma.role.create({ data: role }) 52 | } 53 | 54 | update(id: number, role: CreateRoleDto): Promise { 55 | return this.prisma.role.update({ where: { id }, data: role }) 56 | } 57 | 58 | remove(id: number): Promise { 59 | return this.prisma.role.delete({ where: { id } }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /server/src/modules/role/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common' 2 | import { Reflector } from '@nestjs/core' 3 | import { Role } from '@prisma/client' 4 | 5 | @Injectable() 6 | export class RolesGuard implements CanActivate { 7 | constructor(private readonly reflector: Reflector) {} 8 | 9 | canActivate(context: ExecutionContext): boolean { 10 | const requiredRoles = this.reflector.getAllAndOverride('roles', [ 11 | context.getHandler(), 12 | context.getClass(), 13 | ]) 14 | 15 | if (!requiredRoles) { 16 | return true 17 | } 18 | 19 | const { user } = context.switchToHttp().getRequest() 20 | if (user) { 21 | return requiredRoles.some(role => 22 | user.roles?.map((role: Role) => role.code).includes(role) 23 | ) 24 | } 25 | 26 | return false 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /server/src/modules/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { PrismaModule } from '../prisma/prisma.module' 3 | 4 | @Module({ 5 | imports: [PrismaModule], 6 | exports: [PrismaModule], 7 | }) 8 | export class SharedModule {} 9 | -------------------------------------------------------------------------------- /server/src/modules/tag/admin.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Query, 5 | DefaultValuePipe, 6 | ParseIntPipe, 7 | UseFilters, 8 | NotFoundException, 9 | Param, 10 | Post, 11 | Body, 12 | Put, 13 | Delete, 14 | HttpCode, 15 | } from '@nestjs/common' 16 | 17 | import { Roles } from '../../decorators/roles.decorator' 18 | import { HttpExceptionFilter } from '../../filters/http-exception.filter' 19 | import { Pagination, PAGE_SIZE } from '../../types/pagination' 20 | 21 | import { Tag } from '@prisma/client' 22 | import { CreateTagDto } from './create-tag.dto' 23 | import { TagService } from './tag.service' 24 | 25 | @Roles('admin') 26 | @Controller('tag') 27 | export class AdminController { 28 | constructor(private tagService: TagService) {} 29 | 30 | @Get() 31 | findAll( 32 | @Query('page', new DefaultValuePipe(1), ParseIntPipe) 33 | page: number, 34 | @Query('page_size', new DefaultValuePipe(PAGE_SIZE), ParseIntPipe) 35 | pageSize: number 36 | ): Promise> { 37 | return this.tagService.findAll({ page, pageSize }) 38 | } 39 | 40 | @Post() 41 | create(@Body('tag') tag: CreateTagDto): Promise { 42 | return this.tagService.create(tag) 43 | } 44 | 45 | @Put(':id') 46 | @UseFilters(HttpExceptionFilter) 47 | async update( 48 | @Param('id', ParseIntPipe) id: number, 49 | @Body('tag') tag: Tag 50 | ): Promise { 51 | const res = await this.tagService.update(id, tag) 52 | if (res) { 53 | return res 54 | } 55 | 56 | throw new NotFoundException() 57 | } 58 | 59 | @Delete(':id') 60 | @HttpCode(204) 61 | @UseFilters(HttpExceptionFilter) 62 | async remove(@Param('id', ParseIntPipe) id: number): Promise { 63 | const res = await this.tagService.remove(id) 64 | 65 | if (!res) { 66 | throw new NotFoundException() 67 | } 68 | 69 | return null 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /server/src/modules/tag/create-tag.dto.ts: -------------------------------------------------------------------------------- 1 | export class CreateTagDto { 2 | name: string 3 | } 4 | -------------------------------------------------------------------------------- /server/src/modules/tag/tag.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { SharedModule } from '../shared/shared.module' 4 | import { AdminController } from './admin.controller' 5 | import { TagService } from './tag.service' 6 | 7 | @Module({ 8 | imports: [SharedModule], 9 | controllers: [AdminController], 10 | providers: [TagService], 11 | }) 12 | export class TagModule {} 13 | -------------------------------------------------------------------------------- /server/src/modules/tag/tag.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | import { Pagination } from '../../types/pagination' 4 | import { PrismaService } from '../prisma/prisma.service' 5 | import { Tag } from '@prisma/client' 6 | import { CreateTagDto } from './create-tag.dto' 7 | 8 | @Injectable() 9 | export class TagService { 10 | constructor(private prisma: PrismaService) {} 11 | 12 | async findAll({ 13 | page, 14 | pageSize, 15 | }: Pagination): Promise> { 16 | const tags = await this.prisma.tag.findMany({ 17 | skip: (page - 1) * pageSize, 18 | take: pageSize, 19 | orderBy: { createdAt: 'desc' }, 20 | }) 21 | 22 | return { 23 | page, 24 | pageSize, 25 | count: tags.length, 26 | results: tags, 27 | } 28 | } 29 | 30 | create(tag: CreateTagDto): Promise { 31 | return this.prisma.tag.create({ data: tag }) 32 | } 33 | 34 | async update(id: number, tag: Tag): Promise { 35 | return this.prisma.tag.update({ where: { id }, data: tag }) 36 | } 37 | 38 | async remove(id: number): Promise { 39 | return this.prisma.tag.delete({ where: { id } }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /server/src/modules/topic/admin.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Param, 4 | Body, 5 | HttpCode, 6 | Get, 7 | Post, 8 | Put, 9 | Delete, 10 | UseFilters, 11 | NotFoundException, 12 | ParseIntPipe, 13 | HttpStatus, 14 | HttpException, 15 | } from '@nestjs/common' 16 | import { Topic } from '@prisma/client' 17 | 18 | import { Roles } from '../../decorators/roles.decorator' 19 | import { HttpExceptionFilter } from '../../filters/http-exception.filter' 20 | import { getCustomErrorMessage } from '../../utils/prisma-errors' 21 | import { CreateTopicDto } from './create-topic.dto' 22 | import { TopicService } from './topic.service' 23 | 24 | @Roles('admin') 25 | @Controller('admin/topic') 26 | export class AdminController { 27 | constructor(private topicService: TopicService) {} 28 | 29 | @Get() 30 | findAll(): Promise { 31 | return this.topicService.findAll() 32 | } 33 | 34 | @Post() 35 | async create( 36 | @Body('topic') topic: CreateTopicDto 37 | ): Promise { 38 | try { 39 | return await this.topicService.create(topic) 40 | } catch (error) { 41 | throw new HttpException( 42 | getCustomErrorMessage(error.code, '分类'), 43 | HttpStatus.BAD_REQUEST 44 | ) 45 | } 46 | } 47 | 48 | @Put(':id') 49 | @UseFilters(HttpExceptionFilter) 50 | async update( 51 | @Param('id', ParseIntPipe) id: number, 52 | @Body('topic') topic: Topic 53 | ): Promise { 54 | const res = await this.topicService.update(id, topic) 55 | if (res) { 56 | return res 57 | } 58 | 59 | throw new NotFoundException() 60 | } 61 | 62 | @Delete(':id') 63 | @HttpCode(204) 64 | @UseFilters(HttpExceptionFilter) 65 | async delete(@Param('id', ParseIntPipe) id: number): Promise { 66 | const res = await this.topicService.remove(+id) 67 | if (res) { 68 | return res 69 | } 70 | 71 | throw new NotFoundException() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /server/src/modules/topic/create-topic.dto.ts: -------------------------------------------------------------------------------- 1 | export class CreateTopicDto { 2 | name: string 3 | code: string 4 | orderIndex: number 5 | } 6 | -------------------------------------------------------------------------------- /server/src/modules/topic/topic.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param } from '@nestjs/common' 2 | 3 | import { Public } from '../../decorators/jwt.decorator' 4 | import { Topic } from '@prisma/client' 5 | import { TopicService } from './topic.service' 6 | 7 | @Public() 8 | @Controller('topic') 9 | export class TopicController { 10 | constructor(private topicService: TopicService) {} 11 | 12 | @Get() 13 | findAll(): Promise { 14 | return this.topicService.findAll() 15 | } 16 | 17 | @Get(':code') 18 | findByCode(@Param('code') code: string): Promise { 19 | return this.topicService.findByCode(code) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/src/modules/topic/topic.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { SharedModule } from '../shared/shared.module' 4 | import { TopicController } from './topic.controller' 5 | import { AdminController } from './admin.controller' 6 | import { TopicService } from './topic.service' 7 | 8 | @Module({ 9 | imports: [SharedModule], 10 | controllers: [TopicController, AdminController], 11 | providers: [TopicService], 12 | exports: [TopicService], 13 | }) 14 | export class TopicModule {} 15 | -------------------------------------------------------------------------------- /server/src/modules/topic/topic.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { Topic } from '@prisma/client' 3 | import { PrismaService } from '../prisma/prisma.service' 4 | import { CreateTopicDto } from './create-topic.dto' 5 | 6 | @Injectable() 7 | export class TopicService { 8 | constructor(private prisma: PrismaService) {} 9 | 10 | findAll(): Promise { 11 | return this.prisma.topic.findMany({ 12 | orderBy: { orderIndex: 'asc' }, 13 | }) 14 | } 15 | 16 | findById(id: number): Promise { 17 | return this.prisma.topic.findUnique({ where: { id } }) 18 | } 19 | 20 | findByCode(code: string): Promise { 21 | return this.prisma.topic.findUnique({ where: { code } }) 22 | } 23 | 24 | create(topic: CreateTopicDto): Promise { 25 | return this.prisma.topic.create({ data: topic }) 26 | } 27 | 28 | async update(id: number, topic: Topic): Promise { 29 | return this.prisma.topic.update({ where: { id }, data: topic }) 30 | } 31 | 32 | remove(id: number): Promise { 33 | return this.prisma.topic.delete({ where: { id } }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server/src/modules/user/admin.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Param, 5 | NotFoundException, 6 | UseFilters, 7 | Query, 8 | ParseIntPipe, 9 | DefaultValuePipe, 10 | } from '@nestjs/common' 11 | 12 | import { Pagination, PAGE_SIZE } from '../../types/pagination' 13 | import { HttpExceptionFilter } from '../../filters/http-exception.filter' 14 | import { Roles } from '../../decorators/roles.decorator' 15 | 16 | import { User } from '@prisma/client' 17 | import { UserService } from './user.service' 18 | 19 | @Controller('admin/user') 20 | @Roles('admin') 21 | export class AdminController { 22 | constructor(private userService: UserService) {} 23 | 24 | @Get() 25 | findAll( 26 | @Query('page', new DefaultValuePipe(1), ParseIntPipe) 27 | page: number, 28 | @Query('page_size', new DefaultValuePipe(PAGE_SIZE), ParseIntPipe) 29 | pageSize: number 30 | ): Promise> { 31 | return this.userService.findAll({ page, pageSize }) 32 | } 33 | 34 | @Get(':id') 35 | @UseFilters(HttpExceptionFilter) 36 | async findById(@Param('id', ParseIntPipe) id: number): Promise { 37 | const user = await this.userService.findById(id) 38 | if (user) { 39 | return user 40 | } 41 | 42 | throw new NotFoundException() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /server/src/modules/user/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | export class CreateUserDto { 2 | username: string 3 | password: string 4 | repeatPassword: string 5 | } 6 | -------------------------------------------------------------------------------- /server/src/modules/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Param, 5 | NotFoundException, 6 | UseFilters, 7 | ParseIntPipe, 8 | } from '@nestjs/common' 9 | 10 | import { User } from '@prisma/client' 11 | import { Public } from '../../decorators/jwt.decorator' 12 | import { HttpExceptionFilter } from '../../filters/http-exception.filter' 13 | import { UserService } from './user.service' 14 | 15 | @Public() 16 | @Controller('user') 17 | export class UserController { 18 | constructor(private userService: UserService) {} 19 | 20 | @Get(':id') 21 | @UseFilters(HttpExceptionFilter) 22 | async findById(@Param('id', ParseIntPipe) id: number): Promise { 23 | const user = await this.userService.findById(id) 24 | 25 | if (user) { 26 | return user 27 | } 28 | throw new NotFoundException() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /server/src/modules/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { SharedModule } from '../shared/shared.module' 4 | import { UserService } from './user.service' 5 | import { UserController } from './user.controller' 6 | import { AdminController } from './admin.controller' 7 | 8 | @Module({ 9 | imports: [SharedModule], 10 | controllers: [UserController, AdminController], 11 | providers: [UserService], 12 | exports: [UserService], 13 | }) 14 | export class UserModule {} 15 | -------------------------------------------------------------------------------- /server/src/modules/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, HttpException, HttpStatus } from '@nestjs/common' 2 | 3 | import { User } from '@prisma/client' 4 | import { getHash } from '../../utils/get-hash' 5 | import { Pagination } from '../../types/pagination' 6 | import { PrismaService } from '../prisma/prisma.service' 7 | import { CreateUserDto } from './create-user.dto' 8 | 9 | @Injectable() 10 | export class UserService { 11 | constructor(private prisma: PrismaService) {} 12 | 13 | onApplicationBootstrap() { 14 | // waiting roles created 15 | setTimeout(async () => { 16 | await this.createAdmin() 17 | }, 1000) 18 | } 19 | 20 | private async createAdmin() { 21 | try { 22 | await this.prisma.user.create({ 23 | data: { 24 | username: 'admin@example.com', 25 | password: getHash('123456'), 26 | roles: { 27 | connect: [{ code: 'user' }, { code: 'admin' }], 28 | }, 29 | }, 30 | }) 31 | 32 | console.log(`Created admin`) 33 | console.log(`Email: admin@example.com`) 34 | console.log(`Password: 123456`) 35 | } catch { 36 | // 37 | } 38 | } 39 | 40 | async findAll(params: { 41 | page: number 42 | pageSize: number 43 | }): Promise> { 44 | const { page, pageSize } = params 45 | 46 | const users = await this.prisma.user.findMany({ 47 | skip: (page - 1) * pageSize, 48 | take: pageSize, 49 | orderBy: { createdAt: 'desc' }, 50 | }) 51 | 52 | return { 53 | page, 54 | pageSize, 55 | count: users.length, 56 | results: users.map(user => { 57 | user.password = undefined 58 | return user 59 | }), 60 | } 61 | } 62 | 63 | findById(id: number) { 64 | return this.prisma.user.findUnique({ 65 | where: { id }, 66 | include: { 67 | roles: true, 68 | }, 69 | }) 70 | } 71 | 72 | // email as username 73 | findByEmail(username: string) { 74 | return this.prisma.user.findUnique({ 75 | where: { username }, 76 | include: { 77 | roles: true, 78 | }, 79 | }) 80 | } 81 | 82 | create(user: CreateUserDto) { 83 | return this.prisma.user.create({ 84 | data: user, 85 | }) 86 | } 87 | 88 | async resetPassword( 89 | username: string, 90 | oldPassword: string, 91 | newPassword: string 92 | ) { 93 | const user = await this.findByEmail(username) 94 | if (oldPassword.trim() === '' || newPassword.trim() === '') { 95 | throw new HttpException('密码不能为空', HttpStatus.BAD_REQUEST) 96 | } 97 | if (user.password !== getHash(oldPassword)) { 98 | throw new HttpException('密码不正确', HttpStatus.BAD_REQUEST) 99 | } 100 | if (newPassword.length < 8) { 101 | throw new HttpException('密码长度不能小于 8', HttpStatus.BAD_REQUEST) 102 | } 103 | 104 | return this.prisma.user.update({ 105 | where: { username }, 106 | data: { 107 | password: getHash(newPassword), 108 | }, 109 | }) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /server/src/types/pagination.ts: -------------------------------------------------------------------------------- 1 | // default page size 2 | export const PAGE_SIZE = 16 3 | 4 | export interface Pagination { 5 | page: number 6 | pageSize: number 7 | count?: number 8 | results?: T[] 9 | } 10 | -------------------------------------------------------------------------------- /server/src/utils/get-hash.ts: -------------------------------------------------------------------------------- 1 | import { createHmac } from 'crypto' 2 | 3 | export const getHash = (password: string) => 4 | createHmac('sha256', process.env.SHA_SECRET).update(password).digest('hex') 5 | -------------------------------------------------------------------------------- /server/src/utils/prisma-errors.ts: -------------------------------------------------------------------------------- 1 | const ERRORS = { 2 | P2002: '已存在', 3 | } 4 | 5 | export const getCustomErrorMessage = (code: string, title: string) => 6 | title + (ERRORS[code] || '') 7 | -------------------------------------------------------------------------------- /server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /web/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | -------------------------------------------------------------------------------- /web/.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_BASE_URL=http://localhost:8081/api 2 | -------------------------------------------------------------------------------- /web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /web/.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .idea 39 | -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.15.0-alpine AS builder 2 | 3 | WORKDIR /opt/app 4 | 5 | COPY package.json pnpm-lock.yaml ./ 6 | RUN corepack enable && pnpm install 7 | 8 | COPY . . 9 | RUN pnpm build 10 | 11 | CMD pnpm start 12 | -------------------------------------------------------------------------------- /web/Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | docker compose -f docker-compose.test.yaml up web -d 3 | test-down: 4 | docker compose -f docker-compose.test.yaml down 5 | prod: 6 | docker compose up web -d 7 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /web/docker-compose.test.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | image: nest-react-full-stack-web 7 | container_name: nest-react-full-stack-web 8 | restart: always 9 | ports: 10 | - 127.0.0.1:3000:3000 11 | env_file: 12 | - .env 13 | environment: 14 | VIRTUAL_PORT: 3000 15 | VIRTUAL_PATH: / 16 | -------------------------------------------------------------------------------- /web/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | imge: nest-react-full-stack-web 7 | container_name: nest-react-full-stack-web 8 | restart: always 9 | ports: 10 | - 127.0.0.1:3000:3000 11 | env_file: 12 | - .env 13 | environment: 14 | LETSENCRYPT_HOST: ${DOMAIN} 15 | VIRTUAL_HOST: ${DOMAIN} 16 | VIRTUAL_PORT: 3000 17 | VIRTUAL_PATH: / 18 | networks: 19 | - nginx-proxy 20 | 21 | networks: 22 | nginx-proxy: 23 | external: true 24 | -------------------------------------------------------------------------------- /web/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | publicRuntimeConfig: { 4 | // Will be available on both server and client 5 | staticFolder: '/static', 6 | }, 7 | }; 8 | 9 | export default nextConfig; 10 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.1.0", 4 | "packageManager": "pnpm@9.4.0", 5 | "private": true, 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "bootstrap": "^5.3.3", 14 | "date-fns": "^3.6.0", 15 | "github-markdown-css": "^5.6.1", 16 | "next": "14.2.4", 17 | "query-string": "^9.0.0", 18 | "react": "^18", 19 | "react-bootstrap": "^2.10.3", 20 | "react-dom": "^18", 21 | "react-icons": "^5.2.1", 22 | "react-markdown": "^9.0.1", 23 | "react-syntax-highlighter": "^15.5.0", 24 | "react-toastify": "^10.0.5", 25 | "remark-gfm": "^4.0.0", 26 | "remove-markdown": "^0.5.0" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "^20", 30 | "@types/react": "^18", 31 | "@types/react-dom": "^18", 32 | "@types/react-syntax-highlighter": "^15.5.13", 33 | "@types/remove-markdown": "^0.3.4", 34 | "eslint": "^8", 35 | "eslint-config-next": "14.2.4", 36 | "postcss": "^8", 37 | "tailwindcss": "^3.4.4", 38 | "typescript": "^5" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /web/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /web/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/app/article/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { useArticle } from '../../../hooks/useArticle' 2 | import { ArticlePage } from '../../../themes' 3 | 4 | interface Props { 5 | params: { 6 | id: string 7 | } 8 | } 9 | 10 | export default async function Article({ params }: Props) { 11 | const { getArticleById } = useArticle() 12 | const article = await getArticleById(Number(params.id)) 13 | if (!article) { 14 | return
404
15 | } 16 | return 17 | } 18 | -------------------------------------------------------------------------------- /web/src/app/global.css: -------------------------------------------------------------------------------- 1 | @import "bootstrap/dist/css/bootstrap.min.css"; 2 | /*@tailwind base;*/ 3 | @tailwind components; 4 | @tailwind utilities; 5 | 6 | body { 7 | background-color: #f5f5f5; 8 | } 9 | -------------------------------------------------------------------------------- /web/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import { ToastContainer } from 'react-toastify' 3 | // import 'katex/dist/katex.min.css' 4 | import 'react-toastify/dist/ReactToastify.css' 5 | 6 | import { BaseLayout } from '../themes' 7 | import './global.css' 8 | 9 | export const metadata = { 10 | title: 'blog', 11 | description: 'blog', 12 | } 13 | 14 | export default function RootLayout({ children }: { children: ReactNode }) { 15 | return ( 16 | 17 | 18 | {children} 19 | 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /web/src/app/login/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useAuth } from '../../hooks/useAuth' 4 | import { LoginPage } from '../../themes' 5 | 6 | export default function Login() { 7 | const { setEmail, setPassword, onLogin } = useAuth() 8 | 9 | return ( 10 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /web/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { useArticle } from '../hooks/useArticle' 2 | import { HomePage } from '../themes' 3 | 4 | export default async function Home() { 5 | const { getArticleList } = useArticle() 6 | const articleList = await getArticleList({ page: 1 }) 7 | 8 | return 9 | } 10 | -------------------------------------------------------------------------------- /web/src/app/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useResetPassword } from '../../hooks/useResetPassword' 4 | import { ResetPasswordPage } from '../../themes' 5 | 6 | export default function ResetPassword() { 7 | const { 8 | setOldPassword, 9 | setNewPassword, 10 | setConfirmPassword, 11 | onResetPassword, 12 | } = useResetPassword() 13 | 14 | return ( 15 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /web/src/app/topic/[code]/page.tsx: -------------------------------------------------------------------------------- 1 | import { useArticle } from '../../../hooks/useArticle' 2 | import { TopicPage } from '../../../themes' 3 | 4 | export default async function Topic({ params }: { params: { code: string } }) { 5 | const { getArticleListByTopic } = useArticle() 6 | const articleList = await getArticleListByTopic(params.code, { page: 1 }) 7 | 8 | return 9 | } 10 | -------------------------------------------------------------------------------- /web/src/components/markdown-viewer/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactMarkdown, { ExtraProps } from 'react-markdown' 3 | import remarkGfm from 'remark-gfm' 4 | import { default as SyntaxHighlighter } from 'react-syntax-highlighter' 5 | import { nightOwl } from 'react-syntax-highlighter/dist/cjs/styles/prism' 6 | 7 | import 'github-markdown-css/github-markdown-light.css' 8 | 9 | interface Props { 10 | className?: string 11 | content: string 12 | } 13 | 14 | export const MarkdownViewer = ({ className, content }: Props) => { 15 | className = className ? ' ' + className : '' 16 | 17 | return ( 18 | & 23 | React.HTMLAttributes & 24 | ExtraProps, 25 | ) { 26 | const { children, className } = props 27 | const match = /language-(\w+)/.exec(className || '') 28 | return match ? ( 29 | 37 | {String(children).replace(/\n$/, '')} 38 | 39 | ) : ( 40 | 41 | {children} 42 | 43 | ) 44 | }, 45 | }} 46 | remarkPlugins={[remarkGfm]} 47 | skipHtml={false} 48 | > 49 | {content} 50 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /web/src/components/markdown-viewer/markdown-viewer.module.scss: -------------------------------------------------------------------------------- 1 | .markdown-viewer { 2 | > :global(pre) { 3 | padding: 0; 4 | border-radius: 0; 5 | } 6 | 7 | > :global(pre > div) { 8 | margin: 0 !important; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /web/src/hooks/useArticle.ts: -------------------------------------------------------------------------------- 1 | import { getApiUrl, getList, getItem } from '../utils/request' 2 | 3 | const api = { 4 | list: '/article/', 5 | detail: (id: number) => `/article/${id}/`, 6 | } 7 | 8 | interface Query { 9 | page?: number 10 | pageSize?: number 11 | } 12 | 13 | export function useArticle() { 14 | async function getArticleList(query: Query = { page: 1 }) { 15 | const url = getApiUrl(api.list, query) 16 | return getList(url) 17 | } 18 | 19 | async function getArticleListByTopic( 20 | topic: string, 21 | query: Query = { page: 1 }, 22 | ) { 23 | const url = getApiUrl(api.list, { ...query, topic }) 24 | return getList(url) 25 | } 26 | 27 | async function getArticleById(id: number) { 28 | const url = getApiUrl(api.detail(id)) 29 | return getItem(url) 30 | } 31 | 32 | return { 33 | getArticleList, 34 | getArticleListByTopic, 35 | getArticleById, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web/src/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useRouter } from 'next/navigation' 3 | import { toast } from 'react-toastify' 4 | import { getApiUrl } from '../utils/request' 5 | 6 | const api = { 7 | login: '/auth/login/', 8 | user: '/auth/user/', 9 | } 10 | 11 | async function login(username: string, password: string) { 12 | const url = getApiUrl(api.login) 13 | try { 14 | const res = await fetch(url, { 15 | method: 'POST', 16 | body: JSON.stringify({ 17 | username, 18 | password, 19 | }), 20 | }) 21 | return res.json() 22 | } catch { 23 | return null 24 | } 25 | } 26 | 27 | // async function getUser() { 28 | // const url = getApiUrl(api.user) 29 | // const user = await getItem(url) 30 | // if (user) { 31 | // window.location.href = '/' 32 | // } 33 | // } 34 | 35 | export const useAuth = () => { 36 | const router = useRouter() 37 | const [email, setEmail] = useState('') 38 | const [password, setPassword] = useState('') 39 | 40 | const onLogin = async () => { 41 | const res = await login(email, password) 42 | if (res) { 43 | router.push('/') 44 | } else { 45 | toast.error('登录失败') 46 | } 47 | } 48 | 49 | return { 50 | setEmail, 51 | setPassword, 52 | onLogin, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /web/src/hooks/useResetPassword.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useRouter } from 'next/navigation' 3 | import { toast } from 'react-toastify' 4 | import { getApiUrl } from '../utils/request' 5 | 6 | const api = { 7 | resetPassword: '/auth/reset-password/', 8 | } 9 | 10 | async function resetPassword(oldPassword: string, newPassword: string) { 11 | const url = getApiUrl(api.resetPassword) 12 | 13 | try { 14 | const res = await fetch(url, { 15 | method: 'POST', 16 | body: JSON.stringify({ 17 | oldPassword, 18 | newPassword, 19 | }), 20 | }) 21 | return res.json() 22 | } catch { 23 | return null 24 | } 25 | } 26 | 27 | export const useResetPassword = () => { 28 | const [oldPassword, setOldPassword] = useState('') 29 | const [newPassword, setNewPassword] = useState('') 30 | const [confirmPassword, setConfirmPassword] = useState('') 31 | 32 | const router = useRouter() 33 | const onResetPassword = async () => { 34 | if (newPassword !== confirmPassword) { 35 | toast('两次密码不一致') 36 | return 37 | } 38 | 39 | const res = await resetPassword(oldPassword, newPassword) 40 | if (res) { 41 | router.push('/') 42 | } else { 43 | toast('重置密码失败') 44 | } 45 | } 46 | 47 | return { 48 | setOldPassword, 49 | setNewPassword, 50 | setConfirmPassword, 51 | onResetPassword, 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /web/src/hooks/useTopic.ts: -------------------------------------------------------------------------------- 1 | import { getApiUrl, getItem } from '../utils/request' 2 | 3 | const api = { 4 | topic: '/topic/', 5 | } 6 | 7 | export function useTopic() { 8 | const url = getApiUrl(api.topic) 9 | 10 | function getTopicList() { 11 | return getItem(url) 12 | } 13 | 14 | return { 15 | getTopicList, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /web/src/themes/default/components/article-item/article-content.tsx: -------------------------------------------------------------------------------- 1 | import removeMarkdown from 'remove-markdown' 2 | 3 | function getSlicedContent(content: string) { 4 | const c = content.slice(0, 200) 5 | const c2 = c.length < content.length ? c + '...' : c 6 | return removeMarkdown(c2) 7 | } 8 | 9 | export const ArticleContent = ({ content }: { content: string }) => ( 10 |
11 | {getSlicedContent(content)} 12 |
13 | ) 14 | -------------------------------------------------------------------------------- /web/src/themes/default/components/article-item/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { ArticleMeta } from '../article-meta' 3 | import { ArticleContent } from './article-content' 4 | 5 | export const ArticleItem = ({ article }: { article: Article }) => ( 6 |
7 | 11 | {article.title} 12 | 13 | 14 | 15 |
16 | ) 17 | -------------------------------------------------------------------------------- /web/src/themes/default/components/article-list/divider.tsx: -------------------------------------------------------------------------------- 1 | export const Divider = ({ 2 | index, 3 | length, 4 | }: { 5 | index: number 6 | length: number 7 | }) => 8 | index !== length - 1 ? ( 9 |
10 | ) : null 11 | -------------------------------------------------------------------------------- /web/src/themes/default/components/article-list/index.tsx: -------------------------------------------------------------------------------- 1 | import { ArticleItem } from '../article-item' 2 | import { Divider } from './divider' 3 | 4 | export const ArticleList = ({ articles }: { articles: Article[] }) => ( 5 |
6 | {articles.length ? ( 7 | articles.map((article, index) => ( 8 |
9 | 10 | 11 |
12 | )) 13 | ) : ( 14 |
没有相关文章
15 | )} 16 |
17 | ) 18 | -------------------------------------------------------------------------------- /web/src/themes/default/components/article-meta/article-meta-date.tsx: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns' 2 | import { BiTimeFive } from 'react-icons/bi' 3 | import { addClassName } from '../../utils/addClassName' 4 | 5 | const formattedDate = (date: string) => format(new Date(date), 'yyyy年MM月dd日') 6 | 7 | export const ArticleMetaDate = ({ 8 | date, 9 | className, 10 | }: { 11 | date: string 12 | className?: string 13 | }) => ( 14 |
15 | 16 |
{formattedDate(date)}
17 |
18 | ) 19 | -------------------------------------------------------------------------------- /web/src/themes/default/components/article-meta/article-meta-topic.tsx: -------------------------------------------------------------------------------- 1 | import { BiPurchaseTagAlt } from 'react-icons/bi' 2 | 3 | export const ArticleMetaTopic = ({ topicName }: { topicName: string }) => ( 4 |
5 | 6 |
{topicName}
7 |
8 | ) 9 | -------------------------------------------------------------------------------- /web/src/themes/default/components/article-meta/index.tsx: -------------------------------------------------------------------------------- 1 | import { ArticleMetaDate } from './article-meta-date' 2 | import { ArticleMetaTopic } from './article-meta-topic' 3 | import { addClassName } from '../../utils/addClassName' 4 | 5 | export const ArticleMeta = ({ 6 | className, 7 | date, 8 | topicName, 9 | }: { 10 | date: string 11 | topicName: string 12 | className?: string 13 | }) => ( 14 |
20 | 21 | 22 |
23 | ) 24 | -------------------------------------------------------------------------------- /web/src/themes/default/components/article/article-footer.tsx: -------------------------------------------------------------------------------- 1 | import { BiLink } from 'react-icons/bi' 2 | 3 | export const ArticleFooter = ({ 4 | host, 5 | path, 6 | }: { 7 | host: string 8 | path: string 9 | }) => ( 10 |
11 |
12 | 13 |
14 | 本文地址: 15 | {host + path} 16 |
17 |
18 |
19 | ) 20 | -------------------------------------------------------------------------------- /web/src/themes/default/index.ts: -------------------------------------------------------------------------------- 1 | export { BaseLayout } from './layout' 2 | export { HomePage } from './pages/home' 3 | export { TopicPage } from './pages/topic' 4 | export { ArticlePage } from './pages/article' 5 | export { LoginPage } from './pages/login' 6 | export { ResetPasswordPage } from './pages/reset-password' 7 | -------------------------------------------------------------------------------- /web/src/themes/default/layout/base-footer.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from 'react-bootstrap' 2 | 3 | export const BaseFooter = () => ( 4 |
5 | 6 |
Copyright © 2023 - All Rights Reserved
7 |
8 | Powered by Nest and React 9 | 15 | 本站源码 16 | 17 |
18 |
19 |
20 | ) 21 | -------------------------------------------------------------------------------- /web/src/themes/default/layout/base-header.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Container, Nav, Navbar } from 'react-bootstrap' 4 | 5 | export function BaseHeader() { 6 | return ( 7 | 13 | 14 | blog 15 | 16 | 17 | 23 | 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /web/src/themes/default/layout/base-layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import { Container } from 'react-bootstrap' 3 | import { BaseHeader } from './base-header' 4 | import { BaseFooter } from './base-footer' 5 | 6 | export function BaseLayout({ children }: { children: ReactNode }) { 7 | return ( 8 | <> 9 | 10 | 11 | {children} 12 | 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /web/src/themes/default/layout/index.ts: -------------------------------------------------------------------------------- 1 | export { BaseLayout } from './base-layout' 2 | -------------------------------------------------------------------------------- /web/src/themes/default/pages/article.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { NextPage } from 'next' 4 | import { usePathname } from 'next/navigation' 5 | import ErrorPage from 'next/error' 6 | import { MarkdownViewer } from '@/components/markdown-viewer' 7 | import { ArticleMeta } from '../components/article-meta' 8 | import { ArticleFooter } from '../components/article/article-footer' 9 | 10 | interface Props { 11 | host: string 12 | article: Article 13 | } 14 | 15 | export const ArticlePage: NextPage = ({ host, article }) => { 16 | const pathname = usePathname() 17 | 18 | return article ? ( 19 |
20 |

{article.title}

21 | 26 | 27 | 28 |
29 | ) : ( 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /web/src/themes/default/pages/home.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next' 2 | import { ArticleList } from '../components/article-list' 3 | 4 | interface Props { 5 | articleList: Pagination
6 | } 7 | 8 | export const HomePage: NextPage = ({ articleList }) => 9 | articleList && 10 | -------------------------------------------------------------------------------- /web/src/themes/default/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Form, Button } from 'react-bootstrap' 2 | 3 | export function LoginPage({ 4 | setEmail, 5 | setPassword, 6 | onLogin, 7 | }: { 8 | setEmail: (email: string) => void 9 | setPassword: (password: string) => void 10 | onLogin: () => void 11 | }) { 12 | return ( 13 | 14 | 登录 15 | 16 |
17 | 18 | 邮箱 19 | setEmail(e.target.value)} 23 | /> 24 | 25 | 26 | 密码 27 | setPassword(e.target.value)} 31 | /> 32 | 33 | 36 | 37 |
38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /web/src/themes/default/pages/reset-password.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Form, Button } from 'react-bootstrap' 2 | 3 | export function ResetPasswordPage({ 4 | setOldPassword, 5 | setNewPassword, 6 | setConfirmPassword, 7 | onResetPassword, 8 | }: { 9 | setOldPassword: (password: string) => void 10 | setNewPassword: (password: string) => void 11 | setConfirmPassword: (password: string) => void 12 | onResetPassword: () => void 13 | }) { 14 | return ( 15 | 16 | 设置密码 17 | 18 |
19 | 20 | 旧密码 21 | setOldPassword(e.target.value)} 25 | /> 26 | 27 | 28 | 新密码 29 | setNewPassword(e.target.value)} 33 | /> 34 | 35 | 36 | 确认密码 37 | setConfirmPassword(e.target.value)} 41 | /> 42 | 43 | 46 | 47 |
48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /web/src/themes/default/pages/topic.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | import { ArticleList } from '../components/article-list' 3 | 4 | interface Props { 5 | articleList: Pagination
6 | } 7 | 8 | export const TopicPage: NextPage = ({ articleList }) => 9 | articleList && 10 | -------------------------------------------------------------------------------- /web/src/themes/default/utils/addClassName.ts: -------------------------------------------------------------------------------- 1 | export function addClassName(className?: string) { 2 | return className ? ' ' + className : '' 3 | } 4 | -------------------------------------------------------------------------------- /web/src/themes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './default' 2 | -------------------------------------------------------------------------------- /web/src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare interface Pagination { 2 | page: number 3 | pageSize: number 4 | total: number 5 | results: T[] 6 | } 7 | 8 | declare interface Topic { 9 | id: number 10 | code: string 11 | name: string 12 | orderIndex: number 13 | createdAt: string 14 | updatedAt: string 15 | } 16 | 17 | declare interface Article { 18 | id: number 19 | title: string 20 | content: string 21 | topicId: number 22 | topic: Topic 23 | authorId: number 24 | tags: string[] 25 | viewCount: number 26 | likeCount: number 27 | collectCount: number 28 | createdAt: string 29 | updatedAt: string 30 | } 31 | -------------------------------------------------------------------------------- /web/src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import getConfig from 'next/config' 2 | import queryString from 'query-string' 3 | 4 | export function getApiUrl(path: string, query: object = {}) { 5 | const { publicRuntimeConfig } = getConfig() 6 | const { apiBaseUrl } = publicRuntimeConfig 7 | const qStr = queryString.stringify(query, { skipEmptyString: true }) 8 | const q = qStr ? `?${qStr}` : '' 9 | return `${apiBaseUrl}${path}${q}` 10 | } 11 | 12 | export async function getList(url: string) { 13 | try { 14 | const res = await fetch(url) 15 | return res.json() 16 | } catch { 17 | return { 18 | page: 1, 19 | pageSize: 16, 20 | total: 0, 21 | results: [], 22 | } 23 | } 24 | } 25 | 26 | export async function getItem(url: string) { 27 | try { 28 | const res = await fetch(url) 29 | return res.json() 30 | } catch (err) { 31 | return null 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /web/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": 14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | --------------------------------------------------------------------------------