├── .editorconfig ├── .github └── workflows │ ├── nodejs.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── react.code-snippets ├── LICENSE ├── README.md ├── config ├── basic.ts ├── config.ts └── routes.ts ├── mock └── .gitkeep ├── package.json ├── public └── images │ ├── cnode_icon_32.png │ ├── cnode_icon_64.png │ ├── cnodejs.svg │ ├── cnodejs_light.svg │ └── favicon.ico ├── scripts └── zip.js ├── src ├── access.ts ├── app.tsx ├── component │ ├── Brand │ │ ├── index.less │ │ └── index.tsx │ ├── CommentForm │ │ └── index.tsx │ ├── CommentList │ │ ├── index.less │ │ └── index.tsx │ ├── Markdown │ │ ├── index.less │ │ └── index.tsx │ ├── MessageList │ │ ├── index.less │ │ └── index.tsx │ ├── ModComponent │ │ ├── Calendar.tsx │ │ ├── DatePicker.tsx │ │ ├── TimePicker.tsx │ │ └── index.tsx │ ├── RightContent │ │ └── index.tsx │ └── TopicList │ │ ├── index.less │ │ └── index.tsx ├── config │ └── index.ts ├── constants │ └── index.ts ├── global.less ├── layout.tsx ├── layout │ ├── component │ │ ├── AppQrcode.tsx │ │ └── UserInfo.tsx │ └── index.tsx ├── model │ ├── globals.ts │ ├── message.ts │ └── user.ts ├── page │ ├── 404.tsx │ ├── about │ │ └── index.tsx │ ├── api │ │ └── index.tsx │ ├── auth │ │ ├── index.less │ │ └── index.tsx │ ├── document.ejs │ ├── home │ │ └── index.tsx │ ├── links │ │ └── index.tsx │ ├── message │ │ └── index.tsx │ ├── topic │ │ ├── component │ │ │ └── SubTitle.tsx │ │ ├── detail.tsx │ │ ├── edit │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── index.less │ │ └── index.tsx │ └── user │ │ ├── index.less │ │ └── index.tsx ├── service │ ├── message.ts │ ├── topic.ts │ └── user.ts └── util │ └── index.ts ├── tests └── util.test.ts ├── tsconfig.json └── typings.d.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | - master 11 | pull_request: 12 | branches: 13 | - main 14 | - master 15 | schedule: 16 | - cron: '0 2 * * *' 17 | 18 | jobs: 19 | build: 20 | runs-on: ${{ matrix.os }} 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | node-version: [16.x] 26 | os: [ubuntu-latest] 27 | 28 | steps: 29 | - name: Checkout Git Source 30 | uses: actions/checkout@v2 31 | 32 | - name: Use Node.js ${{ matrix.node-version }} 33 | uses: actions/setup-node@v1 34 | with: 35 | node-version: ${{ matrix.node-version }} 36 | 37 | - name: Install Dependencies 38 | run: npm i -g npminstall && npminstall 39 | 40 | - name: Continuous Integration 41 | run: npm run ci 42 | 43 | - name: Code Coverage 44 | uses: codecov/codecov-action@v1 45 | with: 46 | token: ${{ secrets.CODECOV_TOKEN }} 47 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Actions Release 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ${{ matrix.os }} 10 | 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | node-version: [16.x] 15 | os: [ubuntu-latest] 16 | 17 | steps: 18 | - name: Checkout Git Source 19 | uses: actions/checkout@v2 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: Install Dependencies 27 | run: npm i -g npminstall && npminstall 28 | 29 | - name: Continuous integration 30 | run: npm run ci 31 | 32 | - name: Semantic Release 33 | run: npm run build && npm run build:zip && npm run semantic-release 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /npm-debug.log* 6 | /yarn-error.log 7 | /yarn.lock 8 | /package-lock.json 9 | 10 | # production 11 | /dist 12 | dist.zip 13 | 14 | # misc 15 | .DS_Store 16 | 17 | # umi 18 | /src/.umi 19 | /src/.umi-production 20 | /src/.umi-test 21 | 22 | # env 23 | .env.local 24 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.md 2 | **/*.svg 3 | **/*.ejs 4 | **/*.html 5 | package.json 6 | 7 | .umi 8 | .umi-production 9 | .umi-test 10 | 11 | dist 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 80, 5 | "overrides": [ 6 | { 7 | "files": ".prettierrc", 8 | "options": { "parser": "json" } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/react.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "React-TypeScript Function Component": { 3 | "prefix": "rfct", 4 | "body": [ 5 | "import React from 'react';", 6 | "", 7 | "const ${0:${TM_FILENAME_BASE/(.*)/${1:/capitalize}/}}: React.FC = (props) => {", 8 | " return null;", 9 | "};", 10 | "", 11 | "export default ${0:${TM_FILENAME_BASE/(.*)/${1:/capitalize}/}};", 12 | "", 13 | "interface Props {", 14 | "};" 15 | ], 16 | "description": "React-TypeScript Function Component" 17 | } 18 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 CNodejs.org 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 | # React-CNode.js 2 | 3 | > Frontend Powered By React For CNode.js 4 | 5 | ## Development 6 | 7 | Install dependencies, 8 | 9 | ```bash 10 | $ yarn 11 | ``` 12 | 13 | Start the dev server, 14 | 15 | ```bash 16 | $ yarn dev 17 | ``` 18 | 19 | ## Contributors 20 | 21 | [![contributors](https://ergatejs.implements.io/badges/contributors/cnodejs/react-cnode.svg?owner=cnodejs&repo=react-cnode&type=svg&width=1232&size=64&padding=8)](https://github.com/cnodejs/react-cnode/graphs/contributors) 22 | -------------------------------------------------------------------------------- /config/basic.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | logo: '/images/cnodejs.svg', 3 | title: 'CNode.js', 4 | description: 'Node.js 专业中文社区', 5 | concept: 6 | 'CNode 社区为国内最专业的 Node.js 开源技术社区,致力于 Node.js 的技术研究。', 7 | }; 8 | -------------------------------------------------------------------------------- /config/config.ts: -------------------------------------------------------------------------------- 1 | import routes from './routes'; 2 | 3 | import { defineConfig } from 'umi'; 4 | 5 | export default defineConfig({ 6 | // cnodejs.org 7 | favicon: '/images/favicon.ico', 8 | metas: [ 9 | { 10 | name: 'keywords', 11 | content: 'nodejs, node, express, connect, socket.io', 12 | }, 13 | { 14 | name: 'referrer', 15 | content: 'always', 16 | }, 17 | { 18 | name: 'author', 19 | content: 'EDP@TaoBao', 20 | }, 21 | { 22 | name: 'wb:webmaster', 23 | content: '617be6bd946c6b96', 24 | }, 25 | { 26 | name: 'wb:webmaster', 27 | content: '617be6bd946c6b96', 28 | }, 29 | ], 30 | links: [ 31 | { 32 | type: 'image/x-icon', 33 | rel: 'icon', 34 | href: '//static2.cnodejs.org/public/images/cnode_icon_32.png', 35 | }, 36 | { 37 | title: 'RSS', 38 | type: 'application/rss+xml', 39 | rel: 'alternate', 40 | href: 'https://cnodejs.org/rss', 41 | }, 42 | ], 43 | 44 | analytics: { 45 | ga: 'UA-41753901-5', 46 | }, 47 | 48 | // umi.js 49 | hash: true, 50 | singular: true, 51 | 52 | publicPath: process.env.NODE_ENV === 'development' ? '/' : '/', 53 | 54 | fastRefresh: {}, 55 | mfsu: {}, 56 | esbuild: {}, 57 | webpack5: {}, 58 | 59 | nodeModulesTransform: { 60 | type: 'none', 61 | exclude: [], 62 | }, 63 | 64 | targets: { 65 | chrome: 79, 66 | firefox: false, 67 | safari: false, 68 | edge: false, 69 | ios: false, 70 | }, 71 | 72 | externals: { 73 | react: 'window.React', 74 | 'react-dom': 'window.ReactDOM', 75 | antd: 'window.antd', 76 | dayjs: 'window.dayjs', 77 | '@ant-design/icons': 'window.icons', 78 | 'markdown-it': 'window.markdownit', 79 | 'react-markdown-editor-lite': 'window.ReactMarkdownEditorLite', 80 | }, 81 | 82 | styles: 83 | process.env.NODE_ENV === 'development' 84 | ? [ 85 | '//cdn.jsdelivr.net/npm/antd@4.x/dist/antd.css', 86 | '//cdn.jsdelivr.net/npm/react-markdown-editor-lite@1.x/lib/index.css', 87 | ] 88 | : [ 89 | '//cdn.jsdelivr.net/npm/antd@4.x/dist/antd.min.css', 90 | '//cdn.jsdelivr.net/npm/react-markdown-editor-lite@1.x/lib/index.css', 91 | ], 92 | 93 | scripts: 94 | process.env.NODE_ENV === 'development' 95 | ? [ 96 | '//cdn.jsdelivr.net/npm/react@17.x/umd/react.development.js', 97 | '//cdn.jsdelivr.net/npm/react-dom@17.x/umd/react-dom.development.js', 98 | '//cdn.jsdelivr.net/npm/antd@4.x/dist/antd.js', 99 | '//cdn.jsdelivr.net/npm/@ant-design/icons@4.x/dist/index.umd.js', 100 | '//cdn.jsdelivr.net/npm/dayjs@1.x/dayjs.min.js', 101 | '//cdn.jsdelivr.net/npm/react-markdown-editor-lite@1.x/lib/index.js', 102 | ] 103 | : [ 104 | '//cdn.jsdelivr.net/npm/react@17.x/umd/react.production.min.js', 105 | '//cdn.jsdelivr.net/npm/react-dom@17.x/umd/react-dom.production.min.js', 106 | '//cdn.jsdelivr.net/npm/antd@4.x/dist/antd.min.js', 107 | '//cdn.jsdelivr.net/npm/@ant-design/icons@4.x/dist/index.umd.min.js', 108 | '//cdn.jsdelivr.net/npm/dayjs@1.x/dayjs.min.js', 109 | '//cdn.jsdelivr.net/npm/react-markdown-editor-lite@1.x/lib/index.js', 110 | ], 111 | 112 | antd: {}, 113 | 114 | theme: { 115 | '@primary-color': '#1DA57A', 116 | }, 117 | 118 | dva: { 119 | immer: true, 120 | }, 121 | 122 | layout: {}, 123 | 124 | locale: false, 125 | 126 | qiankun: { 127 | master: { 128 | apps: [], 129 | }, 130 | }, 131 | 132 | routes, 133 | }); 134 | -------------------------------------------------------------------------------- /config/routes.ts: -------------------------------------------------------------------------------- 1 | import { IRoute } from 'umi'; 2 | 3 | const routes: IRoute[] = [ 4 | { 5 | path: '/auth', 6 | exact: true, 7 | layout: false, 8 | component: '@/page/auth', 9 | }, 10 | { 11 | path: '/', 12 | exact: false, 13 | component: '@/layout/index', 14 | routes: [ 15 | { 16 | path: '/', 17 | exact: true, 18 | icon: 'home', 19 | name: '主页', 20 | description: 21 | 'CNode 社区为国内最专业的 Node.js 开源技术社区,致力于 Node.js 的技术研究。', 22 | component: '@/page/topic', 23 | }, 24 | { 25 | path: '/my/messages', 26 | exact: true, 27 | icon: 'message', 28 | title: '未读消息', 29 | access: 'canReadMessage', 30 | component: '@/page/message', 31 | }, 32 | { 33 | path: '/about', 34 | exact: true, 35 | icon: 'info', 36 | name: '关于我们', 37 | component: '@/page/about', 38 | }, 39 | { 40 | path: '/links', 41 | exact: true, 42 | icon: 'link', 43 | name: '友情链接', 44 | component: '@/page/links', 45 | }, 46 | { 47 | path: '/api', 48 | exact: true, 49 | icon: 'api', 50 | name: 'API', 51 | component: '@/page/api', 52 | }, 53 | { 54 | path: '/topic/create', 55 | exact: true, 56 | title: '新建话题', 57 | component: '@/page/topic/edit', 58 | access: 'canPostTopic', 59 | }, 60 | { 61 | path: '/topic/:id', 62 | exact: true, 63 | component: '@/page/topic/detail', 64 | }, 65 | { 66 | path: '/topic/:id/edit', 67 | exact: true, 68 | component: '@/page/topic/edit', 69 | access: 'canPostTopic', 70 | }, 71 | { 72 | path: '/user/:loginname', 73 | exact: true, 74 | component: '@/page/user/', 75 | }, 76 | ], 77 | }, 78 | 79 | { component: '@/page/404' }, 80 | ]; 81 | 82 | export default routes; 83 | -------------------------------------------------------------------------------- /mock/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnodejs/react-cnode/5f28c073cede4aedac5c1720f94c06d7433ee146/mock/.gitkeep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-cnode", 3 | "description": "Frontend Powered By React For CNode.js", 4 | "version": "development", 5 | "private": false, 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "umi dev", 9 | "build": "umi build", 10 | "build:zip": "node ./scripts/zip.js", 11 | "ci": "npm run test", 12 | "postinstall": "umi generate tmp", 13 | "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'", 14 | "test": "umi-test", 15 | "test:coverage": "umi-test --coverage", 16 | "semantic-release": "semantic-release" 17 | }, 18 | "gitHooks": { 19 | "pre-commit": "lint-staged" 20 | }, 21 | "lint-staged": { 22 | "*.{js,jsx,less,md,json}": [ 23 | "prettier --write" 24 | ], 25 | "*.ts?(x)": [ 26 | "prettier --parser=typescript --write" 27 | ] 28 | }, 29 | "ci": { 30 | "type": "github", 31 | "os": { 32 | "github": "linux" 33 | }, 34 | "version": "16.x" 35 | }, 36 | "release": { 37 | "branche": "master", 38 | "tagFormat": "${version}", 39 | "plugins": [ 40 | "@semantic-release/commit-analyzer", 41 | "@semantic-release/release-notes-generator", 42 | [ 43 | "@semantic-release/changelog", 44 | { 45 | "changelogFile": "History.md" 46 | } 47 | ], 48 | [ 49 | "@semantic-release/github", 50 | { 51 | "assets": { 52 | "path": "dist.zip", 53 | "label": "Assets Distribution" 54 | }, 55 | "addReleases": "bottom" 56 | } 57 | ] 58 | ] 59 | }, 60 | "dependencies": { 61 | "dotenv": "^10.0.0", 62 | "react": "17.x", 63 | "react-dom": "17.x", 64 | "rehype-attr": "^2.0.7", 65 | "rehype-raw": "^6.1.1", 66 | "rehype-sanitize": "^5.0.1", 67 | "rehype-stringify": "^9.0.2", 68 | "remark-gfm": "^3.0.1", 69 | "remark-parse": "^10.0.1", 70 | "remark-rehype": "^10.1.0", 71 | "unified": "^10.1.1" 72 | }, 73 | "devDependencies": { 74 | "@ant-design/icons": "^4.7.0", 75 | "@ant-design/pro-card": "^1.18.20", 76 | "@ant-design/pro-layout": "^6.32.1", 77 | "@semantic-release/changelog": "^6.0.1", 78 | "@types/dotenv": "^8.2.0", 79 | "@types/jest": "^27.4.0", 80 | "@types/markdown-it": "^12.2.3", 81 | "@types/react": "^17.0.0", 82 | "@types/react-dom": "^17.0.0", 83 | "@umijs/plugin-esbuild": "^1.4.1", 84 | "@umijs/plugin-qiankun": "^2.35.4", 85 | "@umijs/preset-react": "1.x", 86 | "@umijs/test": "^3.5.20", 87 | "ahooks": "^3.1.3", 88 | "compressing": "^1.5.1", 89 | "dayjs": "^1.10.7", 90 | "egg-ci": "^1.19.0", 91 | "lint-staged": "^10.0.7", 92 | "prettier": "^2.2.0", 93 | "react-markdown-editor-lite": "^1.3.2", 94 | "semantic-release": "^18.0.1", 95 | "typescript": "^4.1.2", 96 | "umi": "^3.5.20", 97 | "yorkie": "^2.0.0" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /public/images/cnode_icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnodejs/react-cnode/5f28c073cede4aedac5c1720f94c06d7433ee146/public/images/cnode_icon_32.png -------------------------------------------------------------------------------- /public/images/cnode_icon_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnodejs/react-cnode/5f28c073cede4aedac5c1720f94c06d7433ee146/public/images/cnode_icon_64.png -------------------------------------------------------------------------------- /public/images/cnodejs.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 18 | 25 | 28 | 36 | 42 | 45 | 54 | 55 | -------------------------------------------------------------------------------- /public/images/cnodejs_light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 18 | 25 | 28 | 36 | 42 | 45 | 54 | 55 | -------------------------------------------------------------------------------- /public/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnodejs/react-cnode/5f28c073cede4aedac5c1720f94c06d7433ee146/public/images/favicon.ico -------------------------------------------------------------------------------- /scripts/zip.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const compressing = require('compressing'); 3 | 4 | const source = path.resolve(__dirname, '../dist'); 5 | const target = path.resolve(__dirname, '../dist.zip'); 6 | 7 | const run = async () => { 8 | try { 9 | await compressing.zip.compressDir(source, target); 10 | console.log('compressing:zip:done'); 11 | } catch (error) { 12 | console.log(error); 13 | } 14 | }; 15 | 16 | run(); 17 | -------------------------------------------------------------------------------- /src/access.ts: -------------------------------------------------------------------------------- 1 | export default function (initialState: InitialState) { 2 | const { token } = initialState; 3 | 4 | return { 5 | canPostTopic: !!token, 6 | canPostComment: !!token, 7 | canReadMessage: !!token, 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import dayjs from 'dayjs'; 3 | import relativeTime from 'dayjs/plugin/relativeTime'; 4 | import 'dayjs/locale/zh'; 5 | 6 | import { MicroApp, IRoute, request as requestClient, RequestConfig } from 'umi'; 7 | import { PageContainer } from '@ant-design/pro-layout'; 8 | // import { BASE_URL } from './constants'; 9 | import { loadInitialState } from '@/util'; 10 | import proLayout from './layout'; 11 | 12 | dayjs.locale('zh'); 13 | dayjs.extend(relativeTime); 14 | 15 | const qiankunApps: QiankunApp[] = []; 16 | 17 | export async function getInitialState(): Promise { 18 | const initialState = loadInitialState(); 19 | return initialState; 20 | } 21 | 22 | export const layout = proLayout; 23 | 24 | export const qiankun = async () => { 25 | try { 26 | // const params = { method: 'get', json: {} }; 27 | // const { apps } = await requestClient<{ 28 | // apps: QiankunApp[]; 29 | // }>(`${BASE_URL}/getFeConfig`, params); 30 | 31 | // console.log('===getFeConfig', apps); 32 | 33 | // apps 34 | // .filter(({ type }) => type === 'App') 35 | // .sort((appA, appB) => appA.order - appB.order) 36 | // .forEach((app) => qiankunApps.push(app)); 37 | 38 | return { 39 | apps: [ 40 | { 41 | name: 'swagger', 42 | entry: 'https://s.implements.io/microapp/pg-swagger/', 43 | }, 44 | ], 45 | }; 46 | } catch (error) { 47 | return {}; 48 | } 49 | }; 50 | 51 | export const patchRoutes = ({ routes }: { routes: Array }) => { 52 | const root = routes[0]; 53 | 54 | qiankunApps.forEach((item) => { 55 | const { name, path, locale, remark } = item; 56 | if (!root.routes) { 57 | return; 58 | } 59 | 60 | root.routes.push({ 61 | name, 62 | path, 63 | locale: locale || '', 64 | exact: true, 65 | hideInMenu: false, 66 | hideInNav: false, 67 | component: (props: any) => { 68 | return ( 69 | 70 | 71 | 72 | ); 73 | }, 74 | }); 75 | }); 76 | }; 77 | 78 | export const request: RequestConfig = { 79 | timeout: 6 * 1000, 80 | errorConfig: {}, 81 | middlewares: [], 82 | requestInterceptors: [], 83 | responseInterceptors: [], 84 | }; 85 | -------------------------------------------------------------------------------- /src/component/Brand/index.less: -------------------------------------------------------------------------------- 1 | .container { 2 | height: 100%; 3 | display: inline-flex; 4 | align-items: center; 5 | } 6 | 7 | .logo { 8 | height: 32px; 9 | } 10 | 11 | .title { 12 | color: rgba(0, 0, 0, 0.85); 13 | margin: 0; 14 | padding: 0 4px; 15 | } 16 | 17 | .description { 18 | color: #222; 19 | opacity: 0.55; 20 | margin: 0 0 0 0.4em; 21 | padding: 8px 0 0 8px; 22 | } 23 | -------------------------------------------------------------------------------- /src/component/Brand/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { RouteContext } from '@ant-design/pro-layout'; 3 | 4 | import * as styles from './index.less'; 5 | 6 | const Brand: React.FC = ({ logo, title, description }) => { 7 | const { collapsed, isMobile } = useContext(RouteContext); 8 | 9 | return ( 10 |
11 | logo 12 | {collapsed || isMobile ? null : ( 13 |

{description}

14 | )} 15 |
16 | ); 17 | }; 18 | 19 | export default Brand; 20 | 21 | interface Props { 22 | logo?: string; 23 | title?: string; 24 | description: string; 25 | } 26 | -------------------------------------------------------------------------------- /src/component/CommentForm/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Comment, Avatar, Button } from 'antd'; 3 | import Markdown from '@/component/Markdown'; 4 | 5 | const CommentForm: React.FC = (props) => { 6 | const { data, user, onSubmit, onSubmitText = '提交评论' } = props; 7 | const { loginname, avatar_url } = user; 8 | const [value, setValue] = useState(''); 9 | 10 | useEffect(() => { 11 | if (!data) { 12 | return; 13 | } 14 | setValue(data); 15 | }, [data]); 16 | 17 | return ( 18 | {loginname}} 20 | avatar={} 21 | actions={[ 22 | , 36 | ]} 37 | content={} 38 | /> 39 | ); 40 | }; 41 | 42 | export default CommentForm; 43 | 44 | interface Props { 45 | data?: string; 46 | user: { 47 | loginname: string; 48 | avatar_url: string; 49 | }; 50 | onSubmit: (value: string) => Promise; 51 | onSubmitText?: string; 52 | } 53 | -------------------------------------------------------------------------------- /src/component/CommentList/index.less: -------------------------------------------------------------------------------- 1 | .list { 2 | img { 3 | max-width: 100%; 4 | overflow: hidden; 5 | } 6 | } 7 | 8 | .detail { 9 | * { 10 | overflow: hidden; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/component/CommentList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import dayjs from 'dayjs'; 3 | import { Link } from 'umi'; 4 | import Markdown from '@/component/Markdown'; 5 | 6 | import { Comment, Avatar, Divider } from 'antd'; 7 | import { 8 | LikeFilled, 9 | // EditFilled, 10 | // DeleteFilled, 11 | CommentOutlined, 12 | } from '@ant-design/icons'; 13 | 14 | import * as styles from './index.less'; 15 | 16 | const unflatten = (array: Node[], parent?: Node, tree?: Node[]) => { 17 | let _parent = parent || { id: null, children: [] }; 18 | let _tree = tree || []; 19 | 20 | const children = array.filter((child) => child.reply_id === _parent.id); 21 | 22 | if (children.length > 0) { 23 | if (!_parent.id) { 24 | _tree = children; 25 | } else { 26 | _parent.children = children; 27 | } 28 | children.forEach((child) => unflatten(array, child)); 29 | } 30 | 31 | return _tree; 32 | }; 33 | 34 | const CommentList: React.FC = (props) => { 35 | const { list, onLike, onReply, replyRender } = props; 36 | const tree = unflatten(list); 37 | 38 | const CommentDetail: React.FC<{ 39 | data: Node; 40 | }> = ({ data }) => { 41 | const { id, author, content, create_at, children } = data; 42 | const { loginname, avatar_url } = author; 43 | 44 | return ( 45 | 46 | 47 | 48 | { 53 | onLike && onLike(data); 54 | }} 55 | />, 56 | // access.admin 57 | // , 58 | // , 59 | { 61 | onReply && onReply(data); 62 | }} 63 | />, 64 | ]} 65 | author={{author.loginname}} 66 | datetime={ 67 | {dayjs(create_at).format('YYYY-MM-DD hh:mm:ss')} 68 | } 69 | avatar={ 70 | 71 | 72 | 73 | } 74 | content={ 75 |
76 | 77 |
78 | } 79 | > 80 | {replyRender(id)} 81 | 82 | {children?.map((item) => ( 83 | 84 | ))} 85 |
86 |
87 | ); 88 | }; 89 | 90 | return ( 91 |
92 | {tree.map((item) => ( 93 | 94 | ))} 95 |
96 | ); 97 | }; 98 | 99 | export default CommentList; 100 | 101 | interface Props { 102 | list: ReplyModel[]; 103 | 104 | onLike?: (record: Node) => void; 105 | onEdit?: (record: Node) => void; 106 | onReply?: (record: Node) => void; 107 | onDelete?: (record: Node) => void; 108 | 109 | replyRender: (id: string) => React.ReactNode; 110 | } 111 | 112 | interface Node extends ReplyModel { 113 | children?: Node[]; 114 | } 115 | -------------------------------------------------------------------------------- /src/component/Markdown/index.less: -------------------------------------------------------------------------------- 1 | .markdown { 2 | :global { 3 | .editor-container { 4 | > .sec-html { 5 | > .html-wrap { 6 | padding: 0; 7 | } 8 | } 9 | } 10 | } 11 | } 12 | 13 | .markdown_render { 14 | border: none; 15 | } 16 | 17 | .markdown_editor { 18 | min-height: 180px; 19 | } 20 | -------------------------------------------------------------------------------- /src/component/Markdown/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { unified } from 'unified'; 3 | import remarkParse from 'remark-parse'; 4 | import remarkRehype from 'remark-rehype'; 5 | import rehypeRaw from 'rehype-raw'; 6 | import remarkGfm from 'remark-gfm'; 7 | import rehypeAttr from 'rehype-attr'; 8 | import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; 9 | import rehypeStringify from 'rehype-stringify'; 10 | import type { Schema } from 'hast-util-sanitize'; 11 | 12 | import MdEditor from 'react-markdown-editor-lite'; 13 | // import 'react-markdown-editor-lite/esm/index.less'; 14 | 15 | import * as styles from './index.less'; 16 | 17 | const CONFIG_MAP = { 18 | render: { 19 | view: { 20 | menu: false, 21 | md: false, 22 | html: true, 23 | }, 24 | classname: styles.markdown_render, 25 | }, 26 | editor: { 27 | view: { 28 | menu: true, 29 | md: true, 30 | html: false, 31 | }, 32 | classname: styles.markdown_editor, 33 | }, 34 | }; 35 | 36 | const CONFIG_SCHEMA: Schema = { 37 | ...defaultSchema, 38 | attributes: { 39 | ...defaultSchema.attributes, 40 | img: [...(defaultSchema?.attributes?.img || []), ['style']], 41 | }, 42 | }; 43 | 44 | const processor = unified() 45 | .use(remarkParse) 46 | .use(remarkGfm) 47 | .use(remarkRehype, { allowDangerousHtml: true }) 48 | .use(rehypeRaw) 49 | .use(rehypeAttr, { properties: 'attr' }) 50 | .use(rehypeSanitize, CONFIG_SCHEMA) 51 | .use(rehypeStringify); 52 | 53 | const Markdown: React.FC = (props) => { 54 | const { value = '', type, onChange, customClassName = '' } = props; 55 | const { view, classname: defaultClassName } = CONFIG_MAP[type]; 56 | 57 | let classname = `${styles.markdown} ${defaultClassName}`; 58 | 59 | if (customClassName) { 60 | classname += ` ${customClassName}`; 61 | } 62 | 63 | return ( 64 | { 70 | const content = await processor.process(text); 71 | return content.toString(); 72 | }} 73 | onChange={(data) => { 74 | onChange && onChange(data.text); 75 | }} 76 | /> 77 | ); 78 | }; 79 | 80 | export default Markdown; 81 | 82 | interface Props { 83 | type: 'editor' | 'render'; 84 | customClassName?: string; 85 | 86 | value?: string; 87 | onChange?: (text: string) => void; 88 | } 89 | -------------------------------------------------------------------------------- /src/component/MessageList/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd/es/style/variable.less'; 2 | 3 | .list { 4 | :global { 5 | .ant-card { 6 | padding: 0; 7 | > .ant-card-body { 8 | padding: 0; 9 | } 10 | } 11 | } 12 | } 13 | 14 | .link { 15 | color: @text-color; 16 | 17 | &:hover { 18 | color: @primary-color; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/component/MessageList/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import dayjs from 'dayjs'; 3 | import { Link } from 'umi'; 4 | import { Space, Avatar, Tag, List } from 'antd'; 5 | 6 | import { MESSAGE_TYPE_MAP, MessageType } from '@/constants'; 7 | 8 | import * as styles from './index.less'; 9 | 10 | const MessageList: React.FC = ({ dataSource, loading, onClick }) => { 11 | return ( 12 | { 16 | const { 17 | id: messageId, 18 | type: _type, 19 | create_at, 20 | topic: { id, title }, 21 | author: { loginname, avatar_url }, 22 | } = item; 23 | 24 | const type = MESSAGE_TYPE_MAP[_type as MessageType]; 25 | 26 | return ( 27 | 28 | 31 |
36 | 37 | 38 | 39 | {loginname} 40 | 41 | 42 |
43 | 44 | {type.name} 45 | 46 | } 47 | title={ 48 | { 52 | onClick && onClick(messageId); 53 | }} 54 | > 55 | {title} 56 | 57 | } 58 | /> 59 |
{dayjs(create_at).fromNow()}
60 |
61 | ); 62 | }} 63 | /> 64 | ); 65 | }; 66 | 67 | export default MessageList; 68 | 69 | interface Props { 70 | dataSource?: MessageModel[]; 71 | loading?: boolean; 72 | onClick?: (id: string) => void; 73 | } 74 | -------------------------------------------------------------------------------- /src/component/ModComponent/Calendar.tsx: -------------------------------------------------------------------------------- 1 | import { Dayjs } from 'dayjs'; 2 | import dayjsGenerateConfig from 'rc-picker/es/generate/dayjs'; 3 | import generateCalendar from 'antd/es/calendar/generateCalendar'; 4 | import 'antd/es/calendar/style'; 5 | 6 | const Calendar = generateCalendar(dayjsGenerateConfig); 7 | 8 | export default Calendar; 9 | -------------------------------------------------------------------------------- /src/component/ModComponent/DatePicker.tsx: -------------------------------------------------------------------------------- 1 | import { Dayjs } from 'dayjs'; 2 | import dayjsGenerateConfig from 'rc-picker/es/generate/dayjs'; 3 | import generatePicker from 'antd/es/date-picker/generatePicker'; 4 | import 'antd/es/date-picker/style/index'; 5 | 6 | const DatePicker = generatePicker(dayjsGenerateConfig); 7 | 8 | export default DatePicker; 9 | -------------------------------------------------------------------------------- /src/component/ModComponent/TimePicker.tsx: -------------------------------------------------------------------------------- 1 | import { Dayjs } from 'dayjs'; 2 | import * as React from 'react'; 3 | import DatePicker from './DatePicker'; 4 | import { PickerTimeProps } from 'antd/es/date-picker/generatePicker'; 5 | 6 | export interface TimePickerProps 7 | extends Omit, 'picker'> {} 8 | 9 | const TimePicker = React.forwardRef((props, ref) => { 10 | return ; 11 | }); 12 | 13 | TimePicker.displayName = 'TimePicker'; 14 | 15 | export default TimePicker; 16 | -------------------------------------------------------------------------------- /src/component/ModComponent/index.tsx: -------------------------------------------------------------------------------- 1 | import Calendar from './Calendar'; 2 | import DatePicker from './DatePicker'; 3 | import TimePicker from './TimePicker'; 4 | 5 | export { Calendar, DatePicker, TimePicker }; 6 | -------------------------------------------------------------------------------- /src/component/RightContent/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { history, useModel, Link } from 'umi'; 3 | import { Avatar, Space, Button, Badge, Menu, Dropdown } from 'antd'; 4 | 5 | const RightContent: React.FC = (props) => { 6 | const { user, logout } = useModel('user'); 7 | const { count } = useModel('message'); 8 | 9 | if (!user) { 10 | return ( 11 |
12 | 20 |
21 | ); 22 | } 23 | 24 | const { loginname, avatar_url } = user; 25 | 26 | const menu = ( 27 | 28 | 29 | 个人资料 30 | 31 | 32 | 33 | 未读消息 34 | 35 | 36 | 37 | 38 | { 41 | e.preventDefault(); 42 | logout(); 43 | }} 44 | > 45 | 退出登录 46 | 47 | 48 | 49 | ); 50 | 51 | return ( 52 |
53 | 54 | 55 | 56 | 57 | {loginname} 58 | 59 | 60 | 61 |
62 | ); 63 | }; 64 | 65 | export default RightContent; 66 | 67 | interface Props {} 68 | -------------------------------------------------------------------------------- /src/component/TopicList/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd/es/style/variable.less'; 2 | 3 | .list { 4 | :global { 5 | .ant-card { 6 | padding: 0; 7 | > .ant-card-body { 8 | padding: 0; 9 | } 10 | } 11 | } 12 | } 13 | 14 | .link { 15 | color: @text-color; 16 | 17 | &:hover { 18 | color: @primary-color; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/component/TopicList/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import dayjs from 'dayjs'; 3 | import { Link } from 'umi'; 4 | import { Space, Avatar, Tag, List } from 'antd'; 5 | import { TABS_MAP, TabType } from '@/constants'; 6 | 7 | import * as styles from './index.less'; 8 | 9 | const TopicList: React.FC = ({ dataSource, loading, loadMore }) => { 10 | return ( 11 | { 16 | const { 17 | id, 18 | title, 19 | last_reply_at, 20 | tab: _tab, 21 | top, 22 | author, 23 | reply_count, 24 | visit_count, 25 | } = item; 26 | 27 | const category = TABS_MAP[_tab as TabType]; 28 | const { loginname, avatar_url } = author; 29 | 30 | const renderReplyVisit = () => 31 | typeof visit_count === 'number' && ( 32 |
38 | 43 | {reply_count} 44 | 45 | /{visit_count} 46 |
47 | ); 48 | 49 | return ( 50 | 51 | 54 | 55 | 56 | 57 | {renderReplyVisit()} 58 | {top ? ( 59 | 置顶 60 | ) : ( 61 | category && ( 62 | {category.name} 63 | ) 64 | )} 65 | 66 | } 67 | title={ 68 | 69 | {title} 70 | 71 | } 72 | /> 73 |
{dayjs(last_reply_at).fromNow()}
74 |
75 | ); 76 | }} 77 | /> 78 | ); 79 | }; 80 | 81 | export default TopicList; 82 | 83 | interface Props { 84 | dataSource?: TopicModel[]; 85 | loading?: boolean; 86 | loadMore?: React.ReactNode; 87 | } 88 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import config from '../../config/basic'; 2 | 3 | export default config; 4 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const BASE_URL = 'https://cnodejs.org'; 2 | 3 | export const TABS_MAP = { 4 | good: { 5 | name: '精华', 6 | color: '#87d068', 7 | }, 8 | share: { 9 | name: '分享', 10 | color: '#2db7f5', 11 | }, 12 | ask: { 13 | name: '问答', 14 | color: '#999', 15 | }, 16 | job: { 17 | name: '招聘', 18 | color: '#108ee9', 19 | }, 20 | dev: { 21 | name: '客户端测试', 22 | color: 'green', 23 | }, 24 | }; 25 | 26 | export type TabType = keyof typeof TABS_MAP; 27 | 28 | export const MESSAGE_TYPE_MAP = { 29 | at: { 30 | name: '提到了你', 31 | color: '#108ee9', 32 | }, 33 | reply: { 34 | name: '回复了你', 35 | color: 'green', 36 | }, 37 | }; 38 | 39 | export type MessageType = keyof typeof MESSAGE_TYPE_MAP; 40 | 41 | export enum FORM_TYPE { 42 | LOGIN = 'login', 43 | REGISTER = 'register', 44 | } 45 | -------------------------------------------------------------------------------- /src/global.less: -------------------------------------------------------------------------------- 1 | #root-master { 2 | height: 100%; 3 | } 4 | 5 | .ant-input-number { 6 | width: 100% !important; 7 | } 8 | 9 | .ant-layout-content { 10 | max-width: 100%; 11 | overflow: hidden; 12 | } 13 | 14 | .cnode-header { 15 | display: flex; 16 | align-items: center; 17 | } 18 | 19 | .cnode-header-right { 20 | margin-right: 16px; 21 | } 22 | 23 | .top-nav-menu { 24 | justify-content: flex-end; 25 | padding: 0 48px; 26 | } 27 | -------------------------------------------------------------------------------- /src/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, history } from 'umi'; 3 | import { Avatar, Tooltip, Button } from 'antd'; 4 | 5 | import { 6 | DefaultFooter, 7 | BasicLayoutProps, 8 | MenuDataItem, 9 | } from '@ant-design/pro-layout'; 10 | 11 | import config from '../config/basic'; 12 | import Brand from './component/Brand'; 13 | import RightContent from './component/RightContent'; 14 | 15 | // const RightContent: React.FC<{ 16 | // user?: UserModel; 17 | // }> = (props) => { 18 | // const user = props?.user; 19 | 20 | // if (!user) { 21 | // return ( 22 | //
23 | // 31 | //
32 | // ); 33 | // } 34 | 35 | // const { loginname, avatar_url } = user; 36 | // return ( 37 | //
38 | // 39 | // 40 | // 41 | //
42 | // ); 43 | // }; 44 | 45 | const layoutConfig = ({ 46 | initialState, 47 | }: { 48 | initialState: InitialState; 49 | }): BasicLayoutProps => { 50 | const { title, logo, description } = config; 51 | 52 | return { 53 | navTheme: 'light', 54 | layout: 'top', 55 | headerHeight: 64, 56 | fixedHeader: true, 57 | contentWidth: 'Fluid', 58 | 59 | logo, 60 | title, 61 | 62 | menuHeaderRender: () => { 63 | return ; 64 | }, 65 | 66 | menuDataRender: (menuData: MenuDataItem[]) => { 67 | let menus: MenuDataItem[] = []; 68 | const apps: MenuDataItem[] = []; 69 | menuData.forEach((item) => { 70 | if (item.path === '/' && item.exact !== true) { 71 | menus = menus.concat(item.children); 72 | return; 73 | } 74 | 75 | if (!item.microApp) { 76 | menus.push(item); 77 | return; 78 | } 79 | 80 | apps.push({ 81 | ...item, 82 | name: item.microApp, 83 | title: item.microApp, 84 | }); 85 | }); 86 | 87 | return [...menus, ...apps]; 88 | }, 89 | 90 | menuItemRender: (item) => 91 | item.path && {item.name}, 92 | 93 | rightContentRender: () => { 94 | return ; 95 | }, 96 | 97 | footerRender: () => ( 98 | 102 | ), 103 | 104 | onPageChange: () => {}, 105 | }; 106 | }; 107 | 108 | export default layoutConfig; 109 | -------------------------------------------------------------------------------- /src/layout/component/AppQrcode.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ProCard from '@ant-design/pro-card'; 3 | 4 | const AppQrcode: React.FC = (props) => { 5 | return ( 6 | 7 |
14 | 客户端二维码 19 | 客户端源码地址 20 |
21 |
22 | ); 23 | }; 24 | 25 | export default AppQrcode; 26 | 27 | interface Props {} 28 | -------------------------------------------------------------------------------- /src/layout/component/UserInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useModel } from 'umi'; 3 | import { useRequest } from 'ahooks'; 4 | import { Avatar, Space } from 'antd'; 5 | import ProCard from '@ant-design/pro-card'; 6 | 7 | import * as API from '@/service/user'; 8 | 9 | const UserInfo: React.FC = (props) => { 10 | const { user } = useModel('user'); 11 | 12 | if (!user) { 13 | return null; 14 | } 15 | 16 | const { loginname, avatar_url } = user; 17 | 18 | const { data } = useRequest( 19 | async () => { 20 | if (!loginname) { 21 | return; 22 | } 23 | 24 | const res = await API.loadUser({ loginname }); 25 | return res.data; 26 | }, 27 | { 28 | refreshDeps: [loginname], 29 | }, 30 | ); 31 | 32 | const renderMore = () => { 33 | if (!data) { 34 | return null; 35 | } 36 | 37 | return ( 38 |
39 | 积分:{data?.score} 40 |
41 | ); 42 | }; 43 | 44 | return ( 45 | 46 | 47 | 48 | {loginname} 49 | 50 | {renderMore()} 51 | 52 | ); 53 | }; 54 | 55 | export default UserInfo; 56 | 57 | interface Props {} 58 | -------------------------------------------------------------------------------- /src/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ProCard from '@ant-design/pro-card'; 3 | 4 | import { PageContainer } from '@ant-design/pro-layout'; 5 | import { BackTop, Space } from 'antd'; 6 | import { IRoute, Link } from 'umi'; 7 | 8 | import AppQrcode from './component/AppQrcode'; 9 | import UserInfo from './component/UserInfo'; 10 | 11 | const getCurrentRoute = (route: IRoute, path: string): IRoute | undefined => { 12 | let target; 13 | 14 | if (route.exact && route.path === path) { 15 | return route; 16 | } 17 | 18 | if (!route.routes) { 19 | return; 20 | } 21 | 22 | for (const _route of route.routes) { 23 | target = getCurrentRoute(_route, path); 24 | if (target) { 25 | break; 26 | } 27 | } 28 | 29 | return target; 30 | }; 31 | 32 | const BREADCRUMB_NAME_MAP = { 33 | user: '用户', 34 | topic: '话题', 35 | edit: '编辑', 36 | }; 37 | 38 | const Layout: React.FC> = (props) => { 39 | const { route, location } = props; 40 | const currentRoute = getCurrentRoute(route, location.pathname); 41 | 42 | let headerConfig: any = { 43 | title: currentRoute?.title || currentRoute?.name, 44 | subTitle: currentRoute?.description, 45 | }; 46 | 47 | const detailPaths = location.pathname.match(/\/(topic|user)\/(\w+)(\/\w+)?/); 48 | 49 | if (detailPaths) { 50 | const [pathname, category, id, status] = detailPaths; 51 | 52 | const isEdit = status === '/edit'; 53 | 54 | const routes = [ 55 | { 56 | path: '/', 57 | breadcrumbName: '主页', 58 | }, 59 | { 60 | path: '/', 61 | breadcrumbName: BREADCRUMB_NAME_MAP[category as 'user' | 'topic'], 62 | }, 63 | { 64 | path: `/${category}/${id}`, 65 | breadcrumbName: id, 66 | }, 67 | ]; 68 | 69 | if (isEdit) { 70 | routes.push({ 71 | path: pathname, 72 | breadcrumbName: BREADCRUMB_NAME_MAP['edit'], 73 | }); 74 | } 75 | 76 | headerConfig = { 77 | title: null, 78 | breadcrumb: { 79 | itemRender: (route: { path: string; breadcrumbName: string }) => { 80 | return {route.breadcrumbName}; 81 | }, 82 | routes, 83 | }, 84 | }; 85 | } 86 | 87 | return ( 88 | 89 | 90 | {props.children} 91 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | ); 110 | }; 111 | 112 | export default Layout; 113 | 114 | interface Props { 115 | route: IRoute; 116 | match: { 117 | isExact: boolean; 118 | path: string; 119 | }; 120 | location: Location; 121 | } 122 | -------------------------------------------------------------------------------- /src/model/globals.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | namespace: 'global', 3 | state: { 4 | user: 'suyi', 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/model/message.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useEffect } from 'react'; 2 | import { loadInitialState } from '@/util'; 3 | import * as API from '@/service/message'; 4 | 5 | export default () => { 6 | const initialState = loadInitialState(); 7 | const { token } = initialState; 8 | 9 | const [count, setCount] = useState(0); 10 | const [message, setMessage] = useState>(); 11 | const [unreadMessage, setUnreadMessage] = useState>(); 12 | 13 | useEffect(() => { 14 | if (!token) { 15 | return; 16 | } 17 | 18 | load(); 19 | fetch(); 20 | }, [token]); 21 | 22 | const load = useCallback(async () => { 23 | if (!token) { 24 | return; 25 | } 26 | 27 | const { data } = await API.countMessage({ 28 | accesstoken: token, 29 | }); 30 | 31 | setCount(data); 32 | }, [token]); 33 | 34 | const fetch = useCallback(async () => { 35 | if (!token) { 36 | return; 37 | } 38 | 39 | const { data } = await API.listMessage({ 40 | accesstoken: token, 41 | mdrender: false, 42 | }); 43 | 44 | setMessage(data.has_read_messages); 45 | setUnreadMessage(data.hasnot_read_messages); 46 | }, [token]); 47 | 48 | const mark = useCallback( 49 | async (id: string) => { 50 | if (!token) { 51 | return; 52 | } 53 | 54 | await API.markMessage(id, { 55 | accesstoken: token, 56 | }); 57 | 58 | load(); 59 | fetch(); 60 | }, 61 | [token], 62 | ); 63 | 64 | const markAll = useCallback(async () => { 65 | if (!token) { 66 | return; 67 | } 68 | 69 | await API.markAllMessage({ 70 | accesstoken: token, 71 | }); 72 | 73 | load(); 74 | fetch(); 75 | }, [token]); 76 | 77 | return { count, message, unreadMessage, load, fetch, mark, markAll }; 78 | }; 79 | -------------------------------------------------------------------------------- /src/model/user.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | import { loadInitialState } from '@/util'; 3 | 4 | export default () => { 5 | const initialState = loadInitialState(); 6 | 7 | const [user, setUser] = useState(initialState.user); 8 | 9 | const login = useCallback((data: InitialState, useLocalStorage = false) => { 10 | const { user: userInfo } = data; 11 | 12 | if (useLocalStorage) { 13 | window.localStorage.setItem('initialState', JSON.stringify(data)); 14 | } 15 | setUser(userInfo); 16 | }, []); 17 | 18 | const logout = useCallback(() => { 19 | window.localStorage.removeItem('initialState'); 20 | setUser(undefined); 21 | }, []); 22 | 23 | const reload = useCallback(() => { 24 | const state = loadInitialState(); 25 | setUser(state.user); 26 | }, []); 27 | 28 | return { user, login, logout, reload }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/page/404.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const NotFound: React.FC = (props) => { 4 | return null; 5 | }; 6 | 7 | export default NotFound; 8 | 9 | interface Props {} 10 | -------------------------------------------------------------------------------- /src/page/about/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Markdown from '@/component/Markdown'; 3 | 4 | const content = ` 5 | ## 关于 6 | 7 | CNode 社区为国内最大最具影响力的 Node.js 开源技术社区,致力于 Node.js 的技术研究。 8 | 9 | CNode 社区由一批热爱 Node.js 技术的工程师发起,目前已经吸引了互联网各个公司的专业技术人员加入,我们非常欢迎更多对 Node.js 感兴趣的朋友。 10 | 11 | CNode 的 SLA 保证是,一个9,即 90.000000%。 12 | 13 | 社区目前由 [@alsotang](http://cnodejs.org/user/alsotang) 在维护,有问题请联系:https://github.com/alsotang 14 | 15 | 请关注我们的官方微博:http://weibo.com/cnodejs 16 | 17 | 18 | ## 客户端 19 | 20 | 客户端由 [@soliury](https://cnodejs.org/user/soliury) 开发维护。 21 | 22 | 源码地址: https://github.com/soliury/noder-react-native 。 23 | 24 | 立即体验 CNode 客户端,直接扫描页面右侧二维码。 25 | 26 | 另,安卓用户同时可选择:https://github.com/TakWolf/CNode-Material-Design ,这是 Java 原生开发的安卓客户端。 27 | 28 | 29 | ## 贡献者 30 | 31 | > egg-cnode 32 | 33 | [![contributors](https://ergatejs.implements.io/badges/contributors/cnodejs/egg-cnode.svg?owner=cnodejs&repo=egg-cnode&type=svg&width=1232&size=64&padding=8)](https://github.com/cnodejs/egg-cnode/graphs/contributors) 34 | `; 35 | 36 | const AboutPage: React.FC = (props) => { 37 | return ; 38 | }; 39 | 40 | export default AboutPage; 41 | 42 | interface Props {} 43 | -------------------------------------------------------------------------------- /src/page/api/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MicroApp } from 'umi'; 3 | 4 | const ApiPage: React.FC = (props) => { 5 | return ( 6 | 10 | ); 11 | }; 12 | 13 | export default ApiPage; 14 | 15 | interface Props {} 16 | -------------------------------------------------------------------------------- /src/page/auth/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd/es/style/themes/default.less'; 2 | 3 | .container { 4 | display: flex; 5 | flex-direction: column; 6 | height: 100vh; 7 | overflow: auto; 8 | background: @layout-body-background; 9 | 10 | background-image: url('https://gw.alipayobjects.com/zos/rmsportal/TVYTbAXWheQpRcWDaDMu.svg'); 11 | background-repeat: no-repeat; 12 | background-position: center 110px; 13 | background-size: 100%; 14 | } 15 | 16 | .lang { 17 | width: 100%; 18 | height: 40px; 19 | line-height: 44px; 20 | text-align: right; 21 | :global(.ant-dropdown-trigger) { 22 | margin-right: 24px; 23 | } 24 | } 25 | 26 | .content { 27 | flex: 1; 28 | // padding: 32px 0; 29 | padding-top: 100px; 30 | align-items: center; 31 | display: flex; 32 | flex-direction: column; 33 | } 34 | 35 | .main { 36 | width: 480px; 37 | margin: 0 auto; 38 | } 39 | 40 | .top { 41 | text-align: center; 42 | } 43 | 44 | .header { 45 | height: 44px; 46 | line-height: 44px; 47 | a { 48 | text-decoration: none; 49 | } 50 | } 51 | 52 | .logo { 53 | height: 44px; 54 | margin-right: 16px; 55 | vertical-align: top; 56 | } 57 | 58 | .title { 59 | position: relative; 60 | top: 2px; 61 | color: @heading-color; 62 | font-weight: 600; 63 | font-size: 33px; 64 | font-family: Avenir, 'Helvetica Neue', Arial, Helvetica, sans-serif; 65 | } 66 | 67 | .desc { 68 | margin-top: 12px; 69 | margin-bottom: 40px; 70 | color: @text-color-secondary; 71 | font-size: @font-size-base; 72 | } 73 | 74 | .btn { 75 | width: 100%; 76 | } 77 | 78 | .change { 79 | float: right; 80 | } 81 | -------------------------------------------------------------------------------- /src/page/auth/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useModel, history, Link } from 'umi'; 3 | import { Form, Input, Checkbox, Button } from 'antd'; 4 | import { FORM_TYPE } from '@/constants'; 5 | 6 | import config from '@/config/'; 7 | import * as API from '@/service/user'; 8 | 9 | import * as styles from './index.less'; 10 | 11 | const AUTH_TYPE_MAP = { 12 | login: { 13 | switchType: '注册', 14 | }, 15 | register: { 16 | switchType: '登录', 17 | }, 18 | }; 19 | 20 | const AuthPage: React.FC = () => { 21 | const { refresh, initialState } = useModel('@@initialState'); 22 | const { login } = useModel('user'); 23 | const [type, setType] = useState(FORM_TYPE.LOGIN); 24 | 25 | const { switchType } = AUTH_TYPE_MAP[type]; 26 | 27 | useEffect(() => { 28 | if (initialState?.token) { 29 | history.push('/'); 30 | } 31 | }, [initialState?.token]); 32 | 33 | const onFinish = async (values: any) => { 34 | const { accessToken } = values; 35 | 36 | if (type === FORM_TYPE.LOGIN) { 37 | const data = await API.authByAccessToken({ 38 | accesstoken: accessToken, 39 | }); 40 | 41 | const { id, loginname, avatar_url } = data; 42 | 43 | login( 44 | { 45 | user: { 46 | id, 47 | loginname, 48 | avatar_url, 49 | }, 50 | token: accessToken, 51 | }, 52 | !!values.remember, 53 | ); 54 | refresh(); 55 | } 56 | }; 57 | 58 | const handleChangeType = () => { 59 | const target = 60 | type === FORM_TYPE.LOGIN ? FORM_TYPE.REGISTER : FORM_TYPE.LOGIN; 61 | setType(target); 62 | }; 63 | 64 | const renderTop = () => ( 65 | <> 66 |
67 | 68 | logo 69 | 70 |
71 |
{config.description}
72 | 73 | ); 74 | 75 | const renderSwtichAuthType = () => { 76 | return ( 77 | 80 | ); 81 | }; 82 | 83 | return ( 84 |
85 |
86 |
{renderTop()}
87 | 88 |
89 |
95 | {/* 109 | 110 | */} 111 | 112 | 123 | 124 | 125 | 126 | 127 | 128 | 记住 129 | 130 | {/* {renderSwtichAuthType()} */} 131 | 132 | 133 | 134 | 137 | 138 |
139 |
140 |
141 |
142 | ); 143 | }; 144 | 145 | interface Props {} 146 | 147 | export default AuthPage; 148 | -------------------------------------------------------------------------------- /src/page/document.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /src/page/home/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const HomePage: React.FC = () => { 4 | return null; 5 | }; 6 | 7 | export default HomePage; 8 | 9 | interface Props {} 10 | -------------------------------------------------------------------------------- /src/page/links/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const LinksPage: React.FC = (props) => { 4 | return
TODO.
; 5 | }; 6 | 7 | export default LinksPage; 8 | 9 | interface Props {} 10 | -------------------------------------------------------------------------------- /src/page/message/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useModel } from 'umi'; 3 | import { Divider, Button } from 'antd'; 4 | import ProCard from '@ant-design/pro-card'; 5 | import MessageList from '@/component/MessageList'; 6 | 7 | const MessagePage: React.FC = (props) => { 8 | const { message, unreadMessage, mark, markAll } = useModel('message'); 9 | 10 | const renderUnreadMessage = () => { 11 | if (unreadMessage?.length === 0) { 12 | return 暂无新消息; 13 | } 14 | 15 | return ( 16 | mark(id)} /> 17 | ); 18 | }; 19 | 20 | return ( 21 |
22 | { 30 | markAll(); 31 | }} 32 | > 33 | 标记全部 34 | 35 | } 36 | > 37 | {renderUnreadMessage()} 38 | 39 | 40 | 41 | 42 | 43 |
44 | ); 45 | }; 46 | 47 | export default MessagePage; 48 | 49 | interface Props {} 50 | -------------------------------------------------------------------------------- /src/page/topic/component/SubTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import dayjs from 'dayjs'; 3 | 4 | import { Avatar, Divider, Space } from 'antd'; 5 | import { Link, useModel } from 'umi'; 6 | import { FormOutlined } from '@ant-design/icons'; 7 | 8 | const SubTitle: React.FC = (props) => { 9 | const { author, create_at, visit_count, reply_count, author_id } = props; 10 | 11 | const { user } = useModel('user'); 12 | 13 | const renderEdit = () => 14 | user?.id === author_id && ( 15 | 16 | 17 | 18 | ); 19 | 20 | return ( 21 | }> 22 | 23 | 24 | 25 | 发布:{dayjs(create_at).format('YYYY-MM-DD hh:mm:ss')} 26 | 浏览:{visit_count} 27 | 回复:{reply_count} 28 | 29 | {renderEdit()} 30 | 31 | ); 32 | }; 33 | 34 | export default SubTitle; 35 | 36 | interface Props { 37 | create_at: Date; 38 | reply_count: number; 39 | visit_count: number; 40 | 41 | author: { 42 | loginname: string; 43 | avatar_url: string; 44 | }; 45 | 46 | author_id: string; 47 | } 48 | -------------------------------------------------------------------------------- /src/page/topic/detail.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { useParams, useModel } from 'umi'; 4 | import { useRequest } from 'ahooks'; 5 | import { PageHeader, Divider } from 'antd'; 6 | import * as API from '@/service/topic'; 7 | 8 | import Markdown from '@/component/Markdown'; 9 | import CommentList from '@/component/CommentList'; 10 | import CommentForm from '@/component/CommentForm'; 11 | import SubTitle from './component/SubTitle'; 12 | 13 | const TopicDetailPage: React.FC> = (props) => { 14 | const params: Record = useParams(); 15 | const topicId = params?.id; 16 | 17 | const { user } = useModel('user'); 18 | 19 | const { initialState } = useModel('@@initialState'); 20 | const token = initialState?.token; 21 | 22 | const [reply, setReply] = useState(); 23 | 24 | if (!topicId) { 25 | return null; 26 | } 27 | 28 | const { data, refresh } = useRequest( 29 | async () => { 30 | if (!topicId) { 31 | return; 32 | } 33 | 34 | const res = await API.loadTopic({ 35 | id: topicId, 36 | mdrender: false, 37 | }); 38 | 39 | return res.data; 40 | }, 41 | { 42 | refreshDeps: [topicId], 43 | }, 44 | ); 45 | 46 | if (!data) { 47 | return null; 48 | } 49 | 50 | const onComment = async (data: { content: string; reply_id?: string }) => { 51 | if (!token) { 52 | return; 53 | } 54 | 55 | if (!data?.content) { 56 | return; 57 | } 58 | 59 | await API.createReply(topicId, { 60 | ...data, 61 | accesstoken: token, 62 | }); 63 | }; 64 | 65 | const onLike = async (record: ReplyModel) => { 66 | const { id: replyId } = record; 67 | 68 | if (!replyId || !token) { 69 | return; 70 | } 71 | 72 | await API.updateReplyUps(replyId, { 73 | accesstoken: token, 74 | }); 75 | }; 76 | 77 | const onReply = (record: ReplyModel) => { 78 | if (reply) { 79 | setReply(null); 80 | return; 81 | } 82 | 83 | setReply(record); 84 | }; 85 | 86 | const renderTopicDetail = () => { 87 | if (!data) { 88 | return null; 89 | } 90 | 91 | return ( 92 |
93 | 94 | 95 |
96 | ); 97 | }; 98 | 99 | const renderCommentList = () => { 100 | if (!data) { 101 | return null; 102 | } 103 | 104 | const { replies } = data; 105 | 106 | return ( 107 | 113 | ); 114 | }; 115 | 116 | const renderCommentForm = () => { 117 | if (!user) { 118 | return null; 119 | } 120 | 121 | const handleSubmit = async (content: string) => { 122 | await onComment({ 123 | content, 124 | }); 125 | refresh(); 126 | }; 127 | 128 | return ( 129 | <> 130 | 131 | 132 | 133 | ); 134 | }; 135 | 136 | const renderReply = (id: string) => { 137 | if (!user || id !== reply?.id) { 138 | return null; 139 | } 140 | 141 | const handleSubmit = async (content: string) => { 142 | if (!reply) { 143 | return; 144 | } 145 | 146 | await onComment({ 147 | content, 148 | reply_id: reply?.id, 149 | }); 150 | 151 | setReply(null); 152 | refresh(); 153 | }; 154 | 155 | return ( 156 | 162 | ); 163 | }; 164 | 165 | return ( 166 | window.history.back()}> 167 | 168 | {renderTopicDetail()} 169 | {renderCommentList()} 170 | {renderCommentForm()} 171 | 172 | ); 173 | }; 174 | 175 | export default TopicDetailPage; 176 | 177 | interface Props {} 178 | -------------------------------------------------------------------------------- /src/page/topic/edit/index.less: -------------------------------------------------------------------------------- 1 | .editor_create { 2 | min-height: 600px; 3 | } 4 | -------------------------------------------------------------------------------- /src/page/topic/edit/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useModel, useHistory, useParams, useRequest } from 'umi'; 3 | import { Form, Input, Select, Button, Space } from 'antd'; 4 | import { TABS_MAP } from '@/constants'; 5 | 6 | import Markdown from '@/component/Markdown'; 7 | 8 | import * as API from '@/service/topic'; 9 | import * as styles from './index.less'; 10 | 11 | const TopicEditPage: React.FC = (props) => { 12 | const history = useHistory(); 13 | const [form] = Form.useForm(); 14 | const { initialState } = useModel('@@initialState'); 15 | const { user } = useModel('user'); 16 | 17 | const token = initialState?.token; 18 | 19 | const { id } = useParams<{ id?: string }>(); 20 | 21 | useRequest( 22 | async () => { 23 | if (!id) return; 24 | const { data } = await API.loadTopic({ 25 | id, 26 | mdrender: false, 27 | }); 28 | 29 | if (data.author_id !== user?.id) { 30 | history.push(location.pathname.replace(/\/edit$/, '')); 31 | return; 32 | } 33 | 34 | form.setFieldsValue({ 35 | title: data.title, 36 | content: data.content, 37 | tab: data.tab, 38 | }); 39 | }, 40 | { 41 | ready: !!id, 42 | }, 43 | ); 44 | 45 | const onFinish = async (values: any) => { 46 | if (!token) { 47 | return; 48 | } 49 | 50 | if (id) { 51 | await API.updateTopic({ 52 | topic_id: id, 53 | ...values, 54 | accesstoken: token, 55 | }); 56 | } else { 57 | await API.createTopic({ 58 | ...values, 59 | accesstoken: token, 60 | }); 61 | } 62 | 63 | onReset(); 64 | 65 | history.push('/'); 66 | }; 67 | 68 | const onReset = () => { 69 | form.resetFields(); 70 | }; 71 | 72 | const tabs = Object.entries(TABS_MAP).map(([value, info]) => { 73 | return { 74 | label: info.name, 75 | value, 76 | }; 77 | }); 78 | 79 | return ( 80 |
81 |
88 | 93 | 94 | 95 | 96 |