├── .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 | 
10 |
11 | ### Web
12 |
13 | 
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 |
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 |
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 |
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 |
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 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------