├── .eslintrc.js ├── .gitignore ├── .prettierrc.js ├── .vscode └── settings.json ├── README.md ├── __tests__ ├── chipList.test.tsx └── toast.test.tsx ├── index.html ├── jest-setup.ts ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json ├── robots.txt └── workbox-service-worker.js ├── src ├── App.tsx ├── assets │ ├── css │ │ ├── iconfont.css │ │ └── reset.css │ ├── default.png │ ├── logo192.png │ ├── placeholder.png │ └── sun_main.png ├── components │ ├── ChipList │ │ └── index.tsx │ ├── HideOnScroll │ │ └── index.tsx │ ├── LoadingPage │ │ └── index.tsx │ ├── MovieItem │ │ └── index.tsx │ ├── MovieList │ │ └── index.tsx │ ├── MyAppBar │ │ └── index.tsx │ ├── MyDrawer │ │ └── index.tsx │ ├── SearchBar │ │ └── index.tsx │ ├── Toast │ │ ├── index.tsx │ │ └── toast.tsx │ ├── ratioImage │ │ └── index.tsx │ └── swiper │ │ ├── index.tsx │ │ └── style.css ├── http │ └── index.ts ├── main.tsx ├── pages │ ├── Detail │ │ └── index.tsx │ ├── Home │ │ ├── homeCategory.tsx │ │ ├── homeMain.tsx │ │ └── index.tsx │ ├── Play │ │ └── index.tsx │ ├── Search │ │ └── index.tsx │ └── WatchHistory │ │ └── index.tsx ├── store │ ├── index.tsx │ └── unstate-next.tsx ├── types │ ├── constant.ts │ ├── index.ts │ └── swiper.ts ├── utils │ ├── index.ts │ ├── storeUtils.ts │ └── theme.ts └── vite-env.d.ts ├── tsconfig.json └── vite.config.ts /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: ['eslint:recommended', 'plugin:react/recommended', 'plugin:react/jsx-runtime', "plugin:@typescript-eslint/recommended" ], 7 | parser: '@typescript-eslint/parser', 8 | parserOptions: { 9 | ecmaFeatures: { 10 | jsx: true, 11 | }, 12 | ecmaVersion: 13, 13 | sourceType: 'module', 14 | }, 15 | plugins: ['react', '@typescript-eslint'], 16 | rules: { 17 | 'react/prop-types': 0, 18 | '@typescript-eslint/no-explicit-any': 'off', 19 | '@typescript-eslint/no-empty-function': 'off', 20 | }, 21 | settings: { 22 | react: { 23 | version: 'detect', 24 | }, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | build 5 | dist-ssr 6 | *.local 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | tabWidth: 2, 4 | printWidth: 100, 5 | semi: true, 6 | singleQuote: true 7 | }; -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "swipeable", 4 | "vite", 5 | "vitejs" 6 | ] 7 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 风影院 H5版本 2 | 3 | ![Build And Upload COS](https://github.com/CodeByZack/kongtv-react/workflows/Build%20And%20Upload%20COS/badge.svg) 4 | 5 | 6 | 换了ios手机,之前看做的看视频的app只有安卓版本的。由于iosApp开发的条件限制,暂时先做一个H5的用用。 7 | 8 | 如果喜欢APP,可以试试[安卓版本](https://github.com/CodeByZack/kongtv-android),暂时没有IOS。 9 | 10 | ## 使用 11 | 12 | [线上地址](https://movie.zackdk.com/) 13 | 14 | IOS手机,请添加到主屏幕使用,体验更好!!! 15 | 16 | 基本功能已开发完。 17 | 18 | 支持电视剧、电影、动漫、综艺四个大类节目。 19 | 20 | 可根据年份、地区进行详细筛选。 21 | 22 | 支持关键字模糊搜索,以及搜索历史。 23 | 24 | 首页推荐每天自动爬取爱奇艺电视剧、电影、动漫、综艺排行前六。 25 | 26 | 支持日间/夜间模式切换。 27 | 28 | 支持观看历史记录。 29 | 30 | 支持PWA,可添加到桌面使用。 31 | 32 | 技术栈: react、react-hook、 unstate-next、 react-router、 material-ui 33 | 34 | 35 | ## 运行项目 36 | 37 | ### 本地 38 | 39 | ``` 40 | git clone https://github.com/CodeByZack/kongtv-react 41 | 42 | npm i 43 | 44 | npm run start 45 | ``` 46 | 47 | ### 打包 48 | 49 | `npm run build`用于github action自动构建,请勿使用。 50 | 51 | 请使用`npm run build-dev`。 52 | 53 | 54 | ## 问题记录 55 | 开发过程中,一些配置问题的[记录(待整理)](https://www.yuque.com/zackdk/web/an8i5p)。 56 | 57 | ## ~~todo~~ 58 | 59 | ~~删除redux相关,使用unstated-next~~ 60 | 61 | ~~删除antd-mobild~~ 62 | 63 | ~~使用material ui~~ 64 | 65 | ~~添加PWA支持(IOS需要手动添加到主屏幕)~~ 66 | 67 | ~~增加本地观看记录~~ 68 | 69 | ~~增加分类~~ 70 | 71 | ~~优化体验,增加夜间模式~~ 72 | 73 | ## gif预览 74 | 75 | ![gif预览](https://apks-1252514056.cos.ap-chengdu.myqcloud.com/demo.gif) 76 | 77 | 78 | ## 截图预览(old) 79 | 80 | ![首页](https://apks-1252514056.cos.ap-chengdu.myqcloud.com/%E9%A6%96%E9%A1%B5web.png) 81 | 82 | ![详情页](https://apks-1252514056.cos.ap-chengdu.myqcloud.com/%E8%AF%A6%E6%83%85web.png) 83 | 84 | ![播放页](https://apks-1252514056.cos.ap-chengdu.myqcloud.com/%E6%92%AD%E6%94%BE-web.png) -------------------------------------------------------------------------------- /__tests__/chipList.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from '@testing-library/react'; 2 | import ChipList, { YearChipList, AreaChipList } from '../src/components/ChipList'; 3 | 4 | describe('ChipList', () => { 5 | test('默认ChipList组件', () => { 6 | const mockClick = jest.fn(); 7 | 8 | const renderResult = render(); 9 | 10 | expect(renderResult.getByText('年代:')).toBeInTheDocument(); 11 | 12 | expect(renderResult.getAllByRole('button').length).toEqual(10); 13 | 14 | expect(renderResult.getByText('2020').parentElement).toHaveClass('MuiChip-outlinedDefault'); 15 | 16 | expect(renderResult.getByText('2021').parentElement).not.toHaveClass('MuiChip-outlinedDefault'); 17 | 18 | fireEvent.click(renderResult.getByText('2021')); 19 | 20 | expect(mockClick.mock.calls.length).toEqual(1); 21 | 22 | expect(mockClick.mock.calls[0][0]).toEqual('2021'); 23 | }); 24 | 25 | test('年份ChipList组件', () => { 26 | const mockClick = jest.fn(); 27 | 28 | const renderResult = render(); 29 | 30 | expect(renderResult.getByText('年代:')).toBeInTheDocument(); 31 | 32 | expect(renderResult.getAllByRole('button').length).toEqual(10); 33 | 34 | expect(renderResult.getByText('2020').parentElement).toHaveClass('MuiChip-outlinedDefault'); 35 | 36 | expect(renderResult.getByText('2021').parentElement).not.toHaveClass('MuiChip-outlinedDefault'); 37 | 38 | fireEvent.click(renderResult.getByText('2014')); 39 | 40 | expect(mockClick.mock.calls.length).toEqual(1); 41 | 42 | expect(mockClick.mock.calls[0][0]).toEqual('2014'); 43 | }); 44 | 45 | test('地区ChipList组件', () => { 46 | const mockClick = jest.fn(); 47 | 48 | const renderResult = render(); 49 | 50 | expect(renderResult.getByText('地区:')).toBeInTheDocument(); 51 | 52 | expect(renderResult.getAllByRole('button').length).toEqual(16); 53 | 54 | expect(renderResult.getByText('大陆').parentElement).toHaveClass('MuiChip-outlinedDefault'); 55 | 56 | expect(renderResult.getByText('法国').parentElement).not.toHaveClass('MuiChip-outlinedDefault'); 57 | 58 | fireEvent.click(renderResult.getByText('意大利')); 59 | 60 | expect(mockClick.mock.calls.length).toEqual(1); 61 | 62 | expect(mockClick.mock.calls[0][0]).toEqual('意大利'); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /__tests__/toast.test.tsx: -------------------------------------------------------------------------------- 1 | import Toast from '../src/components/Toast'; 2 | import { screen } from '@testing-library/react'; 3 | import { act } from 'react-dom/test-utils'; 4 | 5 | describe('Toast',()=>{ 6 | 7 | test('测试loading', ()=>{ 8 | act(()=>{ 9 | Toast.loading("测试loading",0); 10 | }); 11 | 12 | console.log(document.body); 13 | 14 | expect(screen.findByText('测试loading')).toBeInTheDocument(); 15 | }); 16 | 17 | }); -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 | 28 | 29 | 风影院,像风一样自由~ 30 | 31 | 32 |
33 | 44 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /jest-setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'jsdom', 5 | setupFilesAfterEnv: ['./jest-setup.ts'] 6 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-ts", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "tsc && vite build", 7 | "build-dev": "tsc && vite build", 8 | "serve": "vite preview", 9 | "pr": "prettier --write \"src/**/*{.tsx,.ts}\"", 10 | "lint": "eslint --ext .ts,.tsx src/", 11 | "analyze": "source-map-explorer 'dist/assets/*.js' --no-border-checks", 12 | "test": "jest" 13 | }, 14 | "dependencies": { 15 | "@emotion/react": "^11.5.0", 16 | "@emotion/styled": "^11.3.0", 17 | "@mui/icons-material": "^5.0.5", 18 | "@mui/material": "^5.0.6", 19 | "@mui/styles": "^5.0.2", 20 | "axios": "^0.23.0", 21 | "react": "^18.0.0", 22 | "react-dom": "^18.0.0", 23 | "react-router-dom": "^5.3.0", 24 | "react-swipeable-views": "^0.14.0", 25 | "source-map-explorer": "^2.5.2" 26 | }, 27 | "devDependencies": { 28 | "@testing-library/jest-dom": "^5.15.0", 29 | "@testing-library/react": "^12.1.2", 30 | "@types/jest": "^27.0.2", 31 | "@types/react": "^17.0.43", 32 | "@types/react-dom": "^17.0.14", 33 | "@types/react-router-dom": "^5.3.2", 34 | "@types/react-swipeable-views": "^0.13.1", 35 | "@typescript-eslint/eslint-plugin": "^5.2.0", 36 | "@typescript-eslint/parser": "^5.2.0", 37 | "@vitejs/plugin-react": "^1.0.0", 38 | "eslint": "^8.1.0", 39 | "eslint-plugin-react": "^7.26.1", 40 | "jest": "^27.3.1", 41 | "less": "^4.1.2", 42 | "prettier": "^2.4.1", 43 | "ts-jest": "^27.0.7", 44 | "typescript": "^4.4.4", 45 | "vite": "^2.6.4" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeByZack/kongtv-react/daca97cfc7cb3fcf11933e08a3bc53e6a33537a5/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeByZack/kongtv-react/daca97cfc7cb3fcf11933e08a3bc53e6a33537a5/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeByZack/kongtv-react/daca97cfc7cb3fcf11933e08a3bc53e6a33537a5/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "风影院", 3 | "name": "风影院", 4 | "description": "风影院,像风一样自由...", 5 | "icons": [ 6 | { 7 | "src": "favicon.ico", 8 | "sizes": "64x64 32x32 24x24 16x16", 9 | "type": "image/x-icon" 10 | }, 11 | { 12 | "src": "logo192.png", 13 | "type": "image/png", 14 | "sizes": "192x192" 15 | }, 16 | { 17 | "src": "logo512.png", 18 | "type": "image/png", 19 | "sizes": "512x512" 20 | } 21 | ], 22 | "start_url": ".", 23 | "display": "standalone", 24 | "theme_color": "#000000", 25 | "background_color": "#ffffff" 26 | } 27 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /public/workbox-service-worker.js: -------------------------------------------------------------------------------- 1 | importScripts('https://storage.googleapis.com/workbox-cdn/releases/5.1.2/workbox-sw.js'); 2 | 3 | const { routing, strategies, expiration } = workbox; 4 | const { registerRoute } = routing; 5 | const { NetworkFirst, CacheFirst, StaleWhileRevalidate } = strategies; 6 | const { ExpirationPlugin } = expiration; 7 | 8 | if (workbox) { 9 | console.log(`Yay! Workbox is loaded 🎉`); 10 | } else { 11 | console.log(`Boo! Workbox didn't load 😬`); 12 | } 13 | 14 | 15 | 16 | // 处理html 17 | registerRoute(({request})=>request.destination === 'document',new StaleWhileRevalidate()); 18 | 19 | // 处理js 20 | registerRoute(({request})=>request.destination === 'script',new StaleWhileRevalidate()); 21 | 22 | 23 | 24 | // 处理css 25 | const styleCapture = ({request})=>request.destination === 'style'; 26 | const styleCache = new StaleWhileRevalidate({ cacheName : 'css-cache' }); 27 | registerRoute(styleCapture,styleCache); 28 | 29 | 30 | // 处理图片 31 | const imageCapture = ({request})=>request.destination === 'image'; 32 | const imageCache = new CacheFirst({ 33 | cacheName : 'image-cache', 34 | plugins : [ 35 | new ExpirationPlugin({ 36 | // Cache only 100 images. 37 | maxEntries: 100, 38 | // Cache for a maximum of a week. 39 | maxAgeSeconds: 7 * 24 * 60 * 60, 40 | }) 41 | ] 42 | }); 43 | registerRoute(imageCapture,imageCache); 44 | 45 | 46 | // 处理m3u8 和 ts 文件 47 | const matchCb = ({url, request, event}) => { 48 | return /(\.m3u8|\.ts)/.test(url.pathname); 49 | }; 50 | 51 | registerRoute( matchCb,new CacheFirst({ 52 | cacheName : 'video-cache', 53 | plugins : [ 54 | new ExpirationPlugin({ 55 | // Cache only 100 images. 56 | maxEntries: 10000, 57 | // Cache for a maximum of a week. 58 | maxAgeSeconds: 24 * 60 * 60, 59 | }) 60 | ] 61 | })) -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Slide } from '@mui/material'; 2 | import { BrowserRouter, Route, Switch } from 'react-router-dom'; 3 | import store from './store'; 4 | import { checkBrowser } from './utils'; 5 | import { ThemeProvider } from '@mui/material/styles'; 6 | import { lazy, Suspense, useState } from 'react'; 7 | import themeObj from './utils/theme'; 8 | import LoadingPage from './components/LoadingPage'; 9 | 10 | const Home = lazy(() => import('./pages/Home')); 11 | const Play = lazy(() => import('./pages/Play')); 12 | const Detail = lazy(() => import('./pages/Detail')); 13 | const Search = lazy(() => import('./pages/Search')); 14 | const WatchHistory = lazy(() => import('./pages/WatchHistory')); 15 | 16 | const routes = [ 17 | { path: '/play', Component: Play }, 18 | { path: '/detail', Component: Detail }, 19 | { path: '/search/:query', Component: Search }, 20 | { path: '/search', Component: Search }, 21 | { path: '/watchhistory', Component: WatchHistory }, 22 | { path: '/', Component: Home }, 23 | ]; 24 | 25 | const isSafari = checkBrowser() === 'Safari'; 26 | // const isMobile = checkIsMobile(); 27 | 28 | const renderWithTransition = (path: string, Component: React.FC) => { 29 | const render = (props: any) => { 30 | const { match, history } = props; 31 | 32 | if (!isSafari) { 33 | const direction = history.action === 'POP' ? 'right' : 'left'; 34 | return ( 35 | 36 |
37 | 38 |
39 |
40 | ); 41 | } else { 42 | if (history.action === 'POP') { 43 | return ; 44 | } else { 45 | return ( 46 | 47 |
48 | 49 |
50 |
51 | ); 52 | } 53 | } 54 | }; 55 | return ( 56 | 57 | {render} 58 | 59 | ); 60 | }; 61 | 62 | const App = () => { 63 | const [theme, setTheme] = useState(themeObj.defaultTheme); 64 | const toggoleTheme = (type: any): void => { 65 | setTheme(themeObj.ThemeArr[type]); 66 | themeObj.setThemeLocal(type); 67 | }; 68 | return ( 69 | 70 | 71 | 72 | }> 73 | 74 | {routes.map(({ path, Component }) => renderWithTransition(path, Component))} 75 | 76 | 77 | 78 | 79 | 80 | ); 81 | }; 82 | 83 | export default App; 84 | -------------------------------------------------------------------------------- /src/assets/css/iconfont.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'iconfont'; /* project id 1724904 */ 3 | src: url('//at.alicdn.com/t/font_1724904_8ryev8rq29r.eot'); 4 | src: url('//at.alicdn.com/t/font_1724904_8ryev8rq29r.eot?#iefix') format('embedded-opentype'), 5 | url('//at.alicdn.com/t/font_1724904_8ryev8rq29r.woff2') format('woff2'), 6 | url('//at.alicdn.com/t/font_1724904_8ryev8rq29r.woff') format('woff'), 7 | url('//at.alicdn.com/t/font_1724904_8ryev8rq29r.ttf') format('truetype'), 8 | url('//at.alicdn.com/t/font_1724904_8ryev8rq29r.svg#iconfont') format('svg'); 9 | } 10 | -------------------------------------------------------------------------------- /src/assets/css/reset.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | } 4 | a[title] { 5 | display: none; 6 | } 7 | -------------------------------------------------------------------------------- /src/assets/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeByZack/kongtv-react/daca97cfc7cb3fcf11933e08a3bc53e6a33537a5/src/assets/default.png -------------------------------------------------------------------------------- /src/assets/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeByZack/kongtv-react/daca97cfc7cb3fcf11933e08a3bc53e6a33537a5/src/assets/logo192.png -------------------------------------------------------------------------------- /src/assets/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeByZack/kongtv-react/daca97cfc7cb3fcf11933e08a3bc53e6a33537a5/src/assets/placeholder.png -------------------------------------------------------------------------------- /src/assets/sun_main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeByZack/kongtv-react/daca97cfc7cb3fcf11933e08a3bc53e6a33537a5/src/assets/sun_main.png -------------------------------------------------------------------------------- /src/components/ChipList/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Chip, Typography } from '@mui/material'; 2 | import { noop } from '../../types/constant'; 3 | 4 | const year = ['全部', '2023', '2022', '2021', '2020', '2019', '2018', '2017', '2016', '2015', '2014', '2013']; 5 | const area = [ 6 | '全部', 7 | '大陆', 8 | '香港', 9 | '台湾', 10 | '美国', 11 | '法国', 12 | '英国', 13 | '日本', 14 | '韩国', 15 | '德国', 16 | '泰国', 17 | '印度', 18 | '意大利', 19 | '西班牙', 20 | '加拿大', 21 | '其他', 22 | ]; 23 | interface IChipListProp { 24 | data?: string[]; 25 | title?: string; 26 | onChange?: (txt: string) => void; 27 | now?: string; 28 | } 29 | 30 | const ChipList = (props: IChipListProp) => { 31 | const { data = year, title = '年代', onChange = noop, now = '' } = props; 32 | const handleClick = (item: string) => { 33 | if (item === '全部') { 34 | onChange(''); 35 | } else { 36 | onChange(item); 37 | } 38 | }; 39 | 40 | return ( 41 | 49 | 50 | {title}: 51 | 52 | {data.map((i) => ( 53 | handleClick(i)} 57 | size="small" 58 | label={i} 59 | variant={now === i ? 'outlined' : undefined} 60 | /> 61 | ))} 62 | 63 | ); 64 | }; 65 | 66 | export interface IChipGroupProps { 67 | onChange: (txt: string) => void; 68 | nowShow: string; 69 | } 70 | 71 | export const YearChipList: React.FC = ({ onChange, nowShow }) => { 72 | return ; 73 | }; 74 | 75 | export const AreaChipList: React.FC = ({ onChange, nowShow }) => { 76 | return ; 77 | }; 78 | 79 | export default ChipList; 80 | -------------------------------------------------------------------------------- /src/components/HideOnScroll/index.tsx: -------------------------------------------------------------------------------- 1 | import { Fade, useScrollTrigger } from '@mui/material'; 2 | import { PropsWithChildren } from 'react'; 3 | 4 | const HideOnScroll = (props: PropsWithChildren) => { 5 | const { children } = props; 6 | const trigger = useScrollTrigger(); 7 | return ( 8 | 9 | {children} 10 | 11 | ); 12 | }; 13 | export default HideOnScroll; 14 | -------------------------------------------------------------------------------- /src/components/LoadingPage/index.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress, Stack } from '@mui/material'; 2 | 3 | const LoadingPage = () => { 4 | return ( 5 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default LoadingPage; 17 | -------------------------------------------------------------------------------- /src/components/MovieItem/index.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardMedia, CardContent, Typography, Skeleton } from '@mui/material'; 2 | import defaultImg from '../../assets/placeholder.png'; 3 | import store from '../../store'; 4 | import { IMovieItem } from '../../types'; 5 | interface IProps { 6 | data: IMovieItem; 7 | } 8 | 9 | const MovieItem = (props: IProps) => { 10 | const { data } = props; 11 | // const styles = useStyles(); 12 | 13 | const { detail, jumpUtil } = store.useContainer(); 14 | const { jumpToDetail } = jumpUtil; 15 | const onMovieClick = () => { 16 | detail.setNowMovie(data); 17 | jumpToDetail(data); 18 | }; 19 | 20 | const bkUrl = `url(${data.vod_pic}),url(${defaultImg})`; 21 | 22 | return ( 23 | 24 | 33 | 38 | 39 | {data.vod_name} 40 | 41 | 42 | 43 | ); 44 | }; 45 | 46 | export const MovieItemSkeleton = () => { 47 | return ( 48 | 49 | 50 | 56 | 57 | 58 | 63 | 64 | . 65 | 66 | 67 | 68 | 69 | ); 70 | }; 71 | 72 | export default MovieItem; 73 | -------------------------------------------------------------------------------- /src/components/MovieList/index.tsx: -------------------------------------------------------------------------------- 1 | import { Grid } from '@mui/material'; 2 | import { IMovieItem } from '../../types'; 3 | import MovieItem, { MovieItemSkeleton } from '../MovieItem'; 4 | 5 | interface IProps { 6 | movies: IMovieItem[]; 7 | } 8 | 9 | const MovieList = (props: IProps) => { 10 | const { movies } = props; 11 | 12 | if (!movies) return null; 13 | 14 | return ( 15 |
16 | 17 | {movies.map((tile, index) => ( 18 | 19 | 20 | 21 | ))} 22 | 23 |
24 | ); 25 | }; 26 | 27 | export const MovieListSkeleton = () => { 28 | return ( 29 | 30 | {[1, 2, 3, 4, 5, 6].map((tile) => ( 31 | 32 | 33 | 34 | ))} 35 | 36 | ); 37 | }; 38 | 39 | export default MovieList; 40 | -------------------------------------------------------------------------------- /src/components/MyAppBar/index.tsx: -------------------------------------------------------------------------------- 1 | import { AppBar, IconButton, Toolbar, Typography } from '@mui/material'; 2 | import HideOnScroll from '../HideOnScroll'; 3 | import MenuIcon from '@mui/icons-material/Menu'; 4 | import ArrowBackIcon from '@mui/icons-material/ArrowBack'; 5 | import SearchIcon from '@mui/icons-material/Search'; 6 | import { PropsWithChildren } from 'react'; 7 | import { noop } from '../../types/constant'; 8 | 9 | interface IProps { 10 | title: string; 11 | toggoleDrawer?: () => void; 12 | onSearch?: () => void; 13 | } 14 | 15 | interface INavBarProps { 16 | title: string; 17 | onBack?: () => void; 18 | } 19 | 20 | const MyAppBar = (props: PropsWithChildren) => { 21 | const { title, toggoleDrawer, onSearch = noop, ...restProps } = props; 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {title} 32 | 33 | 34 | 35 | 36 | 37 | {props.children} 38 | 39 | 40 | ); 41 | }; 42 | 43 | export const NavBar = (props: INavBarProps) => { 44 | const { title = '风影院', onBack = noop } = props; 45 | 46 | return ( 47 | 48 | 49 | 50 | 51 | 52 | 53 | {title} 54 | 55 | 56 | 57 | ); 58 | }; 59 | 60 | export default MyAppBar; 61 | -------------------------------------------------------------------------------- /src/components/MyDrawer/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Drawer, 3 | IconButton, 4 | ImageListItem, 5 | ImageListItemBar, 6 | List, 7 | ListItem, 8 | ListItemIcon, 9 | ListItemText, 10 | } from '@mui/material'; 11 | import { noop } from '../../types/constant'; 12 | import drawerImg from '../../assets/sun_main.png'; 13 | import logoImg from '../../assets/logo192.png'; 14 | 15 | interface IProps { 16 | open: boolean; 17 | onClose?: () => void; 18 | menus?: { txt: string; icon: React.ReactNode; onClick?: (txt: string) => void }[]; 19 | } 20 | 21 | const MyDrawer = (props: IProps) => { 22 | const { open, onClose = noop, menus = [] } = props; 23 | return ( 24 | 25 | 26 | 像风一样自由~ 27 | 33 | 34 | 35 | } 36 | /> 37 | 38 | 39 | {menus.map((menu) => { 40 | const { txt, icon, onClick = noop } = menu; 41 | return ( 42 | onClick('watchhistory')}> 43 | {icon} 44 | 45 | 46 | ); 47 | })} 48 | 49 | 50 | ); 51 | }; 52 | export default MyDrawer; 53 | -------------------------------------------------------------------------------- /src/components/SearchBar/index.tsx: -------------------------------------------------------------------------------- 1 | import ArrowBackIcon from '@mui/icons-material/ArrowBack'; 2 | import SearchIcon from '@mui/icons-material/Search'; 3 | import { AppBar, Box, Chip, ClickAwayListener, IconButton, InputBase } from '@mui/material'; 4 | import { useState } from 'react'; 5 | import storeUtils from '../../utils/storeUtils'; 6 | 7 | interface IProps { 8 | onBack: () => void; 9 | onSearch: (str: string) => void; 10 | placeholder?: string; 11 | } 12 | 13 | const SearchBar = (props: IProps) => { 14 | const { onBack, onSearch, placeholder } = props; 15 | const [showHistory, setShowHistory] = useState(false); 16 | const [searchText, setSearchText] = useState(''); 17 | 18 | const handleKeyUp: React.KeyboardEventHandler = (e) => { 19 | if (e.keyCode === 13 && onSearch) { 20 | onSearch(searchText); 21 | } 22 | }; 23 | 24 | const handleHistoryClick = (e: string) => () => { 25 | setSearchText(e); 26 | onSearch(e); 27 | setShowHistory(false); 28 | }; 29 | 30 | return ( 31 | setShowHistory(false)}> 32 | 33 | 40 | 41 | 42 | 43 | setShowHistory(true)} 45 | value={searchText} 46 | onKeyUp={handleKeyUp} 47 | onChange={(e) => setSearchText(e.target.value)} 48 | sx={{ ml: 1, flex: 1, color: 'common.white' }} 49 | placeholder={placeholder || ''} 50 | /> 51 | onSearch(searchText)} 54 | size="large" 55 | > 56 | 57 | 58 | 59 | {showHistory && ( 60 | 61 | {storeUtils.getSearchHistory()?.map((t) => ( 62 | 71 | ))} 72 | 73 | )} 74 | 75 | 76 | ); 77 | }; 78 | 79 | export default SearchBar; 80 | -------------------------------------------------------------------------------- /src/components/Toast/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRef } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { ControlToast, IControlRef, ToastType } from './toast'; 4 | 5 | const createNotice = () => { 6 | const div = document.createElement('div'); 7 | document.body.appendChild(div); 8 | let timerId: number; 9 | 10 | const destroy = () => { 11 | ReactDOM.unmountComponentAtNode(div); 12 | }; 13 | 14 | const loading = (content: string, duration: number = 1000) => { 15 | const ref = createRef(); 16 | ReactDOM.render(, div); 17 | ref.current?.show({ 18 | type: ToastType.Loading, 19 | content, 20 | }); 21 | 22 | if (timerId) { 23 | clearTimeout(timerId); 24 | } 25 | 26 | if (duration > 0) { 27 | setTimeout(destroy, duration); 28 | } 29 | }; 30 | 31 | const info = (content: string, duration: number = 1000) => { 32 | const ref = createRef(); 33 | ReactDOM.render(, div); 34 | ref.current?.show({ 35 | type: ToastType.Info, 36 | content, 37 | }); 38 | if (timerId) { 39 | clearTimeout(timerId); 40 | } 41 | 42 | if (duration > 0) { 43 | setTimeout(destroy, duration); 44 | } 45 | }; 46 | 47 | return { 48 | loading, 49 | info, 50 | destroy, 51 | }; 52 | }; 53 | 54 | let notice: ReturnType; 55 | 56 | const getInstance = () => { 57 | if (!notice) { 58 | notice = createNotice(); 59 | } 60 | return notice; 61 | }; 62 | 63 | export default { 64 | info: (content: string, duration?: number) => getInstance().info(content, duration), 65 | loading: (content: string, duration?: number) => getInstance().loading(content, duration), 66 | destroy: () => getInstance().destroy(), 67 | }; 68 | -------------------------------------------------------------------------------- /src/components/Toast/toast.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress, Backdrop, Stack, Typography, Box } from '@mui/material'; 2 | import { forwardRef, useImperativeHandle, useRef, useState } from 'react'; 3 | 4 | export enum ToastType { 5 | Loading, 6 | Info, 7 | } 8 | 9 | interface IProps { 10 | type: ToastType; 11 | content: string; 12 | } 13 | 14 | interface IControlProps {} 15 | 16 | export interface IControlRef { 17 | show: (toastProps: IProps, duration?: number) => void; 18 | hide: () => void; 19 | } 20 | 21 | const Toast = (props: IProps) => { 22 | const { type = 'info', content } = props; 23 | return ( 24 | theme.zIndex.appBar + 1, 35 | }} 36 | > 37 | 48 | {type === ToastType.Loading && } 49 | {content} 50 | 51 | 52 | ); 53 | }; 54 | 55 | export const ControlToast = forwardRef((props, ref) => { 56 | const [toastProps, setToastProps] = useState(); 57 | const [visible, setVisible] = useState(false); 58 | 59 | const hide = () => { 60 | setToastProps(undefined); 61 | setVisible(false); 62 | }; 63 | 64 | useImperativeHandle( 65 | ref, 66 | () => { 67 | return { 68 | show: (toastProps: IProps) => { 69 | setToastProps(toastProps); 70 | setVisible(true); 71 | }, 72 | hide, 73 | }; 74 | }, 75 | [] 76 | ); 77 | if (!toastProps || !visible) return null; 78 | return ; 79 | }); 80 | 81 | export default Toast; 82 | -------------------------------------------------------------------------------- /src/components/ratioImage/index.tsx: -------------------------------------------------------------------------------- 1 | import DefaultImg from '../../assets/placeholder.png'; 2 | const style = { 3 | position: 'relative', 4 | paddingBottom: '140%', 5 | }; 6 | 7 | const imgStyle: any = { 8 | position: 'absolute', 9 | left: 0, 10 | right: 0, 11 | width: '100%', 12 | height: '100%', 13 | objectFit: 'cover', 14 | }; 15 | const errImg = (e: any, DefaultImg: string) => { 16 | e.target.src = DefaultImg; 17 | }; 18 | const RatioImage = (props: any) => { 19 | const { imgUrl, imgAlt, ratio = 1 } = props; 20 | 21 | const height = `${ratio * 100}%`; 22 | 23 | const wrapperStyle: any = { 24 | ...style, 25 | paddingBottom: height, 26 | }; 27 | 28 | return ( 29 |
30 | {/* {imgAlt} (e.target.src = DefaultImg)} /> */} 31 | {imgAlt} errImg(e, DefaultImg)} /> 32 |
33 | ); 34 | }; 35 | export default RatioImage; 36 | -------------------------------------------------------------------------------- /src/components/swiper/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import RatioImage from '../ratioImage'; 3 | import './style.css'; 4 | import { swiperStyle, setSwipeType } from '../../types/swiper'; 5 | import { noop } from '../../types/constant'; 6 | const defaultStyle = { visibility: 'hidden', width: 0, height: 0 }; 7 | const styles = [ 8 | { 9 | transform: 'translate(-150%, -50%) scale3d(0.8, 0.8, 0.8)', 10 | zIndex: 1, 11 | }, 12 | { 13 | transform: 'translate(-100%, -50%) scale3d(0.9, 0.9, 0.9)', 14 | zIndex: 2, 15 | }, 16 | { 17 | transform: 'translate(-50%, -50%) scale3d(1, 1, 1)', 18 | zIndex: 3, 19 | }, 20 | { 21 | transform: 'translate(0%, -50%) scale3d(0.9, 0.9, 0.9)', 22 | zIndex: 2, 23 | }, 24 | { 25 | transform: 'translate(50%, -50%) scale3d(0.8, 0.8, 0.8)', 26 | zIndex: 1, 27 | }, 28 | ]; 29 | 30 | const calculatingStyle = (index: number, length: number): swiperStyle[] => { 31 | const resStyle: swiperStyle[] = []; 32 | for (let i = 0; i < length; i++) { 33 | const _index = !index 34 | ? i - index 35 | : index > 0 36 | ? i - index >= 0 37 | ? i - index 38 | : i - index + length 39 | : i - index > 4 40 | ? i - index - length 41 | : i - index; 42 | const style = styles[_index] ?? defaultStyle; 43 | resStyle.push(style); 44 | } 45 | // console.log( index,'index' ); 46 | // console.log( resStyle,'resStyle' ); 47 | return resStyle; 48 | }; 49 | 50 | const Swiper = (props: any): any => { 51 | const { imgArr = [], autoPlay = true, onSwiperItemClick = noop } = props; 52 | const [index, setIndex] = useState(0); 53 | const [_styles, setStyle]: [any, any] = useState([]); 54 | let xStart: number; 55 | const startHandle = (e: any): void => { 56 | e.stopPropagation(); 57 | xStart = e.touches[0].pageX; 58 | }; 59 | const moveHandle = (e: any): void => { 60 | e.stopPropagation(); 61 | const touch = e.touches[0]; 62 | xStart - touch.pageX <= -50 && swiperFn(index, 'right'); 63 | xStart - touch.pageX >= 50 && swiperFn(index, 'left'); 64 | }; 65 | 66 | const setIndexFn: setSwipeType = (index: number, direction: string): number => { 67 | const _index = 68 | direction === 'left' 69 | ? index - 1 === -imgArr.length 70 | ? 0 71 | : index - 1 72 | : index + 1 === imgArr.length 73 | ? 0 74 | : index + 1; 75 | return _index; 76 | }; 77 | const swiperFn: setSwipeType = (index: number, direction: string): void => { 78 | const _index = setIndexFn(index, direction); 79 | setIndex(_index); 80 | setStyle(calculatingStyle(_index, imgArr.length)); 81 | }; 82 | useEffect(() => { 83 | if (!imgArr.length) return; 84 | const timer = setTimeout(() => swiperFn(index, 'right'), 2000); 85 | return () => clearTimeout(timer); 86 | //eslint-disable-next-line 87 | }, [imgArr, autoPlay, index]); 88 | useEffect(() => { 89 | imgArr.length && setStyle(calculatingStyle(0, imgArr.length)); 90 | }, [imgArr]); 91 | 92 | return ( 93 |
94 | {imgArr.map((d: any, i: any) => { 95 | return ( 96 |
onSwiperItemClick(d, i)} 101 | > 102 | {/* {d.vod_name} */} 103 | 104 |
105 | ); 106 | })} 107 |
108 | ); 109 | }; 110 | 111 | export default Swiper; 112 | -------------------------------------------------------------------------------- /src/components/swiper/style.css: -------------------------------------------------------------------------------- 1 | .ks-swiper { 2 | position: relative; 3 | width: 100%; 4 | padding-bottom: 55%; 5 | } 6 | .ks-swiper-item { 7 | text-align: center; 8 | position: absolute; 9 | width: 35%; 10 | color: #fff; 11 | top: 50%; 12 | left: 50%; 13 | transform: translate(-50%, -50%); 14 | transition: all 0.5s ease; 15 | } 16 | -------------------------------------------------------------------------------- /src/http/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { ICommonRequestParams, IMovieItem, MovieType } from '../types'; 3 | 4 | // axios.defaults.baseURL = 'http://47.94.254.236:55'; 5 | // axios.defaults.baseURL = 'https://fengxiaoci.cn'; 6 | axios.defaults.baseURL = 'https://api.zackdk.com/movie'; 7 | // axios.defaults.baseURL = 'http://127.0.0.1:8360/movie'; 8 | axios.defaults.timeout = 20000; 9 | 10 | axios.interceptors.request.use( 11 | function (config) { 12 | return config; 13 | }, 14 | function (error) { 15 | return Promise.reject(error); 16 | } 17 | ); 18 | 19 | axios.interceptors.response.use( 20 | function (response) { 21 | // console.log(response); 22 | return response.data; 23 | }, 24 | function (error) { 25 | // Toast.error(error.message); 26 | return Promise.reject(error); 27 | } 28 | ); 29 | 30 | const getIndex = () => axios.get('/index'); 31 | 32 | const getCategory = (type: MovieType, params: ICommonRequestParams) => 33 | axios.get(`/${type}`, { params }); 34 | 35 | const searchMovie = (searchText: string) => { 36 | const params = { 37 | router: 'search', 38 | word: searchText, 39 | pagesize: 100, 40 | }; 41 | return axios.get('/search', { params }); 42 | }; 43 | 44 | const clearCacheAndRefresh = async () => { 45 | const res = await axios.get('http://fengxiaoci.cn/api.php/timming/index.html?enforce=1&name=bdzy'); 46 | const res2 = await axios.get('https://api.fengxiaoci.cn/movie/updateindex'); 47 | }; 48 | 49 | export { getIndex, getCategory, searchMovie, clearCacheAndRefresh }; 50 | export default axios; 51 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import App from './App'; 3 | const container = document.getElementById('root'); 4 | const root = createRoot(container!); 5 | root.render(); 6 | -------------------------------------------------------------------------------- /src/pages/Detail/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { 3 | Paper, 4 | Card, 5 | CardMedia, 6 | Typography, 7 | Tabs, 8 | Tab, 9 | Grid, 10 | Button, 11 | Fade, 12 | Box, 13 | CssBaseline, 14 | } from '@mui/material'; 15 | import store from '../../store'; 16 | import { NavBar } from '../../components/MyAppBar'; 17 | import { IJuJi, IPlayInfo } from '../../types'; 18 | import storeUtils from '../../utils/storeUtils'; 19 | 20 | interface IDescLine { 21 | title: string; 22 | desc: string; 23 | lines?: number; 24 | } 25 | 26 | const decodeJuJi = (playUrls: string) => { 27 | return playUrls 28 | .split('$$$') 29 | .map((diffSource) => { 30 | const jujiArr: IJuJi[] = diffSource.split('#').map((juji) => { 31 | const [text, link] = juji.split('$'); 32 | return { text, link }; 33 | }); 34 | return jujiArr; 35 | }) 36 | .filter((arr) => arr[0].link.endsWith('.m3u8')); 37 | }; 38 | 39 | const DescLine = (props: IDescLine) => { 40 | const { title, desc, lines = 1 } = props; 41 | 42 | return ( 43 | 56 | {title} 57 | 58 | {desc} 59 | 60 | 61 | ); 62 | }; 63 | 64 | const MovieDetail = () => { 65 | const { detail, jumpUtil } = store.useContainer(); 66 | const { jumpToPlay, jumpBack } = jumpUtil; 67 | const { nowMovie, clear } = detail; 68 | const [tabValue, setTabValue] = useState(0); 69 | 70 | if (!nowMovie) return null; 71 | 72 | const onPlayClick = (item: IJuJi) => () => { 73 | storeUtils.addWatchHistory(nowMovie, item); 74 | const palyObj: IPlayInfo = { 75 | title: nowMovie.vod_name, 76 | ...item, 77 | }; 78 | jumpToPlay(palyObj); 79 | }; 80 | 81 | const onBackClick = (): void => { 82 | clear(); 83 | jumpBack(); 84 | }; 85 | 86 | const playSources = decodeJuJi(nowMovie.vod_play_url); 87 | 88 | return ( 89 | 90 | 91 | onBackClick()} /> 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 简介: 108 | 109 | 110 | {nowMovie.vod_blurb} 111 | 112 | 113 | 114 | 115 | 116 | 剧集列表: 117 | 118 | setTabValue(v)}> 119 | {playSources.map((t, i) => ( 120 | 121 | ))} 122 | 123 | {playSources.map((playSource, index) => { 124 | return ( 125 | 126 | 127 | {playSource.map((juji, i) => { 128 | return ( 129 | 130 | 139 | 140 | ); 141 | })} 142 | 143 | 144 | ); 145 | })} 146 | 147 | 148 | ); 149 | }; 150 | 151 | export default MovieDetail; 152 | -------------------------------------------------------------------------------- /src/pages/Home/homeCategory.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress, Box } from '@mui/material'; 2 | import { useEffect, useRef } from 'react'; 3 | import { YearChipList, AreaChipList } from '../../components/ChipList'; 4 | import MovieList from '../../components/MovieList'; 5 | import store from '../../store'; 6 | import { MovieType } from '../../types'; 7 | 8 | interface IProps { 9 | type: MovieType; 10 | } 11 | 12 | const HomeCategory = (props: IProps) => { 13 | const { type } = props; 14 | const categoryObj = store.useContainer()[type]; 15 | const { filterOption, setFilterOption, getData, isFetching, list } = categoryObj; 16 | const boxRef = useRef(); 17 | 18 | const handleFilterChange = (key: string) => (item: string) => { 19 | const option = { ...filterOption, [key]: item }; 20 | setFilterOption(option); 21 | }; 22 | 23 | useEffect(() => { 24 | if (!boxRef.current) return; 25 | let ticking = false; 26 | const doSomething = () => { 27 | if (isFetching) return; 28 | const { scrollHeight, scrollTop, clientHeight } = document.documentElement; 29 | if (scrollHeight - scrollTop - clientHeight < 20) { 30 | getData(); 31 | } 32 | }; 33 | const handleScroll = () => { 34 | if (!ticking) { 35 | window.requestAnimationFrame(function () { 36 | doSomething(); 37 | ticking = false; 38 | }); 39 | ticking = true; 40 | } 41 | }; 42 | boxRef.current?.addEventListener('scroll', handleScroll); 43 | return () => { 44 | boxRef.current?.removeEventListener('scroll', handleScroll); 45 | }; 46 | // eslint-disable-next-line 47 | }, [isFetching]); 48 | 49 | return ( 50 | 58 | 59 | 60 | 61 | {isFetching && ( 62 | 63 | 64 | 65 | )} 66 | 67 | ); 68 | }; 69 | export default HomeCategory; 70 | -------------------------------------------------------------------------------- /src/pages/Home/homeMain.tsx: -------------------------------------------------------------------------------- 1 | import Swiper from '../../components/swiper'; 2 | import store from '../../store'; 3 | import { IMovieItem } from '../../types'; 4 | import LocalMoviesIcon from '@mui/icons-material/LocalMovies'; 5 | import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; 6 | import { Box, Skeleton, Typography } from '@mui/material'; 7 | import MovieList, { MovieListSkeleton } from '../../components/MovieList'; 8 | import { useMemo } from 'react'; 9 | 10 | interface IHomeItemProps { 11 | title: string; 12 | movies: IMovieItem[]; 13 | } 14 | 15 | const HomeItem = (props: IHomeItemProps) => { 16 | const { title, movies } = props; 17 | return ( 18 | 23 | 30 | 31 | {title} 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | const HomeItemSkeleton = (props: Pick) => { 41 | return ( 42 | 47 | 54 | 55 | {props.title} 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | }; 63 | const HomeMainSkeleton = () => { 64 | return ( 65 |
66 | 67 | 68 | 69 | 70 | 71 |
72 | ); 73 | }; 74 | 75 | const splitAdviceMovie = (data: IMovieItem[]) => { 76 | const dy = data.filter((movie) => movie.type_id_1 === 1); 77 | const dsj = data.filter((movie) => movie.type_id_1 === 2); 78 | const zy = data.filter((movie) => movie.type_id === 4); 79 | const dm = data.filter((movie) => movie.type_id === 3); 80 | 81 | const swipers = [dy[0], dsj[0], zy[0], dm[0], dy[1]].filter((i) => i); 82 | 83 | return { 84 | homeItems: [ 85 | { title: '热播电影', data: dy }, 86 | { title: '电视剧', data: dsj }, 87 | { title: '热播综艺', data: zy }, 88 | { title: '热播动漫', data: dm }, 89 | ], 90 | swipers, 91 | }; 92 | }; 93 | 94 | const HomeMain = () => { 95 | const { home } = store.useContainer(); 96 | const { adviceMovieList: data, isFetching } = home; 97 | const { detail, jumpUtil } = store.useContainer(); 98 | const { jumpToDetail } = jumpUtil; 99 | const { homeItems, swipers } = useMemo(() => splitAdviceMovie(data), [data]); 100 | 101 | const onSwiperItemClick = (movie: IMovieItem) => { 102 | detail.setNowMovie(movie); 103 | jumpToDetail(movie); 104 | }; 105 | 106 | if (isFetching) { 107 | return ; 108 | } 109 | 110 | return ( 111 | 112 | 113 | {homeItems.map((item) => ( 114 | 115 | ))} 116 | 117 | ); 118 | }; 119 | 120 | export default HomeMain; 121 | -------------------------------------------------------------------------------- /src/pages/Home/index.tsx: -------------------------------------------------------------------------------- 1 | import { CssBaseline, Tab, Tabs, Toolbar, Box } from '@mui/material'; 2 | import SwipeableViews from 'react-swipeable-views'; 3 | import MyDrawer from '../../components/MyDrawer'; 4 | import MyAppBar from '../../components/MyAppBar'; 5 | import store from '../../store'; 6 | import HomeCategory from './homeCategory'; 7 | import HomeMain from './homeMain'; 8 | import HistoryIcon from '@mui/icons-material/History'; 9 | import InfoIcon from '@mui/icons-material/Info'; 10 | import LightIcon from '@mui/icons-material/Brightness7'; 11 | import RefreshIcon from '@mui/icons-material/Refresh'; 12 | import DarkIcon from '@mui/icons-material/Brightness4'; 13 | import themeObj from '../../utils/theme'; 14 | import { TABS, TABS_NAME } from '../../types/constant'; 15 | import Toast from '../../components/Toast'; 16 | import { clearCacheAndRefresh } from '../../http'; 17 | const swStyle = { height: '100%' }; 18 | 19 | const useInitMenus = ( 20 | jumpToWatchHistory: () => void, 21 | toggoleTheme: () => void, 22 | themeHelper: any 23 | ) => { 24 | const menus = [ 25 | { txt: '观看记录', icon: , onClick: jumpToWatchHistory }, 26 | { 27 | txt: themeHelper.theme.palette.mode === 'dark' ? '夜间模式' : '白天模式', 28 | icon: themeHelper.theme.palette.mode === 'dark' ? : , 29 | onClick: toggoleTheme, 30 | }, 31 | { 32 | txt: '更新资源', 33 | onClick: async () => { 34 | }, 35 | icon: , 36 | }, 37 | { txt: '关于', icon: }, 38 | ]; 39 | return menus; 40 | }; 41 | 42 | const Home = () => { 43 | const { home, jumpUtil, themeHelper } = store.useContainer(); 44 | const { drawerStatus, setDrawerStatus, tabIndex, setTabIndex } = home; 45 | const toggoleTheme = () => { 46 | themeHelper.theme === themeObj.ThemeArr.dark 47 | ? themeHelper.toggoleTheme('light') 48 | : themeHelper.toggoleTheme('dark'); 49 | setDrawerStatus(false); 50 | }; 51 | const menus = useInitMenus(jumpUtil.jumpToWatchHistory, toggoleTheme, themeHelper); 52 | 53 | const handleChange = (event: React.SyntheticEvent, newValue: number) => { 54 | setTabIndex(newValue); 55 | }; 56 | 57 | const handleChangeIndex = (index: number) => { 58 | setTabIndex(index); 59 | }; 60 | 61 | return ( 62 | 71 | 72 | jumpUtil.jumpToSearch()} 75 | toggoleDrawer={() => setDrawerStatus(true)} 76 | > 77 | 84 | 85 | {TABS.map((item) => { 86 | return ; 87 | })} 88 | 89 | 90 | setDrawerStatus(false)} /> 91 | 92 | 93 | 94 | 100 | 107 | 108 | {TABS.map((item) => ( 109 | 110 | ))} 111 | 112 | 113 | 114 | ); 115 | }; 116 | export default Home; 117 | -------------------------------------------------------------------------------- /src/pages/Play/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { Box, CssBaseline } from '@mui/material'; 3 | import { NavBar } from '../../components/MyAppBar'; 4 | import store from '../../store'; 5 | import { getQuery } from '../../utils'; 6 | import { useLocation } from 'react-router-dom'; 7 | 8 | const VIDEO_ID = 'VIDEO_ID'; 9 | 10 | const PLAYER_CONFIG = (url: string) => ({ 11 | id: VIDEO_ID, 12 | url, 13 | playsinline: true, 14 | whitelist: [''], 15 | playbackRate: [0.5, 0.75, 1, 1.5, 2], 16 | defaultPlaybackRate: 1, 17 | download: true, 18 | closeVideoTouch: true, 19 | airplay: true, 20 | fluid: true, 21 | }); 22 | 23 | const PlayMovie = () => { 24 | const { jumpUtil } = store.useContainer(); 25 | const { jumpBack } = jumpUtil; 26 | const location = useLocation(); 27 | const nowPlay = getQuery(location.search); 28 | 29 | useEffect(() => { 30 | if (!nowPlay.url) return; 31 | const playerXG = new window.HlsJsPlayer(PLAYER_CONFIG(nowPlay.url)); 32 | return () => playerXG.destroy(); 33 | }, [nowPlay.url]); 34 | 35 | return ( 36 | 37 | 38 | 39 |
40 |
41 | ); 42 | }; 43 | export default PlayMovie; 44 | -------------------------------------------------------------------------------- /src/pages/Search/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CssBaseline, Toolbar } from '@mui/material'; 2 | import MovieList from '../../components/MovieList'; 3 | import SearchBar from '../../components/SearchBar'; 4 | import store from '../../store'; 5 | 6 | const MovieSearch = () => { 7 | const { searchState, jumpUtil } = store.useContainer(); 8 | const { jumpBack } = jumpUtil; 9 | const { searchRes, search } = searchState; 10 | 11 | const onSearch = (e: string) => { 12 | if (!e) { 13 | console.info('输入搜索关键字!'); 14 | return; 15 | } 16 | search(e); 17 | }; 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | }; 29 | export default MovieSearch; 30 | -------------------------------------------------------------------------------- /src/pages/WatchHistory/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { 3 | List, 4 | ListItem, 5 | ListItemAvatar, 6 | ListItemText, 7 | Avatar, 8 | Box, 9 | CssBaseline, 10 | } from '@mui/material'; 11 | import store from '../../store'; 12 | import storeUtils from '../../utils/storeUtils'; 13 | import { IMovieItem } from '../../types'; 14 | import { NavBar } from '../../components/MyAppBar'; 15 | 16 | const WatchHistory = () => { 17 | const [history, setHistory] = useState< 18 | (IMovieItem & { 19 | watch_history?: string | undefined; 20 | })[] 21 | >([]); 22 | const { jumpUtil, detail } = store.useContainer(); 23 | const { jumpBack, jumpToDetail } = jumpUtil; 24 | useEffect(() => { 25 | const watchHistory = storeUtils.getWatchHistory(); 26 | if (watchHistory?.length) { 27 | setHistory(watchHistory); 28 | } 29 | }, []); 30 | 31 | const jump = (movie: IMovieItem) => { 32 | detail.setNowMovie(movie); 33 | jumpToDetail(movie); 34 | }; 35 | 36 | return ( 37 | <> 38 | 39 | 40 | {history.length > 0 ? ( 41 | 42 | {history.map((item) => { 43 | const textInfo = ( 44 | <> 45 | {`${item.vod_area}-${item.vod_class}-${item.vod_director}`} 49 | {`观看至${item.watch_history}`} 50 | 51 | ); 52 | 53 | return ( 54 | { 58 | jump(item); 59 | }} 60 | > 61 | 62 | 63 | 64 | 69 | 70 | ); 71 | })} 72 | 73 | ) : ( 74 | '' 75 | )} 76 | 77 | ); 78 | }; 79 | export default WatchHistory; 80 | -------------------------------------------------------------------------------- /src/store/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | import { createContainer } from './unstate-next'; 3 | import { useState, useEffect } from 'react'; 4 | import { getCategory, getIndex, searchMovie } from '../http'; 5 | import React from 'react'; 6 | import { IMovieItem, IPlayInfo, MovieType } from '../types'; 7 | import storeUtils from '../utils/storeUtils'; 8 | import { useHistory } from 'react-router-dom'; 9 | import Toast from '../components/Toast'; 10 | 11 | const useStore = (themeHelper: any) => { 12 | const home = useHome(); 13 | const dy = useCategory(MovieType.dy); 14 | const dsj = useCategory(MovieType.dsj); 15 | const dm = useCategory(MovieType.dm); 16 | const zy = useCategory(MovieType.zy); 17 | const detail = useDetail(); 18 | const searchState = useSearch(); 19 | const jumpUtil = useJumpUtil(); 20 | 21 | return { 22 | home, 23 | dy, 24 | dsj, 25 | dm, 26 | zy, 27 | detail, 28 | searchState, 29 | jumpUtil, 30 | themeHelper, 31 | }; 32 | }; 33 | 34 | const useJumpUtil = () => { 35 | const history = useHistory(); 36 | 37 | const jumpToDetail = (data: IMovieItem) => { 38 | history.push({ pathname: '/detail', state: data }); 39 | }; 40 | 41 | const jumpToSearch = () => { 42 | history.push({ pathname: '/search' }); 43 | }; 44 | 45 | const jumpToWatchHistory = () => { 46 | history.push({ pathname: '/watchhistory' }); 47 | }; 48 | 49 | const jumpToPlay = (playInfo: IPlayInfo) => { 50 | const url = `/play?name=${playInfo.title}-${playInfo.text}&url=${playInfo.link}`; 51 | history.push(url); 52 | }; 53 | 54 | const jumpToHome = (msg?: string) => { 55 | // eslint-disable-next-line no-console 56 | console.log(msg); 57 | history.push({ pathname: '/' }); 58 | }; 59 | 60 | const jumpBack = () => history.goBack(); 61 | 62 | return { jumpToDetail, jumpToWatchHistory, jumpToSearch, jumpToPlay, jumpBack, jumpToHome }; 63 | }; 64 | 65 | const useHome = () => { 66 | const [tabIndex, setTabIndex] = useState(0); 67 | const [isFetching, setIsFetching] = useState(false); 68 | const [drawerStatus, setDrawerStatus] = useState(false); 69 | const [adviceMovieList, setAdviceMovieList] = useState([]); 70 | 71 | const getAdviceData = async () => { 72 | setIsFetching(true); 73 | const data = await getIndex(); 74 | setAdviceMovieList(data); 75 | setIsFetching(false); 76 | }; 77 | 78 | useEffect(() => { 79 | getAdviceData(); 80 | }, []); 81 | 82 | return { 83 | isFetching, 84 | tabIndex, 85 | setTabIndex, 86 | adviceMovieList, 87 | drawerStatus, 88 | setDrawerStatus, 89 | }; 90 | }; 91 | const useCategory = (type: MovieType) => { 92 | const [page, setPage] = useState(0); 93 | const [list, setList] = useState([]); 94 | const [isFetching, setIsFetching] = useState(false); 95 | const [filterOption, setFilterOption] = useState<{ [x: string]: any }>({}); 96 | 97 | const getData = async (resetPage?: boolean) => { 98 | if (resetPage) { 99 | setIsFetching(true); 100 | const data = await getCategory(type, { page: 1, pagesize: 9, ...filterOption }); 101 | const newList = [...data]; 102 | setList(newList); 103 | setPage(1); 104 | setIsFetching(false); 105 | return; 106 | } 107 | 108 | if (isFetching) return; 109 | setIsFetching(true); 110 | const data = await getCategory(type, { page: page + 1, pagesize: 9, ...filterOption }); 111 | const newList = [...list, ...data]; 112 | setList(newList); 113 | setPage(page + 1); 114 | setIsFetching(false); 115 | }; 116 | 117 | useEffect(() => { 118 | getData(true); 119 | }, [filterOption]); 120 | 121 | return { 122 | isFetching, 123 | page, 124 | list, 125 | getData, 126 | filterOption, 127 | setFilterOption, 128 | }; 129 | }; 130 | const useDetail = () => { 131 | const [nowMovie, setNowMovie] = useState(); 132 | const clear = () => setNowMovie(undefined); 133 | return { 134 | nowMovie, 135 | setNowMovie, 136 | clear, 137 | }; 138 | }; 139 | 140 | const useSearch = () => { 141 | const [searchRes, setSearchRes] = useState([]); 142 | 143 | const search = async (text: string) => { 144 | Toast.loading('正在加载数据', 0); 145 | // setSearchText(searchText); 146 | storeUtils.addSearchHistory(text); 147 | const data = await searchMovie(text); 148 | if (data.length === 0) { 149 | Toast.destroy(); 150 | Toast.info('没有搜索到相关影片'); 151 | } else { 152 | Toast.destroy(); 153 | } 154 | setSearchRes(data); 155 | }; 156 | return { 157 | searchRes, 158 | search, 159 | }; 160 | }; 161 | 162 | const store = createContainer(useStore); 163 | 164 | export const injectStore = 165 | >(Comp: T) => 166 | (props: React.ComponentProps) => 167 | ( 168 | 169 | 170 | 171 | ); 172 | 173 | export default store; 174 | -------------------------------------------------------------------------------- /src/store/unstate-next.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const EMPTY: unique symbol = Symbol(); 4 | 5 | export interface ContainerProviderProps { 6 | initialState?: State; 7 | children: React.ReactNode; 8 | } 9 | 10 | export interface Container { 11 | Provider: React.ComponentType>; 12 | useContainer: () => Value; 13 | } 14 | 15 | export function createContainer( 16 | useHook: (initialState?: State) => Value 17 | ): Container { 18 | const Context = React.createContext(EMPTY); 19 | 20 | function Provider(props: ContainerProviderProps) { 21 | const value = useHook(props.initialState); 22 | return {props.children}; 23 | } 24 | 25 | function useContainer(): Value { 26 | const value = React.useContext(Context); 27 | if (value === EMPTY) { 28 | throw new Error('Component must be wrapped with '); 29 | } 30 | return value; 31 | } 32 | 33 | return { Provider, useContainer }; 34 | } 35 | 36 | export function useContainer(container: Container): Value { 37 | return container.useContainer(); 38 | } 39 | -------------------------------------------------------------------------------- /src/types/constant.ts: -------------------------------------------------------------------------------- 1 | import { MovieType } from '.'; 2 | 3 | export const noop = () => {}; 4 | 5 | export const TABS = [MovieType.dy, MovieType.dsj, MovieType.dm, MovieType.zy]; 6 | 7 | export const TABS_NAME = { 8 | [MovieType.dy]: '电影', 9 | [MovieType.dsj]: '电视剧', 10 | [MovieType.dm]: '动漫', 11 | [MovieType.zy]: '综艺', 12 | }; 13 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface IMovieItem { 2 | vod_id: number; 3 | type_id: number; 4 | vod_time: number; 5 | vod_level: number; 6 | type_id_1: number; 7 | vod_actor: string; 8 | vod_area: string; 9 | vod_blurb: string; 10 | vod_class: string; 11 | vod_content: string; 12 | vod_director: string; 13 | vod_lang: string; 14 | vod_name: string; 15 | vod_pic: string; 16 | vod_play_url: string; 17 | vod_remarks: string; 18 | vod_year: string; 19 | } 20 | 21 | export interface ICommonRequestParams { 22 | page?: number; 23 | pagesize?: number; 24 | year?: string; 25 | area?: string; 26 | } 27 | 28 | export enum MovieType { 29 | dy = 'dy', 30 | dsj = 'dsj', 31 | zy = 'zy', 32 | dm = 'dm', 33 | } 34 | 35 | export interface IJuJi { 36 | text: string; 37 | link: string; 38 | } 39 | export interface IPlayInfo extends IJuJi { 40 | title: string; 41 | } 42 | -------------------------------------------------------------------------------- /src/types/swiper.ts: -------------------------------------------------------------------------------- 1 | export interface swiperStyle { 2 | transform?: string; 3 | zIndex?: number; 4 | } 5 | export type setSwipeType = (index: number, direction: string) => any; 6 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export const getQuery = (str: string) => { 2 | const queryStr = str.split('?')[1]; 3 | const arr = queryStr.split('&'); 4 | const obj = arr.reduce<{ [x: string]: any }>((acc, now) => { 5 | const [k, v] = now.split('='); 6 | acc[k] = decodeURIComponent(v); 7 | return acc; 8 | }, {}); 9 | return obj; 10 | }; 11 | 12 | export const checkBrowser = () => { 13 | const userAgent = navigator.userAgent; //取得浏览器的userAgent字符串 14 | if (userAgent.indexOf('Opera') > -1) { 15 | return 'Opera'; 16 | } //判断是否Opera浏览器 17 | if (userAgent.indexOf('Firefox') > -1) { 18 | return 'FF'; 19 | } //判断是否Firefox浏览器 20 | if (userAgent.indexOf('Chrome') > -1) { 21 | return 'Chrome'; 22 | } 23 | if (userAgent.indexOf('Safari') > -1) { 24 | return 'Safari'; 25 | } //判断是否Safari浏览器 26 | // if (userAgent.indexOf("compatible") > -1 && userAgent.indexOf("MSIE") > -1 && !isOpera) { 27 | // return "IE"; 28 | // }; //判断是否IE浏览器 29 | }; 30 | 31 | export const checkIsMobile = () => { 32 | if ( 33 | navigator.userAgent.match( 34 | /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i 35 | ) 36 | ) { 37 | // alert('手机端') 38 | return true; 39 | } else { 40 | // alert('PC端') 41 | return false; 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/utils/storeUtils.ts: -------------------------------------------------------------------------------- 1 | import { IJuJi, IMovieItem } from '../types'; 2 | 3 | let WATCH_HISTORY: Array | null = null; 4 | let SEARCH_HISTORY: Array | null = null; 5 | 6 | const WATCH_HISTORY_KEY = 'WATCH_HISTORY_KEY'; 7 | const SEARCH_HISTORY_KEY = 'SEARCH_HISTORY_KEY'; 8 | 9 | const initLocalStorage = () => { 10 | const wHistory = localStorage.getItem(WATCH_HISTORY_KEY) || '[]'; 11 | const sHistory = localStorage.getItem(SEARCH_HISTORY_KEY) || '[]'; 12 | WATCH_HISTORY = JSON.parse(wHistory); 13 | SEARCH_HISTORY = JSON.parse(sHistory); 14 | }; 15 | 16 | const getWatchHistory = () => { 17 | return WATCH_HISTORY; 18 | }; 19 | 20 | const getSearchHistory = () => { 21 | return SEARCH_HISTORY; 22 | }; 23 | 24 | const addWatchHistory = (obj: IMovieItem & { watch_history?: string }, item: IJuJi) => { 25 | if (!WATCH_HISTORY) return; 26 | 27 | const oldRecord = WATCH_HISTORY.find((o) => obj.vod_id === o.vod_id); 28 | if (oldRecord && oldRecord.watch_history === item.text) return; 29 | 30 | if (!oldRecord) { 31 | obj.watch_history = item.text; 32 | WATCH_HISTORY.push(obj); 33 | if (WATCH_HISTORY.length > 30) { 34 | WATCH_HISTORY.shift(); 35 | } 36 | localStorage.setItem(WATCH_HISTORY_KEY, JSON.stringify(WATCH_HISTORY)); 37 | } else { 38 | oldRecord.watch_history = item.text; 39 | localStorage.setItem(WATCH_HISTORY_KEY, JSON.stringify(WATCH_HISTORY)); 40 | } 41 | }; 42 | 43 | const addSearchHistory = (obj: string) => { 44 | if (!SEARCH_HISTORY) return; 45 | if (SEARCH_HISTORY.includes(obj)) return; 46 | SEARCH_HISTORY.push(obj); 47 | if (SEARCH_HISTORY.length > 10) { 48 | SEARCH_HISTORY.shift(); 49 | } 50 | localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(SEARCH_HISTORY)); 51 | }; 52 | 53 | initLocalStorage(); 54 | 55 | export default { 56 | getWatchHistory, 57 | getSearchHistory, 58 | addSearchHistory, 59 | addWatchHistory, 60 | }; 61 | -------------------------------------------------------------------------------- /src/utils/theme.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from '@mui/material/styles'; 2 | 3 | const themeDark = createTheme({ 4 | palette: { 5 | mode: 'dark', 6 | primary: { 7 | main: '#757575', 8 | }, 9 | secondary: { 10 | main: '#757575', 11 | }, 12 | }, 13 | }); 14 | 15 | const themeNormal = createTheme({ 16 | palette: { 17 | primary: { 18 | main: '#009688', 19 | }, 20 | secondary: { 21 | main: '#009688', 22 | }, 23 | }, 24 | }); 25 | 26 | const ThemeArr: any = { 27 | dark: themeDark, 28 | light: themeNormal, 29 | }; 30 | 31 | const getThemeLocal = (): string => { 32 | const themeLocal = localStorage.getItem('themelocal') || 'light'; 33 | return ThemeArr[themeLocal]; 34 | }; 35 | const setThemeLocal = (type: string) => { 36 | localStorage.setItem('themelocal', type); 37 | }; 38 | 39 | const defaultTheme = getThemeLocal(); 40 | 41 | export default { 42 | defaultTheme, 43 | ThemeArr, 44 | setThemeLocal, 45 | }; 46 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /* 3 | * ts图片声明导入文件 4 | */ 5 | // declare module '*.scss' { 6 | // const css: { [key: string]: string }; 7 | // export default css; 8 | // } 9 | declare module '*.svg'; 10 | declare module '*.png'; 11 | declare module '*.jpg'; 12 | declare module '*.jpeg'; 13 | declare module '*.gif'; 14 | declare module '*.bmp'; 15 | declare module '*.tiff'; 16 | declare interface Window { 17 | HlsJsPlayer: any; 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": false, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "suppressImplicitAnyIndexErrors":true 19 | }, 20 | "include": ["./src","./src/vite-env.d.ts","./jest-setup.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | build: { 8 | sourcemap: true, 9 | outDir: 'build', 10 | rollupOptions: { 11 | // external: ['react', 'react-dom'], 12 | // output: { 13 | // globals: { 14 | // react: 'React', 15 | // 'react-dom': 'ReactDOM', 16 | // }, 17 | // }, 18 | }, 19 | }, 20 | }); 21 | --------------------------------------------------------------------------------