├── .env ├── .env.development ├── .env.production ├── .gitignore ├── README.md ├── eslint.config.js ├── index.html ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── public └── favicon.ico ├── src ├── apis │ ├── comment │ │ ├── index.ts │ │ └── types.ts │ ├── favorite │ │ ├── index.ts │ │ └── types.ts │ ├── history │ │ ├── index.ts │ │ └── types.ts │ ├── illustration │ │ ├── index.ts │ │ └── types.ts │ ├── illustrator │ │ ├── index.ts │ │ └── types.ts │ ├── index.ts │ ├── label │ │ ├── index.ts │ │ └── types.ts │ ├── tool │ │ ├── index.ts │ │ └── types.ts │ ├── types.ts │ └── user │ │ ├── index.ts │ │ └── types.ts ├── app.tsx ├── assets │ ├── imgs │ │ ├── 404.png │ │ ├── 500.png │ │ ├── empty │ │ │ ├── empty1.png │ │ │ ├── empty2.png │ │ │ ├── empty3.png │ │ │ ├── empty4.png │ │ │ ├── empty5.png │ │ │ ├── empty6.png │ │ │ └── empty7.png │ │ └── upload-successfully.gif │ └── svgs │ │ ├── logo.svg │ │ ├── pagination-left.svg │ │ ├── pagination-more.svg │ │ ├── pagination-right.svg │ │ └── pixiv.svg ├── components │ ├── common │ │ ├── animated-list │ │ │ └── index.tsx │ │ ├── create-folder-modal │ │ │ └── index.tsx │ │ ├── create-illustrator-modal │ │ │ └── index.tsx │ │ ├── empty │ │ │ └── index.tsx │ │ ├── favorite-item │ │ │ └── index.tsx │ │ ├── grey-button │ │ │ └── index.tsx │ │ ├── hana-card │ │ │ └── index.tsx │ │ ├── hana-cropper │ │ │ └── index.tsx │ │ ├── hana-modal │ │ │ └── index.tsx │ │ ├── hana-viewer │ │ │ └── index.tsx │ │ ├── header │ │ │ ├── index.tsx │ │ │ ├── search-dropdown.tsx │ │ │ ├── sidebar.tsx │ │ │ └── user-dropdown.tsx │ │ ├── label-img-item │ │ │ └── index.tsx │ │ ├── label-item │ │ │ └── index.tsx │ │ ├── layout-list │ │ │ └── index.tsx │ │ ├── lazy-img │ │ │ └── index.tsx │ │ ├── loading │ │ │ └── index.tsx │ │ ├── pagination │ │ │ └── index.tsx │ │ ├── user-item │ │ │ └── index.tsx │ │ ├── virtual-list │ │ │ └── index.tsx │ │ ├── waterfall-item │ │ │ └── index.tsx │ │ └── work-item │ │ │ ├── index.tsx │ │ │ └── types.ts │ ├── explore │ │ ├── latest-list │ │ │ └── index.tsx │ │ ├── user-list │ │ │ └── index.tsx │ │ └── work-list │ │ │ └── index.tsx │ ├── followed-new │ │ └── main-list │ │ │ └── index.tsx │ ├── home │ │ ├── followed-works │ │ │ └── index.tsx │ │ ├── label-list │ │ │ └── index.tsx │ │ └── recommended-works │ │ │ └── index.tsx │ ├── illustrator │ │ ├── info-modal.tsx │ │ └── waterfall-flow.tsx │ ├── login │ │ ├── bg-slide │ │ │ └── index.tsx │ │ └── login-window │ │ │ └── index.tsx │ ├── motion │ │ ├── animated-div.tsx │ │ └── preset.ts │ ├── personal-center │ │ ├── edit-modal.tsx │ │ ├── favorites │ │ │ ├── header.tsx │ │ │ ├── sidebar.tsx │ │ │ └── work-list.tsx │ │ ├── header.tsx │ │ ├── history │ │ │ ├── history-list.tsx │ │ │ └── search-result.tsx │ │ ├── info-modal.tsx │ │ ├── label-list │ │ │ └── index.tsx │ │ ├── user-list │ │ │ └── index.tsx │ │ └── work-list │ │ │ └── index.tsx │ ├── search-result │ │ ├── label-info │ │ │ └── index.tsx │ │ ├── user-list │ │ │ └── index.tsx │ │ └── work-list │ │ │ └── index.tsx │ ├── skeleton │ │ ├── favorite-list.tsx │ │ ├── favorite-work-list.tsx │ │ ├── img-loading.tsx │ │ ├── label-list.tsx │ │ ├── user-list.tsx │ │ └── work-list.tsx │ ├── upload │ │ ├── form │ │ │ └── index.tsx │ │ ├── img-upload │ │ │ ├── draggable-img.tsx │ │ │ └── index.tsx │ │ └── success │ │ │ └── index.tsx │ └── work-detail │ │ ├── comments │ │ ├── comment.tsx │ │ ├── index.tsx │ │ └── input-window.tsx │ │ ├── user-info │ │ └── index.tsx │ │ ├── view-list │ │ └── index.tsx │ │ ├── work-info │ │ └── index.tsx │ │ └── work-slide-window │ │ └── index.tsx ├── hooks │ ├── index.ts │ ├── useAtBottom.ts │ ├── useAtTop.ts │ ├── useFloat.ts │ ├── useLoading.ts │ ├── useMap.ts │ └── useWinChange.ts ├── main.tsx ├── pages │ ├── error │ │ └── index.tsx │ ├── explore │ │ └── index.tsx │ ├── followed-new │ │ └── index.tsx │ ├── home │ │ └── index.tsx │ ├── illustrator │ │ └── index.tsx │ ├── login │ │ └── index.tsx │ ├── not-found │ │ └── index.tsx │ ├── personal-center │ │ ├── index.tsx │ │ ├── my-fans │ │ │ └── index.tsx │ │ ├── my-favorites │ │ │ └── index.tsx │ │ ├── my-follow │ │ │ └── index.tsx │ │ ├── my-history │ │ │ └── index.tsx │ │ ├── my-likes │ │ │ └── index.tsx │ │ └── my-works │ │ │ └── index.tsx │ ├── search-result │ │ └── index.tsx │ ├── upload │ │ └── index.tsx │ └── work-detail │ │ └── index.tsx ├── router │ ├── index.tsx │ └── utils │ │ ├── auth-router.tsx │ │ ├── auto-top.tsx │ │ ├── lazy-load.tsx │ │ └── personal-page.tsx ├── service │ ├── index.ts │ └── request │ │ ├── index.ts │ │ └── types.ts ├── store │ ├── index.ts │ ├── modules │ │ ├── favorites.ts │ │ ├── searchHistory.ts │ │ ├── uploadForm.ts │ │ ├── user.ts │ │ └── viewList.ts │ └── types.ts ├── styles │ └── index.css ├── utils │ ├── base64ToFile.ts │ ├── cn.ts │ ├── colorHue.ts │ ├── constants.ts │ ├── detectPixiv.ts │ ├── download.ts │ ├── index.ts │ ├── sleep.ts │ ├── tempId.ts │ └── types.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── uno.config.ts ├── vite-env.d.ts └── vite.config.ts /.env: -------------------------------------------------------------------------------- 1 | VITE_APP_TITLE=Picals 2 | VITE_PORT=3030 3 | VITE_BASE=/ -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | VITE_BASE_URL=http://localhost:0721 -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | VITE_BASE_URL=https://picals-api.caelum.moe -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | picals-logo 3 |
4 | 5 | ### 项目简介 6 | 7 | **Picals** 是一个受 [Pixiv](https://www.pixiv.net) 启发、纯粹由兴趣驱动、从零设计并编写的一个插画收藏平台。 8 | 9 | 它旨在为大家提供一个相较于 Pixiv 更方便、在国内浏览更快捷的找图、存图的方式,并且和论坛的功能相结合,营造和谐、舒适的国内插画讨论环境。 10 | 11 | ### 项目特色 12 | 13 | 1. **支持收藏夹功能。** 14 | 15 | 灵感来源于 Bilibili 的收藏功能, 用户能够在浏览插画的时候对插画进行指定收藏夹的收藏。收藏夹可由用户自行创建,并且可以在个人中心对收藏的插画进行批量管理,同时也支持作品名搜索快速找到收藏的插画。 16 | 17 | 2. **维护多种插画浏览列表。** 18 | 19 | 当用户从某一个插画列表(如推荐作品列表)点击进入到作品详情页后,会同时维护两种列表: 20 | 21 | 1. 点击进入时的插画列表; 22 | 2. 该插画发布者的插画列表。 23 | 24 | 使用户能够通过插画列表快速浏览到下一个想要浏览的作品。并且用户能够同时在不同列表之间进行切换浏览,也可以一键跳出回到最开始进入的作品页。 25 | 26 | 3. **引入大图查看器。** 27 | 28 | 本项目在作品详情页中引入了一款基于 React 实现的图片查看器:[react-photo-view](https://github.com/MinJieLiu/react-photo-view),并在其基础之上进行了一些自定义封装,提供了如图片放大、缩小、旋转、**原图下载** 等实用功能。 29 | 30 | 4. **支持历史记录浏览。** 31 | 32 | 灵感来源于 Bilibili 的历史记录。用户在浏览某个作品后,会将浏览信息记录至后台,用户可在个人中心的 **浏览记录** 页签中找到自己浏览过的作品。同时也支持历史记录的作品名搜索功能。 33 | 34 | ### 技术栈 35 | 36 | 这个项目主要的技术栈为 **React + Nest.js** ,使用 **TypeScript** 作为唯一开发语言。 37 | 38 | ### 相关文档 39 | 40 | - [**项目概述**](https://nonhana.xyz/2024/03/12/picals-about/Picals%E9%A1%B9%E7%9B%AE%E6%A6%82%E8%BF%B0/) 41 | - [**项目功能分析**](https://nonhana.xyz/2024/03/12/picals-about/Picals%E9%A1%B9%E7%9B%AE%E5%8A%9F%E8%83%BD%E5%88%86%E6%9E%90/) 42 | - [**项目 UI 原型设计**](https://nonhana.xyz/2024/03/12/picals-about/Picals%E9%A1%B9%E7%9B%AEUI%E5%8E%9F%E5%9E%8B%E8%AE%BE%E8%AE%A1/) 43 | - [**项目技术栈设计**](https://nonhana.xyz/2024/03/12/picals-about/Picals%E9%A1%B9%E7%9B%AE%E6%8A%80%E6%9C%AF%E6%A0%88%E8%AE%BE%E8%AE%A1/) 44 | - [**项目数据库设计**](https://nonhana.xyz/2024/03/15/picals-about/Picals%E6%95%B0%E6%8D%AE%E5%BA%93%E8%AE%BE%E8%AE%A1%E6%96%87%E6%A1%A3/) 45 | - [**项目接口文档**](https://picals.apifox.cn) 46 | - [**项目部署方案**](https://nonhana.xyz/2024/06/03/picals-about/Picals%E9%A1%B9%E7%9B%AE%E9%83%A8%E7%BD%B2%E6%96%B9%E6%A1%88/) 47 | - [**记录一些开发过程中踩的坑**](https://nonhana.xyz/2024/05/23/picals-about/%E8%AE%B0%E5%BD%95%E4%B8%80%E4%BA%9B%E5%BC%80%E5%8F%91%E8%BF%87%E7%A8%8B%E4%B8%AD%E8%B8%A9%E7%9A%84%E5%9D%91/) 48 | - [**一些复杂功能的设计文档汇总**](https://nonhana.xyz/2024/07/07/picals-about/%E4%B8%80%E4%BA%9B%E5%A4%8D%E6%9D%82%E5%8A%9F%E8%83%BD%E7%9A%84%E8%AE%BE%E8%AE%A1%E6%96%87%E6%A1%A3%E6%B1%87%E6%80%BB/) 49 | 50 | ### 仓库 51 | 52 | 前端项目:[https://github.com/nonhana/Picals-Frontend-React](https://github.com/nonhana/Picals-Frontend-React) 53 | 54 | 后端项目:[https://github.com/nonhana/Picals-Backend-Nest](https://github.com/nonhana/Picals-Backend-Nest) 55 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | react: true, 5 | unocss: true, 6 | pnpm: true, 7 | rules: { 8 | 'react-hooks/rules-of-hooks': 'off', 9 | 'react-hooks/exhaustive-deps': 'off', 10 | 'react/no-array-index-key': 'off', 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Picals 8 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "picals-frontend", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "private": true, 6 | "packageManager": "pnpm@10.11.0", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "lint": "eslint .", 11 | "lint:fix": "eslint . --fix", 12 | "preview": "serve -s dist" 13 | }, 14 | "dependencies": { 15 | "@ant-design/icons": "catalog:", 16 | "@ctrl/tinycolor": "catalog:", 17 | "@dnd-kit/core": "catalog:", 18 | "@dnd-kit/modifiers": "catalog:", 19 | "@dnd-kit/sortable": "catalog:", 20 | "@dnd-kit/utilities": "catalog:", 21 | "@iconify/react": "catalog:", 22 | "@reduxjs/toolkit": "catalog:", 23 | "@types/node": "catalog:", 24 | "@types/react-transition-group": "catalog:", 25 | "@unocss/reset": "catalog:", 26 | "antd": "catalog:", 27 | "axios": "catalog:", 28 | "cropperjs": "catalog:", 29 | "dayjs": "catalog:", 30 | "dotenv": "catalog:", 31 | "framer-motion": "catalog:", 32 | "lodash": "catalog:", 33 | "rc-virtual-list": "catalog:", 34 | "react": "catalog:", 35 | "react-content-loader": "catalog:", 36 | "react-cropper": "catalog:", 37 | "react-device-detect": "catalog:", 38 | "react-dom": "catalog:", 39 | "react-error-boundary": "catalog:", 40 | "react-loader-spinner": "catalog:", 41 | "react-photo-view": "catalog:", 42 | "react-redux": "catalog:", 43 | "react-router": "catalog:", 44 | "react-transition-group": "catalog:", 45 | "redux-persist": "catalog:" 46 | }, 47 | "devDependencies": { 48 | "@ant-design/v5-patch-for-react-19": "catalog:", 49 | "@antfu/eslint-config": "catalog:", 50 | "@eslint-react/eslint-plugin": "catalog:", 51 | "@types/lodash": "catalog:", 52 | "@types/react": "catalog:", 53 | "@types/react-dom": "catalog:", 54 | "@unocss/eslint-config": "catalog:", 55 | "@unocss/preset-rem-to-px": "catalog:", 56 | "@unocss/transformer-attributify-jsx": "catalog:", 57 | "@vitejs/plugin-react": "catalog:", 58 | "clsx": "catalog:", 59 | "eslint": "catalog:", 60 | "eslint-plugin-react-hooks": "catalog:", 61 | "eslint-plugin-react-refresh": "catalog:", 62 | "rollup-plugin-visualizer": "catalog:", 63 | "typescript": "catalog:", 64 | "unocss": "catalog:", 65 | "unocss-preset-scrollbar": "catalog:", 66 | "vite": "catalog:", 67 | "vite-plugin-compression": "catalog:", 68 | "vite-plugin-imagemin": "catalog:" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | catalog: 2 | '@ant-design/icons': ^6.0.0 3 | '@ant-design/v5-patch-for-react-19': ^1.0.3 4 | '@antfu/eslint-config': ^4.13.2 5 | '@ctrl/tinycolor': ^4.1.0 6 | '@dnd-kit/core': ^6.3.1 7 | '@dnd-kit/modifiers': ^9.0.0 8 | '@dnd-kit/sortable': ^10.0.0 9 | '@dnd-kit/utilities': ^3.2.2 10 | '@eslint-react/eslint-plugin': ^1.50.0 11 | '@iconify/react': ^6.0.0 12 | '@reduxjs/toolkit': ^2.8.2 13 | '@types/lodash': ^4.17.17 14 | '@types/node': ^22.15.21 15 | '@types/react': ^19.1.5 16 | '@types/react-dom': ^19.1.5 17 | '@types/react-transition-group': ^4.4.12 18 | '@unocss/eslint-config': ^66.1.2 19 | '@unocss/preset-rem-to-px': ^66.1.2 20 | '@unocss/reset': ^66.1.2 21 | '@unocss/transformer-attributify-jsx': ^66.1.2 22 | '@vitejs/plugin-react': ^4.5.0 23 | antd: ^5.25.3 24 | axios: ^1.9.0 25 | clsx: ^2.1.1 26 | cropperjs: ~1.6.0 27 | dayjs: ^1.11.13 28 | dotenv: ^16.5.0 29 | eslint: ^9.27.0 30 | eslint-plugin-react-hooks: ^5.2.0 31 | eslint-plugin-react-refresh: ^0.4.20 32 | framer-motion: ^12.13.0 33 | lodash: ^4.17.21 34 | rc-virtual-list: ^3.18.6 35 | react: ^19.1.0 36 | react-content-loader: ^7.0.2 37 | react-cropper: ^2.3.3 38 | react-device-detect: ^2.2.3 39 | react-dom: ^19.1.0 40 | react-error-boundary: ^6.0.0 41 | react-loader-spinner: ^6.1.6 42 | react-photo-view: ^1.2.7 43 | react-redux: ^9.2.0 44 | react-router: ^7.6.1 45 | react-transition-group: ^4.4.5 46 | redux-persist: ^6.0.0 47 | rollup-plugin-visualizer: ^6.0.0 48 | typescript: ^5.8.3 49 | unocss: ^66.1.2 50 | unocss-preset-scrollbar: ^3.2.0 51 | vite: ^6.3.5 52 | vite-plugin-compression: ^0.5.1 53 | vite-plugin-imagemin: ^0.6.1 54 | onlyBuiltDependencies: 55 | - '@parcel/watcher' 56 | - cwebp-bin 57 | - esbuild 58 | - gifsicle 59 | - jpegtran-bin 60 | - mozjpeg 61 | - optipng-bin 62 | - pngquant-bin 63 | - unrs-resolver 64 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonhana/picals-frontend-react/d0a03fa2c69e2892c744e6e2665fe87477b968bf/public/favicon.ico -------------------------------------------------------------------------------- /src/apis/comment/index.ts: -------------------------------------------------------------------------------- 1 | import type { Id } from '../types' 2 | 3 | import type { CommentItem, IPostCommentReq } from './types' 4 | import request from '@/service' 5 | 6 | // 获取某个作品的评论列表 7 | export function getCommentListAPI(params: Id) { 8 | return request({ 9 | url: '/api/comment/list', 10 | method: 'GET', 11 | params, 12 | }) 13 | } 14 | 15 | // 发布某个作品的评论 16 | export function postCommentAPI(data: IPostCommentReq) { 17 | return request({ 18 | url: '/api/comment/new', 19 | method: 'POST', 20 | data, 21 | }) 22 | } 23 | 24 | // 删除某条评论 25 | export function deleteCommentAPI(data: Id) { 26 | return request({ 27 | url: '/api/comment/delete', 28 | method: 'POST', 29 | data, 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/apis/comment/types.ts: -------------------------------------------------------------------------------- 1 | // src/apis/comment/types.ts 2 | // 定义 comment 模块的 API 类型 3 | 4 | // #region 请求体类型 5 | export interface IPostCommentReq { 6 | /** 7 | * 评论内容 8 | */ 9 | content: string 10 | /** 11 | * 作品id 12 | */ 13 | id: string 14 | /** 15 | * 回复信息 16 | */ 17 | replyInfo?: { 18 | /** 19 | * 回复的评论id,如果是回复一级评论,这个id为被回复的评论的id; 20 | * 如果是回复二级评论,这个id为被回复的评论的父评论id。 21 | */ 22 | replyCommentId: string 23 | /** 24 | * 回复的用户id,仅针对对二级评论的回复,需要带上回复的用户id 25 | */ 26 | replyUserId?: string 27 | } 28 | } 29 | // # endregion 30 | 31 | // #region 响应体类型 32 | export interface CommentItem { 33 | /** 34 | * 评论作者信息 35 | */ 36 | authorInfo: CommentItemAuthorInfo 37 | /** 38 | * 子评论列表 39 | */ 40 | childComments: ChildCommentElement[] 41 | /** 42 | * 评论内容 43 | */ 44 | content: string 45 | /** 46 | * 评论创建日期 47 | */ 48 | createdAt: string 49 | /** 50 | * 评论id 51 | */ 52 | id: string 53 | /** 54 | * 评论等级,0-一级评论,1-二级评论 55 | */ 56 | level: number 57 | /** 58 | * 回复的用户信息 59 | */ 60 | replyTo?: CommentItemReplyTo 61 | } 62 | 63 | export interface CommentItemAuthorInfo { 64 | /** 65 | * 用户头像 66 | */ 67 | avatar: string 68 | /** 69 | * 用户id 70 | */ 71 | id: string 72 | /** 73 | * 用户名 74 | */ 75 | username: string 76 | } 77 | 78 | export interface ChildCommentElement { 79 | /** 80 | * 评论作者信息 81 | */ 82 | authorInfo: ChildCommentAuthorInfo 83 | /** 84 | * 子评论列表 85 | */ 86 | childComments: ChildCommentElement[] 87 | /** 88 | * 评论内容 89 | */ 90 | content: string 91 | /** 92 | * 评论创建日期 93 | */ 94 | createdAt: string 95 | /** 96 | * 评论id 97 | */ 98 | id: string 99 | /** 100 | * 评论等级,0-一级评论,1-二级评论 101 | */ 102 | level: number 103 | /** 104 | * 回复的用户信息 105 | */ 106 | replyTo?: ChildCommentReplyTo 107 | } 108 | 109 | export interface ChildCommentAuthorInfo { 110 | /** 111 | * 用户头像 112 | */ 113 | avatar: string 114 | /** 115 | * 用户id 116 | */ 117 | id: string 118 | /** 119 | * 用户名 120 | */ 121 | username: string 122 | } 123 | 124 | export interface ChildCommentReplyTo { 125 | /** 126 | * 用户id 127 | */ 128 | id: string 129 | /** 130 | * 用户名 131 | */ 132 | username: string 133 | } 134 | 135 | export interface CommentItemReplyTo { 136 | /** 137 | * 用户id 138 | */ 139 | id: string 140 | /** 141 | * 用户名 142 | */ 143 | username: string 144 | } 145 | // # endregion 146 | -------------------------------------------------------------------------------- /src/apis/favorite/index.ts: -------------------------------------------------------------------------------- 1 | import type { Id, Pagination, WorkNormalItem } from '../types' 2 | 3 | import type { 4 | FavoriteDetailInfo, 5 | IChangeFavoriteOrderReq, 6 | ICopyFavoriteWorksReq, 7 | IEditFavoriteReq, 8 | IGetSearchResultNumReq, 9 | IMoveFavoriteWorksReq, 10 | INewFavoriteReq, 11 | } from './types' 12 | import request from '@/service' 13 | 14 | // 新建收藏夹 15 | export function newFavoriteAPI(data: INewFavoriteReq) { 16 | return request({ 17 | url: '/api/favorite/new', 18 | method: 'POST', 19 | data, 20 | }) 21 | } 22 | 23 | // 编辑收藏夹 24 | export function editFavoriteAPI(data: IEditFavoriteReq) { 25 | return request({ 26 | url: `/api/favorite/edit?id=${data.id}`, 27 | method: 'POST', 28 | data, 29 | }) 30 | } 31 | 32 | // 删除收藏夹 33 | export function deleteFavoriteAPI(data: Id) { 34 | return request({ 35 | url: '/api/favorite/delete', 36 | method: 'POST', 37 | data, 38 | }) 39 | } 40 | 41 | // 修改收藏夹的排序 42 | export function changeFavoriteOrderAPI(data: IChangeFavoriteOrderReq) { 43 | return request({ 44 | url: '/api/favorite/order', 45 | method: 'POST', 46 | data, 47 | }) 48 | } 49 | 50 | // 获取收藏夹详细信息 51 | export function getFavoriteDetailAPI(params: Id) { 52 | return request({ 53 | url: '/api/favorite/detail', 54 | method: 'GET', 55 | params, 56 | }) 57 | } 58 | 59 | // 分页获取收藏夹作品列表 60 | export function getFavoriteWorkListAPI(params: Pagination) { 61 | return request({ 62 | url: '/api/favorite/works', 63 | method: 'GET', 64 | params, 65 | }) 66 | } 67 | 68 | // 获取收藏夹作品id列表 69 | export function getFavoriteWorkIdListAPI(params: Id) { 70 | return request({ 71 | url: '/api/favorite/works-id', 72 | method: 'GET', 73 | params, 74 | }) 75 | } 76 | 77 | // 搜索收藏夹内部的作品 78 | export function searchFavoriteWorkAPI(params: Pagination) { 79 | return request({ 80 | url: '/api/favorite/search', 81 | method: 'GET', 82 | params, 83 | }) 84 | } 85 | 86 | // 获取搜索结果数量 87 | export function getSearchResultNumAPI(params: IGetSearchResultNumReq) { 88 | return request({ 89 | url: '/api/favorite/search-count', 90 | method: 'GET', 91 | params, 92 | }) 93 | } 94 | 95 | // 移动作品到其他收藏夹 96 | export function moveFavoriteWorksAPI(data: IMoveFavoriteWorksReq) { 97 | return request({ 98 | url: '/api/favorite/move', 99 | method: 'POST', 100 | data, 101 | }) 102 | } 103 | 104 | // 复制作品到其他收藏夹 105 | export function copyFavoriteWorksAPI(data: ICopyFavoriteWorksReq) { 106 | return request({ 107 | url: '/api/favorite/copy', 108 | method: 'POST', 109 | data, 110 | }) 111 | } 112 | -------------------------------------------------------------------------------- /src/apis/favorite/types.ts: -------------------------------------------------------------------------------- 1 | // src/apis/favorite/types.ts 2 | // 定义 favorite 模块的 API 类型 3 | 4 | // #region 请求体类型 5 | export interface INewFavoriteReq { 6 | /** 7 | * 收藏夹封面,可以不传,不传的时候默认把最新的作品的第一张图当作封面 8 | */ 9 | cover?: string 10 | /** 11 | * 收藏夹简介 12 | */ 13 | intro: string 14 | /** 15 | * 收藏夹名称 16 | */ 17 | name: string 18 | } 19 | 20 | export interface IEditFavoriteReq extends Partial { 21 | id: string 22 | } 23 | 24 | export interface IChangeFavoriteOrderReq { 25 | orderList: { 26 | /** 27 | * 收藏夹id 28 | */ 29 | id: string 30 | /** 31 | * 收藏夹顺序 32 | */ 33 | order: number 34 | }[] 35 | } 36 | 37 | export interface IGetSearchResultNumReq { 38 | keyword: string 39 | favoriteId: string 40 | } 41 | 42 | export interface IMoveFavoriteWorksReq { 43 | idList: string[] 44 | fromId: string 45 | toId: string 46 | } 47 | 48 | export interface ICopyFavoriteWorksReq { 49 | idList: string[] 50 | toId: string 51 | } 52 | // # endregion 53 | 54 | // #region 响应体类型 55 | export interface FavoriteDetailInfo { 56 | /** 57 | * 收藏夹封面 58 | */ 59 | cover: null | string 60 | /** 61 | * 创建者id 62 | */ 63 | creatorId: string 64 | /** 65 | * 创建者名称 66 | */ 67 | creatorName: string 68 | /** 69 | * 收藏夹id 70 | */ 71 | id: string 72 | /** 73 | * 收藏夹简介 74 | */ 75 | intro: string 76 | /** 77 | * 收藏夹名称 78 | */ 79 | name: string 80 | /** 81 | * 收藏夹作品总数 82 | */ 83 | workNum: number 84 | } 85 | // #endregion 86 | -------------------------------------------------------------------------------- /src/apis/history/index.ts: -------------------------------------------------------------------------------- 1 | import type { HistoryItem, Id, Pagination } from '../types' 2 | 3 | import type { IGetViewHistoryTotalReq, ISearchViewHistoryReq } from './types' 4 | import request from '@/service' 5 | 6 | // 分页获取用户某天的浏览历史记录 7 | export function getViewHistoryAPI(params: Pagination) { 8 | return request({ 9 | url: '/api/history/list', 10 | method: 'GET', 11 | params, 12 | }) 13 | } 14 | 15 | // 获取用户某天的浏览历史记录总数 16 | export function getViewHistoryTotalAPI(params: IGetViewHistoryTotalReq) { 17 | return request({ 18 | url: '/api/history/count', 19 | method: 'GET', 20 | params, 21 | }) 22 | } 23 | 24 | // 新增用户浏览记录 25 | export function postViewHistoryAPI(data: Id) { 26 | return request({ 27 | url: '/api/history/new', 28 | method: 'POST', 29 | data, 30 | }) 31 | } 32 | 33 | // 删除某条历史记录 34 | export function deleteViewHistoryAPI(data: Id) { 35 | return request({ 36 | url: '/api/history/delete', 37 | method: 'POST', 38 | data, 39 | }) 40 | } 41 | 42 | // 清除全部历史记录 43 | export function clearViewHistoryAPI() { 44 | return request({ 45 | url: '/api/history/clear', 46 | method: 'POST', 47 | }) 48 | } 49 | 50 | // 搜索历史记录 51 | export function searchViewHistoryAPI(params: ISearchViewHistoryReq) { 52 | return request({ 53 | url: '/api/history/search', 54 | method: 'GET', 55 | params, 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /src/apis/history/types.ts: -------------------------------------------------------------------------------- 1 | // src/apis/history/types.ts 2 | // 定义 history 模块的 API 类型 3 | 4 | // #region 请求体类型 5 | export interface IGetViewHistoryTotalReq { 6 | date: string // 当前的日期,以 YYYY-MM-DD 格式表示 7 | } 8 | 9 | export interface ISearchViewHistoryReq { 10 | keyword: string 11 | } 12 | // # endregion 13 | -------------------------------------------------------------------------------- /src/apis/illustration/index.ts: -------------------------------------------------------------------------------- 1 | import type { Id, Pagination, WorkNormalItem } from '../types' 2 | 3 | import type { 4 | IEditWorkReq, 5 | IGetRandomBackgroundsReq, 6 | IGetRandomBackgroundsRes, 7 | ISearchWorksIdListReq, 8 | IUploadWorkReq, 9 | WorkDetailInfo, 10 | } from './types' 11 | import request from '@/service' 12 | 13 | // 分页获取推荐作品列表 14 | export function getRecommendWorksAPI(params: Pagination) { 15 | return request({ 16 | url: '/api/illustration/recommend', 17 | method: 'GET', 18 | params, 19 | }) 20 | } 21 | 22 | // 分页获取最新作品列表 23 | export function getLatestWorksAPI(params: Pagination) { 24 | return request({ 25 | url: '/api/illustration/latest', 26 | method: 'GET', 27 | params, 28 | }) 29 | } 30 | 31 | // 获取已关注用户新作 32 | export function getFollowNewWorksAPI(params: Pagination) { 33 | return request({ 34 | url: '/api/illustration/following', 35 | method: 'GET', 36 | params, 37 | }) 38 | } 39 | 40 | // 获取已关注用户新作总数 41 | export function getFollowNewWorksTotalAPI() { 42 | return request({ 43 | url: '/api/illustration/following-count', 44 | method: 'GET', 45 | }) 46 | } 47 | 48 | // 获取已关注用户新作id列表 49 | export function getFollowNewWorksIdListAPI() { 50 | return request({ 51 | url: '/api/illustration/following-id', 52 | method: 'GET', 53 | }) 54 | } 55 | 56 | // 上传作品 57 | export function uploadWorkAPI(data: IUploadWorkReq) { 58 | return request({ 59 | url: '/api/illustration/upload', 60 | method: 'POST', 61 | data, 62 | }) 63 | } 64 | 65 | // 编辑作品 66 | export function editWorkAPI(data: IEditWorkReq) { 67 | return request({ 68 | url: '/api/illustration/edit', 69 | method: 'POST', 70 | params: { 71 | id: data.id, 72 | }, 73 | data, 74 | }) 75 | } 76 | 77 | // 删除作品 78 | export function deleteWorkAPI(params: Id) { 79 | return request({ 80 | url: '/api/illustration/delete', 81 | method: 'POST', 82 | params, 83 | }) 84 | } 85 | 86 | // 获取某个作品的详细信息 87 | export function getWorkDetailAPI(params: Id) { 88 | return request({ 89 | url: '/api/illustration/detail', 90 | method: 'GET', 91 | params, 92 | }) 93 | } 94 | 95 | // 获取某个作品的简要信息 96 | export function getWorkSimpleAPI(params: Id) { 97 | return request({ 98 | url: '/api/illustration/simple', 99 | method: 'GET', 100 | params, 101 | }) 102 | } 103 | 104 | // 根据标签分页搜索作品 105 | export function searchWorksByLabelAPI(params: Pagination) { 106 | return request({ 107 | url: '/api/illustration/search', 108 | method: 'GET', 109 | params, 110 | }) 111 | } 112 | 113 | // 获取搜索作品id列表 114 | export function searchWorksIdListAPI(params: ISearchWorksIdListReq) { 115 | return request({ 116 | url: '/api/illustration/search-id', 117 | method: 'GET', 118 | params, 119 | }) 120 | } 121 | 122 | // 新增作品浏览量 123 | export function addWorkViewAPI(params: Id) { 124 | return request({ 125 | url: '/api/illustration/view', 126 | method: 'POST', 127 | params, 128 | }) 129 | } 130 | 131 | // 获取随机背景图片列表 132 | export function getRandomBackgroundsAPI(data: IGetRandomBackgroundsReq) { 133 | return request({ 134 | url: '/api/illustration/background', 135 | method: 'POST', 136 | data, 137 | }) 138 | } 139 | 140 | // 获取数据库内部作品总数 141 | export function getWorkCountAPI() { 142 | return request({ 143 | url: '/api/illustration/work-count', 144 | method: 'GET', 145 | }) 146 | } 147 | -------------------------------------------------------------------------------- /src/apis/illustration/types.ts: -------------------------------------------------------------------------------- 1 | // src/apis/illustration/types.ts 2 | // 定义 illustration 模块的 API 类型 3 | 4 | import type { ImageItem, LabelItem } from '../types' 5 | 6 | // #region 请求体类型 7 | export interface IUploadWorkReq { 8 | /** 9 | * 作品原插画家信息,转载作品需要填写 10 | */ 11 | illustratorInfo?: { 12 | /** 13 | * 作者主页 14 | */ 15 | homeUrl: string 16 | /** 17 | * 作者名,名称 18 | */ 19 | name: string 20 | } 21 | /** 22 | * 图片列表 23 | */ 24 | imgList: string[] 25 | /** 26 | * 作品简介 27 | */ 28 | intro: string 29 | /** 30 | * 是否是AI生成作品 31 | */ 32 | isAIGenerated: boolean 33 | /** 34 | * 转载类型。0-原创,1-转载,2-合集 35 | */ 36 | reprintType: number 37 | /** 38 | * 作品标签列表,单纯的以标签名列表的形式传入 39 | */ 40 | labels: string[] 41 | /** 42 | * 作品名 43 | */ 44 | name: string 45 | /** 46 | * 是否开启评论 47 | */ 48 | openComment: boolean 49 | /** 50 | * 作品原链接 51 | */ 52 | workUrl?: string 53 | } 54 | 55 | export interface IEditWorkReq extends Partial { 56 | id: string 57 | } 58 | 59 | export interface ISearchWorksIdListReq { 60 | labelName: string 61 | sortType: string 62 | } 63 | 64 | export interface IGetRandomBackgroundsReq { 65 | /** 66 | * 选取过的背景图片的所属作品的index列表,以确保每一次请求的背景图片不会重复 67 | */ 68 | chosenIdList: number[] 69 | /** 70 | * 设备类型 71 | */ 72 | device: 'mobile' | 'desktop' 73 | } 74 | // #endregion 75 | 76 | // #region 响应体类型 77 | export interface WorkDetailInfo { 78 | /** 79 | * 作者id 80 | */ 81 | authorId: string 82 | /** 83 | * 被收藏次数 84 | */ 85 | collectNum: number 86 | /** 87 | * 评论个数 88 | */ 89 | commentNum: number 90 | /** 91 | * 创建日期 92 | */ 93 | createdDate: string 94 | /** 95 | * 作品id 96 | */ 97 | id: string 98 | /** 99 | * 作品图片url列表 100 | */ 101 | imgList: string[] 102 | /** 103 | * 图片详细信息列表 104 | */ 105 | images: ImageItem[] 106 | /** 107 | * 作品简介 108 | */ 109 | intro: string 110 | /** 111 | * 是否是AI生成作品 112 | */ 113 | isAIGenerated: boolean 114 | /** 115 | * 是否已经收藏 116 | */ 117 | isCollected: boolean 118 | /** 119 | * 已经被收藏的收藏夹id,如果没有被收藏则不传 120 | */ 121 | favoriteId?: string 122 | /** 123 | * 用户是否已经喜欢 124 | */ 125 | isLiked: boolean 126 | /** 127 | * 是否是转载作品 128 | */ 129 | reprintType: number 130 | /** 131 | * 标签列表 132 | */ 133 | labels: LabelItem[] 134 | /** 135 | * 被喜欢次数 136 | */ 137 | likeNum: number 138 | /** 139 | * 作品名称 140 | */ 141 | name: string 142 | /** 143 | * 是否打开评论 144 | */ 145 | openComment: boolean 146 | /** 147 | * 更新日期 148 | */ 149 | updatedDate: string 150 | /** 151 | * 被浏览次数 152 | */ 153 | viewNum: number 154 | /** 155 | * 原作品地址(转载作品) 156 | */ 157 | workUrl?: string 158 | /** 159 | * 插画家信息(转载作品) 160 | */ 161 | illustrator?: { 162 | id: string 163 | name: string 164 | intro: string 165 | avatar: string 166 | homeUrl: string 167 | workCount: number 168 | } 169 | } 170 | 171 | export interface IGetRandomBackgroundsRes { 172 | result: string[] 173 | chosenIdList: number[] 174 | } 175 | // #endregion 176 | -------------------------------------------------------------------------------- /src/apis/illustrator/index.ts: -------------------------------------------------------------------------------- 1 | import type { Id, Keyword, Pagination, WorkNormalItem } from '../types' 2 | 3 | import type { IEditIllustratorReq, IllustratorInfo, INewIllustratorReq } from './types' 4 | import request from '@/service' 5 | 6 | // 新建插画家 7 | export function newIllustratorAPI(data: INewIllustratorReq) { 8 | return request({ 9 | url: '/api/illustrator/new', 10 | method: 'POST', 11 | data, 12 | }) 13 | } 14 | 15 | // 修改插画家信息 16 | export function editIllustratorAPI(data: IEditIllustratorReq) { 17 | return request({ 18 | url: `/api/illustrator/edit?id=${data.id}`, 19 | method: 'POST', 20 | data, 21 | }) 22 | } 23 | 24 | // 获取某个插画家详细信息 25 | export function getIllustratorDetailAPI(params: Id) { 26 | return request({ 27 | url: '/api/illustrator/detail', 28 | method: 'GET', 29 | params, 30 | }) 31 | } 32 | 33 | // 分页获取插画家列表 34 | export function getIllustratorListInPagesAPI(params: Pagination) { 35 | return request({ 36 | url: '/api/illustrator/list', 37 | method: 'GET', 38 | params, 39 | }) 40 | } 41 | 42 | // 搜索插画家 43 | export function searchIllustratorsAPI(params: Keyword) { 44 | return request({ 45 | url: '/api/illustrator/search', 46 | method: 'GET', 47 | params, 48 | }) 49 | } 50 | 51 | // 分页获取某插画家的作品列表 52 | export function getIllustratorWorksInPagesAPI(params: Pagination) { 53 | return request({ 54 | url: '/api/illustrator/works', 55 | method: 'GET', 56 | params, 57 | }) 58 | } 59 | 60 | // 获取某插画家的作品id列表 61 | export function getIllustratorWorksIdListAPI(params: Id) { 62 | return request({ 63 | url: '/api/illustrator/works-id', 64 | method: 'GET', 65 | params, 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /src/apis/illustrator/types.ts: -------------------------------------------------------------------------------- 1 | // src/apis/illustrator/types.ts 2 | // 定义 illustrator 模块的 API 类型 3 | 4 | // #region 请求体类型 5 | export interface INewIllustratorReq { 6 | /** 7 | * 插画家头像 8 | */ 9 | avatar?: string 10 | /** 11 | * 插画家个人主页,如转载 pixiv 的插画家,需要填写作者的个人主页url 12 | */ 13 | homeUrl: string 14 | /** 15 | * 插画家介绍 16 | */ 17 | intro?: string 18 | /** 19 | * 插画家名称 20 | */ 21 | name: string 22 | } 23 | 24 | export interface IEditIllustratorReq extends Partial { 25 | id: string 26 | } 27 | // #endregion 28 | 29 | // #region 响应体类型 30 | export interface IllustratorInfo { 31 | /** 32 | * 插画家头像 33 | */ 34 | avatar: string | null 35 | /** 36 | * 创建日期 37 | */ 38 | createdAt: string 39 | /** 40 | * 插画家id 41 | */ 42 | id: string 43 | /** 44 | * 插画家介绍 45 | */ 46 | intro: string 47 | /** 48 | * 插画家名称 49 | */ 50 | name: string 51 | /** 52 | * 更新日期 53 | */ 54 | updatedAt: string 55 | /** 56 | * 作品总数 57 | */ 58 | workNum: number 59 | /** 60 | * 插画家个人主页 61 | */ 62 | homeUrl: string 63 | } 64 | // #endregion 65 | -------------------------------------------------------------------------------- /src/apis/index.ts: -------------------------------------------------------------------------------- 1 | export * from './comment' 2 | export * from './favorite' 3 | export * from './history' 4 | export * from './illustration' 5 | export * from './illustrator' 6 | export * from './label' 7 | export * from './tool' 8 | export * from './user' 9 | -------------------------------------------------------------------------------- /src/apis/label/index.ts: -------------------------------------------------------------------------------- 1 | import type { Keyword, LabelItem, Name, Pagination } from '../types' 2 | 3 | import type { INewLabelReq, LabelDetailInfo } from './types' 4 | import request from '@/service' 5 | 6 | // 新建标签 7 | export function newLabelAPI(data: INewLabelReq[]) { 8 | return request({ 9 | url: '/api/label/new', 10 | method: 'POST', 11 | data, 12 | }) 13 | } 14 | 15 | // 搜索标签 16 | export function searchLabelsAPI(params: Keyword) { 17 | return request({ 18 | url: '/api/label/search', 19 | method: 'GET', 20 | params, 21 | }) 22 | } 23 | 24 | // 获取推荐标签列表 25 | export function getRecommendLabelListAPI() { 26 | return request({ 27 | url: '/api/label/recommend', 28 | method: 'GET', 29 | }) 30 | } 31 | 32 | // 分页获取标签列表 33 | export function getLabelsInPagesAPI(params: Pagination) { 34 | return request({ 35 | url: '/api/label/list', 36 | method: 'GET', 37 | params, 38 | }) 39 | } 40 | 41 | // 获取某个标签的详细信息 42 | export function getLabelDetailAPI(params: Name) { 43 | return request({ 44 | url: '/api/label/detail', 45 | method: 'GET', 46 | params, 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /src/apis/label/types.ts: -------------------------------------------------------------------------------- 1 | // src/apis/label/types.ts 2 | // 定义 label 模块的 API 类型 3 | 4 | // #region 请求体类型 5 | export interface INewLabelReq { 6 | value: string 7 | } 8 | // #endregion 9 | 10 | // #region 响应体类型 11 | export interface LabelDetailInfo { 12 | /** 13 | * 标签颜色,由后台进行随机不重复的颜色生成 14 | */ 15 | color: string 16 | /** 17 | * 标签id 18 | */ 19 | id: string 20 | /** 21 | * 标签封面图片,当该标签的作品数达到一定量级后,由管理员在后台进行上传,默认就是随机生成的纯色背景图 22 | */ 23 | cover: string 24 | /** 25 | * 是否是我喜欢的标签 26 | */ 27 | isMyLike: boolean 28 | /** 29 | * 标签名称 30 | */ 31 | name: string 32 | /** 33 | * 该标签下的作品总数 34 | */ 35 | workCount: number 36 | } 37 | // #endregion 38 | -------------------------------------------------------------------------------- /src/apis/tool/index.ts: -------------------------------------------------------------------------------- 1 | import type { IUploadImageReq } from './types' 2 | 3 | import request from '@/service' 4 | 5 | // 上传单张图片并获取上传进度 6 | export function uploadSingleImageAPI(data: IUploadImageReq) { 7 | return request({ 8 | url: '/api/tool/upload-single-img', 9 | method: 'POST', 10 | data, 11 | headers: { 12 | 'Content-Type': 'multipart/form-data', 13 | }, 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/apis/tool/types.ts: -------------------------------------------------------------------------------- 1 | // src/apis/label/types.ts 2 | // 定义 label 模块的 API 类型 3 | 4 | // #region 请求体类型 5 | export interface IUploadImageReq { 6 | image: File 7 | } 8 | // #endregion 9 | -------------------------------------------------------------------------------- /src/apis/types.ts: -------------------------------------------------------------------------------- 1 | // src/apis/types.ts 2 | // 定义所有 API 的公用类型 3 | 4 | // #region 请求体类型 5 | export interface Id { 6 | id: string 7 | } 8 | 9 | export interface Name { 10 | name: string 11 | } 12 | 13 | export interface Email { 14 | email: string 15 | } 16 | 17 | export interface Keyword { 18 | keyword: string 19 | } 20 | 21 | export interface Pagination { 22 | id?: string 23 | keyword?: string 24 | labelName?: string 25 | sortType?: string 26 | date?: string 27 | current: number 28 | pageSize: number 29 | } 30 | 31 | // #endregion 32 | 33 | // #region 数据体类型 34 | export interface UserItem { 35 | /** 36 | * 用户id 37 | */ 38 | id: string 39 | /** 40 | * 用户名 41 | */ 42 | username: string 43 | /** 44 | * 用户邮箱 45 | */ 46 | email: string 47 | /** 48 | * 用户头像 49 | */ 50 | avatar: string 51 | /** 52 | * 用户简介 53 | */ 54 | intro: string 55 | /** 56 | * 是否正在关注 57 | */ 58 | isFollowing: boolean 59 | /** 60 | * 用户作品 id 列表 61 | * 获取用户列表相关接口包含此字段,getUserSimpleAPI 不包含此字段 62 | */ 63 | works?: WorkNormalItem[] 64 | } 65 | 66 | export interface WorkNormalItem { 67 | /** 68 | * 作者头像 69 | */ 70 | authorAvatar: string 71 | /** 72 | * 作者id 73 | */ 74 | authorId: string 75 | /** 76 | * 作者名称 77 | */ 78 | authorName: string 79 | /** 80 | * 作品id 81 | */ 82 | id: string 83 | /** 84 | * 作品图片列表 85 | */ 86 | imgList: string[] 87 | /** 88 | * 作品封面 89 | */ 90 | cover: string 91 | /** 92 | * 用户是否已经喜欢 93 | */ 94 | isLiked: boolean 95 | /** 96 | * 作品名称 97 | */ 98 | name: string 99 | /** 100 | * 创建时间 101 | */ 102 | createdAt: string 103 | } 104 | 105 | export type HistoryItem = Omit & { 106 | /** 107 | * 最后浏览时间 108 | */ 109 | lastTime: string 110 | } 111 | 112 | export interface FavoriteItem { 113 | /** 114 | * 收藏夹封面 115 | */ 116 | cover: null | string 117 | /** 118 | * 收藏夹id 119 | */ 120 | id: string 121 | /** 122 | * 收藏夹简介 123 | */ 124 | intro: string 125 | /** 126 | * 收藏夹名称 127 | */ 128 | name: string 129 | /** 130 | * 收藏夹排行顺序 131 | */ 132 | order: number 133 | /** 134 | * 收藏夹作品总数 135 | */ 136 | workNum: number 137 | } 138 | 139 | export interface LabelItem { 140 | /** 141 | * 标签颜色,由后台进行随机不重复的颜色生成 142 | */ 143 | color: string 144 | /** 145 | * 标签id 146 | */ 147 | id: string 148 | /** 149 | * 标签封面图片,当该标签的作品数达到一定量级后,由管理员在后台进行上传,默认就是随机生成的纯色背景图 150 | */ 151 | cover: null | string 152 | /** 153 | * 标签名称 154 | */ 155 | name: string 156 | } 157 | 158 | export interface ImageItem { 159 | id: string 160 | originUrl: string 161 | originWidth: number 162 | originHeight: number 163 | thumbnailUrl: string 164 | thumbnailWidth: number 165 | thumbnailHeight: number 166 | } 167 | // #endregion 168 | -------------------------------------------------------------------------------- /src/apis/user/types.ts: -------------------------------------------------------------------------------- 1 | // src/apis/user/types.ts 2 | // 定义 user 模块的 API 类型 3 | import type { LabelItem } from '../types' 4 | 5 | // #region 请求体类型 6 | export interface IRefreshTokenReq { 7 | refreshToken: string 8 | } 9 | 10 | export interface IRegisterReq { 11 | email: string 12 | password: string 13 | verification_code: string 14 | } 15 | 16 | export interface ILoginReq { 17 | email: string 18 | password: string 19 | } 20 | 21 | export interface IUpdateUserInfoReq { 22 | /** 23 | * 用户头像 24 | */ 25 | avatar?: string 26 | /** 27 | * 用户空间背景图片 28 | */ 29 | backgroundImg?: string 30 | /** 31 | * 用户个性签名 32 | */ 33 | signature?: string 34 | /** 35 | * 用户名 36 | */ 37 | username?: string 38 | /** 39 | * 用户性别,0-男,1-女,2-未知 40 | */ 41 | gender?: 0 | 1 | 2 42 | } 43 | 44 | export interface IChangePasswordReq { 45 | password: string 46 | verification_code: string 47 | } 48 | 49 | export interface IChangeEmailReq { 50 | email: string 51 | verification_code: string 52 | } 53 | 54 | export interface IFavoriteActionsReq { 55 | id: string 56 | favoriteIds: string[] 57 | } 58 | 59 | // #endregion 60 | 61 | // #region 返回体类型 62 | export interface UserDetailInfo { 63 | /** 64 | * 用户头像 65 | */ 66 | avatar: string 67 | /** 68 | * 用户头像缩略图 69 | */ 70 | littleAvatar: string 71 | /** 72 | * 用户背景图 73 | */ 74 | backgroundImg: string 75 | /** 76 | * 用户收藏的作品数 77 | */ 78 | collectCount: number 79 | /** 80 | * 用户账户的创建时间 81 | */ 82 | createdTime: string 83 | /** 84 | * 用户邮箱 85 | */ 86 | email: string 87 | /** 88 | * 用户粉丝数 89 | */ 90 | fanCount: number 91 | /** 92 | * 用户的收藏夹数量 93 | */ 94 | favoriteCount: number 95 | /** 96 | * 用户关注数 97 | */ 98 | followCount: number 99 | /** 100 | * 用户性别,0-男,1-女,2-未知 101 | */ 102 | gender: 0 | 1 | 2 103 | /** 104 | * 用户id 105 | */ 106 | id: string 107 | /** 108 | * 用户喜欢的作品数 109 | */ 110 | likeCount: number 111 | /** 112 | * 用户发布的原创作品数 113 | */ 114 | originCount: number 115 | /** 116 | * 用户发布的转载作品数 117 | */ 118 | reprintedCount: number 119 | /** 120 | * 用户签名 121 | */ 122 | signature: string 123 | /** 124 | * 用户名 125 | */ 126 | username: string 127 | /** 128 | * 用户是否被当前登录用户关注 129 | */ 130 | isFollowed: boolean 131 | } 132 | 133 | export interface IRefreshTokenRes { 134 | access_token: string 135 | refresh_token: string 136 | } 137 | 138 | export interface UserLoginInfo { 139 | id: string 140 | username: string 141 | email: string 142 | backgroundImg: string 143 | avatar: string 144 | littleAvatar: string 145 | signature: string 146 | gender: number 147 | fanCount: number 148 | followCount: number 149 | originCount: number 150 | reprintedCount: number 151 | likeCount: number 152 | collectCount: number 153 | favoriteCount: number 154 | createdTime: Date 155 | updatedTime: Date 156 | likedLabels: LabelItem[] 157 | } 158 | 159 | export interface ILoginRes { 160 | userInfo: UserLoginInfo 161 | accessToken: string 162 | refreshToken: string 163 | } 164 | // #endregion 165 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppState } from './store/types' 2 | import { useEffect, useRef, useState } from 'react' 3 | import { useSelector } from 'react-redux' 4 | 5 | import { Outlet, useLocation } from 'react-router' 6 | import Header from './components/common/header' 7 | import { useWinChange } from './hooks' 8 | import { SIDEBAR_WHITE_LIST } from './utils/constants' 9 | 10 | function App() { 11 | const { uploadSuccess } = useSelector((state: AppState) => state.uploadForm) 12 | 13 | const location = useLocation() 14 | const [showSideBar, setShowSideBar] = useState(false) 15 | const [naturalSideBarVisible, setNaturalSideBarVisible] = useState(false) // 是否自然地触发侧边栏显示与否,而非点击按钮 16 | const [marginTrigger, setMarginTrigger] = useState(false) // 用来控制主窗口是否向右移动 17 | const appRef = useRef(null) 18 | const currentWidth = useWinChange(appRef) 19 | 20 | useEffect(() => { 21 | if (SIDEBAR_WHITE_LIST.test(location.pathname)) { 22 | if (naturalSideBarVisible) 23 | setMarginTrigger(showSideBar) 24 | else setMarginTrigger(false) 25 | } 26 | else { 27 | setMarginTrigger( 28 | showSideBar && location.pathname !== '/login' && SIDEBAR_WHITE_LIST.test(location.pathname), 29 | ) 30 | } 31 | }, [showSideBar, location.pathname, currentWidth]) 32 | 33 | return ( 34 | <> 35 | {location.pathname !== '/login' && location.pathname !== '/not-found' && !uploadSuccess && ( 36 |
41 | )} 42 |
46 | 47 |
48 | 49 | ) 50 | } 51 | 52 | export default App 53 | -------------------------------------------------------------------------------- /src/assets/imgs/404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonhana/picals-frontend-react/d0a03fa2c69e2892c744e6e2665fe87477b968bf/src/assets/imgs/404.png -------------------------------------------------------------------------------- /src/assets/imgs/500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonhana/picals-frontend-react/d0a03fa2c69e2892c744e6e2665fe87477b968bf/src/assets/imgs/500.png -------------------------------------------------------------------------------- /src/assets/imgs/empty/empty1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonhana/picals-frontend-react/d0a03fa2c69e2892c744e6e2665fe87477b968bf/src/assets/imgs/empty/empty1.png -------------------------------------------------------------------------------- /src/assets/imgs/empty/empty2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonhana/picals-frontend-react/d0a03fa2c69e2892c744e6e2665fe87477b968bf/src/assets/imgs/empty/empty2.png -------------------------------------------------------------------------------- /src/assets/imgs/empty/empty3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonhana/picals-frontend-react/d0a03fa2c69e2892c744e6e2665fe87477b968bf/src/assets/imgs/empty/empty3.png -------------------------------------------------------------------------------- /src/assets/imgs/empty/empty4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonhana/picals-frontend-react/d0a03fa2c69e2892c744e6e2665fe87477b968bf/src/assets/imgs/empty/empty4.png -------------------------------------------------------------------------------- /src/assets/imgs/empty/empty5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonhana/picals-frontend-react/d0a03fa2c69e2892c744e6e2665fe87477b968bf/src/assets/imgs/empty/empty5.png -------------------------------------------------------------------------------- /src/assets/imgs/empty/empty6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonhana/picals-frontend-react/d0a03fa2c69e2892c744e6e2665fe87477b968bf/src/assets/imgs/empty/empty6.png -------------------------------------------------------------------------------- /src/assets/imgs/empty/empty7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonhana/picals-frontend-react/d0a03fa2c69e2892c744e6e2665fe87477b968bf/src/assets/imgs/empty/empty7.png -------------------------------------------------------------------------------- /src/assets/imgs/upload-successfully.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonhana/picals-frontend-react/d0a03fa2c69e2892c744e6e2665fe87477b968bf/src/assets/imgs/upload-successfully.gif -------------------------------------------------------------------------------- /src/assets/svgs/pagination-left.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/assets/svgs/pagination-more.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/assets/svgs/pagination-right.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/assets/svgs/pixiv.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/common/animated-list/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { HistoryItem } from '@/apis/types' 3 | import type { WorkNormalItemInfo } from '@/utils/types' 4 | import { useEffect, useRef } from 'react' 5 | 6 | import WorkItem from '../work-item' 7 | 8 | interface AnimatedListProps { 9 | workList: (WorkNormalItemInfo | HistoryItem)[] 10 | [key: string]: any 11 | } 12 | 13 | const AnimatedList: FC = ({ workList, ...props }) => { 14 | const containerRef = useRef(null) 15 | const itemsRef = useRef([]) 16 | 17 | useEffect(() => { 18 | if (containerRef.current && itemsRef.current.length) { 19 | const containerWidth = containerRef.current.offsetWidth 20 | const itemWidth = itemsRef.current[0].offsetWidth 21 | const lineCount = Math.floor((containerWidth + 20) / itemWidth) 22 | itemsRef.current.forEach((item, index) => { 23 | const line = Math.floor(index / lineCount) 24 | item.style.opacity = '0' 25 | item.style.animation = 'float-up 0.3s forwards' 26 | item.style.animationDelay = `${line * 0.1}s` 27 | }) 28 | } 29 | }, []) 30 | 31 | return ( 32 |
33 | {workList.map((item, index) => ( 34 | 35 | ))} 36 |
37 | ) 38 | } 39 | 40 | export default AnimatedList 41 | -------------------------------------------------------------------------------- /src/components/common/create-folder-modal/index.tsx: -------------------------------------------------------------------------------- 1 | import type { UploadProps } from 'antd' 2 | import type { FC } from 'react' 3 | import type { FavoriteFormInfo } from '@/utils/types' 4 | import { Icon } from '@iconify/react' 5 | import { Flex, Input, message, Modal, notification, Upload } from 'antd' 6 | import { AnimatePresence, motion } from 'framer-motion' 7 | import { useState } from 'react' 8 | 9 | import LazyImg from '../lazy-img' 10 | 11 | interface CreateFolderModalProps { 12 | editMode: boolean 13 | modalStatus: boolean 14 | confirmAction: () => void 15 | cancelAction: () => void 16 | formInfo: FavoriteFormInfo 17 | setFormInfo: React.Dispatch> 18 | } 19 | 20 | const CreateFolderModal: FC = ({ 21 | editMode, 22 | modalStatus, 23 | confirmAction, 24 | cancelAction, 25 | formInfo, 26 | setFormInfo, 27 | }) => { 28 | const [imgHovering, setImgHovering] = useState(false) 29 | 30 | const removeImg = () => { 31 | setFormInfo((prev) => { 32 | const newFormInfo = { ...prev } 33 | delete newFormInfo.cover 34 | return newFormInfo 35 | }) 36 | setImgHovering(false) 37 | } 38 | 39 | const uploadProps: UploadProps = { 40 | name: 'image', 41 | multiple: true, 42 | action: `${import.meta.env.VITE_BASE_URL}/api/tool/upload-single-img`, 43 | showUploadList: false, 44 | accept: '.jpg,.png,.gif', 45 | onChange(info) { 46 | const { status } = info.file 47 | if (status === 'done') { 48 | setFormInfo({ ...formInfo, cover: info.file.response.data }) 49 | message.success(`${info.file.name} 上传成功`) 50 | } 51 | else if (status === 'error') { 52 | notification.error({ 53 | message: '上传失败', 54 | description: info.file.response.message || '未知错误', 55 | }) 56 | } 57 | }, 58 | } 59 | 60 | return ( 61 | 70 | 71 | 72 | 名称: 73 | setFormInfo({ ...formInfo, name: e.target.value })} 78 | /> 79 | 80 | 81 | 简介: 82 | setFormInfo({ ...formInfo, intro: e.target.value })} 87 | /> 88 | 89 | 90 | 封面: 91 | {formInfo.cover 92 | ? ( 93 |
setImgHovering(true)} 96 | onMouseLeave={() => setImgHovering(false)} 97 | > 98 | 99 | {imgHovering && ( 100 | 108 | 移除图片 109 | 110 | )} 111 | 112 | 113 |
114 | ) 115 | : ( 116 | 117 |
118 | 119 |
120 |
121 | )} 122 |
123 |
124 |
125 | ) 126 | } 127 | 128 | export default CreateFolderModal 129 | -------------------------------------------------------------------------------- /src/components/common/empty/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { random } from 'lodash' 3 | import { useEffect, useState } from 'react' 4 | 5 | import LazyImg from '../lazy-img' 6 | 7 | // 动态导入所有图片 8 | const images = import.meta.glob('@/assets/imgs/empty/*.(png|jpg|jpeg|svg)') 9 | const emptyImgs: string[] = [] 10 | 11 | Object.keys(images).forEach(async (key) => { 12 | const module = (await images[key]()) as { default: string } 13 | emptyImgs.push(module.default) 14 | }) 15 | 16 | interface EmptyProps { 17 | width?: number | string 18 | height?: number | string 19 | text?: string 20 | showImg?: boolean 21 | children?: React.ReactNode 22 | [key: string]: any 23 | } 24 | 25 | const Empty: FC = ({ 26 | width = '100%', 27 | height = '100%', 28 | text = '暂无数据', 29 | showImg = true, 30 | children, 31 | ...props 32 | }) => { 33 | const [randomImg, setRandomImg] = useState(undefined) 34 | 35 | useEffect(() => { 36 | const randomIndex = random(0, emptyImgs.length - 1) 37 | setRandomImg(emptyImgs[randomIndex]) 38 | }, []) 39 | 40 | return ( 41 |
46 | {showImg && randomImg && ( 47 | 48 | )} 49 | {text} 50 | {children} 51 |
52 | ) 53 | } 54 | 55 | export default Empty 56 | -------------------------------------------------------------------------------- /src/components/common/favorite-item/index.tsx: -------------------------------------------------------------------------------- 1 | import type { MenuProps } from 'antd' 2 | import type { FC } from 'react' 3 | import { useSortable } from '@dnd-kit/sortable' 4 | import { CSS } from '@dnd-kit/utilities' 5 | import { Icon } from '@iconify/react' 6 | import { Dropdown } from 'antd' 7 | import { use, useState } from 'react' 8 | import { PersonalContext } from '@/pages/personal-center' 9 | 10 | const dropdownList: MenuProps['items'] = [ 11 | { 12 | key: 'edit', 13 | label: 编辑收藏夹, 14 | }, 15 | { 16 | key: 'delete', 17 | label: 删除, 18 | }, 19 | ] 20 | 21 | interface FavoriteItemProps { 22 | id: string 23 | name: string 24 | folderStatus: boolean 25 | onChooseFolder: (id: string) => void 26 | onDeleteFolder: (id: string) => void 27 | onEditFolder: (id: string) => void 28 | } 29 | 30 | const FavoriteItem: FC = ({ 31 | id, 32 | name, 33 | folderStatus, 34 | onChooseFolder, 35 | onDeleteFolder, 36 | onEditFolder, 37 | }) => { 38 | const { isMe } = use(PersonalContext) 39 | 40 | const [hovering, setHovering] = useState(false) 41 | 42 | const { setNodeRef, attributes, listeners, transform, transition } = useSortable({ 43 | id, 44 | transition: { 45 | duration: 500, 46 | easing: 'cubic-bezier(0.25, 1, 0.5, 1)', 47 | }, 48 | }) 49 | const styles = { 50 | transform: CSS.Transform.toString(transform), 51 | transition, 52 | } 53 | 54 | const onChooseItem: MenuProps['onClick'] = ({ key }) => { 55 | switch (key) { 56 | case 'edit': 57 | onEditFolder(id) 58 | break 59 | case 'delete': 60 | onDeleteFolder(id) 61 | break 62 | default: 63 | break 64 | } 65 | } 66 | 67 | return ( 68 |
onChooseFolder(id)} 74 | onMouseEnter={() => setHovering(true)} 75 | onMouseLeave={() => setHovering(false)} 76 | > 77 |
78 | {isMe && ( 79 |
84 | 85 |
86 | )} 87 |
88 | 93 |
94 | {name} 95 |
96 | {isMe && ( 97 | 98 | 99 | 100 | )} 101 |
102 | ) 103 | } 104 | 105 | export default FavoriteItem 106 | -------------------------------------------------------------------------------- /src/components/common/grey-button/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonHTMLAttributes, FC } from 'react' 2 | 3 | type GreyButtonProps = { 4 | children: React.ReactNode 5 | disabled?: boolean 6 | } & ButtonHTMLAttributes 7 | 8 | const GreyButton: FC = ({ 9 | className: extraClassName, 10 | children, 11 | onClick, 12 | disabled = false, 13 | }) => { 14 | // 根据 disabled 状态确定样式 15 | const buttonClasses = disabled 16 | ? 'flex-shrink-0 w-10 h-10 rounded-full bg-black opacity-8 flex items-center justify-center cursor-not-allowed' 17 | : 'flex-shrink-0 w-10 h-10 rounded-full bg-black opacity-8 flex items-center justify-center cursor-pointer transition-duration-300 hover:opacity-32' 18 | 19 | return ( 20 |
21 | {children} 22 |
23 | ) 24 | } 25 | 26 | export default GreyButton 27 | -------------------------------------------------------------------------------- /src/components/common/hana-card/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | const pageCenter = 'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2' 4 | 5 | interface HanaCardProps { 6 | className?: string 7 | width?: number | string 8 | height?: number | string 9 | children?: React.ReactNode 10 | } 11 | 12 | const HanaCard: FC = ({ 13 | className = '', 14 | width = 800, 15 | height = 'auto', 16 | children, 17 | }) => { 18 | return ( 19 |
23 | {children} 24 |
25 | ) 26 | } 27 | 28 | export default HanaCard 29 | -------------------------------------------------------------------------------- /src/components/common/hana-cropper/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { ReactCropperElement } from 'react-cropper' 3 | import { debounce } from 'lodash' 4 | import { useRef, useState } from 'react' 5 | import { Cropper } from 'react-cropper' 6 | 7 | import HanaModal from '../hana-modal' 8 | import 'cropperjs/dist/cropper.css' 9 | 10 | interface HanaCropperProps { 11 | loading: boolean 12 | visible: boolean 13 | setVisible: (visible: boolean) => void 14 | type: 'avatar' | 'background' 15 | imgURL: string 16 | onSaveHandler: (imgURL: string) => void 17 | } 18 | 19 | const HanaCropper: FC = ({ 20 | loading, 21 | visible, 22 | setVisible, 23 | type, 24 | imgURL, 25 | onSaveHandler, 26 | }) => { 27 | const cropperRef = useRef(null) 28 | const [croppedImg, setCroppedImg] = useState('') 29 | 30 | const onCrop = debounce(() => { 31 | if (!cropperRef.current) 32 | return 33 | const imageElement = cropperRef.current 34 | if (!imageElement) 35 | return 36 | const cropper = imageElement.cropper 37 | setCroppedImg(cropper.getCroppedCanvas().toDataURL()) 38 | }, 100) 39 | 40 | return ( 41 | onSaveHandler(croppedImg)} 48 | > 49 | 63 | 64 | ) 65 | } 66 | export default HanaCropper 67 | -------------------------------------------------------------------------------- /src/components/common/hana-modal/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { Icon } from '@iconify/react' 3 | import { Button } from 'antd' 4 | import { AnimatePresence, motion } from 'framer-motion' 5 | import { useEffect } from 'react' 6 | 7 | interface HanaModalProps { 8 | loading?: boolean 9 | zIndex?: number 10 | width?: number 11 | visible: boolean 12 | title: string 13 | allowActivelyClose?: boolean 14 | setVisible: (visible: boolean) => void 15 | children: React.ReactNode 16 | onOk?: () => void // 触发确定按钮的回调 17 | } 18 | 19 | const HanaModal: FC = ({ 20 | loading, 21 | visible, 22 | width = 648, 23 | title, 24 | allowActivelyClose = true, 25 | setVisible, 26 | children, 27 | onOk, 28 | zIndex, 29 | }) => { 30 | const toggleBodyOverflow = (visible: boolean) => { 31 | document.documentElement.style.overflow = visible ? 'hidden scroll' : '' 32 | document.body.style.overflow = visible ? 'hidden' : '' 33 | document.body.style.maxHeight = visible ? '100vh' : '' 34 | } 35 | 36 | useEffect(() => { 37 | toggleBodyOverflow(visible) 38 | }, [visible]) 39 | 40 | const templateClick = () => { 41 | if (allowActivelyClose) { 42 | setVisible(false) 43 | } 44 | } 45 | 46 | return ( 47 | 48 | {visible && ( 49 | 58 | )} 59 | 60 | {visible && ( 61 | 69 |
70 | {title} 71 | {allowActivelyClose && ( 72 | setVisible(false)} 78 | /> 79 | )} 80 |
81 | {children} 82 | 83 | {onOk && ( 84 |
85 | 94 | 97 |
98 | )} 99 |
100 | )} 101 |
102 | ) 103 | } 104 | 105 | export default HanaModal 106 | -------------------------------------------------------------------------------- /src/components/common/header/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { AppState } from '@/store/types' 3 | import { Icon } from '@iconify/react' 4 | import { AnimatePresence } from 'framer-motion' 5 | import { useEffect, useState } from 'react' 6 | import { useSelector } from 'react-redux' 7 | import { Link, useLocation } from 'react-router' 8 | import logo from '@/assets/svgs/logo.svg' 9 | import AnimatedDiv from '@/components/motion/animated-div' 10 | import { 11 | HEADER_MENU_LIST, 12 | HEADER_MENU_LIST_VISITOR, 13 | SIDEBAR_WHITE_LIST, 14 | TRIGGER_MAX_WIDTH, 15 | TRIGGER_MIN_WIDTH, 16 | } from '@/utils/constants' 17 | 18 | interface SidebarProps { 19 | width: number 20 | className?: string 21 | visible: boolean 22 | setVisible: (visible: boolean) => void 23 | } 24 | 25 | const Sidebar: FC = ({ width, className, visible, setVisible }) => { 26 | const { isLogin } = useSelector((state: AppState) => state.user) 27 | 28 | const location = useLocation() 29 | const [maskTrigger, setMaskTrigger] = useState(true) 30 | 31 | useEffect(() => setMaskTrigger(true), [location.pathname]) 32 | 33 | useEffect(() => { 34 | if (!SIDEBAR_WHITE_LIST.test(location.pathname)) 35 | return 36 | if (width < TRIGGER_MIN_WIDTH) 37 | setMaskTrigger(true) 38 | if (width > TRIGGER_MAX_WIDTH) 39 | setMaskTrigger(false) 40 | }, [width, location.pathname]) 41 | 42 | return ( 43 | 44 | {maskTrigger && visible && ( 45 | setVisible(false)} 50 | /> 51 | )} 52 | 53 | {visible && ( 54 | 59 |
60 | setVisible(false)} 66 | /> 67 | picals-logo 68 |
69 | 70 |
    71 | {(isLogin ? HEADER_MENU_LIST : HEADER_MENU_LIST_VISITOR).map(item => ( 72 | 73 |
  • 76 | 77 | {item.name} 78 |
  • 79 | 80 | ))} 81 |
82 |
83 | )} 84 |
85 | ) 86 | } 87 | 88 | export default Sidebar 89 | -------------------------------------------------------------------------------- /src/components/common/label-img-item/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { Link } from 'react-router' 3 | import { isWarmHue } from '@/utils' 4 | 5 | import LazyImg from '../lazy-img' 6 | 7 | interface LabelImgItemProps { 8 | id: string 9 | name: string 10 | color: string 11 | cover: string | null 12 | } 13 | 14 | const LabelImgItem: FC = ({ name, color, cover }) => { 15 | return ( 16 | 20 | 27 |
28 |
29 | 30 | # 31 | {name} 32 | 33 |
34 | 35 | ) 36 | } 37 | 38 | export default LabelImgItem 39 | -------------------------------------------------------------------------------- /src/components/common/label-item/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { Link } from 'react-router' 3 | import { isWarmHue } from '@/utils' 4 | 5 | interface LabelItemProps { 6 | id: string 7 | name: string 8 | color: string 9 | } 10 | 11 | const LabelItem: FC = ({ name, color }) => { 12 | return ( 13 | 18 | 19 | # 20 | {name} 21 | 22 | 23 | ) 24 | } 25 | 26 | export default LabelItem 27 | -------------------------------------------------------------------------------- /src/components/common/lazy-img/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { AnimatePresence } from 'framer-motion' 3 | import { useEffect, useRef, useState } from 'react' 4 | import AnimatedDiv from '@/components/motion/animated-div' 5 | import ImgLoadingSkeleton from '@/components/skeleton/img-loading' 6 | 7 | type LazyImgProps = { 8 | width?: number | string 9 | height?: number | string 10 | imgLoaded?: (url: string) => void 11 | } & React.ImgHTMLAttributes 12 | 13 | const LazyImg: FC = ({ 14 | width = '100%', 15 | height = '100%', 16 | imgLoaded, 17 | className, 18 | src, 19 | alt, 20 | }) => { 21 | const [imgLoading, setImgLoading] = useState(true) 22 | const [isInView, setIsInView] = useState(false) 23 | const imgContainerRef = useRef(null) 24 | const imgRef = useRef(null) 25 | 26 | useEffect(() => { 27 | const observer = new IntersectionObserver((entries) => { 28 | entries.forEach( 29 | (entry) => { 30 | if (entry.isIntersecting) { 31 | setIsInView(true) 32 | observer.disconnect() 33 | } 34 | }, 35 | { threshold: 0.1 }, 36 | ) 37 | }) 38 | 39 | if (imgContainerRef.current) 40 | observer.observe(imgContainerRef.current) 41 | 42 | return () => { 43 | if (imgContainerRef.current) 44 | observer.unobserve(imgContainerRef.current) 45 | } 46 | }, []) 47 | 48 | return ( 49 |
57 | {isInView && ( 58 | {alt} { 65 | setImgLoading(false) 66 | imgLoaded && src && imgLoaded(src) 67 | }} 68 | /> 69 | )} 70 | 71 | {imgLoading && ( 72 | 73 | 74 | 75 | )} 76 | 77 |
78 | ) 79 | } 80 | 81 | export default LazyImg 82 | -------------------------------------------------------------------------------- /src/components/common/loading/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { ProgressBar } from 'react-loader-spinner' 3 | import AnimatedDiv from '@/components/motion/animated-div' 4 | 5 | interface LoadingProps { 6 | loading: boolean 7 | text?: string 8 | } 9 | 10 | const Loading: FC = ({ loading, text }) => { 11 | return ( 12 | loading && ( 13 | 17 |
18 | 19 | {text && {text}} 20 |
21 |
22 | ) 23 | ) 24 | } 25 | 26 | export default Loading 27 | -------------------------------------------------------------------------------- /src/components/common/user-item/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { AppState } from '@/store/types' 3 | import type { UserItemInfo } from '@/utils/types' 4 | import { Button } from 'antd' 5 | import { useSelector } from 'react-redux' 6 | import { Link } from 'react-router' 7 | import { MAX_WIDTH } from '@/utils' 8 | 9 | import Empty from '../empty' 10 | import LazyImg from '../lazy-img' 11 | import WorkItem from '../work-item' 12 | 13 | type UserItemProps = UserItemInfo & { 14 | width: number 15 | follow: (id: string) => void 16 | likeWork: (userId: string, workId: string) => void 17 | } 18 | 19 | const UserItem: FC = ({ 20 | id, 21 | username, 22 | email, 23 | intro, 24 | avatar, 25 | works, 26 | follow, 27 | likeWork, 28 | isFollowing, 29 | width, 30 | }) => { 31 | const { isLogin } = useSelector((state: AppState) => state.user) 32 | const { id: localUserId } = useSelector((state: AppState) => state.user.userInfo) 33 | return ( 34 |
35 | 39 | 40 | 41 | 42 |
43 | 44 | {username} 45 | {email} 46 | 47 | 48 |
49 | {intro} 50 |
51 | 52 | {isLogin 53 | && (localUserId !== id 54 | ? ( 55 | isFollowing 56 | ? ( 57 | 66 | ) 67 | : ( 68 | 77 | ) 78 | ) 79 | : ( 80 | 83 | ))} 84 |
85 | 86 | {!works || works.length === 0 87 | ? ( 88 | 89 | ) 90 | : ( 91 |
92 | {works.slice(0, width === MAX_WIDTH ? 4 : 3).map(work => ( 93 | 94 | ))} 95 |
96 | )} 97 |
98 | ) 99 | } 100 | 101 | export default UserItem 102 | -------------------------------------------------------------------------------- /src/components/common/virtual-list/index.tsx: -------------------------------------------------------------------------------- 1 | import type { CSSProperties } from 'react' 2 | import React, { useCallback, useEffect, useMemo, useState } from 'react' 3 | import { cn } from '@/utils' 4 | 5 | export interface VirtualListProps { 6 | direction: 'vertical' | 'horizontal' 7 | length: number 8 | itemLength: number 9 | data: any[] 10 | renderItem: (item: any, index: number) => React.ReactNode 11 | ref?: React.RefObject<(HTMLDivElement & { posData: { id: string, left: number }[] }) | null> 12 | children?: React.ReactNode 13 | onMouseEnter?: (e: React.MouseEvent) => void 14 | onMouseLeave?: (e: React.MouseEvent) => void 15 | } 16 | 17 | function VirtualList({ 18 | direction, 19 | length, 20 | itemLength, 21 | data, 22 | renderItem, 23 | ref, 24 | children, 25 | onMouseEnter, 26 | onMouseLeave, 27 | }: VirtualListProps) { 28 | const isVertical = direction === 'vertical' 29 | 30 | const [scrollPos, setScrollPos] = useState(0) 31 | 32 | const gap = useMemo(() => { 33 | const containableCount = Math.floor(length / itemLength) 34 | return (length - containableCount * itemLength) / (containableCount - 1) 35 | }, [length, itemLength]) 36 | 37 | useEffect(() => { 38 | if (ref && ref.current) { 39 | ref.current.posData = data.map((item, index) => ({ 40 | id: item.id, 41 | left: index * (itemLength + gap), 42 | })) 43 | } 44 | }, [data, itemLength, gap, ref]) 45 | 46 | const visibleMap = useMemo(() => { 47 | const visibleCount = Math.ceil(length / itemLength) // 比可视区域多渲染一个 48 | const startIndex = Math.floor(scrollPos / (itemLength + gap)) 49 | return { start: startIndex, end: startIndex + visibleCount } 50 | }, [scrollPos, length, itemLength]) 51 | 52 | const startOffset = useMemo( 53 | () => visibleMap.start * (itemLength + gap), 54 | [visibleMap, gap, itemLength], 55 | ) 56 | 57 | const totalSize = useMemo( 58 | () => data.length * itemLength + gap * (data.length - 1), 59 | [gap, data, itemLength], 60 | ) 61 | 62 | const onScroll = useCallback( 63 | (e: React.UIEvent) => { 64 | const target = e.target as HTMLDivElement 65 | setScrollPos(isVertical ? target.scrollTop : target.scrollLeft) 66 | }, 67 | [isVertical], 68 | ) 69 | 70 | const containerStyle = useMemo(() => { 71 | return isVertical ? { height: `${length}px` } : { width: `${length}px`, whiteSpace: 'nowrap' } 72 | }, [isVertical, length]) 73 | 74 | const contentStyle = useMemo(() => { 75 | return isVertical ? { height: `${totalSize}px` } : { width: `${totalSize}px` } 76 | }, [isVertical, totalSize]) 77 | 78 | const listStyle = useMemo(() => { 79 | return isVertical 80 | ? { 81 | transform: `translateY(${startOffset}px)`, 82 | display: 'flex', 83 | flexDirection: 'column', 84 | gap: `${gap}px`, 85 | } 86 | : { 87 | transform: `translateX(${startOffset}px)`, 88 | display: 'flex', 89 | gap: `${gap}px`, 90 | } 91 | }, [isVertical, startOffset, gap]) 92 | 93 | return ( 94 |
95 |
101 |
102 |
103 | {data 104 | .slice(visibleMap.start, visibleMap.end) 105 | .map((item, index) => renderItem(item, index + visibleMap.start))} 106 |
107 |
108 |
109 | {children} 110 |
111 | ) 112 | } 113 | 114 | export default VirtualList 115 | -------------------------------------------------------------------------------- /src/components/common/waterfall-item/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { WorkNormalItem } from '@/apis/types' 3 | import { AnimatePresence } from 'framer-motion' 4 | import { useState } from 'react' 5 | 6 | import { Link } from 'react-router' 7 | import AnimatedDiv from '@/components/motion/animated-div' 8 | import LazyImg from '../lazy-img' 9 | 10 | interface WaterfallItemProps { 11 | item: WorkNormalItem 12 | height: number 13 | [key: string]: any 14 | } 15 | 16 | const WaterfallItem: FC = ({ item, height, ...props }) => { 17 | const [hovering, setHovering] = useState(false) 18 | 19 | return ( 20 | setHovering(true)} 26 | onMouseLeave={() => setHovering(false)} 27 | > 28 | 29 | {hovering && ( 30 | 31 | 35 | 36 | 作品名称: 37 | {item.name} 38 | 39 | 40 | 转载人: 41 | {item.authorName} 42 | 43 | 44 | 转载时间: 45 | {item.createdAt} 46 | 47 | 48 | 49 | )} 50 | 51 | 52 | 53 | ) 54 | } 55 | 56 | export default WaterfallItem 57 | -------------------------------------------------------------------------------- /src/components/common/work-item/types.ts: -------------------------------------------------------------------------------- 1 | import type { HistoryItem } from '@/apis/types' 2 | import type { AnimationVariantKeys } from '@/components/motion/preset' 3 | import type { WorkNormalItemInfo } from '@/utils/types' 4 | 5 | interface PersonalCenterProps { 6 | type: 'personal_center' 7 | itemInfo: WorkNormalItemInfo 8 | like: (id: string) => void 9 | deleteWork: (id: string) => void 10 | } 11 | interface FavoriteProps { 12 | type: 'favorite' 13 | itemInfo: WorkNormalItemInfo 14 | settingStatus: boolean 15 | chooseStatus: boolean 16 | like: (id: string) => void 17 | choose: (id: string) => void 18 | cancel: (id: string) => void 19 | move: (id: string) => void 20 | copy: (id: string) => void 21 | } 22 | interface HistoryProps { 23 | type: 'history' 24 | itemInfo: HistoryItem 25 | } 26 | interface UserWorkProps { 27 | type: 'user_work' 28 | itemInfo: WorkNormalItemInfo 29 | like: (userId: string, workId: string) => void 30 | } 31 | interface LeastProps { 32 | type: 'least' 33 | itemInfo: WorkNormalItemInfo 34 | } 35 | interface LittleProps { 36 | type: 'little' 37 | itemInfo: WorkNormalItemInfo 38 | like: (id: string) => void 39 | } 40 | interface NormalProps { 41 | type: 'normal' 42 | itemInfo: WorkNormalItemInfo 43 | like: (id: string) => void 44 | } 45 | 46 | type T = 47 | | PersonalCenterProps 48 | | FavoriteProps 49 | | HistoryProps 50 | | UserWorkProps 51 | | LeastProps 52 | | LittleProps 53 | | NormalProps 54 | 55 | export type WorkItemProps = Partial> & 56 | Omit & { 57 | animation?: AnimationVariantKeys 58 | [key: string]: any 59 | } 60 | 61 | export type WorkItemType = 62 | | 'normal' 63 | | 'little' 64 | | 'least' 65 | | 'personal_center' 66 | | 'favorite' 67 | | 'history' 68 | | 'user_work' 69 | -------------------------------------------------------------------------------- /src/components/explore/latest-list/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { WorkNormalItemInfo } from '@/utils/types' 3 | import { useEffect, useState } from 'react' 4 | import { useDispatch } from 'react-redux' 5 | import { useLocation } from 'react-router' 6 | import { getLatestWorksAPI, likeActionsAPI } from '@/apis' 7 | import AnimatedList from '@/components/common/animated-list' 8 | import WorkListSkeleton from '@/components/skeleton/work-list' 9 | import { useAtBottom } from '@/hooks' 10 | import { 11 | pushToLatestWorkList, 12 | resetOtherList, 13 | setCurrentList, 14 | setPrevPosition, 15 | } from '@/store/modules/viewList' 16 | 17 | const LatestList: FC = () => { 18 | const location = useLocation() 19 | 20 | const dispatch = useDispatch() 21 | 22 | const [current, setCurrent] = useState(1) 23 | const [latestWorkList, setLatestWorkList] = useState< 24 | { 25 | page: number 26 | list: WorkNormalItemInfo[] 27 | }[] 28 | >([{ page: current, list: [] }]) 29 | const atBottom = useAtBottom() 30 | const [isFinal, setIsFinal] = useState(false) 31 | 32 | const getLatestWorks = async () => { 33 | try { 34 | const { data } = await getLatestWorksAPI({ pageSize: 30, current }) 35 | if (data.length < 30) 36 | setIsFinal(true) 37 | setLatestWorkList((prev) => { 38 | const result = prev.map((item) => { 39 | if (item.page === current) { 40 | return { page: item.page, list: data } 41 | } 42 | return item 43 | }) 44 | if (!isFinal) 45 | result.push({ page: current + 1, list: [] }) 46 | return result 47 | }) 48 | } 49 | catch (error) { 50 | console.error('出现错误了喵!!', error) 51 | } 52 | } 53 | 54 | useEffect(() => { 55 | if (atBottom && !isFinal) 56 | setCurrent(prev => prev + 1) 57 | }, [atBottom]) 58 | 59 | useEffect(() => { 60 | getLatestWorks() 61 | }, [current]) 62 | 63 | const handleLike = async (page: number, id: string) => { 64 | await likeActionsAPI({ id }) 65 | setLatestWorkList( 66 | latestWorkList.map((item) => { 67 | if (item.page === page) { 68 | return { 69 | page: item.page, 70 | list: item.list.map((work) => { 71 | if (work.id === id) { 72 | return { ...work, isLiked: !work.isLiked } 73 | } 74 | return work 75 | }), 76 | } 77 | } 78 | return item 79 | }), 80 | ) 81 | } 82 | 83 | const addLatestWorks = () => { 84 | dispatch(resetOtherList()) 85 | const result = latestWorkList.reduce((prev, current) => { 86 | return prev.concat(current.list.map(item => item.id)) 87 | }, [] as string[]) 88 | dispatch(pushToLatestWorkList(result)) 89 | dispatch(setCurrentList('latestWorkList')) 90 | dispatch(setPrevPosition(location.pathname + location.search)) 91 | } 92 | 93 | return ( 94 |
95 |
96 | 最新发布 97 |
98 | 99 | {latestWorkList.map( 100 | everyPage => 101 | everyPage.list.length !== 0 && ( 102 | handleLike(everyPage.page, id)} 106 | onClick={addLatestWorks} 107 | /> 108 | ), 109 | )} 110 | 111 | {!isFinal && } 112 |
113 | ) 114 | } 115 | 116 | export default LatestList 117 | -------------------------------------------------------------------------------- /src/components/explore/work-list/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { Pagination } from '@/apis/types' 3 | import type { AppState } from '@/store/types' 4 | import type { WorkNormalItemInfo } from '@/utils/types' 5 | import { useEffect, useState } from 'react' 6 | import { useDispatch, useSelector } from 'react-redux' 7 | import { useLocation } from 'react-router' 8 | import { getRecommendWorksAPI, likeActionsAPI } from '@/apis' 9 | import AnimatedList from '@/components/common/animated-list' 10 | import WorkListSkeleton from '@/components/skeleton/work-list' 11 | import { useAtBottom } from '@/hooks' 12 | import { setTempId } from '@/store/modules/user' 13 | import { 14 | pushToRecommendWorkList, 15 | resetOtherList, 16 | setCurrentList, 17 | setPrevPosition, 18 | } from '@/store/modules/viewList' 19 | import { generateTempId } from '@/utils' 20 | 21 | const WorkList: FC = () => { 22 | const location = useLocation() 23 | 24 | const dispatch = useDispatch() 25 | const { tempId, isLogin } = useSelector((state: AppState) => state.user) 26 | 27 | const [current, setCurrent] = useState(1) 28 | const [recommendWorkList, setRecommendWorkList] = useState< 29 | { 30 | page: number 31 | list: WorkNormalItemInfo[] 32 | }[] 33 | >([{ page: current, list: [] }]) 34 | const atBottom = useAtBottom() 35 | const [isFinal, setIsFinal] = useState(false) 36 | 37 | const getRecommendWorks = async () => { 38 | try { 39 | const params: Pagination = { pageSize: 30, current } 40 | if (!isLogin) { 41 | if (!tempId) 42 | dispatch(setTempId(generateTempId())) 43 | params.id = tempId 44 | } 45 | const { data } = await getRecommendWorksAPI(params) 46 | if (data.length < 30) 47 | setIsFinal(true) 48 | setRecommendWorkList((prev) => { 49 | const result = prev.map((item) => { 50 | if (item.page === current) { 51 | return { page: item.page, list: data } 52 | } 53 | return item 54 | }) 55 | if (!isFinal) 56 | result.push({ page: current + 1, list: [] }) 57 | return result 58 | }) 59 | } 60 | catch (error) { 61 | console.error('出现错误了喵!!', error) 62 | } 63 | } 64 | 65 | useEffect(() => { 66 | if (atBottom && !isFinal) 67 | setCurrent(prev => prev + 1) 68 | }, [atBottom]) 69 | 70 | useEffect(() => { 71 | getRecommendWorks() 72 | }, [current]) 73 | 74 | const handleLike = async (page: number, id: string) => { 75 | await likeActionsAPI({ id }) 76 | setRecommendWorkList( 77 | recommendWorkList.map((item) => { 78 | if (item.page === page) { 79 | return { 80 | page: item.page, 81 | list: item.list.map((work) => { 82 | if (work.id === id) { 83 | return { ...work, isLiked: !work.isLiked } 84 | } 85 | return work 86 | }), 87 | } 88 | } 89 | return item 90 | }), 91 | ) 92 | } 93 | 94 | const addRecommendWorks = () => { 95 | dispatch(resetOtherList()) 96 | const result = recommendWorkList.reduce((prev, current) => { 97 | return prev.concat(current.list.map(item => item.id)) 98 | }, [] as string[]) 99 | dispatch(pushToRecommendWorkList(result)) 100 | dispatch(setCurrentList('recommendWorkList')) 101 | dispatch(setPrevPosition(location.pathname + location.search)) 102 | } 103 | 104 | return ( 105 |
106 |
107 | 推荐作品 108 |
109 | 110 | {recommendWorkList.map( 111 | everyPage => 112 | everyPage.list.length !== 0 && ( 113 | handleLike(everyPage.page, id)} 117 | onClick={addRecommendWorks} 118 | /> 119 | ), 120 | )} 121 | 122 | {!isFinal && } 123 |
124 | ) 125 | } 126 | 127 | export default WorkList 128 | -------------------------------------------------------------------------------- /src/components/followed-new/main-list/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { AppState } from '@/store/types' 3 | import type { WorkNormalItemInfo } from '@/utils/types' 4 | import { AnimatePresence } from 'framer-motion' 5 | import { useEffect, useState } from 'react' 6 | import { useDispatch, useSelector } from 'react-redux' 7 | import { useLocation } from 'react-router' 8 | import { getFollowNewWorksAPI, getFollowNewWorksIdListAPI, likeActionsAPI } from '@/apis' 9 | import AnimatedList from '@/components/common/animated-list' 10 | import Empty from '@/components/common/empty' 11 | import AnimatedDiv from '@/components/motion/animated-div' 12 | import WorkListSkeleton from '@/components/skeleton/work-list' 13 | import { useMap } from '@/hooks/useMap' 14 | import { 15 | pushToFollowingNewWorkList, 16 | resetOtherList, 17 | setCurrentList, 18 | setPrevPosition, 19 | } from '@/store/modules/viewList' 20 | 21 | interface MainListProps { 22 | pageSize: number 23 | current: number 24 | } 25 | 26 | const MainList: FC = ({ pageSize, current }) => { 27 | const location = useLocation() 28 | const dispatch = useDispatch() 29 | 30 | const { isLogin } = useSelector((state: AppState) => state.user) 31 | const [workList, setWorkList, updateWorkList] = useMap([]) 32 | const [loading, setLoading] = useState(true) 33 | 34 | const getFollowNewWorks = async () => { 35 | setLoading(true) 36 | try { 37 | const { data } = await getFollowNewWorksAPI({ pageSize, current }) 38 | setWorkList(data) 39 | } 40 | catch (error) { 41 | console.error('出现错误了喵!!', error) 42 | return 43 | } 44 | finally { 45 | setLoading(false) 46 | } 47 | } 48 | 49 | useEffect(() => { 50 | getFollowNewWorks() 51 | }, [current, pageSize]) 52 | 53 | const handleLike = async (id: string) => { 54 | await likeActionsAPI({ id }) 55 | updateWorkList(id, { ...workList.get(id)!, isLiked: !workList.get(id)!.isLiked }) 56 | } 57 | 58 | const addFollowedNewWorkList = async () => { 59 | const { data } = await getFollowNewWorksIdListAPI() 60 | dispatch(resetOtherList()) 61 | dispatch(pushToFollowingNewWorkList(data)) 62 | dispatch(setCurrentList('followingNewWorkList')) 63 | dispatch(setPrevPosition(location.pathname + location.search)) 64 | } 65 | 66 | return ( 67 |
68 |
69 | 已关注用户新作 70 |
71 | 72 | {isLogin 73 | ? ( 74 | <> 75 | {workList.size !== 0 && !loading && ( 76 | 81 | )} 82 | 83 | 84 | {workList.size === 0 && !loading && ( 85 | 86 | 87 | 88 | )} 89 | 90 | {workList.size === 0 && loading && ( 91 | 92 | 93 | 94 | )} 95 | 96 | 97 | ) 98 | : ( 99 | 100 | )} 101 |
102 | ) 103 | } 104 | 105 | export default MainList 106 | -------------------------------------------------------------------------------- /src/components/home/followed-works/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { AppState } from '@/store/types' 3 | import type { WorkNormalItemInfo } from '@/utils/types' 4 | import { AnimatePresence } from 'framer-motion' 5 | import { useEffect } from 'react' 6 | import { useDispatch, useSelector } from 'react-redux' 7 | import { getFollowNewWorksIdListAPI, likeActionsAPI } from '@/apis' 8 | import Empty from '@/components/common/empty' 9 | import LayoutList from '@/components/common/layout-list' 10 | import WorkItem from '@/components/common/work-item' 11 | import AnimatedDiv from '@/components/motion/animated-div' 12 | import WorkListSkeleton from '@/components/skeleton/work-list' 13 | import { useMap } from '@/hooks' 14 | import { 15 | pushToFollowingNewWorkList, 16 | resetOtherList, 17 | setCurrentList, 18 | setPrevPosition, 19 | } from '@/store/modules/viewList' 20 | 21 | interface FollowedWorksProps { 22 | loading: boolean 23 | workList: WorkNormalItemInfo[] 24 | } 25 | 26 | const FollowedWorks: FC = ({ loading, workList: sourceData }) => { 27 | const dispatch = useDispatch() 28 | const { isLogin } = useSelector((state: AppState) => state.user) 29 | const [workList, setWorkList, updateWorkList] = useMap([]) 30 | 31 | useEffect(() => { 32 | setWorkList(sourceData) 33 | }, [sourceData]) 34 | 35 | const handleLike = async (id: string) => { 36 | await likeActionsAPI({ id }) 37 | updateWorkList(id, { ...workList.get(id)!, isLiked: !workList.get(id)!.isLiked }) 38 | } 39 | 40 | const addFollowedNewWorkList = async () => { 41 | const { data } = await getFollowNewWorksIdListAPI() 42 | dispatch(resetOtherList()) 43 | dispatch(pushToFollowingNewWorkList(data)) 44 | dispatch(setCurrentList('followingNewWorkList')) 45 | dispatch(setPrevPosition(location.pathname + location.search)) 46 | } 47 | 48 | return ( 49 |
50 |
51 | 已关注用户新作 52 |
53 | 54 | {isLogin 55 | ? ( 56 | 57 | {sourceData.length !== 0 && !loading && ( 58 | 59 | 60 | {Array.from(workList.values()).map(item => ( 61 | 67 | ))} 68 | 69 | 70 | )} 71 | 72 | {sourceData.length === 0 && !loading && ( 73 | 74 | 75 | 76 | )} 77 | 78 | {sourceData.length === 0 && loading && ( 79 | 80 | 81 | 82 | )} 83 | 84 | ) 85 | : ( 86 | 87 | )} 88 |
89 | ) 90 | } 91 | 92 | export default FollowedWorks 93 | -------------------------------------------------------------------------------- /src/components/home/label-list/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { LabelInfo } from '@/utils/types' 3 | import Empty from '@/components/common/empty' 4 | import LabelItem from '@/components/common/label-item' 5 | import LayoutList from '@/components/common/layout-list' 6 | import AnimatedDiv from '@/components/motion/animated-div' 7 | import LabelListSkeleton from '@/components/skeleton/label-list' 8 | 9 | interface LabelListProps { 10 | loading: boolean 11 | labelList: LabelInfo[] 12 | } 13 | 14 | const LabelList: FC = ({ labelList, loading }) => { 15 | return ( 16 |
17 | {labelList.length !== 0 && !loading && ( 18 | 19 | 20 | {labelList.map(item => ( 21 | 22 | ))} 23 | 24 | 25 | )} 26 | 27 | {labelList.length === 0 && !loading && ( 28 | 29 | 30 | 31 | )} 32 | 33 | {labelList.length === 0 && loading && ( 34 | 35 | 36 | 37 | )} 38 |
39 | ) 40 | } 41 | 42 | export default LabelList 43 | -------------------------------------------------------------------------------- /src/components/home/recommended-works/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { WorkNormalItemInfo } from '@/utils/types' 3 | import { useEffect } from 'react' 4 | import { useDispatch } from 'react-redux' 5 | import { useLocation } from 'react-router' 6 | import { likeActionsAPI } from '@/apis' 7 | import AnimatedList from '@/components/common/animated-list' 8 | import Empty from '@/components/common/empty' 9 | import AnimatedDiv from '@/components/motion/animated-div' 10 | import WorkListSkeleton from '@/components/skeleton/work-list' 11 | import { useMap } from '@/hooks' 12 | import { 13 | pushToRecommendWorkList, 14 | resetOtherList, 15 | setCurrentList, 16 | setPrevPosition, 17 | } from '@/store/modules/viewList' 18 | 19 | interface RecommendedWorksProps { 20 | loading: boolean 21 | workList: WorkNormalItemInfo[] 22 | } 23 | 24 | const RecommendedWorks: FC = ({ loading, workList: sourceData }) => { 25 | const location = useLocation() 26 | const dispatch = useDispatch() 27 | const [workList, setWorkList, setWorkMapList] = useMap([]) 28 | 29 | useEffect(() => { 30 | setWorkList(sourceData) 31 | }, [sourceData]) 32 | 33 | const handleLike = async (id: string) => { 34 | await likeActionsAPI({ id }) 35 | setWorkMapList(id, { ...workList.get(id)!, isLiked: !workList.get(id)!.isLiked }) 36 | } 37 | 38 | const addRecommendWorks = () => { 39 | dispatch(resetOtherList()) 40 | dispatch(pushToRecommendWorkList(sourceData.map(item => item.id))) 41 | dispatch(setCurrentList('recommendWorkList')) 42 | dispatch(setPrevPosition(location.pathname + location.search)) 43 | } 44 | 45 | return ( 46 |
47 |
48 | 推荐作品 49 |
50 | 51 | {workList.size !== 0 && !loading && ( 52 | 57 | )} 58 | 59 | {workList.size === 0 && !loading && ( 60 | 61 | 62 | 63 | )} 64 | 65 | {workList.size === 0 && loading && ( 66 | 67 | 68 | 69 | )} 70 |
71 | ) 72 | } 73 | 74 | export default RecommendedWorks 75 | -------------------------------------------------------------------------------- /src/components/illustrator/info-modal.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { IllustratorInfo } from '@/apis/illustrator/types' 3 | import { useEffect, useState } from 'react' 4 | import { PhotoView } from 'react-photo-view' 5 | import { Link } from 'react-router' 6 | 7 | import HanaModal from '../common/hana-modal' 8 | import HanaViewer from '../common/hana-viewer' 9 | 10 | interface InfoModalProps { 11 | visible: boolean 12 | setVisible: (visible: boolean) => void 13 | info: IllustratorInfo 14 | } 15 | 16 | const InfoModal: FC = ({ visible, setVisible, info }) => { 17 | const [illustratorAvatar, setIllustratorAvatar] = useState('') 18 | 19 | useEffect(() => { 20 | setIllustratorAvatar(info.avatar || `https://fakeimg.pl/400x400?font=noto&text=${info.name}`) 21 | }, [info.avatar]) 22 | 23 | return ( 24 | 25 | <> 26 |
27 |
28 | 29 |
30 | 31 | {info.name} 36 | 37 |
38 |
39 | {info.name} 40 |
41 |
42 | 43 |
44 |
45 | 现收录作品数 46 | {info.workNum} 47 |
48 |
49 | 个人简介 50 | {info.intro || '暂无关于该插画家的简介,待补充!'} 51 |
52 |
53 | 个人主页 54 | 55 | {info.homeUrl} 56 | 57 |
58 |
59 | 60 |
61 | ) 62 | } 63 | 64 | export default InfoModal 65 | -------------------------------------------------------------------------------- /src/components/login/bg-slide/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { debounce } from 'lodash' 3 | import { useEffect, useRef, useState } from 'react' 4 | import { getRandomBackgroundsAPI } from '@/apis' 5 | import LazyImg from '@/components/common/lazy-img' 6 | 7 | const BgSlide: FC = () => { 8 | const slideWindow = useRef(null) 9 | const bgImgListRef = useRef([]) 10 | const intervalRef = useRef(null) 11 | 12 | const [bgImgList, setBgImgList] = useState([]) 13 | const [chosenIdList, setChosenIdList] = useState([]) 14 | const [loadedImgs, setLoadedImgs] = useState([]) 15 | const [isFetching, setIsFetching] = useState(false) 16 | const [isPaused, setIsPaused] = useState(true) 17 | const [index, setIndex] = useState(0) 18 | 19 | const getRandomBackgrounds = async () => { 20 | if (isFetching) 21 | return 22 | setIsFetching(true) 23 | try { 24 | const { data } = await getRandomBackgroundsAPI({ chosenIdList, device: 'desktop' }) 25 | setBgImgList((prev) => { 26 | if (!data.result) 27 | return prev 28 | const newBgImgList = prev.concat(data.result) 29 | bgImgListRef.current = newBgImgList 30 | return newBgImgList 31 | }) 32 | setChosenIdList(data.chosenIdList) 33 | } 34 | catch (error) { 35 | console.error('出现错误了喵!!', error) 36 | return 37 | } 38 | finally { 39 | setIsFetching(false) 40 | } 41 | } 42 | 43 | useEffect(() => { 44 | getRandomBackgrounds() 45 | }, []) 46 | 47 | useEffect(() => { 48 | const debouncedGetRandomBackgrounds = debounce(getRandomBackgrounds, 1000) 49 | debouncedGetRandomBackgrounds() 50 | if (chosenIdList.length === 10) { 51 | debouncedGetRandomBackgrounds.cancel() 52 | } 53 | return () => { 54 | debouncedGetRandomBackgrounds.cancel() 55 | } 56 | }, [chosenIdList]) 57 | 58 | const imgLoaded = (url: string) => { 59 | setLoadedImgs((prev) => { 60 | if (prev.includes(url)) 61 | return prev 62 | return [...prev, url] 63 | }) 64 | } 65 | 66 | useEffect(() => { 67 | if (loadedImgs.length <= index) { 68 | setIsPaused(true) 69 | } 70 | else { 71 | setIsPaused(false) 72 | } 73 | }, [loadedImgs, index]) 74 | 75 | const slideImg = () => { 76 | setIndex((prevIndex) => { 77 | const newIndex = prevIndex === bgImgListRef.current.length - 1 ? 0 : prevIndex + 1 78 | if (slideWindow.current) { 79 | slideWindow.current.style.transform = `translateX(-${newIndex * 100}vw)` 80 | } 81 | return newIndex 82 | }) 83 | } 84 | 85 | useEffect(() => { 86 | if (isPaused && intervalRef.current) { 87 | clearInterval(intervalRef.current) 88 | } 89 | else { 90 | if (!isPaused) { 91 | intervalRef.current = setInterval(slideImg, 5000) 92 | } 93 | } 94 | return () => clearInterval(intervalRef.current!) 95 | }, [isPaused]) 96 | 97 | return ( 98 |
99 | {/* 阻止用户选中图片 */} 100 |
101 |
102 | {bgImgList.map(bgImg => ( 103 | 104 | ))} 105 |
106 |
107 | ) 108 | } 109 | 110 | export default BgSlide 111 | -------------------------------------------------------------------------------- /src/components/motion/animated-div.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps } from 'react' 2 | import type { AnimationVariantKeys } from './preset' 3 | import { motion } from 'framer-motion' 4 | import { getAnimationVariant } from './preset' 5 | 6 | interface Props extends ComponentProps { 7 | type: AnimationVariantKeys 8 | children?: React.ReactNode 9 | } 10 | 11 | function AnimatedDiv({ type, children, ...rest }: Props) { 12 | const animation = getAnimationVariant(type) 13 | 14 | return ( 15 | 22 | {children} 23 | 24 | ) 25 | } 26 | 27 | export default AnimatedDiv 28 | -------------------------------------------------------------------------------- /src/components/motion/preset.ts: -------------------------------------------------------------------------------- 1 | import type { MotionProps } from 'framer-motion' 2 | 3 | export type AnimationVariantKeys = 4 | | 'opacity-gradient' 5 | | 'down-to-up' 6 | | 'up-to-down' 7 | | 'left-to-right' 8 | 9 | const animationVariants: Record = { 10 | 'opacity-gradient': { 11 | initial: { opacity: 0 }, 12 | animate: { opacity: 1 }, 13 | exit: { opacity: 0 }, 14 | transition: { duration: 0.3, ease: 'easeInOut' }, 15 | }, 16 | 'down-to-up': { 17 | initial: { y: '100%' }, 18 | animate: { y: '0%' }, 19 | exit: { y: 'calc(100% + 1.25rem)' }, 20 | transition: { duration: 0.3, ease: 'easeInOut' }, 21 | }, 22 | 'up-to-down': { 23 | initial: { y: '-100%' }, 24 | animate: { y: '0%' }, 25 | exit: { y: 'calc(-100% - 1.25rem)' }, 26 | transition: { duration: 0.3, ease: 'easeInOut' }, 27 | }, 28 | 'left-to-right': { 29 | initial: { x: '-100%' }, 30 | animate: { x: '0%' }, 31 | exit: { x: 'calc(-100% - 1.25rem)' }, 32 | transition: { duration: 0.3, ease: 'easeInOut' }, 33 | }, 34 | } as const 35 | 36 | export function getAnimationVariant(key: AnimationVariantKeys) { 37 | return animationVariants[key] 38 | } 39 | -------------------------------------------------------------------------------- /src/components/personal-center/favorites/header.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { FavoriteDetailInfo } from '@/utils/types' 3 | import { Button } from 'antd' 4 | import { Link } from 'react-router' 5 | import LazyImg from '@/components/common/lazy-img' 6 | 7 | type HeaderProps = FavoriteDetailInfo & { 8 | setStartAppreciate: (status: boolean) => void 9 | } 10 | 11 | const Header: FC = ({ 12 | name, 13 | intro, 14 | creatorId, 15 | creatorName, 16 | cover, 17 | workNum, 18 | setStartAppreciate, 19 | }) => { 20 | return ( 21 |
22 |
23 |
24 | 29 |
30 |
31 | {name} 32 | 33 | 创建者: 34 | {creatorName} 35 | 36 | 37 | {workNum} 38 | 个内容 39 | 40 | {intro} 41 |
42 |
43 | 52 |
53 | ) 54 | } 55 | 56 | export default Header 57 | -------------------------------------------------------------------------------- /src/components/personal-center/history/search-result.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { HistoryItem } from '@/apis/types' 3 | import { Icon } from '@iconify/react' 4 | import dayjs from 'dayjs' 5 | import { useEffect, useState } from 'react' 6 | import { searchViewHistoryAPI } from '@/apis' 7 | import AnimatedList from '@/components/common/animated-list' 8 | import Empty from '@/components/common/empty' 9 | import AnimatedDiv from '@/components/motion/animated-div' 10 | import WorkListSkeleton from '@/components/skeleton/work-list' 11 | 12 | interface SearchResultProps { 13 | keyword: string 14 | searchTrigger: number 15 | } 16 | 17 | const SearchResult: FC = ({ keyword, searchTrigger }) => { 18 | const [resultList, setResultList] = useState([]) 19 | const [resultMap, setResultMap] = useState>({}) 20 | const [gettingResult, setGettingResult] = useState(true) 21 | 22 | const searchHistory = async () => { 23 | setGettingResult(true) 24 | try { 25 | const { data } = await searchViewHistoryAPI({ keyword }) 26 | setResultList(data) 27 | } 28 | catch (error) { 29 | console.error('出现错误了喵!!', error) 30 | return 31 | } 32 | finally { 33 | setGettingResult(false) 34 | } 35 | } 36 | 37 | useEffect(() => { 38 | searchHistory() 39 | }, [searchTrigger]) 40 | 41 | useEffect(() => { 42 | const map: Record = {} 43 | resultList.forEach((item) => { 44 | const date = dayjs(item.lastTime).format('YYYY-MM-DD') 45 | if (!map[date]) { 46 | map[date] = [] 47 | } 48 | map[date].push(item) 49 | }) 50 | setResultMap(map) 51 | }, [resultList]) 52 | 53 | return ( 54 |
55 | {Object.keys(resultMap).length > 0 && !gettingResult && ( 56 | 57 | {Object.keys(resultMap).map(date => ( 58 |
59 |
60 | 61 | {date} 62 |
63 | 64 |
65 | ))} 66 |
67 | )} 68 | 69 | {Object.keys(resultMap).length === 0 && !gettingResult && ( 70 | 71 | 72 | 73 | )} 74 | 75 | {Object.keys(resultMap).length === 0 && gettingResult && ( 76 | 77 | 78 | 79 | )} 80 |
81 | ) 82 | } 83 | 84 | export default SearchResult 85 | -------------------------------------------------------------------------------- /src/components/personal-center/info-modal.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { AppState } from '@/store/types' 3 | import type { UserDetailInfo } from '@/utils/types' 4 | import { Button } from 'antd' 5 | import { PhotoView } from 'react-photo-view' 6 | import { useSelector } from 'react-redux' 7 | 8 | import HanaModal from '../common/hana-modal' 9 | import HanaViewer from '../common/hana-viewer' 10 | 11 | const genderMap = { 12 | 0: '男性', 13 | 1: '女性', 14 | 2: '保密', 15 | } 16 | 17 | interface InfoModalProps { 18 | visible: boolean 19 | setVisible: (visible: boolean) => void 20 | info: UserDetailInfo 21 | follow: () => void 22 | } 23 | 24 | const InfoModal: FC = ({ visible, setVisible, info, follow }) => { 25 | const { isLogin } = useSelector((state: AppState) => state.user) 26 | const { id } = useSelector((state: AppState) => state.user.userInfo) 27 | 28 | return ( 29 | 30 | <> 31 |
32 |
33 | 34 |
35 | 36 | {info.littleAvatar} 41 | 42 |
43 |
44 | {info.username} 45 | {info.email} 46 | {isLogin && id !== info.id && ( 47 | 55 | )} 56 |
57 |
58 |
59 |
60 | 个人简介 61 | {info.intro} 62 |
63 |
64 | 个人性别 65 | {genderMap[info.gender]} 66 |
67 |
68 | 69 |
70 | ) 71 | } 72 | 73 | export default InfoModal 74 | -------------------------------------------------------------------------------- /src/components/personal-center/label-list/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { LabelInfo } from '@/utils/types' 3 | import { use, useEffect, useState } from 'react' 4 | import { getUserWorksLabelsAPI } from '@/apis' 5 | import Empty from '@/components/common/empty' 6 | import LabelItem from '@/components/common/label-item' 7 | import AnimatedDiv from '@/components/motion/animated-div' 8 | import LabelListSkeleton from '@/components/skeleton/label-list' 9 | import { PersonalContext } from '@/pages/personal-center' 10 | 11 | const LabelList: FC = () => { 12 | const { userId } = use(PersonalContext) 13 | 14 | const [labels, setLabels] = useState([]) 15 | const [gettingLabels, setGettingLabels] = useState(true) 16 | 17 | const getLabels = async () => { 18 | setGettingLabels(true) 19 | try { 20 | const { data } = await getUserWorksLabelsAPI({ id: userId! }) 21 | setLabels(data) 22 | } 23 | catch (error) { 24 | console.error('出现错误了喵!!', error) 25 | return 26 | } 27 | finally { 28 | setGettingLabels(false) 29 | } 30 | } 31 | 32 | useEffect(() => { 33 | getLabels() 34 | }, [userId]) 35 | 36 | return ( 37 |
38 | {labels.length !== 0 && !gettingLabels && ( 39 | 40 | {labels.map(label => ( 41 | 42 | ))} 43 | 44 | )} 45 | 46 | {labels.length === 0 && !gettingLabels && ( 47 | 48 | 49 | 50 | )} 51 | 52 | {labels.length === 0 && gettingLabels && ( 53 | 54 | 55 | 56 | )} 57 |
58 | ) 59 | } 60 | 61 | export default LabelList 62 | -------------------------------------------------------------------------------- /src/components/personal-center/user-list/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { UserItemInfo } from '@/utils/types' 3 | import { AnimatePresence } from 'framer-motion' 4 | import { use, useEffect, useState } from 'react' 5 | import { useDispatch } from 'react-redux' 6 | import { getFansListAPI, getFollowingListAPI, likeActionsAPI, userActionsAPI } from '@/apis' 7 | import Empty from '@/components/common/empty' 8 | import Pagination from '@/components/common/pagination' 9 | import UserItem from '@/components/common/user-item' 10 | import AnimatedDiv from '@/components/motion/animated-div' 11 | import UserListSkeleton from '@/components/skeleton/user-list' 12 | import { useMap } from '@/hooks' 13 | import { PersonalContext } from '@/pages/personal-center' 14 | import { decreaseFollowNum, increaseFollowNum } from '@/store/modules/user' 15 | 16 | interface UserListProps { 17 | width: number 18 | total: number 19 | } 20 | 21 | const UserList: FC = ({ width, total }) => { 22 | const dispatch = useDispatch() 23 | 24 | const { currentPath, userId } = use(PersonalContext) 25 | 26 | const [userList, setUserList, updateUserList] = useMap([]) // 用户列表 27 | 28 | // 关注/取消关注 29 | const handleFollow = async (id: string) => { 30 | try { 31 | await userActionsAPI({ id }) 32 | if (!userList.get(id)!.isFollowing) { 33 | dispatch(increaseFollowNum()) 34 | } 35 | else { 36 | dispatch(decreaseFollowNum()) 37 | } 38 | updateUserList(id, { ...userList.get(id)!, isFollowing: !userList.get(id)!.isFollowing }) 39 | } 40 | catch (error) { 41 | console.error('出现错误了喵!!', error) 42 | } 43 | } 44 | 45 | // 喜欢/取消喜欢用户作品 46 | const handleLikeWork = async (userId: string, workId: string) => { 47 | await likeActionsAPI({ id: workId }) 48 | updateUserList(userId, { 49 | ...userList.get(userId)!, 50 | works: userList 51 | .get(userId)! 52 | .works!.map(work => (work.id === workId ? { ...work, isLiked: !work.isLiked } : work)), 53 | }) 54 | } 55 | 56 | /* ----------分页相关--------- */ 57 | const [current, setCurrent] = useState(1) 58 | const pageSize = 6 59 | 60 | const pageChange = (page: number) => { 61 | current !== page && setCurrent(page) 62 | } 63 | 64 | const [gettingUser, setGettingUser] = useState(true) // 获取用户列表中 65 | 66 | const getUserList = async () => { 67 | setGettingUser(true) 68 | try { 69 | const { data } 70 | = currentPath === 'follow' 71 | ? await getFollowingListAPI({ 72 | id: userId, 73 | current, 74 | pageSize, 75 | }) 76 | : await getFansListAPI({ 77 | id: userId, 78 | current, 79 | pageSize, 80 | }) 81 | 82 | setUserList(data) 83 | } 84 | catch (error) { 85 | console.error('出现错误了喵!!', error) 86 | return 87 | } 88 | finally { 89 | setGettingUser(false) 90 | } 91 | } 92 | 93 | useEffect(() => { 94 | getUserList() 95 | }, [currentPath, userId, current]) 96 | 97 | return ( 98 |
99 | 100 | {userList.size !== 0 && !gettingUser && ( 101 | 102 |
103 | {Array.from(userList.values()).map(item => ( 104 | 111 | ))} 112 |
113 |
114 | )} 115 | 116 | {userList.size === 0 && !gettingUser && ( 117 | 118 | 119 | 120 | )} 121 | 122 | {userList.size === 0 && gettingUser && ( 123 | 124 | 125 | 126 | )} 127 |
128 | 129 |
130 | 131 |
132 |
133 | ) 134 | } 135 | 136 | export default UserList 137 | -------------------------------------------------------------------------------- /src/components/skeleton/favorite-list.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { Fragment } from 'react' 3 | import ContentLoader from 'react-content-loader' 4 | 5 | interface FavoriteListSkeletonProps { 6 | row?: number 7 | borderRadius?: number 8 | [key: string]: any 9 | } 10 | 11 | const FavoriteListSkeleton: FC = ({ 12 | row = 5, 13 | borderRadius = 4, 14 | ...props 15 | }) => { 16 | const width = 250 17 | const list = [] 18 | let height 19 | 20 | for (let i = 1; i <= row; i++) { 21 | const itemHeight = 60 22 | const y = itemHeight * (i - 1) 23 | 24 | // 图标 25 | const avatarSize = 24 26 | const avatarX = 22 27 | const avatarY = y + 18 28 | 29 | // 收藏夹名称 30 | const usernameWidth = 150 31 | const usernameHeight = 20 32 | const usernameX = avatarX + avatarSize + 10 33 | const usernameY = avatarY + 2 34 | 35 | list.push( 36 | 37 | 38 | 46 | , 47 | ) 48 | 49 | if (i === row) { 50 | height = y + itemHeight 51 | } 52 | } 53 | 54 | return ( 55 | 56 | {list} 57 | 58 | ) 59 | } 60 | 61 | export default FavoriteListSkeleton 62 | -------------------------------------------------------------------------------- /src/components/skeleton/favorite-work-list.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { Fragment } from 'react' 3 | import ContentLoader from 'react-content-loader' 4 | 5 | interface FavoriteWorkListSkeletonProps { 6 | row?: number 7 | column?: number 8 | padding?: number 9 | borderRadius?: number 10 | [key: string]: any 11 | } 12 | 13 | const FavoriteWorkListSkeleton: FC = ({ 14 | row = 1, 15 | column = 4, 16 | padding = 20, 17 | borderRadius = 4, 18 | ...props 19 | }) => { 20 | const width = 796 21 | const list = [] 22 | 23 | let height 24 | 25 | for (let i = 1; i <= row; i++) { 26 | for (let j = 0; j < column; j++) { 27 | const itemWidth = (width - padding * (column - 1)) / column 28 | const x = j * (itemWidth + padding) 29 | const height1 = itemWidth 30 | const height2 = 20 31 | const height3 = 20 32 | const space = padding + height1 + (padding / 2 + height2) + height3 + padding * 2 33 | const y1 = space * (i - 1) 34 | const y2 = y1 + padding + height1 35 | const y3 = y2 + padding / 2 + height2 36 | list.push( 37 | 38 | 46 | 47 | 48 | , 49 | ) 50 | 51 | if (i === row) { 52 | height = y3 + height3 53 | } 54 | } 55 | } 56 | 57 | return ( 58 | 59 | {list} 60 | 61 | ) 62 | } 63 | 64 | export default FavoriteWorkListSkeleton 65 | -------------------------------------------------------------------------------- /src/components/skeleton/img-loading.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import ContentLoader from 'react-content-loader' 3 | 4 | interface ImgLoadingSkeletonProps { 5 | width?: number | string 6 | height?: number | string 7 | [key: string]: any 8 | } 9 | 10 | const ImgLoadingSkeleton: FC = ({ 11 | width = '100%', 12 | height = '100%', 13 | ...props 14 | }) => { 15 | return ( 16 | 17 | 18 | 19 | ) 20 | } 21 | 22 | export default ImgLoadingSkeleton 23 | -------------------------------------------------------------------------------- /src/components/skeleton/label-list.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useEffect, useState } from 'react' 2 | import ContentLoader from 'react-content-loader' 3 | import { useOutletContext } from 'react-router' 4 | import { MAX_WIDTH, MIN_WIDTH, TRIGGER_MIN_WIDTH } from '@/utils' 5 | 6 | interface LabelListSkeletonProps { 7 | row?: number 8 | padding?: number 9 | borderRadius?: number 10 | [key: string]: any 11 | } 12 | 13 | function LabelListSkeleton({ 14 | row = 1, 15 | padding = 10, 16 | borderRadius = 4, 17 | ...props 18 | }: LabelListSkeletonProps) { 19 | const [width, setWidth] = useState(MAX_WIDTH) 20 | const [column, setColumn] = useState(12) 21 | const currentWidth = useOutletContext() 22 | 23 | useEffect(() => { 24 | if (currentWidth < TRIGGER_MIN_WIDTH) { 25 | setWidth(MIN_WIDTH) 26 | setColumn(10) 27 | } 28 | else { 29 | setWidth(MAX_WIDTH) 30 | setColumn(12) 31 | } 32 | }, [currentWidth]) 33 | 34 | const list = [] 35 | 36 | let height 37 | 38 | for (let i = 1; i <= row; i++) { 39 | for (let j = 0; j < column; j++) { 40 | const itemWidth = (width - padding * (column + 1)) / column 41 | const x = j * (itemWidth + padding) 42 | const height1 = 40 43 | const space = padding + height1 + padding / 2 + padding * 2 44 | const y1 = space * (i - 1) 45 | const y2 = y1 + height1 46 | list.push( 47 | 48 | 56 | , 57 | ) 58 | if (i === row) 59 | height = y2 60 | } 61 | } 62 | 63 | return ( 64 | 65 | {list} 66 | 67 | ) 68 | } 69 | 70 | export default LabelListSkeleton 71 | -------------------------------------------------------------------------------- /src/components/skeleton/user-list.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useEffect, useState } from 'react' 2 | import ContentLoader from 'react-content-loader' 3 | import { useOutletContext } from 'react-router' 4 | import { MAX_WIDTH, MIN_WIDTH, TRIGGER_MIN_WIDTH } from '@/utils' 5 | 6 | interface UserListSkeletonProps { 7 | row?: number 8 | borderRadius?: number 9 | [key: string]: any 10 | } 11 | 12 | function UserListSkeleton({ row = 2, borderRadius = 4, ...props }: UserListSkeletonProps) { 13 | const [width, setWidth] = useState(MAX_WIDTH) 14 | const currentWidth = useOutletContext() 15 | 16 | useEffect(() => { 17 | if (currentWidth < TRIGGER_MIN_WIDTH) { 18 | setWidth(MIN_WIDTH) 19 | } 20 | else { 21 | setWidth(MAX_WIDTH) 22 | } 23 | }, [currentWidth]) 24 | 25 | const list = [] 26 | let height 27 | 28 | for (let i = 1; i <= row; i++) { 29 | const itemWidth = width 30 | const itemHeight = 244 // 头像高度 + 内容高度 31 | const y = itemHeight * (i - 1) 32 | 33 | // 头像 34 | const avatarSize = 80 35 | const avatarX = 16 // 左边距 36 | const avatarY = y + 20 // 上边距 37 | 38 | // 用户名 39 | const usernameWidth = 120 40 | const usernameHeight = 24 41 | const usernameX = avatarX + avatarSize + 16 // 头像右边距 42 | const usernameY = avatarY 43 | 44 | // 简介 45 | const introWidth = 240 // 剩余宽度 46 | const introHeight = 32 47 | const introX = usernameX 48 | const introY = usernameY + usernameHeight + 16 // 用户名下边距 49 | 50 | // 关注按钮 51 | const buttonWidth = 80 52 | const buttonHeight = 32 53 | const buttonX = usernameX 54 | const buttonY = introY + introHeight + 16 // 简介下边距 55 | 56 | // 作品展示 57 | const workWidth = (itemWidth - 460) / 4 58 | const workHeight = workWidth 59 | const workX = introX + 256 60 | 61 | list.push( 62 | 63 | {/* 头像 */} 64 | 65 | 66 | {/* 用户名 */} 67 | 75 | 76 | {/* 简介 */} 77 | 85 | 86 | {/* 关注按钮 */} 87 | 95 | 96 | {/* 作品展示 */} 97 | {Array.from({ length: 4 }).fill(0).map((_, index) => ( 98 | 107 | ))} 108 | , 109 | ) 110 | 111 | if (i === row) { 112 | height = y + itemHeight 113 | } 114 | } 115 | 116 | return ( 117 | 118 | {list} 119 | 120 | ) 121 | } 122 | 123 | export default UserListSkeleton 124 | -------------------------------------------------------------------------------- /src/components/skeleton/work-list.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { Fragment, useEffect, useState } from 'react' 3 | import ContentLoader from 'react-content-loader' 4 | import { useOutletContext } from 'react-router' 5 | import { MAX_WIDTH, MIN_WIDTH, TRIGGER_MIN_WIDTH } from '@/utils' 6 | 7 | interface WorkListSkeletonProps { 8 | heading?: { 9 | width: number 10 | height: number 11 | } 12 | row?: number 13 | padding?: number 14 | borderRadius?: number 15 | [key: string]: any 16 | } 17 | 18 | const WorkListSkeleton: FC = ({ 19 | heading, 20 | row = 2, 21 | padding = 20, 22 | borderRadius = 8, 23 | ...props 24 | }) => { 25 | const [width, setWidth] = useState(MAX_WIDTH) 26 | const [column, setColumn] = useState(6) 27 | const currentWidth = useOutletContext() 28 | 29 | useEffect(() => { 30 | if (currentWidth < TRIGGER_MIN_WIDTH) { 31 | setWidth(MIN_WIDTH) 32 | setColumn(5) 33 | } 34 | else { 35 | setWidth(MAX_WIDTH) 36 | setColumn(6) 37 | } 38 | }, [currentWidth]) 39 | 40 | const list = [] 41 | 42 | let height 43 | 44 | for (let i = 1; i <= row; i++) { 45 | for (let j = 0; j < column; j++) { 46 | const itemWidth = (width - padding * (column + 1)) / column 47 | const x = j * (itemWidth + padding) 48 | const height1 = itemWidth 49 | const height2 = 20 50 | const height3 = 20 51 | const space = padding + height1 + (padding / 2 + height2) + height3 + padding * 2 52 | const y1 = (heading ? heading.height : 0) + space * (i - 1) 53 | const y2 = y1 + padding + height1 54 | const y3 = y2 + padding / 2 + height2 55 | list.push( 56 | 57 | 65 | 66 | 67 | , 68 | ) 69 | 70 | if (i === row) { 71 | height = y3 + height3 72 | } 73 | } 74 | } 75 | 76 | return ( 77 | 78 | {heading && ( 79 | 80 | )} 81 | {list} 82 | 83 | ) 84 | } 85 | 86 | export default WorkListSkeleton 87 | -------------------------------------------------------------------------------- /src/components/upload/img-upload/draggable-img.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { useSortable } from '@dnd-kit/sortable' 3 | import { CSS } from '@dnd-kit/utilities' 4 | import { PhotoView } from 'react-photo-view' 5 | 6 | interface FavoriteItemProps { 7 | id: string 8 | src: string 9 | } 10 | 11 | const DraggableImg: FC = ({ id, src }) => { 12 | const { setNodeRef, attributes, listeners, transform, transition } = useSortable({ 13 | id, 14 | transition: { 15 | duration: 500, 16 | easing: 'cubic-bezier(0.25, 1, 0.5, 1)', 17 | }, 18 | }) 19 | const styles = { 20 | transform: CSS.Transform.toString(transform), 21 | transition, 22 | } 23 | return ( 24 |
31 | 32 | {src} 33 | 34 |
35 | ) 36 | } 37 | 38 | export default DraggableImg 39 | -------------------------------------------------------------------------------- /src/components/upload/success/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { Button } from 'antd' 3 | import { useDispatch } from 'react-redux' 4 | import { useNavigate } from 'react-router' 5 | import uploadSuccessfully from '@/assets/imgs/upload-successfully.gif' 6 | import HanaCard from '@/components/common/hana-card' 7 | import LazyImg from '@/components/common/lazy-img' 8 | import { saveUploadSuccess } from '@/store/modules/uploadForm' 9 | 10 | interface UploadSuccessProps { 11 | workStatus: string | null 12 | } 13 | 14 | const UploadSuccess: FC = ({ workStatus }) => { 15 | const dispatch = useDispatch() 16 | const navigate = useNavigate() 17 | 18 | const returnHome = () => { 19 | navigate('/home') 20 | dispatch(saveUploadSuccess(false)) 21 | } 22 | 23 | const reload = () => { 24 | if (workStatus === 'edit') 25 | navigate('/upload') 26 | else window.location.reload() 27 | dispatch(saveUploadSuccess(false)) 28 | } 29 | 30 | return ( 31 | 32 |
33 | 34 |
35 | 上传成功,正在审核中~! 36 | 上传成功后会展示在首页哦~ 37 | 感谢您对小站做出的贡献!!( > w < ) 38 |
39 | 42 | 45 |
46 |
47 | ) 48 | } 49 | 50 | export default UploadSuccess 51 | -------------------------------------------------------------------------------- /src/components/work-detail/comments/comment.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { AppState } from '@/store/types' 3 | import type { CommentItem } from '@/utils/types' 4 | import { Icon } from '@iconify/react' 5 | import { Button } from 'antd' 6 | import { useState } from 'react' 7 | import { useSelector } from 'react-redux' 8 | import LazyImg from '@/components/common/lazy-img' 9 | 10 | interface Replying { 11 | id: string 12 | isChild: boolean 13 | parentId?: string 14 | userId?: string 15 | } 16 | 17 | interface CommentProps { 18 | comment: CommentItem 19 | style?: React.CSSProperties 20 | reply: (replyData: Replying) => void 21 | deleteComment: (id: string) => void 22 | } 23 | 24 | const Comment: FC = ({ comment, style, reply, deleteComment }) => { 25 | const { id } = useSelector((state: AppState) => state.user.userInfo) 26 | const [showChildComments, setShowChildComments] = useState(false) 27 | 28 | const handleReply = (id: string, isChild: boolean, parentId?: string, userId?: string) => { 29 | reply({ 30 | id, 31 | isChild, 32 | parentId, 33 | userId, 34 | }) 35 | } 36 | 37 | return ( 38 | <> 39 |
40 |
41 | 42 |
43 |
44 |
45 | {comment.authorInfo.username} 46 | {comment.authorInfo.id === id && (你)} 47 | {comment.level === 1 && comment.replyTo && ( 48 | <> 49 | 50 | {comment.replyTo.username} 51 | {comment.replyTo.id === id && (你)} 52 | 53 | )} 54 |
55 |
56 | {comment.content} 57 |
58 |
59 |
60 | {comment.createdAt} 61 | 64 | {comment.level === 0 && comment.childComments?.length !== 0 && ( 65 | 72 | )} 73 |
74 |
75 | 76 | {comment.childComments 77 | && comment.childComments?.length !== 0 78 | && `共有${comment.childComments.length}条${comment.level === 0 ? '回复' : '评论'}`} 79 | 80 | 81 | {comment.authorInfo.id === id && ( 82 |
{ 85 | deleteComment(comment.id) 86 | }} 87 | > 88 | 89 |
90 | )} 91 |
92 |
93 |
94 |
95 | {comment.childComments && showChildComments && ( 96 |
97 | {comment.childComments.map(childComment => ( 98 | handleReply(childComment.id, true, comment.id, comment.authorInfo.id)} 106 | deleteComment={deleteComment} 107 | /> 108 | ))} 109 |
110 | )} 111 | 112 | ) 113 | } 114 | 115 | export default Comment 116 | -------------------------------------------------------------------------------- /src/components/work-detail/comments/input-window.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { AppState } from '@/store/types' 3 | import { Button, Input } from 'antd' 4 | import { AnimatePresence } from 'framer-motion' 5 | import { useSelector } from 'react-redux' 6 | import LazyImg from '@/components/common/lazy-img' 7 | import AnimatedDiv from '@/components/motion/animated-div' 8 | 9 | interface Replying { 10 | id: string 11 | isChild: boolean 12 | parent_id?: string 13 | } 14 | 15 | interface InputWindowProps { 16 | showWindow: boolean 17 | replyTo: string 18 | content: string 19 | setReplyTo: (replyTo: string) => void 20 | setReplyData: (replyData: Replying) => void 21 | setContent: (content: string) => void 22 | onSubmit: (type: 'up' | 'down') => void 23 | } 24 | 25 | const InputWindow: FC = ({ 26 | showWindow, 27 | content, 28 | setReplyTo, 29 | setReplyData, 30 | setContent, 31 | onSubmit, 32 | replyTo, 33 | }) => { 34 | const { userInfo } = useSelector((state: AppState) => state.user) 35 | 36 | const clearReplyInfo = () => { 37 | setReplyData({ 38 | id: '', 39 | isChild: false, 40 | }) 41 | setReplyTo('') 42 | } 43 | 44 | return ( 45 | 46 | {showWindow && ( 47 | 51 |
52 |
53 | 54 |
55 | setContent(event.target.value)} 61 | /> 62 |
63 | 64 | {replyTo && ( 65 | 68 | )} 69 | 70 | 73 |
74 | )} 75 |
76 | ) 77 | } 78 | 79 | export default InputWindow 80 | -------------------------------------------------------------------------------- /src/components/work-detail/user-info/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { AppState } from '@/store/types' 3 | import type { UserItemInfo } from '@/utils/types' 4 | import { Button } from 'antd' 5 | import { useSelector } from 'react-redux' 6 | import { Link } from 'react-router' 7 | import LazyImg from '@/components/common/lazy-img' 8 | 9 | interface UserInfoProps { 10 | userInfo: UserItemInfo 11 | onFollow: (id: string) => void 12 | } 13 | 14 | const UserInfo: FC = ({ userInfo, onFollow }) => { 15 | const { isLogin } = useSelector((state: AppState) => state.user) 16 | const { id } = useSelector((state: AppState) => state.user.userInfo) 17 | 18 | return ( 19 |
20 |
21 | 25 | 26 | 27 | 28 | {userInfo.username} 29 | 30 |
31 |
32 | {userInfo.intro} 33 |
34 | {userInfo.id !== id && isLogin && ( 35 | 43 | )} 44 |
45 | ) 46 | } 47 | 48 | export default UserInfo 49 | -------------------------------------------------------------------------------- /src/components/work-detail/work-slide-window/index.tsx: -------------------------------------------------------------------------------- 1 | import type { VirtualListProps } from '@/components/common/virtual-list' 2 | import type { WorkNormalItemInfo } from '@/utils/types' 3 | import { useMemo } from 'react' 4 | import LayoutList from '@/components/common/layout-list' 5 | import ImgLoadingSkeleton from '@/components/skeleton/img-loading' 6 | 7 | interface WorkSlideWindowProps extends Partial { 8 | workId: string 9 | workList: { 10 | page: number 11 | list: WorkNormalItemInfo[] 12 | }[] 13 | setWorkListEnd?: (status: boolean) => void 14 | isFinal?: boolean 15 | initializing?: boolean 16 | setInitializing?: (status: boolean) => void 17 | } 18 | 19 | function WorkSlideWindow({ 20 | workId, 21 | workList, 22 | setWorkListEnd, 23 | isFinal = true, 24 | initializing, 25 | setInitializing, 26 | ...rest 27 | }: WorkSlideWindowProps) { 28 | const virtualListItems = useMemo( 29 | () => workList.map(everyPage => everyPage.list).flat(), 30 | [workList], 31 | ) 32 | 33 | return ( 34 | 45 | {!isFinal && } 46 | 47 | ) 48 | } 49 | 50 | export default WorkSlideWindow 51 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useAtBottom' 2 | export * from './useAtTop' 3 | export * from './useFloat' 4 | export * from './useLoading' 5 | export * from './useMap' 6 | export * from './useWinChange' 7 | -------------------------------------------------------------------------------- /src/hooks/useAtBottom.ts: -------------------------------------------------------------------------------- 1 | import { debounce } from 'lodash' 2 | import { useEffect, useState } from 'react' 3 | 4 | function useAtBottom(): boolean { 5 | const [isBottom, setIsBottom] = useState(false) 6 | useEffect(() => { 7 | const handleScroll = debounce(() => { 8 | const scrollTop = document.body.scrollTop // 滚动高度 9 | const scrollHeight = document.body.scrollHeight // 滚动总高度 10 | const clientHeight = document.body.clientHeight // 可视区域高度 11 | 12 | if (scrollTop + clientHeight >= scrollHeight - 100) { 13 | if (isBottom) 14 | return 15 | setIsBottom(true) 16 | } 17 | else { 18 | if (!isBottom) 19 | return 20 | setIsBottom(false) 21 | } 22 | }, 50) 23 | 24 | document.body.addEventListener('scroll', handleScroll) 25 | 26 | return () => document.body.removeEventListener('scroll', handleScroll) 27 | }) 28 | return isBottom 29 | } 30 | 31 | export { useAtBottom } 32 | -------------------------------------------------------------------------------- /src/hooks/useAtTop.ts: -------------------------------------------------------------------------------- 1 | import { debounce } from 'lodash' 2 | import { useEffect, useState } from 'react' 3 | 4 | // 判断是否在顶部 5 | function useAtTop(): boolean { 6 | const [isTop, setIsTop] = useState(true) 7 | useEffect(() => { 8 | const handleScroll = debounce(() => { 9 | const scrollTop = document.body.scrollTop // 滚动高度 10 | if (scrollTop <= 0) { 11 | if (isTop) 12 | return 13 | setIsTop(true) 14 | } 15 | else { 16 | if (!isTop) 17 | return 18 | setIsTop(false) 19 | } 20 | }, 50) 21 | 22 | document.body.addEventListener('scroll', handleScroll) 23 | 24 | return () => document.body.removeEventListener('scroll', handleScroll) 25 | }) 26 | return isTop 27 | } 28 | 29 | function useAtTopNoRerender(eventCallback: (isTop: boolean) => void) { 30 | useEffect(() => { 31 | const handleScroll = debounce(() => { 32 | const scrollTop = document.body.scrollTop 33 | if (scrollTop <= 0) { 34 | eventCallback(true) 35 | } 36 | else { 37 | eventCallback(false) 38 | } 39 | }, 50) 40 | document.addEventListener('scroll', handleScroll) 41 | return () => document.removeEventListener('scroll', handleScroll) 42 | }, [eventCallback]) 43 | } 44 | 45 | export { useAtTop, useAtTopNoRerender } 46 | -------------------------------------------------------------------------------- /src/hooks/useFloat.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { FLOAT_DURATION } from '@/utils' 3 | 4 | interface Props { 5 | opacity?: number 6 | top?: string 7 | duration?: number 8 | } 9 | 10 | export function useFloat({ 11 | opacity: initialOpacity = 0, 12 | top: initialTop = '1rem', 13 | duration = FLOAT_DURATION, 14 | }: Props = {}) { 15 | const [opacity, setOpacity] = useState(initialOpacity) 16 | const [top, setTop] = useState(initialTop) 17 | 18 | useEffect(() => { 19 | const timer = setTimeout(() => { 20 | setOpacity(1) 21 | setTop('0') 22 | }, duration) 23 | return () => clearTimeout(timer) 24 | }, []) 25 | 26 | return [opacity, top] as const 27 | } 28 | -------------------------------------------------------------------------------- /src/hooks/useLoading.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | function useLoading() { 4 | const [loading, setLoading] = useState(true) 5 | const [error, setError] = useState(null) 6 | 7 | const handlePromise = async (promise: Promise) => { 8 | try { 9 | setLoading(true) 10 | const result = await promise 11 | return result 12 | } 13 | catch (error: any) { 14 | setError(error) 15 | } 16 | finally { 17 | setLoading(false) 18 | } 19 | } 20 | 21 | return { loading, error, handlePromise } 22 | } 23 | 24 | export { useLoading } 25 | -------------------------------------------------------------------------------- /src/hooks/useMap.ts: -------------------------------------------------------------------------------- 1 | import { message } from 'antd' 2 | import { useState } from 'react' 3 | 4 | type MapType = Map 5 | 6 | function useMap( 7 | initialItems: T[], 8 | ): [MapType, (items: T[]) => void, (id: string, newItem: T) => void, (id: string) => void] { 9 | const convertArrayToMap = (items: T[]): MapType => { 10 | const map = new Map() 11 | items.forEach((item) => { 12 | if (!('id' in item)) { 13 | message.error('Every item in the array should have a field named "id"!') 14 | return 15 | } 16 | map.set(String(item.id), item) 17 | }) 18 | return map 19 | } 20 | 21 | const [map, setMap] = useState>(convertArrayToMap(initialItems)) 22 | 23 | const setSource = (items: T[]) => { 24 | setMap(convertArrayToMap(items)) 25 | } 26 | 27 | const updateItem = (id: string, newItem: T) => { 28 | setMap((prev) => { 29 | const newMap = new Map(prev) 30 | newMap.set(id, newItem) 31 | return newMap 32 | }) 33 | } 34 | 35 | const deleteItem = (id: string) => { 36 | setMap((prev) => { 37 | const newMap = new Map(prev) 38 | newMap.delete(id) 39 | return newMap 40 | }) 41 | } 42 | 43 | return [map, setSource, updateItem, deleteItem] 44 | } 45 | 46 | export { useMap } 47 | -------------------------------------------------------------------------------- /src/hooks/useWinChange.ts: -------------------------------------------------------------------------------- 1 | import type { RefObject } from 'react' 2 | import { debounce } from 'lodash' 3 | import { useEffect, useState } from 'react' 4 | 5 | /** 6 | * @description 监听窗口变化,返回当前窗口宽度 7 | * @param target - 监听的目标元素 8 | * @returns - 当前窗口宽度 9 | */ 10 | function useWinChange(target: RefObject): number { 11 | const [width, setWidth] = useState(window.innerWidth) 12 | 13 | useEffect(() => { 14 | const handleResize = debounce(() => { 15 | if (target.current) { 16 | setWidth(target.current.offsetWidth) 17 | } 18 | }, 20) 19 | window.addEventListener('resize', handleResize) 20 | return () => window.removeEventListener('resize', handleResize) 21 | }, []) 22 | 23 | return width 24 | } 25 | 26 | export { useWinChange } 27 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { ConfigProvider } from 'antd' 2 | import { createRoot } from 'react-dom/client' 3 | import { ErrorBoundary } from 'react-error-boundary' 4 | import { Provider } from 'react-redux' 5 | import { RouterProvider } from 'react-router' 6 | import { PersistGate } from 'redux-persist/integration/react' 7 | import ErrorPage from './pages/error' 8 | import router from './router' 9 | import { persistor, store } from './store' 10 | import 'react-photo-view/dist/react-photo-view.css' 11 | import '@ant-design/v5-patch-for-react-19' 12 | import 'virtual:uno.css' 13 | import '@unocss/reset/normalize.css' 14 | import '@/styles/index.css' 15 | 16 | createRoot(document.getElementById('root')!).render( 17 | 18 | 19 | 24 | 25 | 26 | 27 | 28 | 29 | , 30 | ) 31 | -------------------------------------------------------------------------------- /src/pages/error/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FallbackProps } from 'react-error-boundary' 2 | import { Button } from 'antd' 3 | import { useNavigate } from 'react-router' 4 | import ErrorImg from '@/assets/imgs/500.png' 5 | import HanaCard from '@/components/common/hana-card' 6 | import LazyImg from '@/components/common/lazy-img' 7 | 8 | function Error({ error }: FallbackProps) { 9 | const navigate = useNavigate() 10 | return ( 11 |
12 | 13 |
14 | 15 |
16 | 页面发生错误: 17 |
18 | {error.message} 19 |
20 | 请向管理员汇报~! 21 | 24 |
25 |
26 | ) 27 | } 28 | 29 | export default Error 30 | -------------------------------------------------------------------------------- /src/pages/explore/index.tsx: -------------------------------------------------------------------------------- 1 | import type { MenuProps } from 'antd' 2 | import type { FC } from 'react' 3 | import { ClockCircleOutlined, PictureOutlined, UserOutlined } from '@ant-design/icons' 4 | import { Menu } from 'antd' 5 | import { useEffect, useRef, useState } from 'react' 6 | import { useNavigate, useOutletContext, useParams } from 'react-router' 7 | import LatestList from '@/components/explore/latest-list' 8 | import UserList from '@/components/explore/user-list' 9 | import WorkList from '@/components/explore/work-list' 10 | import { MAX_WIDTH, MIN_WIDTH, TRIGGER_MIN_WIDTH } from '@/utils' 11 | 12 | const items: MenuProps['items'] = [ 13 | { 14 | label: '推荐作品', 15 | key: 'recommend', 16 | icon: , 17 | }, 18 | { 19 | label: '最新发布', 20 | key: 'latest', 21 | icon: , 22 | }, 23 | { 24 | label: '推荐用户', 25 | key: 'users', 26 | icon: , 27 | }, 28 | ] 29 | 30 | const Explore: FC = () => { 31 | const navigate = useNavigate() 32 | const { type } = useParams() 33 | 34 | const [width, setWidth] = useState(MAX_WIDTH) 35 | const exploreRef = useRef(null) 36 | const currentWidth = useOutletContext() 37 | 38 | useEffect(() => { 39 | if (currentWidth < TRIGGER_MIN_WIDTH) { 40 | setWidth(MIN_WIDTH) 41 | } 42 | else { 43 | setWidth(MAX_WIDTH) 44 | } 45 | }, [currentWidth]) 46 | 47 | const checkoutMenu: MenuProps['onClick'] = (e) => { 48 | navigate(`/explore/${e.key}`) 49 | } 50 | 51 | return ( 52 |
53 |
54 | 61 | {type === 'recommend' 62 | ? ( 63 | 64 | ) 65 | : type === 'latest' 66 | ? ( 67 | 68 | ) 69 | : type === 'users' 70 | ? ( 71 | 72 | ) 73 | : null} 74 |
75 |
76 | ) 77 | } 78 | 79 | export default Explore 80 | -------------------------------------------------------------------------------- /src/pages/followed-new/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { useEffect, useRef, useState } from 'react' 3 | import { useOutletContext } from 'react-router' 4 | import { getFollowNewWorksTotalAPI } from '@/apis' 5 | import Pagination from '@/components/common/pagination' 6 | import MainList from '@/components/followed-new/main-list' 7 | import { MAX_WIDTH, MIN_WIDTH, TRIGGER_MIN_WIDTH } from '@/utils' 8 | 9 | const FollowedNew: FC = () => { 10 | const [current, setCurrent] = useState(1) 11 | const [total, setTotal] = useState(0) 12 | 13 | const getFollowNewWorksTotal = async () => { 14 | try { 15 | const { data } = await getFollowNewWorksTotalAPI() 16 | setTotal(data) 17 | } 18 | catch (error) { 19 | console.error('出现错误了喵!!', error) 20 | } 21 | } 22 | 23 | useEffect(() => { 24 | getFollowNewWorksTotal() 25 | }, []) 26 | 27 | const [width, setWidth] = useState(MAX_WIDTH) 28 | const newRef = useRef(null) 29 | const currentWidth = useOutletContext() 30 | 31 | const pageSize = 30 32 | 33 | const onChange = (page: number) => { 34 | setCurrent(page) 35 | } 36 | 37 | useEffect(() => { 38 | if (currentWidth < TRIGGER_MIN_WIDTH) { 39 | setWidth(MIN_WIDTH) 40 | } 41 | else { 42 | setWidth(MAX_WIDTH) 43 | } 44 | }, [currentWidth]) 45 | 46 | return ( 47 |
48 |
49 | 50 | 51 |
52 |
53 | ) 54 | } 55 | 56 | export default FollowedNew 57 | -------------------------------------------------------------------------------- /src/pages/home/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { Pagination } from '@/apis/types' 3 | import type { AppState } from '@/store/types' 4 | import type { LabelInfo, WorkNormalItemInfo } from '@/utils/types' 5 | import { Icon } from '@iconify/react' 6 | import { useEffect, useState } from 'react' 7 | import { useDispatch, useSelector } from 'react-redux' 8 | import { useOutletContext } from 'react-router' 9 | import { getFollowNewWorksAPI, getRecommendLabelListAPI, getRecommendWorksAPI } from '@/apis' 10 | import GreyButton from '@/components/common/grey-button' 11 | import FollowedWorks from '@/components/home/followed-works' 12 | import LabelList from '@/components/home/label-list/index' 13 | import RecommendedWorks from '@/components/home/recommended-works' 14 | import { setTempId } from '@/store/modules/user' 15 | import { generateTempId, MAX_WIDTH, MIN_WIDTH, TRIGGER_MIN_WIDTH } from '@/utils' 16 | 17 | const Home: FC = () => { 18 | const dispatch = useDispatch() 19 | const { isLogin, tempId } = useSelector((state: AppState) => state.user) 20 | 21 | const [width, setWidth] = useState(MAX_WIDTH) 22 | const currentWidth = useOutletContext() 23 | 24 | useEffect(() => { 25 | if (currentWidth < TRIGGER_MIN_WIDTH) { 26 | setWidth(MIN_WIDTH) 27 | } 28 | else { 29 | setWidth(MAX_WIDTH) 30 | } 31 | }, [currentWidth]) 32 | 33 | /* ----------获取数据相关---------- */ 34 | 35 | // 标签列表相关 36 | const [labelList, setLabelList] = useState([]) 37 | const [gettingLabelList, setGettingLabelList] = useState(true) 38 | 39 | const getLabelList = async () => { 40 | setGettingLabelList(true) 41 | try { 42 | const { data } = await getRecommendLabelListAPI() 43 | setLabelList(data) 44 | } 45 | catch (error) { 46 | console.error('出现错误了喵!!', error) 47 | return 48 | } 49 | finally { 50 | setGettingLabelList(false) 51 | } 52 | } 53 | 54 | // 最新关注作品相关 55 | const [followWorkList, setFollowWorkList] = useState([]) 56 | const [gettingFollowWorkList, setGettingFollowWorkList] = useState(true) 57 | 58 | const getFollowNewWorks = async () => { 59 | setGettingFollowWorkList(true) 60 | try { 61 | const { data } = await getFollowNewWorksAPI({ pageSize: 30, current: 1 }) 62 | setFollowWorkList(data) 63 | } 64 | catch (error) { 65 | console.error('出现错误了喵!!', error) 66 | return 67 | } 68 | finally { 69 | setGettingFollowWorkList(false) 70 | } 71 | } 72 | 73 | // 获取推荐作品相关 74 | const [recommendWorkList, setRecommendWorkList] = useState([]) 75 | const [gettingRecommendWorkList, setGettingRecommendWorkList] = useState(true) 76 | 77 | const getRecommendWorks = async () => { 78 | setGettingRecommendWorkList(true) 79 | setRecommendWorkList([]) 80 | try { 81 | const params: Pagination = { pageSize: 30, current: 1 } 82 | if (!isLogin) { 83 | if (!tempId) 84 | dispatch(setTempId(generateTempId())) 85 | params.id = tempId 86 | } 87 | const { data } = await getRecommendWorksAPI(params) // 只获取一页 88 | setRecommendWorkList(data) 89 | } 90 | catch (error) { 91 | console.error('出现错误了喵!!', error) 92 | return 93 | } 94 | finally { 95 | setGettingRecommendWorkList(false) 96 | } 97 | } 98 | 99 | useEffect(() => { 100 | getLabelList() 101 | if (isLogin) 102 | getFollowNewWorks() 103 | getRecommendWorks() 104 | }, []) 105 | 106 | return ( 107 |
108 |
109 | 110 | {isLogin && } 111 | 112 |
113 | 114 |
115 | 116 | 117 | 118 |
119 |
120 | ) 121 | } 122 | 123 | export default Home 124 | -------------------------------------------------------------------------------- /src/pages/illustrator/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { IllustratorInfo } from '@/apis/illustrator/types' 3 | import { ArrowLeftOutlined } from '@ant-design/icons' 4 | import { Button } from 'antd' 5 | import { useEffect, useState } from 'react' 6 | import { PhotoView } from 'react-photo-view' 7 | import { useNavigate, useParams } from 'react-router' 8 | import { getIllustratorDetailAPI } from '@/apis' 9 | import HanaViewer from '@/components/common/hana-viewer' 10 | import InfoModal from '@/components/illustrator/info-modal' 11 | import WaterfallFlow from '@/components/illustrator/waterfall-flow' 12 | 13 | const Illustrator: FC = () => { 14 | const navigate = useNavigate() 15 | const { illustratorId } = useParams<{ illustratorId: string }>() 16 | 17 | const [infoModalVisible, setInfoModalVisible] = useState(false) 18 | const [illustratorInfo, setIllustratorInfo] = useState() 19 | 20 | const getIllustratorDetail = async () => { 21 | try { 22 | const { data } = await getIllustratorDetailAPI({ id: illustratorId! }) 23 | setIllustratorInfo(data) 24 | } 25 | catch (error) { 26 | console.error('出现错误了喵!!', error) 27 | } 28 | } 29 | 30 | useEffect(() => { 31 | getIllustratorDetail() 32 | }, [illustratorId]) 33 | 34 | const [startAppreciate, setStartAppreciate] = useState(false) 35 | 36 | return ( 37 |
38 |
39 | {illustratorInfo && ( 40 |
41 | 49 |
50 |
51 | 52 | 58 | {illustratorInfo.name} 66 | 67 | 68 |
69 | {illustratorInfo.name} 70 |
71 |
72 | 75 | 83 |
84 |
85 | )} 86 |
87 | 88 |
89 |
90 | 91 | {illustratorInfo && ( 92 | 97 | )} 98 |
99 | ) 100 | } 101 | 102 | export default Illustrator 103 | -------------------------------------------------------------------------------- /src/pages/login/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { BrowserView } from 'react-device-detect' 3 | import BgSlide from '@/components/login/bg-slide' 4 | import LoginWindow from '@/components/login/login-window' 5 | 6 | const Login: FC = () => { 7 | return ( 8 |
9 | 10 | 11 | 12 | 13 |
14 | ) 15 | } 16 | 17 | export default Login 18 | -------------------------------------------------------------------------------- /src/pages/not-found/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { Button } from 'antd' 3 | import { useNavigate } from 'react-router' 4 | import notFound from '@/assets/imgs/404.png' 5 | import HanaCard from '@/components/common/hana-card' 6 | import LazyImg from '@/components/common/lazy-img' 7 | 8 | const NotFound: FC = () => { 9 | const navigate = useNavigate() 10 | return ( 11 |
12 | 13 |
14 | 15 |
16 | 请求路径不存在~的说~ 17 | 如果页面有误,请及时反馈 18 | 21 |
22 |
23 | ) 24 | } 25 | 26 | export default NotFound 27 | -------------------------------------------------------------------------------- /src/pages/personal-center/index.tsx: -------------------------------------------------------------------------------- 1 | import type { MenuProps } from 'antd' 2 | import type { FC } from 'react' 3 | import type { AppState } from '@/store/types' 4 | import { 5 | HeartOutlined, 6 | HistoryOutlined, 7 | PictureOutlined, 8 | StarOutlined, 9 | TeamOutlined, 10 | UserOutlined, 11 | } from '@ant-design/icons' 12 | import { Menu } from 'antd' 13 | import { createContext, useEffect, useState } from 'react' 14 | import { useSelector } from 'react-redux' 15 | import { Outlet, useLocation, useNavigate, useOutletContext, useParams } from 'react-router' 16 | import Header from '@/components/personal-center/header' 17 | import { MAX_WIDTH, MIN_WIDTH, TRIGGER_MIN_WIDTH } from '@/utils' 18 | 19 | const PersonalContext = createContext({ isMe: false, currentPath: '', userId: '', width: 0 }) 20 | 21 | const PersonalCenter: FC = () => { 22 | const location = useLocation() 23 | const navigate = useNavigate() 24 | const [currentPath, setCurrentPath] = useState('works') 25 | const { userId } = useParams() 26 | 27 | const { id: localUserId } = useSelector((state: AppState) => state.user.userInfo) 28 | const isMe = userId === localUserId 29 | 30 | const [menuItems, setMenuItems] = useState([ 31 | { 32 | label: '插画', 33 | key: 'works', 34 | icon: , 35 | }, 36 | { 37 | label: '最近喜欢', 38 | key: 'likes', 39 | icon: , 40 | }, 41 | { 42 | label: '收藏集', 43 | key: 'favorites', 44 | icon: , 45 | }, 46 | { 47 | label: '关注', 48 | key: 'follow', 49 | icon: , 50 | }, 51 | { 52 | label: '粉丝', 53 | key: 'fans', 54 | icon: , 55 | }, 56 | ]) 57 | 58 | useEffect(() => { 59 | if (isMe) { 60 | setMenuItems(prev => [ 61 | ...prev, 62 | { 63 | label: '浏览记录', 64 | key: 'history', 65 | icon: , 66 | }, 67 | ]) 68 | } 69 | else { 70 | setMenuItems(prev => prev.filter(item => item.key !== 'history')) 71 | } 72 | }, [isMe]) 73 | 74 | const checkoutMenu: MenuProps['onClick'] = (e) => { 75 | setCurrentPath(e.key) 76 | navigate(e.key) 77 | } 78 | 79 | useEffect(() => { 80 | setCurrentPath(location.pathname.split('/')[3]) 81 | }, [location.pathname]) 82 | 83 | const [width, setWidth] = useState(MAX_WIDTH) 84 | const currentWidth = useOutletContext() 85 | 86 | useEffect(() => { 87 | if (currentWidth < TRIGGER_MIN_WIDTH) { 88 | setWidth(MIN_WIDTH) 89 | } 90 | else { 91 | setWidth(MAX_WIDTH) 92 | } 93 | }, [currentWidth]) 94 | 95 | return ( 96 | 97 |
98 |
99 | 106 |
110 | 111 |
112 |
113 |
114 | ) 115 | } 116 | 117 | export { PersonalContext } 118 | export default PersonalCenter 119 | -------------------------------------------------------------------------------- /src/pages/personal-center/my-fans/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { use, useEffect, useState } from 'react' 3 | import { getFansTotalAPI } from '@/apis' 4 | import UserList from '@/components/personal-center/user-list' 5 | 6 | import { PersonalContext } from '..' 7 | 8 | const MyFans: FC = () => { 9 | const { userId, width } = use(PersonalContext) 10 | 11 | const [total, setTotal] = useState(0) // 用户总数 12 | 13 | const getFansTotal = async () => { 14 | try { 15 | const { data } = await getFansTotalAPI({ id: userId }) 16 | setTotal(data) 17 | } 18 | catch (error) { 19 | console.error('出现错误了喵!!', error) 20 | } 21 | } 22 | 23 | useEffect(() => { 24 | getFansTotal() 25 | }, [userId]) 26 | 27 | return ( 28 |
29 |
30 | 粉丝列表 31 |
32 | {total} 33 |
34 |
35 | 36 |
37 | ) 38 | } 39 | 40 | export default MyFans 41 | -------------------------------------------------------------------------------- /src/pages/personal-center/my-follow/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { use, useEffect, useState } from 'react' 3 | import { getFollowingTotalAPI } from '@/apis' 4 | import UserList from '@/components/personal-center/user-list' 5 | 6 | import { PersonalContext } from '..' 7 | 8 | const MyFollow: FC = () => { 9 | const { userId, width } = use(PersonalContext) 10 | 11 | const [total, setTotal] = useState(0) // 用户总数 12 | 13 | const getFollowingTotal = async () => { 14 | try { 15 | const { data } = await getFollowingTotalAPI({ id: userId }) 16 | setTotal(data) 17 | } 18 | catch (error) { 19 | console.error('出现错误了喵!!', error) 20 | } 21 | } 22 | 23 | useEffect(() => { 24 | getFollowingTotal() 25 | }, [userId]) 26 | 27 | return ( 28 |
29 |
30 | 关注列表 31 |
32 | {total} 33 |
34 |
35 | 36 |
37 | ) 38 | } 39 | 40 | export default MyFollow 41 | -------------------------------------------------------------------------------- /src/pages/personal-center/my-history/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { Button, Input, message } from 'antd' 3 | import { useEffect, useState } from 'react' 4 | import HistoryList from '@/components/personal-center/history/history-list' 5 | import SearchResult from '@/components/personal-center/history/search-result' 6 | 7 | const { Search } = Input 8 | 9 | const MyHistory: FC = () => { 10 | /* ----------搜索相关---------- */ 11 | const [keyword, setKeyword] = useState('') 12 | const [searching, setSearching] = useState(false) 13 | const [searchTrigger, setSearchTrigger] = useState(0) 14 | 15 | // 触发搜索 16 | const onSearch = async () => { 17 | if (keyword === '') { 18 | message.warning('搜索内容不能为空') 19 | return 20 | } 21 | setSearching(true) 22 | setSearchTrigger(prev => prev + 1) 23 | } 24 | 25 | useEffect(() => { 26 | if (searching) 27 | return 28 | setKeyword('') 29 | setSearchTrigger(0) 30 | }, [searching]) 31 | 32 | return ( 33 |
34 |
35 | 浏览记录 36 |
37 | {searching && ( 38 | 41 | )} 42 | setKeyword(e.target.value)} 47 | onSearch={onSearch} 48 | /> 49 |
50 |
51 | 52 | {searching 53 | ? ( 54 | 55 | ) 56 | : ( 57 | 58 | )} 59 |
60 | ) 61 | } 62 | 63 | export default MyHistory 64 | -------------------------------------------------------------------------------- /src/pages/personal-center/my-likes/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { use, useEffect, useState } from 'react' 3 | import { getUserLikeWorksTotalAPI } from '@/apis' 4 | import WorkList from '@/components/personal-center/work-list' 5 | 6 | import { PersonalContext } from '..' 7 | 8 | const MyLikes: FC = () => { 9 | const { userId } = use(PersonalContext) 10 | 11 | const [workCount, setWorkCount] = useState(0) 12 | 13 | const getLikeWorkCount = async () => { 14 | try { 15 | const { data } = await getUserLikeWorksTotalAPI({ id: userId! }) 16 | setWorkCount(data) 17 | } 18 | catch (error) { 19 | console.error('出现错误了喵!!', error) 20 | } 21 | } 22 | 23 | useEffect(() => { 24 | getLikeWorkCount() 25 | }, [userId]) 26 | 27 | return ( 28 |
29 |
30 | 最近喜欢 31 |
32 | {workCount} 33 |
34 |
35 | 36 |
37 | ) 38 | } 39 | 40 | export default MyLikes 41 | -------------------------------------------------------------------------------- /src/pages/personal-center/my-works/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { use, useEffect, useState } from 'react' 3 | import { getUserWorksTotalAPI } from '@/apis' 4 | import LabelList from '@/components/personal-center/label-list' 5 | import WorkList from '@/components/personal-center/work-list' 6 | 7 | import { PersonalContext } from '..' 8 | 9 | const MyWorks: FC = () => { 10 | const { userId } = use(PersonalContext) 11 | 12 | const [workCount, setWorkCount] = useState(0) 13 | 14 | const getWorkCount = async () => { 15 | try { 16 | const { data } = await getUserWorksTotalAPI({ id: userId }) 17 | setWorkCount(data) 18 | } 19 | catch (error) { 20 | console.error('出现错误了喵!!', error) 21 | } 22 | } 23 | 24 | useEffect(() => { 25 | getWorkCount() 26 | }, [userId]) 27 | 28 | return ( 29 |
30 |
31 | 插画 32 |
33 | {workCount} 34 |
35 |
36 |
37 | 38 |
39 | 40 |
41 | ) 42 | } 43 | 44 | export default MyWorks 45 | -------------------------------------------------------------------------------- /src/router/index.tsx: -------------------------------------------------------------------------------- 1 | import type { RouteObject } from 'react-router' 2 | import { lazy } from 'react' 3 | import { createBrowserRouter, Navigate } from 'react-router' 4 | import App from '@/app' 5 | import PersonalCenter from '@/pages/personal-center' 6 | 7 | import AuthRouter from './utils/auth-router' 8 | import AutoTop from './utils/auto-top' 9 | import LazyLoad from './utils/lazy-load' 10 | import PersonalPage from './utils/personal-page' 11 | 12 | const routeList: RouteObject[] = [ 13 | { 14 | path: '/', 15 | element: ( 16 | 17 | 18 | 19 | ), 20 | children: [ 21 | { 22 | path: '/', 23 | element: , 24 | }, 25 | { 26 | path: 'login', 27 | element: LazyLoad(lazy(() => import('@/pages/login'))), 28 | }, 29 | { 30 | path: 'home', 31 | element: LazyLoad(lazy(() => import('@/pages/home'))), 32 | }, 33 | { 34 | path: 'followed-new', 35 | element: {LazyLoad(lazy(() => import('@/pages/followed-new')))}, 36 | }, 37 | { 38 | path: 'explore', 39 | element: , 40 | }, 41 | { 42 | path: 'explore/:type', 43 | element: LazyLoad(lazy(() => import('@/pages/explore'))), 44 | }, 45 | { 46 | path: 'search-result', 47 | element: LazyLoad(lazy(() => import('@/pages/search-result'))), 48 | }, 49 | { 50 | path: 'work-detail/:workId', 51 | element: LazyLoad(lazy(() => import('@/pages/work-detail'))), 52 | }, 53 | { 54 | path: 'illustrator/:illustratorId', 55 | element: LazyLoad(lazy(() => import('@/pages/illustrator'))), 56 | }, 57 | { 58 | path: 'upload', 59 | element: {LazyLoad(lazy(() => import('@/pages/upload')))}, 60 | }, 61 | { 62 | path: 'personal-center/:userId', 63 | element: , 64 | }, 65 | { 66 | path: 'personal-center/:userId', 67 | element: , 68 | children: [ 69 | { 70 | path: 'works', 71 | element: LazyLoad(lazy(() => import('@/pages/personal-center/my-works'))), 72 | }, 73 | { 74 | path: 'likes', 75 | element: LazyLoad(lazy(() => import('@/pages/personal-center/my-likes'))), 76 | }, 77 | { 78 | path: 'favorites', 79 | element: LazyLoad(lazy(() => import('@/pages/personal-center/my-favorites'))), 80 | }, 81 | { 82 | path: 'follow', 83 | element: LazyLoad(lazy(() => import('@/pages/personal-center/my-follow'))), 84 | }, 85 | { 86 | path: 'fans', 87 | element: LazyLoad(lazy(() => import('@/pages/personal-center/my-fans'))), 88 | }, 89 | { 90 | path: 'history', 91 | element: ( 92 | 93 | {LazyLoad(lazy(() => import('@/pages/personal-center/my-history')))} 94 | 95 | ), 96 | }, 97 | ], 98 | }, 99 | { 100 | path: '/not-found', 101 | element: LazyLoad(lazy(() => import('@/pages/not-found'))), 102 | }, 103 | { 104 | path: '*', 105 | element: , 106 | }, 107 | ], 108 | }, 109 | ] 110 | 111 | const router = createBrowserRouter(routeList) 112 | 113 | export default router 114 | -------------------------------------------------------------------------------- /src/router/utils/auth-router.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { AppState } from '@/store/types' 3 | import { message } from 'antd' 4 | import { useEffect } from 'react' 5 | import { useSelector } from 'react-redux' 6 | import { useNavigate } from 'react-router' 7 | 8 | const AuthRouter: FC<{ children: React.ReactNode }> = ({ children }) => { 9 | const { isLogin } = useSelector((state: AppState) => state.user) 10 | const navigate = useNavigate() 11 | 12 | useEffect(() => { 13 | if (!isLogin) { 14 | message.info('您尚未登录,无法进入此页面哦~') 15 | navigate('/home') 16 | } 17 | }, [isLogin, navigate]) 18 | 19 | if (isLogin) 20 | return <>{children} 21 | return null 22 | } 23 | 24 | export default AuthRouter 25 | -------------------------------------------------------------------------------- /src/router/utils/auto-top.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { useLayoutEffect } from 'react' 3 | import { useLocation } from 'react-router' 4 | 5 | const WHITE_LIST = ['personal-center'] 6 | 7 | interface AutoTopProps { 8 | children: React.ReactNode 9 | } 10 | 11 | const AutoTop: FC = ({ children }) => { 12 | const { pathname } = useLocation() 13 | useLayoutEffect(() => { 14 | if (WHITE_LIST.includes(pathname.split('/')[1])) 15 | return 16 | document.body.scrollTo({ top: 0, behavior: 'smooth' }) 17 | }, [pathname]) 18 | return children 19 | } 20 | 21 | export default AutoTop 22 | -------------------------------------------------------------------------------- /src/router/utils/lazy-load.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentType, LazyExoticComponent, ReactNode } from 'react' 2 | import { Spin } from 'antd' 3 | import { Suspense } from 'react' 4 | 5 | function LazyLoad(Component: LazyExoticComponent): ReactNode { 6 | return ( 7 | 10 | 11 |
12 | )} 13 | > 14 | 15 | 16 | ) 17 | } 18 | 19 | export default LazyLoad 20 | -------------------------------------------------------------------------------- /src/router/utils/personal-page.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { message } from 'antd' 3 | import { use, useEffect } from 'react' 4 | import { useNavigate } from 'react-router' 5 | import { PersonalContext } from '@/pages/personal-center' 6 | 7 | const PersonalPage: FC<{ children: React.ReactNode }> = ({ children }) => { 8 | const { isMe } = use(PersonalContext) 9 | const navigate = useNavigate() 10 | 11 | useEffect(() => { 12 | if (!isMe) { 13 | message.info('无法进入他人的数据页面') 14 | navigate('../works') 15 | } 16 | }, [isMe, navigate]) 17 | 18 | if (isMe) 19 | return <>{children} 20 | return null 21 | } 22 | 23 | export default PersonalPage 24 | -------------------------------------------------------------------------------- /src/service/index.ts: -------------------------------------------------------------------------------- 1 | import type { RequestConfig } from './request/types' 2 | import Request from './request' 3 | 4 | /** 5 | * @description: 后端返回数据类型 6 | * @template T 返回数据体的类型 7 | */ 8 | export interface DEVResponse { 9 | /** 10 | * 状态码 11 | */ 12 | code: number 13 | /** 14 | * 状态信息 15 | */ 16 | message: string 17 | /** 18 | * 返回数据体,类型取决于传入的泛型。如果没有返回数据,为undefined 19 | */ 20 | data: T 21 | } 22 | 23 | // 定义请求类型 24 | interface DEVRequestConfig extends RequestConfig> { 25 | data?: T 26 | } 27 | 28 | // 创建axios的实例 29 | const request = new Request({ 30 | baseURL: import.meta.env.VITE_BASE_URL, // 从环境变量文件中获取baseURL 31 | timeout: 1000 * 60 * 5, // 超时时间5分钟 32 | interceptors: { 33 | // 请求拦截器 34 | requestInterceptors: config => config, 35 | // 响应拦截器 36 | responseInterceptors: (result) => { 37 | return result 38 | }, 39 | }, 40 | }) 41 | 42 | /** 43 | * @description: 封装请求函数 44 | * @generic D 请求参数的类型,默认为any 45 | * @generic T 响应结构的类型,默认为any 46 | * @param {DEVRequestConfig} config 定义请求类型 47 | * @returns {Promise} 返回一个Promise 48 | */ 49 | function devRequest(config: DEVRequestConfig): Promise> { 50 | return request.request>(config) 51 | } 52 | 53 | // 向外暴露devRequest,供API调用 54 | export default devRequest 55 | -------------------------------------------------------------------------------- /src/service/request/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AxiosRequestConfig, 3 | AxiosResponse, 4 | CreateAxiosDefaults, 5 | InternalAxiosRequestConfig, 6 | } from 'axios' 7 | 8 | export interface RequestInterceptors { 9 | // 请求拦截 10 | requestInterceptors?: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig 11 | requestInterceptorsCatch?: (err: any) => any 12 | // 响应拦截 13 | responseInterceptors?: (config: T) => T 14 | responseInterceptorsCatch?: (err: any) => any 15 | } 16 | 17 | // 自定义传入的参数 18 | export interface CreateRequestConfig extends CreateAxiosDefaults { 19 | interceptors?: RequestInterceptors 20 | } 21 | 22 | // 继承AxiosRequestConfig,新增了interceptors用于自定义拦截器 23 | export interface RequestConfig extends AxiosRequestConfig { 24 | interceptors?: RequestInterceptors 25 | } 26 | 27 | export interface CancelRequestSource { 28 | [index: string]: () => void 29 | } 30 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers, configureStore } from '@reduxjs/toolkit' 2 | import { persistReducer, persistStore } from 'redux-persist' 3 | import storage from 'redux-persist/lib/storage' 4 | 5 | import favoriteReducer from './modules/favorites' 6 | import searchHistoryReducer from './modules/searchHistory' 7 | import uploadFormReducer from './modules/uploadForm' 8 | import userReducer from './modules/user' 9 | import viewListReducer from './modules/viewList' 10 | 11 | const rootReducer = combineReducers({ 12 | user: userReducer, 13 | favorite: favoriteReducer, 14 | searchHistory: searchHistoryReducer, 15 | uploadForm: uploadFormReducer, 16 | viewList: viewListReducer, 17 | }) 18 | 19 | // Redux 持久化 20 | const persistConfig = { 21 | key: 'root', 22 | storage, 23 | } 24 | 25 | const persistedReducer = persistReducer(persistConfig, rootReducer) 26 | 27 | export const store = configureStore({ 28 | reducer: persistedReducer, 29 | middleware: getDefaultMiddleware => 30 | getDefaultMiddleware({ 31 | serializableCheck: false, 32 | }), 33 | }) 34 | 35 | export const persistor = persistStore(store) 36 | -------------------------------------------------------------------------------- /src/store/modules/favorites.ts: -------------------------------------------------------------------------------- 1 | import type { FavoriteItemInfo } from '@/utils/types' 2 | import { createSlice } from '@reduxjs/toolkit' 3 | 4 | const favoriteStore = createSlice({ 5 | name: 'favorites', 6 | initialState: { 7 | favoriteList: [] as FavoriteItemInfo[], 8 | }, 9 | reducers: { 10 | setFavoriteList(state, action) { 11 | state.favoriteList = action.payload 12 | }, 13 | reset(state) { 14 | state.favoriteList = [] 15 | }, 16 | }, 17 | }) 18 | 19 | const { setFavoriteList, reset } = favoriteStore.actions 20 | 21 | const favoriteReducer = favoriteStore.reducer 22 | 23 | export { reset, setFavoriteList } 24 | export default favoriteReducer 25 | -------------------------------------------------------------------------------- /src/store/modules/searchHistory.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | const searchHistoryStore = createSlice({ 4 | name: 'searchHistory', 5 | initialState: { 6 | historyList: [] as string[], 7 | }, 8 | reducers: { 9 | // 向历史搜索记录中添加一条记录。如果已存在,提取至最前 10 | addRecord(state, action) { 11 | const { payload } = action 12 | const index = state.historyList.indexOf(payload) 13 | if (index !== -1) { 14 | state.historyList.splice(index, 1) 15 | } 16 | state.historyList.unshift(payload) 17 | }, 18 | // 清空历史搜索记录 19 | clear(state) { 20 | state.historyList = [] 21 | }, 22 | }, 23 | }) 24 | 25 | const { addRecord, clear } = searchHistoryStore.actions 26 | 27 | const searchHistoryReducer = searchHistoryStore.reducer 28 | 29 | // 导出 30 | export { addRecord, clear } 31 | export default searchHistoryReducer 32 | -------------------------------------------------------------------------------- /src/store/modules/uploadForm.ts: -------------------------------------------------------------------------------- 1 | import type { UploadWorkFormInfo } from '@/utils/types' 2 | import { createSlice } from '@reduxjs/toolkit' 3 | 4 | const uploadFormStore = createSlice({ 5 | name: 'uploadForm', 6 | initialState: { 7 | imgList: [] as string[], 8 | formInfo: { 9 | basicInfo: { 10 | name: '', 11 | intro: '', 12 | reprintType: 1, 13 | openComment: true, 14 | isAIGenerated: false, 15 | }, 16 | labels: [], 17 | } as UploadWorkFormInfo, 18 | uploadSuccess: false, 19 | }, 20 | reducers: { 21 | saveImgList(state, action) { 22 | state.imgList = action.payload 23 | }, 24 | saveFormInfo(state, action) { 25 | state.formInfo = action.payload 26 | }, 27 | saveUploadSuccess(state, action) { 28 | state.uploadSuccess = action.payload 29 | }, 30 | }, 31 | }) 32 | 33 | const { saveImgList, saveFormInfo, saveUploadSuccess } = uploadFormStore.actions 34 | 35 | const uploadFormReducer = uploadFormStore.reducer 36 | 37 | // 导出 38 | export { saveFormInfo, saveImgList, saveUploadSuccess } 39 | export default uploadFormReducer 40 | -------------------------------------------------------------------------------- /src/store/modules/user.ts: -------------------------------------------------------------------------------- 1 | import type { LabelItem } from '@/apis/types' 2 | import type { UserInfo } from '@/utils/types' 3 | import { createSlice } from '@reduxjs/toolkit' 4 | import { generateTempId } from '@/utils' 5 | 6 | const userStore = createSlice({ 7 | name: 'user', 8 | initialState: { 9 | userInfo: { 10 | id: '', 11 | username: '', 12 | avatar: '', 13 | littleAvatar: '', 14 | intro: '', 15 | email: '', 16 | fanNum: 0, 17 | followNum: 0, 18 | } as UserInfo, 19 | tempId: '', 20 | likedLabels: [] as LabelItem[], 21 | isLogin: false, 22 | loginBgs: [], 23 | }, 24 | reducers: { 25 | setUserInfo(state, action) { 26 | state.userInfo = action.payload 27 | }, 28 | setTempId(state, action) { 29 | state.tempId = action.payload 30 | }, 31 | setLikedLabels(state, action) { 32 | state.likedLabels = action.payload 33 | }, 34 | addLikedLabel(state, action) { 35 | state.likedLabels.push(action.payload) 36 | }, 37 | removeLikedLabel(state, action) { 38 | state.likedLabels = state.likedLabels.filter(item => item.id !== action.payload) 39 | }, 40 | increaseFollowNum(state) { 41 | state.userInfo.followNum++ 42 | }, 43 | decreaseFollowNum(state) { 44 | state.userInfo.followNum-- 45 | }, 46 | setLoginStatus(state, action) { 47 | state.isLogin = action.payload 48 | }, 49 | logout(state) { 50 | state.userInfo = { 51 | id: '', 52 | username: '', 53 | avatar: '', 54 | littleAvatar: '', 55 | intro: '', 56 | email: '', 57 | fanNum: 0, 58 | followNum: 0, 59 | } 60 | state.tempId = generateTempId() 61 | state.likedLabels = [] 62 | state.isLogin = false 63 | }, 64 | }, 65 | }) 66 | 67 | const { 68 | setUserInfo, 69 | setTempId, 70 | setLikedLabels, 71 | addLikedLabel, 72 | removeLikedLabel, 73 | increaseFollowNum, 74 | decreaseFollowNum, 75 | setLoginStatus, 76 | logout, 77 | } = userStore.actions 78 | 79 | const userReducer = userStore.reducer 80 | 81 | export { 82 | addLikedLabel, 83 | decreaseFollowNum, 84 | increaseFollowNum, 85 | logout, 86 | removeLikedLabel, 87 | setLikedLabels, 88 | setLoginStatus, 89 | setTempId, 90 | setUserInfo, 91 | } 92 | export default userReducer 93 | -------------------------------------------------------------------------------- /src/store/types.ts: -------------------------------------------------------------------------------- 1 | import type { WorkListType } from './modules/viewList' 2 | import type { LabelItem } from '@/apis/types' 3 | 4 | import type { FavoriteItemInfo, UploadWorkFormInfo, UserInfo } from '@/utils/types' 5 | 6 | interface UserState { 7 | userInfo: UserInfo 8 | tempId: string 9 | likedLabels: LabelItem[] 10 | isLogin: boolean 11 | } 12 | 13 | interface FavoritesState { 14 | favoriteList: FavoriteItemInfo[] 15 | } 16 | 17 | interface SearchHistoryState { 18 | historyList: string[] 19 | } 20 | 21 | interface UploadFormState { 22 | imgList: string[] 23 | formInfo: UploadWorkFormInfo 24 | uploadSuccess: boolean 25 | } 26 | 27 | interface ViewListState { 28 | prevPosition: string 29 | prevWorkId: string 30 | workDetailUserId: string 31 | userWorkList: string[] 32 | likeWorkList: string[] 33 | favoriteWorkList: string[] 34 | followingNewWorkList: string[] 35 | recommendWorkList: string[] 36 | latestWorkList: string[] 37 | illustratorWorkList: string[] 38 | searchResultWorkList: string[] 39 | currentList: WorkListType 40 | currentIndex: number 41 | } 42 | 43 | export interface AppState { 44 | user: UserState 45 | favorite: FavoritesState 46 | searchHistory: SearchHistoryState 47 | uploadForm: UploadFormState 48 | viewList: ViewListState 49 | } 50 | -------------------------------------------------------------------------------- /src/styles/index.css: -------------------------------------------------------------------------------- 1 | /* UnoCSS Preflight unavailable */ 2 | 3 | *, 4 | ::before, 5 | ::after { 6 | @apply box-inherit; 7 | } 8 | 9 | :root { 10 | @apply box-border; 11 | } 12 | 13 | html, 14 | body { 15 | @apply size-full bg-white p-0 my-0 mx-auto overflow-auto; 16 | } 17 | 18 | a { 19 | @apply no-underline text-azure; 20 | } 21 | 22 | ::-webkit-scrollbar-track { 23 | @apply bg-azure-400/10 rounded-none; 24 | } 25 | 26 | ::-webkit-scrollbar { 27 | @apply w-2 h-2 appearance-none; 28 | } 29 | 30 | ::-webkit-scrollbar-thumb { 31 | @apply bg-azure-400/30 rounded-md cursor-pointer transition-color; 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/base64ToFile.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description 将 base64 格式的文件转为 File 对象 3 | * @param base64 - base64 格式的文件 4 | * @param fileName - 文件名 5 | * @returns File 对象 6 | */ 7 | export function base64ToFile(base64: string, fileName: string): File { 8 | const [mimeTypePart, base64Data] = base64.split(',') 9 | const mimeType = mimeTypePart.match(/:(.*?);/)![1] 10 | 11 | const byteCharacters = atob(base64Data) 12 | const byteNumbers: number[] = Array.from({ length: byteCharacters.length }) 13 | for (let i = 0; i < byteCharacters.length; i++) { 14 | byteNumbers[i] = byteCharacters.charCodeAt(i) 15 | } 16 | const byteArray = new Uint8Array(byteNumbers) 17 | 18 | const file = new File([byteArray], `${fileName}.${mimeType.split('/')[1]}`, { 19 | type: mimeType, 20 | }) 21 | 22 | return file 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/cn.ts: -------------------------------------------------------------------------------- 1 | import type { ClassValue } from 'clsx' 2 | import { clsx } from 'clsx' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return clsx(inputs) 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/colorHue.ts: -------------------------------------------------------------------------------- 1 | import { TinyColor } from '@ctrl/tinycolor' 2 | 3 | export function isWarmHue(color: string): boolean { 4 | const colorObj = new TinyColor(color) 5 | return colorObj.isLight() 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | // src/utils/constants.ts 2 | // 用以存放项目中的常量 3 | 4 | import type { MenuProps } from 'antd' 5 | 6 | // 下拉框中的选项列表 7 | interface UserHeaderItem { 8 | value: string 9 | name: string 10 | } 11 | export const HEADER_DROPDOWN_LIST: UserHeaderItem[] = [ 12 | { 13 | value: 'works', 14 | name: '我发布的作品', 15 | }, 16 | { 17 | value: 'likes', 18 | name: '我的喜欢', 19 | }, 20 | { 21 | value: 'favorites', 22 | name: '我的收藏', 23 | }, 24 | { 25 | value: 'history', 26 | name: '浏览记录', 27 | }, 28 | { 29 | value: 'profile', 30 | name: '个人资料设置', 31 | }, 32 | ] 33 | 34 | // Header的菜单路由跳转 35 | interface HeaderMenuItem { 36 | icon: string 37 | route: string 38 | name: string 39 | } 40 | export const HEADER_MENU_LIST: HeaderMenuItem[] = [ 41 | { 42 | icon: 'ant-design:picture-filled', 43 | route: '/home', 44 | name: '插画', 45 | }, 46 | { 47 | icon: 'ant-design:user-outlined', 48 | route: '/followed-new', 49 | name: '已关注用户新作', 50 | }, 51 | { 52 | icon: 'ant-design:compass-outlined', 53 | route: '/explore', 54 | name: '探索页', 55 | }, 56 | ] 57 | export const HEADER_MENU_LIST_VISITOR: HeaderMenuItem[] = [ 58 | { 59 | icon: 'ant-design:picture-filled', 60 | route: '/home', 61 | name: '插画', 62 | }, 63 | { 64 | icon: 'ant-design:compass-outlined', 65 | route: '/explore', 66 | name: '探索页', 67 | }, 68 | ] 69 | 70 | // 哪些路由前缀要隐藏Header 71 | export const HEADER_WHITE_LIST: RegExp = /^\/login/ 72 | 73 | // 哪些路由前缀需要对 Sidebar 进行特殊处理 74 | export const SIDEBAR_WHITE_LIST = /^\/(home|followed-new|explore)/ 75 | 76 | // 主页显隐侧边栏的触发宽度 77 | export const TRIGGER_MIN_WIDTH = 1305 78 | export const TRIGGER_MAX_WIDTH = 1545 79 | 80 | // 上传文件的最大大小 81 | export const MAX_WORK_SIZE = 1024 * 1024 * 10 // 上传作品,每张图片最大10MB 82 | export const MAX_INFO_SIZE = 1024 * 1024 * 5 // 修改个人信息(如头像),每张图片最大5MB 83 | 84 | // 加载时的提示语 85 | export const LOADING_TIP = '加载中,请稍等...' 86 | 87 | // 浏览列表名称映射 88 | export const VIEW_LIST_MAP = { 89 | userWorkList: '用户作品', 90 | likeWorkList: '喜欢作品', 91 | favoriteWorkList: '收藏作品', 92 | followingNewWorkList: '关注作品', 93 | recommendWorkList: '推荐作品', 94 | latestWorkList: '最新作品', 95 | illustratorWorkList: '原作作品', 96 | searchResultWorkList: '搜索结果', 97 | } 98 | 99 | // 浏览列表图标映射 100 | export const VIEW_LIST_ICON_MAP = { 101 | userWorkList: 'material-symbols:person-outline', 102 | likeWorkList: 'material-symbols:favorite-outline', 103 | favoriteWorkList: 'material-symbols:star-outline', 104 | followingNewWorkList: 'material-symbols:bookmark-outline', 105 | recommendWorkList: 'material-symbols:recommend-outline', 106 | latestWorkList: 'material-symbols:alarm-outline', 107 | illustratorWorkList: 'material-symbols:school-outline', 108 | searchResultWorkList: 'material-symbols:search', 109 | } 110 | 111 | // 页面变化的宽度 112 | export const MAX_WIDTH = 1245 113 | export const MIN_WIDTH = 1040 114 | 115 | // WorkItem 下拉框数据配置 116 | export const WORKITEM_DROPDOWN_LIST: Map<'personal_center' | 'favorite', MenuProps['items']> 117 | = new Map([ 118 | [ 119 | 'personal_center', 120 | [ 121 | { 122 | key: 'delete', 123 | label: '删除作品', 124 | }, 125 | { 126 | key: 'edit', 127 | label: '编辑作品', 128 | }, 129 | ], 130 | ], 131 | [ 132 | 'favorite', 133 | [ 134 | { 135 | key: 'cancel', 136 | label: '取消收藏', 137 | }, 138 | { 139 | key: 'move', 140 | label: '移动到', 141 | }, 142 | { 143 | key: 'copy', 144 | label: '复制到', 145 | }, 146 | ], 147 | ], 148 | ]) 149 | 150 | export const FLOAT_DURATION = 80 151 | -------------------------------------------------------------------------------- /src/utils/detectPixiv.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description 判断某个插画家的主页地址是否为 Pixiv 用户 3 | * @description 符合如 https://www.pixiv.net/users/000000 形式的地址 4 | * @param homeUrl - 插画家的主页地址 5 | * @returns 是否为 Pixiv 用户 6 | */ 7 | export function verifyPixivUser(homeUrl: string): boolean { 8 | return /^https:\/\/www.pixiv.net\/users\/\d+$/.test(homeUrl) 9 | } 10 | 11 | /** 12 | * @description 判断某个作品地址是否为 Pixiv 作品 13 | * @description 符合如 https://www.pixiv.net/artworks/00000000 形式的地址 14 | * @param workUrl - 作品地址 15 | * @returns 是否为 Pixiv 作品 16 | */ 17 | export function verifyPixivWork(workUrl: string): boolean { 18 | return /^https:\/\/www.pixiv.net\/artworks\/\d+$/.test(workUrl) 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/download.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 获取 Blob 3 | * @param {string} url - 目标文件地址 4 | * @return {Promise} - 返回 Blob 对象的 Promise 5 | */ 6 | async function getBlob(url: string): Promise { 7 | const response = await fetch(url) 8 | if (!response.ok) { 9 | throw new Error(`网络请求失败: ${response.statusText}`) 10 | } 11 | return response.blob() 12 | } 13 | 14 | /** 15 | * 保存文件 16 | * @param {Blob} blob - 需要保存的 Blob 对象 17 | * @param {string} filename - 想要保存的文件名称 18 | */ 19 | function saveAs(blob: Blob, filename: string): void { 20 | const link = document.createElement('a') 21 | const body = document.body 22 | 23 | link.href = URL.createObjectURL(blob) 24 | link.download = filename 25 | 26 | // 隐藏链接 27 | link.style.display = 'none' 28 | body.appendChild(link) 29 | 30 | link.click() 31 | body.removeChild(link) 32 | 33 | URL.revokeObjectURL(link.href) 34 | } 35 | 36 | /** 37 | * 下载文件 38 | * @param {string} url - 目标文件地址 39 | * @param {string} filename - 想要保存的文件名称 40 | */ 41 | export async function download(url: string, filename: string): Promise { 42 | try { 43 | const blob = await getBlob(url) 44 | saveAs(blob, filename) 45 | } 46 | catch (error) { 47 | return Promise.reject(error) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base64ToFile' 2 | export * from './cn' 3 | export * from './colorHue' 4 | export * from './constants' 5 | export * from './detectPixiv' 6 | export * from './download' 7 | export * from './sleep' 8 | export * from './tempId' 9 | -------------------------------------------------------------------------------- /src/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) 2 | -------------------------------------------------------------------------------- /src/utils/tempId.ts: -------------------------------------------------------------------------------- 1 | export function generateTempId() { 2 | return Math.random().toString(36).slice(2) 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | import type { ImageItem, LabelItem } from '@/apis/types' 2 | 3 | export interface Option { label: string, value: string } 4 | 5 | // 用户登录时获取的基本个人信息 6 | export interface UserInfo { 7 | id: string 8 | username: string 9 | email: string 10 | avatar: string 11 | littleAvatar: string 12 | intro: string 13 | fanNum: number 14 | followNum: number 15 | } 16 | 17 | // 用户详细信息 18 | export interface UserDetailInfo { 19 | id: string 20 | username: string 21 | email: string 22 | avatar: string 23 | littleAvatar: string 24 | intro: string 25 | fanNum: number 26 | followNum: number 27 | background_img: string | null 28 | gender: 0 | 1 | 2 29 | isFollowed: boolean 30 | } 31 | 32 | // 标签信息 33 | export interface LabelInfo { 34 | id: string 35 | name: string 36 | cover: string | null 37 | color: string 38 | } 39 | 40 | // 历史搜索记录信息 41 | export interface HistorySearchInfo { 42 | id: string 43 | name: string 44 | time: string 45 | } 46 | 47 | // 普通作品item信息 48 | export interface WorkNormalItemInfo { 49 | id: string 50 | imgList: string[] 51 | cover: string 52 | name: string 53 | authorId: string 54 | authorName: string 55 | authorAvatar: string 56 | isLiked: boolean 57 | } 58 | 59 | // 作品详细信息 60 | export interface WorkDetailInfo { 61 | id: string 62 | imgList: string[] 63 | images: ImageItem[] 64 | name: string 65 | intro: string 66 | labels: LabelItem[] 67 | isLiked: boolean 68 | isCollected: boolean 69 | favoriteIds?: string[] 70 | reprintType: number 71 | openComment: boolean 72 | isAIGenerated: boolean 73 | likeNum: number 74 | viewNum: number 75 | collectNum: number 76 | commentNum: number 77 | createdDate: string 78 | updatedDate: string 79 | authorInfo: { 80 | id: string 81 | username: string 82 | avatar: string 83 | intro: string 84 | isFollowing: boolean 85 | } 86 | workUrl?: string 87 | illustrator?: { 88 | id: string 89 | name: string 90 | avatar: string | null 91 | intro: string 92 | homeUrl: string 93 | workCount: number 94 | } 95 | } 96 | 97 | // 用户item信息 98 | export interface UserItemInfo { 99 | id: string 100 | username: string 101 | email: string 102 | avatar: string 103 | intro: string 104 | isFollowing: boolean 105 | works?: WorkNormalItemInfo[] 106 | } 107 | 108 | // 搜索条件 109 | export interface SearchFilter { 110 | type: string 111 | label: string 112 | sortType: string 113 | } 114 | 115 | // 标签详细信息 116 | export interface LabelDetailInfo extends LabelInfo { 117 | isMyLike: boolean 118 | workCount: number 119 | } 120 | 121 | // 作品评论信息 122 | export interface CommentItem { 123 | id: string 124 | content: string 125 | createdAt: string 126 | authorInfo: { 127 | id: string 128 | username: string 129 | avatar: string 130 | } 131 | level: number // 0-一级评论 1-二级评论 132 | replyTo?: { 133 | id: string 134 | username: string 135 | } 136 | childComments?: CommentItem[] 137 | } 138 | 139 | // 上传作品表单信息 140 | export interface UploadWorkFormInfo { 141 | basicInfo: { 142 | name: string 143 | intro: string 144 | reprintType: number 145 | openComment: boolean 146 | isAIGenerated: boolean 147 | workUrl?: string 148 | } 149 | labels: string[] 150 | illustratorInfo?: { 151 | name: string 152 | homeUrl: string 153 | } 154 | } 155 | 156 | // 收藏夹item信息 157 | export interface FavoriteItemInfo { 158 | id: string 159 | name: string 160 | intro: string 161 | cover: null | string 162 | order: number 163 | workNum: number 164 | } 165 | 166 | // 收藏夹详细信息 167 | export interface FavoriteDetailInfo { 168 | id: string 169 | name: string 170 | intro: string 171 | cover: null | string 172 | workNum: number 173 | creatorId: string 174 | creatorName: string 175 | } 176 | 177 | // 收藏夹表单信息 178 | export interface FavoriteFormInfo { 179 | name: string 180 | intro: string 181 | cover?: string 182 | } 183 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "jsx": "react-jsx", 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "useDefineForClassFields": true, 7 | "baseUrl": "./", 8 | "module": "ESNext", 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "paths": { 12 | "@/*": ["src/*"] 13 | }, 14 | "resolveJsonModule": true, 15 | "allowImportingTsExtensions": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noEmit": true, 23 | "isolatedModules": true, 24 | "skipLibCheck": true 25 | }, 26 | "references": [{ "path": "./tsconfig.node.json" }], 27 | "include": ["src", "src/components/home/label-list/index1/.tsx"] 28 | } 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { presetRemToPx } from '@unocss/preset-rem-to-px' 2 | import { 3 | defineConfig, 4 | presetAttributify, 5 | presetIcons, 6 | presetTypography, 7 | presetWebFonts, 8 | presetWind3, 9 | transformerAttributifyJsx, 10 | transformerDirectives, 11 | transformerVariantGroup, 12 | } from 'unocss' 13 | import { presetScrollbar } from 'unocss-preset-scrollbar' 14 | 15 | export default defineConfig({ 16 | presets: [ 17 | /* Core Presets */ 18 | presetWind3(), 19 | presetAttributify(), 20 | presetIcons(), 21 | presetTypography(), 22 | presetWebFonts({ 23 | provider: 'google', 24 | fonts: { 25 | noto: [ 26 | 'Noto Sans:300,400', 27 | 'Noto Sans SC:300,400', 28 | 'Noto Sans TC:300,400', 29 | 'Noto Sans JP:300,400', 30 | ], 31 | }, 32 | }), 33 | 34 | /* Community Presets */ 35 | presetRemToPx(), 36 | presetScrollbar(), 37 | ], 38 | transformers: [transformerAttributifyJsx(), transformerDirectives(), transformerVariantGroup()], 39 | shortcuts: [['title', 'text-lg font-bold text-[#858585]']], 40 | theme: { 41 | colors: { 42 | azure: { 43 | DEFAULT: '#0090F0', 44 | 50: '#D1EDFF', 45 | 100: '#BDE5FF', 46 | 200: '#94D4FF', 47 | 300: '#6BC4FF', 48 | 400: '#43B4FF', 49 | 500: '#1AA3FF', 50 | 600: '#0090F0', 51 | 700: '#006EB8', 52 | 800: '#004D80', 53 | 900: '#002B48', 54 | 950: '#001A2C', 55 | }, 56 | neutral: { 57 | DEFAULT: '#858585', 58 | 50: '#F5F5F5', 59 | 100: '#EBEBEB', 60 | 200: '#D6D6D6', 61 | 300: '#C2C2C2', 62 | 400: '#ADADAD', 63 | 500: '#999999', 64 | 600: '#858585', 65 | 700: '#666666', 66 | 800: '#474747', 67 | 900: '#292929', 68 | 950: '#1A1A1A', 69 | }, 70 | }, 71 | extend: { 72 | keyframes: { 73 | 'spin': { 74 | '0%': { transform: 'rotate(0deg)' }, 75 | '100%': { transform: 'rotate(360deg)' }, 76 | }, 77 | 'float-up': { 78 | '0%': { 79 | opacity: 0, 80 | transform: 'translateY(50%)', 81 | }, 82 | '100%': { 83 | opacity: 1, 84 | transform: 'translateY(0)', 85 | }, 86 | }, 87 | }, 88 | animation: { 89 | 'spin': 'spin 1s linear infinite', 90 | 'float-up': 'float-up 0.3s forwards', 91 | }, 92 | }, 93 | }, 94 | }) 95 | -------------------------------------------------------------------------------- /vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_BASE_URL: string 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import React from '@vitejs/plugin-react' 3 | import UnoCSS from 'unocss/vite' 4 | import { defineConfig, loadEnv } from 'vite' 5 | import viteCompression from 'vite-plugin-compression' 6 | import viteImagemin from 'vite-plugin-imagemin' 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig(({ mode }) => { 10 | const env = loadEnv(mode, __dirname) 11 | return { 12 | base: env.VITE_BASE, 13 | plugins: [ 14 | UnoCSS(), 15 | React(), 16 | viteImagemin({ 17 | gifsicle: { 18 | optimizationLevel: 7, 19 | interlaced: false, 20 | }, 21 | optipng: { 22 | optimizationLevel: 7, 23 | }, 24 | mozjpeg: { 25 | quality: 20, 26 | }, 27 | pngquant: { 28 | quality: [0.8, 0.9], 29 | speed: 4, 30 | }, 31 | svgo: { 32 | plugins: [ 33 | { 34 | name: 'removeViewBox', 35 | }, 36 | { 37 | name: 'removeEmptyAttrs', 38 | active: false, 39 | }, 40 | ], 41 | }, 42 | }), 43 | viteCompression({ 44 | verbose: true, 45 | disable: false, 46 | threshold: 10240, 47 | algorithm: 'brotliCompress', 48 | deleteOriginFile: true, 49 | }), 50 | ], 51 | build: { 52 | target: 'es2020', 53 | minify: 'esbuild', 54 | cssCodeSplit: true, 55 | rollupOptions: { 56 | output: { 57 | chunkFileNames: 'js/[name]-[hash].js', 58 | entryFileNames: 'js/[name]-[hash].js', 59 | assetFileNames: '[ext]/[name]-[hash].[ext]', 60 | manualChunks(id) { 61 | if (id.includes('node_modules')) { 62 | return 'vendor' 63 | } 64 | }, 65 | }, 66 | }, 67 | }, 68 | resolve: { 69 | alias: { 70 | '@': path.resolve(__dirname, './src'), 71 | }, 72 | }, 73 | server: { 74 | port: Number(env.VITE_PORT), 75 | }, 76 | } 77 | }) 78 | --------------------------------------------------------------------------------