├── package.json ├── backend ├── config │ └── .gitkeep ├── default │ ├── blockMusic.default.json │ ├── blockWords.default.json │ ├── bitSymbols.default.json │ ├── clientSettings.js │ ├── settings.default.js │ ├── configs.type.ts │ └── emojiData.default.json ├── test │ ├── tsconfig.json │ ├── _testTool.ts │ ├── redis.ts │ ├── utils.ts │ ├── api.ts │ ├── handler.ts │ └── tools.ts ├── registerPath.ts ├── tsconfig.json ├── prepare │ ├── bitSymbol.js │ └── uploadImg.js ├── lib │ ├── middlewares.ts │ ├── store.ts │ ├── redis.ts │ ├── tools.ts │ ├── taskCron.ts │ ├── utils.ts │ └── session.ts ├── views │ └── base │ │ ├── layout.tsx │ │ └── hooks.tsx ├── getSettings.ts ├── package.json └── app.ts ├── common ├── interfeces.ts ├── clientSetting.type.ts ├── config.ts └── enums.ts ├── frontend ├── mock │ └── .gitkeep ├── src │ ├── assets │ │ └── .gitkeep │ ├── pages │ │ ├── index.css │ │ ├── index.tsx │ │ ├── __tests__ │ │ │ └── index.test.tsx │ │ ├── document.ejs │ │ └── index │ │ │ ├── handleSelectMessage.tsx │ │ │ └── roomList.tsx │ ├── components │ │ ├── CustomIcon │ │ │ ├── style.less │ │ │ └── index.tsx │ │ ├── focusMobileInput │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── pageLoading │ │ │ ├── style.less │ │ │ └── index.tsx │ │ ├── notification │ │ │ ├── style.less │ │ │ └── index.tsx │ │ ├── tabContent │ │ │ ├── style.less │ │ │ └── index.tsx │ │ ├── scrollShow │ │ │ ├── style.less │ │ │ └── index.tsx │ │ ├── signalIcon │ │ │ ├── style.less │ │ │ └── index.tsx │ │ ├── hooks │ │ │ ├── preventScrollAndSwipe.tsx │ │ │ ├── syncAccessState.tsx │ │ │ └── keyboardListen.tsx │ │ ├── adminActionManage │ │ │ ├── style.less │ │ │ └── listContainer.tsx │ │ ├── roomVisualization │ │ │ └── style.less │ │ ├── scrollPage │ │ │ ├── style.less │ │ │ └── index.tsx │ │ ├── windowHeightListen │ │ │ └── index.tsx │ │ ├── danmu │ │ │ └── index.less │ │ ├── roomName │ │ │ ├── style.less │ │ │ └── index.tsx │ │ ├── roomItem │ │ │ ├── index.tsx │ │ │ └── index.less │ │ ├── list │ │ │ └── style.less │ │ ├── musicList │ │ │ └── index.less │ │ ├── hashRouter │ │ │ └── index.tsx │ │ ├── player │ │ │ └── lyric.tsx │ │ ├── createRoom │ │ │ └── index.tsx │ │ └── musicSearchList │ │ │ └── index.less │ ├── global.css │ ├── app.ts │ ├── layouts │ │ ├── __tests__ │ │ │ └── index.test.tsx │ │ ├── index.tsx │ │ └── index.less │ ├── models │ │ └── connect.d.ts │ ├── base.less │ ├── utils │ │ ├── styleInject.tsx │ │ └── index.ts │ └── services │ │ └── socket.ts ├── .env ├── .eslintrc ├── .prettierignore ├── typings.d.ts ├── config │ ├── baseStyle.conf.ts │ ├── base.conf.ts │ ├── settings.template.ts │ └── type.conf.ts ├── tslint.yml ├── .prettierrc ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── public │ └── icon.svg ├── package.json └── .umirc.ts ├── assets └── screenshot_hall.png ├── .gitignore ├── .github ├── actions │ ├── deploy │ │ ├── action.yml │ │ ├── package.json │ │ └── index.js │ └── deploy_server │ │ ├── package.json │ │ └── server.js └── workflows │ ├── docker_build.yml │ ├── test.yml │ ├── deploy.yml │ └── docker_release.yml ├── runInDocker.sh ├── .dockerignore ├── docker-compose.yml ├── Dockerfile ├── LICENSE └── README.md /package.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /backend/config/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /common/interfeces.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/mock/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/pages/index.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | BROWSER=none 2 | ESLINT=1 -------------------------------------------------------------------------------- /backend/default/blockMusic.default.json: -------------------------------------------------------------------------------- 1 | [ 2 | ] -------------------------------------------------------------------------------- /frontend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-umi" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/components/CustomIcon/style.less: -------------------------------------------------------------------------------- 1 | .customIcon { 2 | font-size: 1rem; 3 | } -------------------------------------------------------------------------------- /backend/default/blockWords.default.json: -------------------------------------------------------------------------------- 1 | ["兼职","招聘","网络","QQ","招聘","有意者","到货","本店","代购","扣扣","客服"] -------------------------------------------------------------------------------- /backend/test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "exclude": [] 4 | } -------------------------------------------------------------------------------- /assets/screenshot_hall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanmmm/MusicRadio/HEAD/assets/screenshot_hall.png -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.md 2 | **/*.svg 3 | **/*.ejs 4 | **/*.html 5 | package.json 6 | .umi 7 | .umi-production 8 | -------------------------------------------------------------------------------- /frontend/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | declare module "*.png"; 3 | declare module "*.less"; 4 | 5 | declare var BlockMusicList: string[] // music ids 用户屏蔽的音乐列表 6 | -------------------------------------------------------------------------------- /frontend/src/components/focusMobileInput/index.less: -------------------------------------------------------------------------------- 1 | .focusMobileInputBox { 2 | position: fixed; 3 | z-index: 300; 4 | bottom: 0px; 5 | left: 0; 6 | width: 100%; 7 | } -------------------------------------------------------------------------------- /frontend/src/components/pageLoading/style.less: -------------------------------------------------------------------------------- 1 | .loading { 2 | width: 100vw; 3 | height: 100vh; 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | } -------------------------------------------------------------------------------- /backend/test/_testTool.ts: -------------------------------------------------------------------------------- 1 | export function wait (time: number) { 2 | return new Promise((resolve) => { 3 | setTimeout(() => { 4 | resolve() 5 | }, time) 6 | }) 7 | } -------------------------------------------------------------------------------- /frontend/config/baseStyle.conf.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | headerHeight: '100px', // px 3 | themeColor: 'white', 4 | highLightColor: '#31c27c', 5 | normalTextColor: 'rgba(225,225,225,.8)', 6 | } 7 | -------------------------------------------------------------------------------- /frontend/config/base.conf.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | maxInputMessage: 30, 3 | mobileMediaQuery: '(max-width: 600px)', 4 | playerHeaderIdSelectorName: 'player', 5 | roomNameSelectorName: 'roomName', 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/components/notification/style.less: -------------------------------------------------------------------------------- 1 | .notifications { 2 | position: fixed; 3 | left: 50%; 4 | transform: translateX(-50%); 5 | z-index: 1400; 6 | > * { 7 | margin-top: 1rem; 8 | } 9 | } -------------------------------------------------------------------------------- /frontend/src/global.css: -------------------------------------------------------------------------------- 1 | html, body, #root { 2 | height: 100%; 3 | } 4 | 5 | p { 6 | margin: 0; 7 | margin-block-start: 0; 8 | } 9 | 10 | body { 11 | margin: 0; 12 | } 13 | 14 | 15 | li { 16 | padding: 0; 17 | } -------------------------------------------------------------------------------- /frontend/tslint.yml: -------------------------------------------------------------------------------- 1 | defaultSeverity: error 2 | extends: 3 | - tslint-react 4 | - tslint-eslint-rules 5 | rules: 6 | eofline: true 7 | no-console: true 8 | no-construct: true 9 | no-debugger: true 10 | no-reference: true 11 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 100, 5 | "overrides": [ 6 | { 7 | "files": ".prettierrc", 8 | "options": { "parser": "json" } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .history 2 | .vscode 3 | api 4 | netease 5 | build 6 | nginx 7 | **/node_modules 8 | common/settings.ts 9 | backend/settings.ts 10 | backend/prepare/*.json 11 | backend/static/* 12 | backend/build 13 | backend/config/* 14 | backend/coverage 15 | backend/**/.env -------------------------------------------------------------------------------- /backend/default/bitSymbols.default.json: -------------------------------------------------------------------------------- 1 | ["T","g","W","r","I","i","z","Z","7","t","1","G","s","p","j","8","4","D","E","H","O","w","6","F","e","A","U","L","m","C","Y","v","0","R","x","9","l","Q","2","q","V","N","c","5","b","y","K","J","n","o","k","B","3","M","h","P","a","d","f","u","S","X"] -------------------------------------------------------------------------------- /frontend/src/app.ts: -------------------------------------------------------------------------------- 1 | export const dva = { 2 | config: { 3 | onError(e) { 4 | e.preventDefault() 5 | console.error('dva error:', e.message); 6 | }, 7 | }, 8 | plugins: [ 9 | // require('dva-logger')(), 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import styles from './index.css'; 3 | import Index from './index/index' 4 | 5 | export default function () { 6 | 7 | return ( 8 |
9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/components/tabContent/style.less: -------------------------------------------------------------------------------- 1 | .tabContentBox { 2 | width: 100%; 3 | height: 100%; 4 | overflow: hidden; 5 | > .container { 6 | height: 100%; 7 | display: flex; 8 | align-items: flex-start; 9 | } 10 | } 11 | 12 | .tabContentItem { 13 | height: 100%; 14 | } -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /common/clientSetting.type.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 前端配置约定 3 | */ 4 | 5 | export default interface ClientSettings { 6 | publicPath?: string; // 当且仅当客户端获取配置文件时 注入 7 | websiteName: string; 8 | socketServer: string; 9 | logoText: string; 10 | defaultMaskImg: string; 11 | notAllowCreateRoom: boolean, // 是否允许创建房间 12 | githubUrl?: string; 13 | } 14 | -------------------------------------------------------------------------------- /.github/actions/deploy/action.yml: -------------------------------------------------------------------------------- 1 | name: 'deployment' 2 | description: 'deployment' 3 | inputs: 4 | server: 5 | required: true 6 | description: 'deployment server address' 7 | image_name: 8 | required: true 9 | description: 'image name' 10 | access_token: 11 | description: 'server access_token' 12 | required: false 13 | default: '' 14 | runs: 15 | using: 'node12' 16 | main: 'index.js' -------------------------------------------------------------------------------- /backend/default/clientSettings.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | publicPath: '/static/', 3 | websiteName: 'Music Radio', 4 | socketServer: '', 5 | httpServer: '', 6 | logoText: 'Music Radio', 7 | defaultMaskImg: 'https://t9.baidu.com/it/u=583874135,70653437&fm=79&app=86&size=h300&n=0&g=4n&f=jpeg?sec=1584869455&t=e49f07e7cffb1564190ecaefbe4f8138', 8 | notAllowCreateRoom: false, 9 | githubUrl: '', 10 | } 11 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /npm-debug.log* 6 | /yarn-error.log 7 | /yarn.lock 8 | /package-lock.json 9 | 10 | # production 11 | /dist 12 | 13 | # misc 14 | .DS_Store 15 | 16 | # umi 17 | .umi 18 | .umi-production 19 | 20 | src/assets/success.ts 21 | src/mockData 22 | src/__tests__ 23 | settings.ts 24 | static 25 | 26 | -------------------------------------------------------------------------------- /runInDocker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pm2 install pm2-logrotate 3 | pm2 set pm2-logrotate:retain 10 4 | pm2 set pm2-logrotate:compress false 5 | pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss 6 | pm2 set pm2-logrotate:max_size 5M 7 | pm2 set pm2-logrotate:rotateInterval '0 0 * * * ' 8 | pm2 set pm2-logrotate:rotateModule true 9 | pm2 set pm2-logrotate:workerInterval 30 10 | pm2 start backend/build/backend/app.js --no-daemon --log-date-format 'YYYY-MM-DD_HH:mm:ss' -------------------------------------------------------------------------------- /.github/actions/deploy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deployment", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@actions/core": "^1.2.4", 14 | "@actions/exec": "^1.0.4", 15 | "@actions/github": "^2.2.0", 16 | "got": "^11.1.3" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/actions/deploy_server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deployment_server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node server.js" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "dotenv": "^8.2.0", 15 | "express": "^4.17.1", 16 | "shelljs": "^0.8.4" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/config/settings.template.ts: -------------------------------------------------------------------------------- 1 | import SettingTypes from '@global/common/clientSetting.type' 2 | 3 | const settings: SettingTypes = { 4 | websiteName: 'Music Radio', 5 | socketServer: '', 6 | logoText: 'Music Radio', 7 | defaultMaskImg: 'https://t9.baidu.com/it/u=583874135,70653437&fm=79&app=86&size=h300&n=0&g=4n&f=jpeg?sec=1584869455&t=e49f07e7cffb1564190ecaefbe4f8138', 8 | notAllowCreateRoom: false, 9 | githubUrl: '' 10 | } 11 | 12 | export default settings 13 | -------------------------------------------------------------------------------- /frontend/src/components/pageLoading/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from 'react' 2 | import Nprogress from 'nprogress' 3 | 4 | import styles from './style.less' 5 | 6 | export default function PageLoading () { 7 | useEffect(() => { 8 | Nprogress.start() 9 | Nprogress.set(0.3) 10 | return () => { 11 | Nprogress.done() 12 | Nprogress.remove() 13 | } 14 | }) 15 | return
16 | 加载中.... 17 |
18 | } 19 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "importHelpers": true, 7 | "jsx": "react", 8 | "esModuleInterop": true, 9 | "sourceMap": true, 10 | "baseUrl": ".", 11 | "strict": false, 12 | "strictNullChecks": false, 13 | "paths": { 14 | "@/*": ["src/*"], 15 | "@global/*": ["../*"], 16 | "@@/*": ["src/.umi/*"] 17 | }, 18 | "allowSyntheticDefaultImports": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/registerPath.ts: -------------------------------------------------------------------------------- 1 | // const tsConfig = require("./tsconfig.json") 2 | import tsConfig from './tsconfig.json' 3 | const tsConfigPaths = require("tsconfig-paths") 4 | 5 | const baseUrl = __dirname // Either absolute or relative path. If relative it's resolved to current working directory. 6 | 7 | export default function () { 8 | const cleanup = tsConfigPaths.register({ 9 | baseUrl, 10 | paths: tsConfig.compilerOptions.paths 11 | }); 12 | return cleanup 13 | } 14 | 15 | // When path registration is no longer needed 16 | // cleanup(); -------------------------------------------------------------------------------- /frontend/src/pages/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import 'jest'; 2 | import Index from '..'; 3 | import React from 'react'; 4 | import renderer, { ReactTestInstance, ReactTestRenderer } from 'react-test-renderer'; 5 | 6 | 7 | describe('Page: index', () => { 8 | it('Render correctly', () => { 9 | const wrapper: ReactTestRenderer = renderer.create(); 10 | expect(wrapper.root.children.length).toBe(1); 11 | const outerLayer = wrapper.root.children[0] as ReactTestInstance; 12 | expect(outerLayer.type).toBe('div'); 13 | expect(outerLayer.children.length).toBe(2); 14 | 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/.history 12 | **/*.*proj.user 13 | **/*.dbmdl 14 | **/*.jfm 15 | **/azds.yaml 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/secrets.dev.yaml 22 | **/values.dev.yaml 23 | **/node_modules 24 | **/.github 25 | backend/config 26 | backend/build 27 | backend/dist 28 | backend/static 29 | backend/coverage 30 | frontend/dist 31 | frontend/static 32 | frontend/config/settings.ts 33 | README.md -------------------------------------------------------------------------------- /frontend/src/components/scrollShow/style.less: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | align-items: center; 4 | &.rightDirection { 5 | justify-content: flex-start; 6 | } 7 | &.leftDirection { 8 | justify-content: flex-end; 9 | } 10 | &.bothDirection { 11 | justify-content: center; 12 | } 13 | > .content { 14 | transition-property: all; 15 | flex-basis: 0; 16 | overflow: hidden; 17 | text-align: center; 18 | white-space: nowrap; 19 | &.show { 20 | flex-grow: 1; 21 | } 22 | > * { 23 | white-space: nowrap; 24 | } 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | music_radio: 5 | image: sanmmmm/music_radio:${MUSIC_RADIO_TAG:-latest} 6 | ports: 7 | - 3001:3001 8 | volumes: 9 | - server-log:/data 10 | - ${CONFIG_DIR:-server-config}:/app/backend/build/backend/config 11 | restart: always 12 | depends_on: 13 | - redis 14 | - neteaseapi 15 | redis: 16 | image: redis 17 | volumes: 18 | - redis-data:/data 19 | restart: always 20 | neteaseapi: 21 | image: binaryify/netease_cloud_music_api 22 | environment: 23 | - no_proxy=* 24 | restart: always 25 | volumes: 26 | redis-data: 27 | server-log: 28 | server-config: -------------------------------------------------------------------------------- /frontend/src/components/signalIcon/style.less: -------------------------------------------------------------------------------- 1 | .signalIcon { 2 | height: 1rem; 3 | overflow: hidden; 4 | &.paused { 5 | > * { 6 | animation-play-state: paused; 7 | } 8 | } 9 | > * { 10 | animation: updown 2s ease-in-out infinite; 11 | width: 2px; 12 | height: 90%; 13 | margin: 0 1px; 14 | display: inline-block; 15 | line-height: 1rem; 16 | vertical-align: bottom; 17 | border-radius: 25%; 18 | } 19 | } 20 | @keyframes updown { 21 | 0% { 22 | transform: translateY(0); 23 | } 24 | 50%{ 25 | transform: translateY(90%); 26 | } 27 | 100% { 28 | transform: translateY(0%); 29 | } 30 | } -------------------------------------------------------------------------------- /frontend/src/layouts/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import 'jest'; 2 | import BasicLayout from '..'; 3 | import React from 'react'; 4 | import renderer, { ReactTestInstance, ReactTestRenderer } from 'react-test-renderer'; 5 | 6 | describe('Layout: BasicLayout', () => { 7 | it('Render correctly', () => { 8 | const wrapper: ReactTestRenderer = renderer.create(); 9 | expect(wrapper.root.children.length).toBe(1); 10 | const outerLayer = wrapper.root.children[0] as ReactTestInstance; 11 | expect(outerLayer.type).toBe('div'); 12 | const title = outerLayer.children[0] as ReactTestInstance; 13 | expect(title.type).toBe('h1'); 14 | expect(title.children[0]).toBe('Yay! Welcome to umi!'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12 2 | WORKDIR /app 3 | RUN npm config set registry https://registry.npm.taobao.org/ && npm install -g pm2 4 | COPY ./frontend/package*.json ./frontend/ 5 | RUN cd frontend && npm install 6 | COPY ./backend/package*.json ./backend/ 7 | RUN cd backend && npm install 8 | COPY ./common/ ./common/ 9 | COPY ./frontend/ ./frontend/ 10 | RUN cd frontend/config && mv settings.template.ts settings.ts 11 | ENV OUTPUT_PATH=../static ASYNC_SETTINGS=1 12 | RUN cd ./frontend && npm run build && rm -r /app/frontend 13 | ENV OUTPUT_PATH= ASYNC_SETTINGS= 14 | COPY ./backend/ ./backend/ 15 | RUN cd backend && npm run build 16 | ENV STATIC_PATH=/app/static NODE_ENV=production 17 | COPY runInDocker.sh . 18 | RUN chmod +x ./runInDocker.sh 19 | EXPOSE 3001 20 | CMD ["./runInDocker.sh"] -------------------------------------------------------------------------------- /frontend/src/components/focusMobileInput/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import bindClass from 'classnames' 3 | 4 | import useKeyBoardListen from '@/components/hooks/keyboardListen' 5 | import styles from './index.less' 6 | 7 | 8 | type Children = (ref: React.MutableRefObject, isFocus?: boolean) => React.ReactElement 9 | 10 | interface Props { 11 | children: Children; 12 | } 13 | 14 | /** 15 | * 移动端下input聚焦后,将input绝对定位避免被弹出的虚拟键盘遮挡 16 | */ 17 | const FocusMobileInput: React.FC = (props) => { 18 | const [inputRef, isFocus] = useKeyBoardListen() 19 | 20 | return
21 | { 22 | props.children(inputRef, isFocus) 23 | } 24 |
25 | } 26 | 27 | export default FocusMobileInput 28 | -------------------------------------------------------------------------------- /frontend/src/components/hooks/preventScrollAndSwipe.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useRef} from 'react' 2 | 3 | export default function () { 4 | const eleRef = useRef(null) 5 | const addEvent = () => { 6 | if (eleRef.current) { 7 | const handler = (e) => { 8 | e.stopPropagation() 9 | } 10 | eleRef.current.addEventListener('wheel', handler) 11 | eleRef.current.addEventListener('touchstart', handler) 12 | return () => { 13 | eleRef.current.removeEventListener('wheel', handler) 14 | eleRef.current.removeEventListener('touchstart', handler) 15 | } 16 | } 17 | } 18 | return (node) => { 19 | eleRef.current = node 20 | addEvent() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/components/CustomIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Icon, IconProps} from '@material-ui/core' 3 | import bindClass from 'classnames' 4 | 5 | import styles from './style.less' 6 | 7 | interface props extends IconProps{ 8 | className?: string; 9 | children: React.ReactText; 10 | } 11 | 12 | export default function CustomIcon (props: props) { 13 | const {className, children: iconType} = props 14 | const newProps = { 15 | ...props, 16 | className: undefined, 17 | children: undefined, 18 | style: { 19 | fontSize: '1rem', 20 | overflow: 'visible', 21 | ...(props.style || {}) 22 | } 23 | } 24 | return 25 | 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/models/connect.d.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction } from 'redux'; 2 | import { PlayListModelState } from './playList'; 3 | import {ChatListModelState} from './chatList' 4 | import {CenterModelState} from './center' 5 | 6 | export { PlayListModelState, ChatListModelState, CenterModelState}; 7 | 8 | export interface Loading { 9 | global: boolean; 10 | effects: { [key: string]: boolean | undefined }; 11 | models: { 12 | chatList?: boolean; 13 | playList?: boolean; 14 | center?: boolean; 15 | }; 16 | } 17 | 18 | export interface ConnectState { 19 | playList: PlayListModelState; 20 | chatList: ChatListModelState; 21 | center: CenterModelState; 22 | loading: Loading; 23 | } 24 | 25 | /** 26 | * @type T: Params matched in dynamic routing 27 | */ 28 | export interface ConnectProps { 29 | dispatch?(action: AnyAction): K; 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/components/adminActionManage/style.less: -------------------------------------------------------------------------------- 1 | @import '~@/base.less'; 2 | 3 | .adminActionListBox { 4 | height: 100%; 5 | } 6 | 7 | .listContainer { 8 | height: 100%; 9 | display: flex; 10 | flex-direction: column; 11 | .header { 12 | } 13 | .list { 14 | min-height: 0; 15 | .noData { 16 | height: 100px; 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | color: @normalTextColor; 21 | } 22 | .bottom { 23 | margin-top: .5rem; 24 | padding: .3rem; 25 | text-align: center; 26 | color: @normalTextColor; 27 | } 28 | } 29 | em { 30 | color: @highLightColor; 31 | font-size: 1.4em; 32 | padding: 0 .2rem; 33 | font-style: normal; 34 | } 35 | } -------------------------------------------------------------------------------- /frontend/src/components/roomVisualization/style.less: -------------------------------------------------------------------------------- 1 | 2 | .roomVisualization { 3 | position: relative; 4 | min-width: 300px; 5 | width: 40vw; 6 | .tooltip { 7 | position: absolute; 8 | border-radius: 8px; 9 | overflow: hidden; 10 | .content { 11 | max-width: 200px; 12 | overflow: hidden; 13 | white-space: nowrap; 14 | text-overflow: ellipsis; 15 | border-radius: 8px; 16 | background-color: rgba(255, 255, 255, 0.8); 17 | padding: 0 .5rem; 18 | box-sizing: border-box; 19 | cursor: pointer; 20 | display: inline-block; 21 | color: black; 22 | font-size: .8rem; 23 | line-height: 1.6em; 24 | height: 1.6em; 25 | > * { 26 | vertical-align: sub; 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "target": "es5", 7 | "strict": false, 8 | "moduleResolution": "node", 9 | "sourceMap": true, 10 | "lib": [ 11 | "es7", 12 | "es2017.object", 13 | "DOM" 14 | ], 15 | "outDir": "./build", 16 | "baseUrl": ".", 17 | "jsx": "react", 18 | "paths": { 19 | "root/*": ["./*"], 20 | "global/*": ["../*"] 21 | }, 22 | "experimentalDecorators": true, 23 | "downlevelIteration": true, 24 | "strictNullChecks": false, 25 | "resolveJsonModule": true 26 | }, 27 | "files": ["app.ts"], 28 | "include": ["**/*.ts", "**/*.tsx", "./default/*.json", "./config/*.json", "./default/*", "./config/*"], 29 | "exclude": ["./test/*.ts"] 30 | } -------------------------------------------------------------------------------- /backend/prepare/bitSymbol.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | function shuffleArr (arr) { 5 | for (let i = arr.length - 1; i > 0; i--) { 6 | const swapIndex = Math.floor(Math.random() * i) 7 | const backUp = arr[i] 8 | arr[i] = arr[swapIndex] 9 | arr[swapIndex] = backUp 10 | } 11 | return arr 12 | } 13 | 14 | function main () { 15 | const arr = [] 16 | let i = 0 17 | while (i < 10) { 18 | arr.push(String.fromCharCode(0x30 + i)) 19 | i ++ 20 | } 21 | i = 0 22 | while (i < 26) { 23 | arr.push(String.fromCharCode(0x41 + i)) 24 | i ++ 25 | } 26 | i = 0 27 | while (i < 26) { 28 | arr.push(String.fromCharCode(0x61 + i)) 29 | i ++ 30 | } 31 | shuffleArr(arr) 32 | fs.writeFileSync(path.resolve(__dirname, 'bitSymbols.json'), JSON.stringify(arr)) 33 | console.log(arr) 34 | } 35 | 36 | main() -------------------------------------------------------------------------------- /backend/default/settings.default.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | neteaseApiServer: 'http://neteaseapi:3000', 3 | httpServer: '', 4 | port: 3001, 5 | sessionKey: 'musicradio', 6 | sessionSecret: 'justdoit', 7 | sessionExpire: 3600 * 24 * 30 , // 单位s 8 | redisPort: 6379, 9 | redisHost: 'redis', // docker redis server 10 | corsOrigin: [], // http/socket server 允许的跨域请求源, 为空时表示没有跨域限制 11 | musicDurationLimit: 60 * 6, // 音乐时长限制 12 | superAdminToken: [], // token 认证模式下的超级管理员token example: ['admin1', 'admin2'] 13 | maxChatListLength: 20, // 服务端记录的房间聊天记录条数上限 14 | hashSalt: 'balalacool', 15 | superAdminRegisterTokens: [], // 超级管理员注册码 example: ['registerToken', 'registerToken1'] 16 | openWordValidate: true, // 是否开启敏感词过滤 17 | coordDataCalcDuration: 60 * 5,// 房间热点数据整理计算刷新周期 单位s 18 | coordDataResetDuration: 60 * 60 * 24, // 热点数据重置周期 单位s 19 | // 以下仅在dev模式下有效 20 | openRandomIpMode: false, // 为用户随机分配所属ip地址 21 | } -------------------------------------------------------------------------------- /frontend/src/components/scrollPage/style.less: -------------------------------------------------------------------------------- 1 | @import '~@/base.less'; 2 | 3 | .scrollOuterBox { 4 | width: 100vw; 5 | height: 100vh; 6 | overflow: hidden; 7 | background-color: transparent; 8 | position: relative; 9 | top: 0; 10 | } 11 | .scrollContainer { 12 | transition: transform 0.4s ease-in-out; 13 | transform: translateY(0); 14 | width: 100%; 15 | } 16 | 17 | .scrollPageContainer { 18 | margin-top: @headerHeight; 19 | height: calc(~"100vh - @{headerHeight}"); 20 | width: 100%; 21 | overflow: hidden; 22 | &.mobile { 23 | margin-top: @mobileHeaderHeight; 24 | height: calc(~"100vh - @{mobileHeaderHeight}"); 25 | } 26 | &.focus { 27 | } 28 | } 29 | 30 | @keyframes scrollEnd { 31 | 0% { 32 | transform: scaleY(1); 33 | } 34 | 35 | 50% { 36 | transform: scaleY(1.05); 37 | } 38 | 39 | 0% { 40 | transform: scaleY(1); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/components/windowHeightListen/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState, useContext} from 'react' 2 | 3 | const HeightContext = React.createContext(0) 4 | 5 | interface Props { 6 | children: React.ReactNode 7 | } 8 | 9 | // context provider 10 | export const WindowHeightProvider: React.FC = (props) => { 11 | const [height, setHeight] = useState(window.innerHeight) 12 | 13 | useEffect(() => { 14 | const handler = () => { 15 | setHeight(window.innerHeight) 16 | } 17 | window.addEventListener('resize', handler) 18 | return () => { 19 | window.removeEventListener('resize', handler) 20 | } 21 | }, []) 22 | return 23 | {props.children} 24 | 25 | } 26 | 27 | // hooks 28 | export default function useListenWindowHeight() { 29 | const windowHeight = useContext(HeightContext) 30 | return windowHeight 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/docker_build.yml: -------------------------------------------------------------------------------- 1 | name: docker build test 2 | 3 | on: 4 | pull_request: 5 | branches: master 6 | paths: 7 | - 'common/**' 8 | - 'frontend/**' 9 | - 'backend/**' 10 | - 'Dockerfile' 11 | push: 12 | branches: master 13 | paths: 14 | - 'common/**' 15 | - 'frontend/**' 16 | - 'backend/**' 17 | - 'Dockerfile' 18 | 19 | jobs: 20 | docker_build: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - 24 | name: Checkout 25 | uses: actions/checkout@v2 26 | - 27 | name: Set up Docker Buildx 28 | id: buildx 29 | uses: crazy-max/ghaction-docker-buildx@v1 30 | with: 31 | buildx-version: latest 32 | qemu-version: latest 33 | - 34 | name: Run Buildx 35 | run: | 36 | docker buildx build \ 37 | --platform linux/amd64 \ 38 | --output "type=image,push=false" \ 39 | --file ./Dockerfile . 40 | -------------------------------------------------------------------------------- /frontend/src/base.less: -------------------------------------------------------------------------------- 1 | @themeColor: white; 2 | @highLightColor: #31c27c; 3 | @normalTextColor: rgba(225,225,225,.8); 4 | @noticeColor: #ec7259; 5 | @advancedColor: goldenrod; 6 | 7 | @headerHeight: 110px; 8 | @mobileHeaderHeight: 70px; 9 | @minContentWidth: 960px; 10 | @maxContentWidth: 1300px; 11 | 12 | 13 | .textOverflow { 14 | overflow: hidden; 15 | white-space: nowrap; 16 | text-overflow: ellipsis; 17 | } 18 | 19 | .radioRandomSignalIcon { 20 | display: flex; 21 | overflow: hidden; 22 | > * { 23 | width: 2px; 24 | height: .8rem; 25 | margin: 0 1px; 26 | background-color: white; 27 | border-radius: 25%; 28 | animation: updown 2s ease-in-out infinite; 29 | } 30 | 31 | @keyframes updown { 32 | 0% { 33 | transform: translateY(0); 34 | } 35 | 50%{ 36 | transform: translateY(80%); 37 | } 38 | 100% { 39 | transform: translateY(0%); 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | defaults: 14 | run: 15 | shell: bash 16 | working-directory: backend 17 | services: 18 | redis: 19 | image: redis 20 | ports: 21 | - 6379:6379 22 | neteaseapi: 23 | image: binaryify/netease_cloud_music_api 24 | ports: 25 | - 3000:3000 26 | strategy: 27 | matrix: 28 | node-version: [12.x, 14.x] 29 | 30 | steps: 31 | - uses: actions/checkout@v2 32 | - name: Use Node.js ${{ matrix.node-version }} 33 | uses: actions/setup-node@v1 34 | with: 35 | node-version: ${{ matrix.node-version }} 36 | - run: npm ci 37 | - name: run test 38 | run: npm test 39 | env: 40 | REDIS_URL: redis://localhost:6379 41 | NETEASE_API_URL: http://localhost:3000 42 | -------------------------------------------------------------------------------- /backend/lib/middlewares.ts: -------------------------------------------------------------------------------- 1 | import {Request, Response, NextFunction} from 'express' 2 | import uuidV4 from 'uuid/v4' 3 | 4 | import settings, {clientSettings} from 'root/getSettings' 5 | 6 | 7 | export function cookieMiddleware (req: Request, res: Response, next: NextFunction) { 8 | let cookie = req.signedCookies[settings.sessionKey] 9 | if (!cookie) { 10 | const uuid = uuidV4() 11 | req.signedCookies = { 12 | ...req.signedCookies, 13 | [settings.sessionKey]: uuid, 14 | } 15 | res.cookie(settings.sessionKey, uuid, { signed: true, maxAge: settings.sessionExpire * 1000 , }) 16 | } 17 | next() 18 | } 19 | 20 | 21 | export function dispatchClientSettings (req: Request, res: Response, next: NextFunction) { 22 | res.jsonp({ 23 | code: 0, 24 | data: { 25 | ...clientSettings, 26 | } 27 | }) 28 | next() 29 | } 30 | 31 | export function umiFileHandler (req: Request, res: Response, next) { 32 | res.send('not found') 33 | next() 34 | } -------------------------------------------------------------------------------- /frontend/src/components/hooks/syncAccessState.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react' 2 | 3 | const componentStateMap = new Map() 4 | 5 | const getMapData = (componentKey: string) => { 6 | return componentStateMap.get(componentKey) 7 | } 8 | 9 | const setMapDate = (componentKey: string, data) => { 10 | componentStateMap.set(componentKey, data) 11 | return data 12 | } 13 | 14 | const generateKey = () => Date.now() + Math.random().toString(32).slice(2) 15 | 16 | // 类似 useRef 17 | export default function (initState?: T) { 18 | const [componentKey, ] = useState(generateKey()) 19 | 20 | if (!componentStateMap.has(componentKey)) { 21 | setMapDate(componentKey, initState) 22 | } 23 | 24 | const syncGetState = () => { 25 | return getMapData(componentKey) as T 26 | } 27 | const syncSetState = (data: T) => { 28 | return setMapDate(componentKey, data) 29 | } 30 | return [syncGetState, syncSetState] as [typeof syncGetState, typeof syncSetState] 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deployment 2 | 3 | on: 4 | repository_dispatch: 5 | types: ['deployment'] 6 | 7 | jobs: 8 | trigger: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: '12' 15 | - 16 | name: npm install 17 | working-directory: .github/actions/deploy 18 | run: npm install 19 | - 20 | name: deployment 21 | uses: ./.github/actions/deploy 22 | with: 23 | server: ${{secrets.DEPLOY_SERVER}} 24 | access_token: ${{secrets.DEPLOY_TOKEN}} 25 | image_name: sanmmmm/music_radio 26 | - 27 | name: deployment_2 28 | uses: ./.github/actions/deploy 29 | with: 30 | server: ${{secrets.DEPLOY_SERVER2}} 31 | access_token: ${{secrets.DEPLOY_TOKEN}} 32 | image_name: sanmmmm/music_radio -------------------------------------------------------------------------------- /backend/test/redis.ts: -------------------------------------------------------------------------------- 1 | import redisCli from 'root/lib/redis' 2 | import { wait } from './_testTool' 3 | 4 | beforeAll(() => { 5 | return redisCli.select(1) 6 | }) 7 | 8 | afterAll(() => { 9 | return redisCli.flushdb() 10 | }) 11 | 12 | beforeEach(() => { 13 | return redisCli.flushdb() 14 | }) 15 | 16 | const genRedisKey = () => '' + Date.now() 17 | it('safeset/safeget', async () => { 18 | const data = { 19 | a: '22323' 20 | } 21 | const key = genRedisKey() 22 | await expect(redisCli.safeSet(key, data)).resolves.not.toThrowError() 23 | await expect(redisCli.safeGet(key)).resolves.toEqual(data) 24 | }) 25 | 26 | it('redis safeset expire', async () => { 27 | const data = { 28 | a: '22323' 29 | } 30 | const key = genRedisKey() 31 | const expire = 3 32 | await expect(redisCli.safeSet(key, data, expire)).resolves.not.toThrowError() 33 | await expect(redisCli.safeGet(key)).resolves.toEqual(data) 34 | await wait(expire * 1000) 35 | await expect(redisCli.safeGet(key)).resolves.toBe(null) 36 | }) -------------------------------------------------------------------------------- /frontend/public/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 sanmmm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/src/components/danmu/index.less: -------------------------------------------------------------------------------- 1 | @import '~@/base.less'; 2 | 3 | .damuListBox { 4 | position: relative; 5 | overflow: hidden; 6 | margin: 1rem; 7 | > .item { 8 | width: 100%; 9 | display: flex; 10 | flex-wrap: nowrap; 11 | align-items: center; 12 | position: absolute; 13 | left: 0; 14 | transition: transform 0.3s ease-in-out; 15 | 16 | z-index: 40; 17 | > .content { 18 | color: white; 19 | filter: none; 20 | &.emoji { 21 | color: @advancedColor; 22 | } 23 | &.advanced { 24 | color: @advancedColor; 25 | } 26 | &.notice { 27 | color: @noticeColor; 28 | } 29 | } 30 | > .placeholder { 31 | flex-basis: 0; 32 | flex-grow: 1; 33 | } 34 | > .content { 35 | .textOverflow(); 36 | max-width: 100%; 37 | color: white; 38 | background-color: rgba(0, 0, 0, 0.3); 39 | border-radius: 8px; 40 | padding: 0 1rem; 41 | cursor: pointer; 42 | filter: brightness(80%); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /backend/views/base/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {normalize} from 'polished' 3 | 4 | import { useScript, useStyle } from './hooks' 5 | 6 | const normalizeStyle = normalize().reduce((obj, style) => { 7 | return { 8 | ...obj, 9 | ...style, 10 | } 11 | }, {}) 12 | export default function DefaultLayout(props) { 13 | const [styleNode, classes] = useStyle('base', { 14 | footer: { 15 | width: '100%', 16 | lineHeight: '40px', 17 | textAlign: 'center', 18 | }, 19 | main: { 20 | height: '100vh', 21 | display: 'flex', 22 | flexDirection: 'column', 23 | }, 24 | content: { 25 | flexGrow: 1, 26 | }, 27 | '@global': { 28 | ...normalizeStyle, 29 | } 30 | }) 31 | return ( 32 | 33 | 34 | {props.title} 35 | 36 | {styleNode} 37 | {props.header} 38 | 39 | 40 |
41 | {props.children} 42 |
43 |
@copyright {new Date().getFullYear()}
44 | 45 | 46 | ); 47 | } -------------------------------------------------------------------------------- /backend/lib/store.ts: -------------------------------------------------------------------------------- 1 | import {UserRoomRecord} from 'root/type' 2 | 3 | export const userRoomInfoMap = new Map() 4 | 5 | export const roomHeatMap = new Map() 6 | 7 | export const roomJoinersMap = new Map() 8 | 9 | export const roomNormalJoinersMap = new Map() 10 | 11 | export const roomAdminsMap = new Map() 12 | 13 | export const roomUpdatedUserRecordsMap = new Map>() 14 | 15 | export const modelMemberIdsMap = new Map>() 16 | 17 | export const redisSetCache = new Map>() 18 | 19 | export const isRedisSetCahceLoaded = new Map() 20 | 21 | export const redisSetToArrayCache = new Map() 22 | 23 | export const blockKeySet = new Set() 24 | 25 | export type RejectType = (reason?: any) => void 26 | export type ResolveType = () => any; 27 | export const reqBlockCallBackQueue = new Map() 28 | export function getBlockWaittingCbQueue (blockKey: string) { 29 | let queue = reqBlockCallBackQueue.get(blockKey) 30 | if (!queue) { 31 | queue = [] 32 | reqBlockCallBackQueue.set(blockKey, queue) 33 | } 34 | return queue 35 | } -------------------------------------------------------------------------------- /backend/default/configs.type.ts: -------------------------------------------------------------------------------- 1 | import bitSymbolsConfig from 'root/default/bitSymbols.default.json' 2 | import BlockWordListConfig from 'root/default/blockWords.default.json' 3 | import BlockMusicListConfig from 'root/default/blockMusic.default.json' 4 | import EmojiDataConfig from 'root/default/emojiData.default.json' 5 | 6 | export interface Settings { 7 | neteaseApiServer: string; 8 | httpServer: string; 9 | port: number; 10 | sessionKey: string; 11 | sessionSecret: string; 12 | sessionExpire: number; // 单位s 13 | redisPort: number; 14 | redisHost: string; 15 | corsOrigin: string[]; // http/socket server 允许的跨域请求源 16 | musicDurationLimit: number; // 音乐时长限制 17 | superAdminToken: string[]; // token 认证模式下的超级管理员token 18 | maxChatListLength: number; // 服务端记录的房间聊天记录条数上限 19 | hashSalt: string; 20 | superAdminRegisterTokens: string[]; 21 | openWordValidate: boolean; // 是否开启敏感词过滤 22 | coordDataCalcDuration: number ;// 房间热点数据整理计算刷新周期 单位s 23 | coordDataResetDuration: number; // 热点数据重置周期 单位s 24 | notAllowCreateRoom: boolean;// 是否允许创建房间 25 | // 以下仅在dev模式下有效 26 | openRandomIpMode: boolean; // 为用户随机分配所属ip地址 27 | } 28 | 29 | // 客户端配置字段类型 30 | export {default as ClientSettings} from 'global/common/clientSetting.type' 31 | 32 | export type BitSymbols = typeof bitSymbolsConfig 33 | 34 | export type BlockWordList = typeof BlockWordListConfig 35 | 36 | export type BlockMusicList = typeof BlockMusicListConfig 37 | 38 | export type EmojiData = typeof EmojiDataConfig 39 | 40 | -------------------------------------------------------------------------------- /frontend/src/components/roomName/style.less: -------------------------------------------------------------------------------- 1 | @import '~@/base.less'; 2 | 3 | .roomHeaderInfo { 4 | width: 100%; 5 | box-sizing: border-box; 6 | padding: 0 1rem; 7 | color: @themeColor; 8 | &.mobile { 9 | } 10 | > .nameLine { 11 | display: flex; 12 | align-items: center; 13 | .nameBox { 14 | flex-grow: 1; 15 | white-space: nowrap; 16 | overflow: hidden; 17 | line-height: 2rem; 18 | height: 2rem; 19 | font-size: 1.3rem; 20 | position: relative; 21 | margin-left: 1rem; 22 | &.mobile { 23 | .unitBase { 24 | text-align: center; 25 | } 26 | } 27 | .unitBase { 28 | } 29 | > .unit { 30 | .unitBase(); 31 | padding: 0 1rem; 32 | position: absolute; 33 | z-index: 10; 34 | top: 0; 35 | left: 100%; 36 | animation-name: slideCycle; 37 | animation-timing-function: linear; 38 | animation-iteration-count: infinite; 39 | animation-fill-mode: forwards; 40 | } 41 | } 42 | } 43 | > .bottomLine { 44 | display: flex; 45 | align-items: center; 46 | justify-content: center; 47 | font-size: .9rem; 48 | } 49 | } 50 | 51 | @keyframes slideCycle { 52 | 0% { 53 | transform: translateX(0); 54 | } 55 | 100% { 56 | transform: translateX(-200%); 57 | } 58 | 59 | } -------------------------------------------------------------------------------- /frontend/src/components/signalIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import bindClass from 'classnames' 3 | 4 | import styleConf from 'config/baseStyle.conf' 5 | import styles from './style.less' 6 | 7 | interface Props { 8 | paused?: boolean; 9 | size?: number; 10 | color?: string; 11 | } 12 | 13 | const signalIconAnimationDuration = 2 14 | 15 | const getInitDelayData = () => [1, 2, 3, 4].map(i => Math.random() ) 16 | 17 | const SignalIcon: React.FC = React.memo((props) => { 18 | const [delayData, _] = useState(getInitDelayData()) 19 | const { paused = false, color = styleConf.themeColor, size } = props 20 | const boxStyle = {} 21 | if (size) { 22 | Object.assign(boxStyle, { 23 | height: size 24 | }) 25 | } 26 | return
27 | { 28 | delayData.map((randomValue, index) => { 29 | const style = { 30 | animationDelay: `-${(randomValue * signalIconAnimationDuration).toFixed(2)}s`, 31 | animationDuration: `${signalIconAnimationDuration}s`, 32 | backgroundColor: color, 33 | } 34 | if (size) { 35 | Object.assign(style, { 36 | height: size, 37 | width: size / 7 * 1.2, 38 | }) 39 | } 40 | return
44 | })} 45 |
46 | }) 47 | 48 | export default SignalIcon 49 | -------------------------------------------------------------------------------- /backend/views/base/hooks.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import jss, { Styles, Classes, } from 'jss' 3 | import preset from 'jss-preset-default' 4 | 5 | jss.setup(preset()) 6 | 7 | const getRandomVariableName = (baseName?: string) => [baseName, Math.random().toString(32).slice(2, 6)].filter(o => !!o).join('_') 8 | 9 | export const useScript = function (func: (global: T) => any, deps: Partial = {}) { 10 | const depStrVarName = getRandomVariableName('depsStr') 11 | const depVarName = getRandomVariableName('deps') 12 | const funcName = func.name || getRandomVariableName('func') 13 | 14 | const scriptStr = ` 15 | const ${depStrVarName} = '${JSON.stringify(deps)}'; 16 | const ${depVarName} = JSON.parse(${depStrVarName}); 17 | Object.assign(${depVarName}, window); 18 | const ${funcName} = ${func.toString()}; 19 | ${funcName}(${depVarName}); 20 | ` 21 | return 25 | } 26 | 27 | const sheetDataMap = new Map() 31 | 32 | export function useStyle(key: string, obj: Partial>) { 33 | let sheetData = sheetDataMap.get(key) 34 | if (!sheetData) { 35 | const sheet = jss.createStyleSheet(obj) 36 | sheetData = { 37 | classes: sheet.classes, 38 | sheetStr: sheet.toString(), 39 | } 40 | sheetDataMap.set(key, sheetData) 41 | } 42 | const { classes, sheetStr } = sheetData 43 | return [ 44 | , 45 | classes, 46 | ] as [JSX.Element, Classes] 47 | } 48 | 49 | 50 | -------------------------------------------------------------------------------- /frontend/src/components/roomItem/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import bindClass from 'classnames' 3 | 4 | import styles from './index.less' 5 | import SignalIcon from '@/components/signalIcon'; 6 | import globalConfigs from '@global/common/config'; 7 | import CustomIcon from '@/components/CustomIcon'; 8 | import { urlCompatible } from '@/utils'; 9 | 10 | interface Props { 11 | pic: string; 12 | playing: string; 13 | title: string; 14 | heat: number; 15 | token: string; 16 | onClick?: (roomToken: string) => any; 17 | width?: string | number; 18 | id: string; 19 | className?: string; 20 | } 21 | 22 | const RoomItemShow: React.FC = React.memo((props) => { 23 | const { pic, playing, title, heat = 0, id, className, token } = props 24 | const handleClick = props.onClick ? props.onClick.bind(null, token) : null 25 | return
26 |
27 |
28 | { 29 | !!pic ? :
暂无播放
} 30 |
31 |
persons{heat}
32 | {!!playing &&
33 | 34 |
正在播放: {playing || '暂无'}
35 |
} 36 |
37 | play-circle 38 |
39 |
40 |
{title || (id === globalConfigs.hallRoomId ? '大厅' : '未命名')}
41 |
42 | }) 43 | 44 | export default RoomItemShow 45 | -------------------------------------------------------------------------------- /backend/test/utils.ts: -------------------------------------------------------------------------------- 1 | import redisCli from 'root/lib/redis'; 2 | import {useBlock} from '../lib/utils' 3 | import {wait} from './_testTool' 4 | 5 | beforeAll(() => { 6 | return redisCli.select(1) 7 | }) 8 | 9 | afterAll(() => { 10 | return redisCli.flushdb() 11 | }) 12 | 13 | describe('useblock', () => { 14 | it('useblock',() => { 15 | return expect(useBlock('testError', { 16 | wait: true, 17 | success: async () => { 18 | throw new Error('test Error') 19 | }, 20 | failed: () => { 21 | } 22 | })).rejects.toThrowError() 23 | }) 24 | 25 | it('useblock success', async () => { 26 | const fn = jest.fn() 27 | await expect(useBlock('exec2', { 28 | wait: true, 29 | success: fn, 30 | failed: () => { 31 | } 32 | })) 33 | expect(fn).toBeCalled() 34 | }) 35 | 36 | it('useblock failed', async () => { 37 | const handleFailed = jest.fn() 38 | expect(useBlock('exec2', { 39 | wait: false, 40 | success: () => {}, 41 | failed: () => { 42 | } 43 | })) 44 | await expect(useBlock('exec2', { 45 | wait: false, 46 | success: () => {}, 47 | failed: handleFailed 48 | })) 49 | expect(handleFailed).toBeCalled() 50 | }) 51 | 52 | 53 | it('useblock wait', async () => { 54 | const f1 = jest.fn() 55 | const f2 = jest.fn() 56 | useBlock('exec3', { 57 | wait: false, 58 | success: () => wait(100), 59 | failed: () => { 60 | } 61 | }) 62 | await useBlock('exec3', { 63 | wait: true, 64 | success: f1, 65 | failed: f2 66 | }) 67 | expect(f1).toBeCalled() 68 | expect(f2).not.toBeCalled() 69 | }) 70 | 71 | }) -------------------------------------------------------------------------------- /frontend/src/components/tabContent/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import bindClass from 'classnames' 3 | 4 | import styles from './style.less' 5 | 6 | interface Props { 7 | activeKey: string | number; 8 | children: React.ReactNode; 9 | } 10 | 11 | const decimalToPercent = (num: number) => `${(num * 100).toFixed(2)}%` 12 | 13 | type TabContent = React.FC & { 14 | Item: typeof TabContentItem 15 | } 16 | 17 | const TabContent: TabContent = React.memo(function (props) { 18 | const {activeKey, children} = props 19 | let childLength = 0, offsetIndex = 0 20 | const renderChildArr: React.ReactElement[] = [] 21 | React.Children.forEach(children, (child: React.ReactElement, index) => { 22 | if (!child) { 23 | return 24 | } 25 | childLength ++ 26 | renderChildArr.push(child) 27 | const {key} = child 28 | if (key === activeKey) { 29 | offsetIndex = index 30 | } 31 | }) 32 | const childWidthRatio = childLength ? 1 / childLength : 0 33 | return
34 |
38 | { 39 | renderChildArr.map(child => React.cloneElement(child, { 40 | width: child.props.width || decimalToPercent(childWidthRatio), 41 | })) 42 | } 43 |
44 |
45 | }) as any 46 | 47 | const TabContentItem: React.FC<{ 48 | key: string; 49 | children: React.ReactNode; 50 | width?: string | number; 51 | }> = (props) => { 52 | return
53 | {props.children} 54 |
55 | } 56 | 57 | TabContent.Item = TabContentItem 58 | 59 | export default TabContent 60 | 61 | -------------------------------------------------------------------------------- /backend/getSettings.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import {getConfig} from './lib/tools' 3 | import {Settings, EmojiData, BlockMusicList, BlockWordList, BitSymbols, ClientSettings} from './default/configs.type' 4 | 5 | const staticPathConfig = process.env.STATIC_PATH || './static' 6 | export const injectedConfigs = { 7 | isProductionMode: process.env.NODE_ENV === 'production', 8 | staticPath: path.isAbsolute(staticPathConfig) ? staticPathConfig : path.resolve(process.cwd(), staticPathConfig), 9 | appendConfigFileDir: process.env.CONFIG_DIR || null, 10 | sessionType: process.env.SESSION_TYPE || 'cookie', 11 | redisUrl: process.env.REDIS_URL, 12 | neteaseApiUrl: process.env.NETEASE_API_URL, 13 | } 14 | 15 | const basePath = __dirname 16 | 17 | const configDirs = ['.', './default', './config'] 18 | if (injectedConfigs.appendConfigFileDir) { 19 | configDirs.push(injectedConfigs.appendConfigFileDir) 20 | } 21 | 22 | const settings = getConfig({ 23 | filename: 'settings.js', 24 | basePath, 25 | dir: configDirs, 26 | silent: true, 27 | }) 28 | 29 | export default settings 30 | 31 | 32 | export const emojiData = getConfig({ 33 | filename: 'emojiData.json', 34 | basePath, 35 | dir: configDirs, 36 | silent: true, 37 | }) 38 | 39 | export const blockMusic = getConfig({ 40 | filename: 'blockMusic.json', 41 | basePath, 42 | dir: configDirs, 43 | silent: true, 44 | }) 45 | 46 | export const blockWords = getConfig({ 47 | filename: 'blockWords.json', 48 | basePath, 49 | dir: configDirs, 50 | silent: true, 51 | }) 52 | 53 | export const bitSymbols = getConfig({ 54 | filename: 'bitSymbols.json', 55 | basePath, 56 | dir: configDirs, 57 | silent: true, 58 | }) 59 | 60 | export const clientSettings = getConfig({ 61 | filename: 'clientSettings.js', 62 | silent: true, 63 | basePath, 64 | dir: configDirs, 65 | }) 66 | 67 | 68 | -------------------------------------------------------------------------------- /frontend/src/components/list/style.less: -------------------------------------------------------------------------------- 1 | @import '~@/base.less'; 2 | 3 | @keyframes loading { 4 | from { 5 | transform: rotate(0deg); 6 | } to { 7 | transform: rotate(360deg); 8 | } 9 | } 10 | 11 | .table { 12 | position: relative; 13 | .hide { 14 | visibility: hidden; 15 | } 16 | .loading { 17 | position: absolute; 18 | left: 0; 19 | top: 0; 20 | z-index: 20; 21 | width: 100%; 22 | height: 100%; 23 | background-color: rgba(155, 155, 155, 0.2); 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | > .icon { 28 | color: @themeColor; 29 | font-size: 1.2rem; 30 | animation: loading 1s linear infinite; 31 | } 32 | } 33 | .noData { 34 | height: 100px; 35 | width: 100%; 36 | display: flex; 37 | align-items: center; 38 | justify-content: center; 39 | } 40 | .tableContent { 41 | width: 100%; 42 | height: 100%; 43 | min-height: 100px; 44 | table-layout: fixed; 45 | display: flex; 46 | flex-direction: column; 47 | align-items: flex-start; 48 | .header { 49 | width: 100%; 50 | border-bottom: 1px solid @normalTextColor; 51 | line-height: 1.5em; 52 | color: @themeColor; 53 | } 54 | .body { 55 | width: 100%; 56 | flex-basis: 0; 57 | flex-grow: 1; 58 | } 59 | .line { 60 | width: 100%; 61 | display: flex; 62 | align-items: center; 63 | .cell { 64 | @padding: .5rem; 65 | text-align: left; 66 | padding: @padding; 67 | box-sizing: border-box; 68 | overflow: hidden; 69 | text-overflow: ellipsis; 70 | white-space: nowrap; 71 | > * { 72 | width: calc(~"100% - @{padding} * 2"); 73 | } 74 | } 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /backend/lib/redis.ts: -------------------------------------------------------------------------------- 1 | import IoRedis from 'ioredis' 2 | 3 | import settings, {injectedConfigs} from 'root/getSettings' 4 | 5 | export class Redis extends IoRedis { 6 | async safeSet (key: string, value: any, expire = 3600) { 7 | return this.set(key, JSON.stringify(value), 'EX', expire) 8 | } 9 | async safeGet (key) { 10 | const dataStr = await this.get(key) 11 | return dataStr ? JSON.parse(dataStr) : null 12 | } 13 | /** 14 | * 15 | * @param key 缓存key 16 | * @param getData 数据获取函数 17 | * @param expire 缓存有限期 单位:秒 18 | * @param deps 依赖该缓存的上级缓存的keys, 该缓存更新之后会清除上游依赖缓存 19 | */ 20 | async tryGet (key: string, getData: (cacheKey: string) => Promise | T, expire: number, deps: string[] = []) { 21 | const redisKey = `musicradio:cache:${key}` 22 | let {cache, deps: oldDeps = []} = (await this.safeGet(redisKey) as {cache: T, deps: string[]}) || {} 23 | deps = Array.from(new Set(oldDeps.concat(deps))) 24 | let needSave = deps.length !== oldDeps.length; 25 | 26 | if (!cache) { 27 | needSave = true 28 | cache = await getData(redisKey) 29 | if (deps.length) { 30 | await this.del(...deps) 31 | } 32 | } 33 | if (needSave) { 34 | await this.safeSet(redisKey, { 35 | cache, 36 | deps 37 | }, expire) 38 | } 39 | return cache as T 40 | } 41 | 42 | async getCache (key) { 43 | const redisKey = `musicradio:cache:${key}` 44 | const {cache, deps: oldDeps = []} = (await this.safeGet(redisKey) as {cache: T, deps: string[]}) || {} 45 | return cache 46 | } 47 | 48 | async setCache (key: string, cache: T, expire: number) { 49 | const redisKey = `musicradio:cache:${key}` 50 | const {cache: oldCache, deps = []} = (await this.safeGet(redisKey) as {cache: T, deps: string[]}) || {} 51 | await this.safeSet(redisKey, { 52 | cache, 53 | deps 54 | }, expire) 55 | if (deps.length) { 56 | await this.del(...deps) 57 | } 58 | } 59 | } 60 | 61 | export default injectedConfigs.redisUrl ? new Redis(injectedConfigs.redisUrl) : new Redis(settings.redisPort, settings.redisHost) -------------------------------------------------------------------------------- /frontend/src/layouts/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import styles from './index.less'; 3 | import 'react-perfect-scrollbar/dist/css/styles.css'; 4 | import { connect } from 'dva' 5 | import {history as router} from 'umi' 6 | import { useMediaQuery } from 'react-responsive' 7 | import bindClass from 'classnames' 8 | 9 | import Notification from '@/components/notification' 10 | import {HashRouter} from '@/components/hashRouter' 11 | import { WindowHeightProvider } from '@/components/windowHeightListen/index' 12 | import configs from 'config/base.conf' 13 | import settings from 'config/settings' 14 | import { ConnectProps, ConnectState, PlayListModelState, CenterModelState } from '@/models/connect' 15 | import { ScoketStatus } from '@global/common/enums' 16 | import Header from './header' 17 | 18 | interface LayoutProps extends ConnectProps { 19 | nowPlaying: PlayListModelState['nowPlaying']; 20 | userInfo: CenterModelState['userInfo']; 21 | nowRoomInfo: CenterModelState['nowRoomInfo']; 22 | socketStatus: CenterModelState['nowSocketStatus']; 23 | } 24 | 25 | 26 | const BasicLayout: React.FC = React.memo(props => { 27 | const { nowPlaying, dispatch } = props 28 | const isMobile = useMediaQuery({ query: configs.mobileMediaQuery }) 29 | useEffect(() => { 30 | dispatch({ 31 | type: 'center/saveData', 32 | payload: { 33 | isMobile 34 | } 35 | }) 36 | if (isMobile) { 37 | document.documentElement.style.fontSize = "14px" 38 | } 39 | }, []) 40 | 41 | return ( 42 | 43 | 44 |
45 |
46 | 47 | {props.children} 48 | 49 |
50 |
51 |
54 |
55 |
56 |
57 |
58 |
59 | ); 60 | }); 61 | 62 | export default connect(({ playList: { nowPlaying }, center: { nowSocketStatus, userInfo, nowRoomInfo } }: ConnectState) => { 63 | return { 64 | nowPlaying, 65 | nowRoomInfo, 66 | socketStatus: nowSocketStatus, 67 | userInfo, 68 | } 69 | })(BasicLayout); 70 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "umi dev", 4 | "build": "umi build", 5 | "publish": "cross-env ASYNC_SETTINGS=1 umi build & cpy dist ../backend/static", 6 | "analyze": "cross-env ANALYZE=1 umi build", 7 | "test": "umi test", 8 | "lint:es": "eslint --ext .js src mock tests", 9 | "lint:ts": "tslint \"src/**/*.ts\" \"src/**/*.tsx\"" 10 | }, 11 | "dependencies": { 12 | "@material-ui/core": "^4.6.1", 13 | "@material-ui/icons": "^4.9.1", 14 | "@material-ui/lab": "^4.0.0-alpha.47", 15 | "antd": "^4.2.0", 16 | "classnames": "^2.2.6", 17 | "dayjs": "^1.8.17", 18 | "dva-logger": "^1.0.0", 19 | "echarts": "^4.7.0", 20 | "echarts-for-react": "^2.0.15-beta.1", 21 | "react": "^16.8.6", 22 | "react-dnd": "^9.4.0", 23 | "react-dnd-html5-backend": "^10.0.2", 24 | "react-dom": "^16.8.6", 25 | "react-perfect-scrollbar": "^1.5.3", 26 | "react-responsive": "^8.0.1", 27 | "react-swipeable": "^5.4.0", 28 | "socket.io-client": "^2.3.0" 29 | }, 30 | "devDependencies": { 31 | "@types/echarts": "^4.4.6", 32 | "@types/jest": "^23.3.12", 33 | "@types/nprogress": "^0.2.0", 34 | "@types/react": "^16.7.18", 35 | "@types/react-dom": "^16.0.11", 36 | "@types/react-test-renderer": "^16.0.3", 37 | "@types/socket.io-client": "^1.4.32", 38 | "@umijs/preset-react": "^1", 39 | "@umijs/types": "^3.1.3", 40 | "babel-eslint": "^9.0.0", 41 | "babel-plugin-import": "^1.12.2", 42 | "babel-plugin-transform-remove-console": "^6.9.4", 43 | "cpy": "^8.1.0", 44 | "cpy-cli": "^3.1.0", 45 | "cross-env": "^7.0.2", 46 | "eslint": "^5.4.0", 47 | "eslint-config-umi": "^1.4.0", 48 | "eslint-plugin-flowtype": "^2.50.0", 49 | "eslint-plugin-import": "^2.14.0", 50 | "eslint-plugin-jsx-a11y": "^5.1.1", 51 | "eslint-plugin-react": "^7.11.1", 52 | "husky": "^0.14.3", 53 | "lint-staged": "^7.2.2", 54 | "react-test-renderer": "^16.7.0", 55 | "ts-loader": "^7.0.3", 56 | "tslint": "^5.12.0", 57 | "tslint-eslint-rules": "^5.4.0", 58 | "tslint-react": "^3.6.0", 59 | "typescript": "^3.8.3", 60 | "umi": "^3.1.3" 61 | }, 62 | "lint-staged": { 63 | "*.{ts,tsx}": [ 64 | "tslint --fix", 65 | "git add" 66 | ], 67 | "*.{js,jsx}": [ 68 | "eslint --fix", 69 | "git add" 70 | ] 71 | }, 72 | "engines": { 73 | "node": ">=10.13" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /frontend/src/components/scrollShow/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {useRef, useEffect, useState} from 'react' 2 | import bindClass from 'classnames'; 3 | 4 | import style from './style.less' 5 | 6 | interface Props { 7 | children: React.ReactNode; 8 | show: boolean; 9 | transitionDuration: number; 10 | direction: ('right' | 'left' | 'both'); // 滚动展开方向 11 | className?: string; 12 | afterShow?: () => any; 13 | afterHide?: () => any; 14 | } 15 | 16 | export default React.memo((props) => { 17 | const {className, direction, children, show, transitionDuration = 0.5, afterShow, afterHide} = props 18 | const contentRef = useRef(null) 19 | const [containerWidth, setContainerWidth] = useState(null) 20 | const [isInitial, setIsInitial] = useState(true) 21 | 22 | useEffect(() => { 23 | setIsInitial(false) 24 | requestAnimationFrame(() => { 25 | if (contentRef.current) { 26 | const eleRect = contentRef.current.getBoundingClientRect() 27 | setContainerWidth(eleRect.width) 28 | } 29 | }) 30 | }, []) 31 | 32 | useEffect(() => { 33 | let timer = null 34 | if (show) { 35 | timer = setTimeout(() => { 36 | afterShow && afterShow() 37 | }, transitionDuration * 1000) 38 | } else if (!isInitial) { 39 | timer = setTimeout(() => { 40 | afterHide && afterHide() 41 | }, transitionDuration * 1000) 42 | } 43 | return () => { 44 | if (timer) { 45 | clearTimeout(timer) 46 | } 47 | } 48 | }, [show]) 49 | 50 | const isMeasure = !containerWidth 51 | const isShow = !isMeasure && show 52 | return 59 |
66 | {children} 67 |
68 |
69 | }) 70 | -------------------------------------------------------------------------------- /common/config.ts: -------------------------------------------------------------------------------- 1 | import {ServerListenSocketEvents} from './enums' 2 | 3 | type B = keyof typeof ServerListenSocketEvents | 'default' 4 | 5 | const apiFrequeryLimit: { 6 | [key in B]: number; 7 | } = { 8 | default: 1000, 9 | [ServerListenSocketEvents.sendMessage]: 5000, 10 | [ServerListenSocketEvents.pausePlaying]: 3000, 11 | [ServerListenSocketEvents.startPlaying]: 3000, 12 | [ServerListenSocketEvents.changeProgress]: 3000, 13 | [ServerListenSocketEvents.switchPlayMode]: 3000, 14 | [ServerListenSocketEvents.voteToCutMusic]: 5000, 15 | [ServerListenSocketEvents.addPlayListItems]: 3000, 16 | [ServerListenSocketEvents.movePlayListItem]: 3000, 17 | [ServerListenSocketEvents.deletePlayListItems]: 3000, 18 | [ServerListenSocketEvents.blockPlayListItems]: 2000, 19 | [ServerListenSocketEvents.unblockPlayListItems]: 2000, 20 | [ServerListenSocketEvents.searchMedia]: 5000, 21 | [ServerListenSocketEvents.getMediaDetail]: 5000, 22 | [ServerListenSocketEvents.banUserComment]: 2000, 23 | [ServerListenSocketEvents.blockUser]: 2000, 24 | [ServerListenSocketEvents.blockUserIp]: 2000, 25 | [ServerListenSocketEvents.revokeAction]: 2000, 26 | [ServerListenSocketEvents.createRoom]: 5000, 27 | [ServerListenSocketEvents.destroyRoom]: 5000, 28 | [ServerListenSocketEvents.joinRoom]: 2000, 29 | [ServerListenSocketEvents.quitRoom]: 2000, 30 | [ServerListenSocketEvents.getRoomData]: 3000, 31 | [ServerListenSocketEvents.recommendRoom]: 800, 32 | [ServerListenSocketEvents.getEmojiList]: 800, 33 | [ServerListenSocketEvents.getRoomAdminActionList]: 2000, 34 | [ServerListenSocketEvents.disconnect]: -1, 35 | [ServerListenSocketEvents.withdrawlMessage]: 2000, 36 | [ServerListenSocketEvents.setNickName]: 2000, 37 | [ServerListenSocketEvents.cutUserStatus]: 2000, 38 | [ServerListenSocketEvents.getOnlineUserList]: 2000, 39 | [ServerListenSocketEvents.getRoomData]: 2000, 40 | [ServerListenSocketEvents.manageRoomAdmin]: 2000, 41 | [ServerListenSocketEvents.getRoomCoordHotData]: 2000, 42 | [ServerListenSocketEvents.cutMusic]: 3000, 43 | } 44 | 45 | 46 | export default { 47 | authTokenFeildName: 'authToken', // token 认证的key 48 | hallRoomId: 'globalRoomId', 49 | hallRoomToken: 'hallRoomToken', 50 | initNickNamePerfix: 'musicradiodefault', 51 | roomAutoPlayTypes: ['流行', '民谣', '电子', '古风', '乡村', '摇滚', '轻音乐', '古典'], 52 | apiFrequeryLimit, 53 | roomUrlPrefix: 'room', 54 | } 55 | 56 | -------------------------------------------------------------------------------- /frontend/src/utils/styleInject.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback} from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { TextField, Button, Tabs, Tab, Slider} from '@material-ui/core' 4 | import {TextFieldProps} from '@material-ui/core/TextField' 5 | import styleConfigs from 'config/baseStyle.conf' 6 | 7 | const {themeColor, highLightColor, normalTextColor} = styleConfigs 8 | 9 | const CustomTextFeildContent = withStyles({ 10 | root: { 11 | color: normalTextColor, 12 | '& textarea, input': { 13 | color: normalTextColor, 14 | }, 15 | '& .MuiInput-underline:before': { 16 | borderBottomColor: `${normalTextColor} !important`, 17 | }, 18 | '& .MuiInput-underline:after': { 19 | borderBottomColor: themeColor, 20 | }, 21 | '& .Mui-disabled': { 22 | textAlign: 'center', 23 | cursor: 'not-allowed', 24 | } 25 | }, 26 | })(TextField) 27 | 28 | 29 | export const CustomTextFeild: React.FC = (props) => { 30 | const {onKeyDown} = props 31 | const handleKeyDown = useCallback((e) => { 32 | e && e.stopPropagation() 33 | onKeyDown && onKeyDown(e) 34 | }, [onKeyDown]) 35 | return 36 | } 37 | 38 | 39 | export const CustomBtn = withStyles({ 40 | root: { 41 | fontSize: '1rem', 42 | '&:hover .MuiButton-label': { 43 | color: themeColor 44 | }, 45 | '& .MuiButton-label': { 46 | color: normalTextColor 47 | } 48 | } 49 | })(Button) 50 | 51 | export const CustomTabs = withStyles({ 52 | root: { 53 | borderBottom: `1px solid ${normalTextColor}`, 54 | }, 55 | indicator: { 56 | backgroundColor: themeColor, 57 | }, 58 | 59 | })(Tabs) 60 | 61 | export const CustomTab = withStyles({ 62 | root: { 63 | fontSize: '1rem' 64 | }, 65 | selected: { 66 | color: themeColor 67 | }, 68 | })(Tab) 69 | 70 | export const VolumeSlider = withStyles({ 71 | root: { 72 | color: 'rgb(155, 155, 155)', 73 | }, 74 | track: { 75 | backgroundColor: 'white' 76 | }, 77 | thumb: { 78 | width: '8px', 79 | height: '8px', 80 | marginLeft: '-2px', 81 | marginTop: '-3px', 82 | backgroundColor: 'white', 83 | '&::after': { 84 | display: 'none', 85 | } 86 | } 87 | })(Slider) 88 | -------------------------------------------------------------------------------- /frontend/src/components/hooks/keyboardListen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useMemo, useRef } from 'react' 2 | 3 | interface Options { 4 | } 5 | // 移动端下监听键盘弹起/隐藏 6 | export default function useKeyBoardListener(options?: Options) { 7 | const [isShow, setIsShow] = useState(false) 8 | const inputBoxRef = useRef(null) 9 | const {isAndroid, isiOS} = useMemo(() => { 10 | const u = navigator.userAgent 11 | return { 12 | isAndroid: u.indexOf('Android') > -1 || u.indexOf('Adr') > -1, 13 | isiOS: !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/) 14 | } 15 | }, [navigator.userAgent]) 16 | 17 | 18 | useEffect(() => { 19 | if (!isAndroid && !isiOS) { 20 | return 21 | } 22 | if (!inputBoxRef.current) { 23 | return 24 | } 25 | const handleShow = () => { 26 | setIsShow(true) 27 | } 28 | const handleHide = () => { 29 | setIsShow(false) 30 | } 31 | 32 | const focusHandler = (e) => { 33 | e.stopPropagation() 34 | handleShow() 35 | } 36 | const unRegister: [string, EventListenerOrEventListenerObject][] = [] 37 | inputBoxRef.current.addEventListener('focus', focusHandler) 38 | unRegister.push(['focus', focusHandler]) 39 | if (isAndroid) { 40 | const originHeight = document.documentElement.clientHeight || document.body.clientHeight; 41 | const resizeHandler = (e) => { 42 | e.stopPropagation() 43 | const nowHeight = document.documentElement.clientHeight || document.body.clientHeight; 44 | if (nowHeight === originHeight) { 45 | handleHide() 46 | } 47 | } 48 | 49 | window.addEventListener('resize', resizeHandler) 50 | unRegister.push(['resize', resizeHandler]) 51 | } else { 52 | const blurHandler = (e) => { 53 | e.stopPropagation() 54 | handleHide() 55 | } 56 | inputBoxRef.current.addEventListener('blur', blurHandler) 57 | unRegister.push(['blur', blurHandler]) 58 | } 59 | 60 | return () => { 61 | if (!inputBoxRef.current) { 62 | return 63 | } 64 | unRegister.forEach(([eventName, handler]) => { 65 | inputBoxRef.current.removeEventListener(eventName, handler) 66 | }) 67 | } 68 | 69 | }, [isAndroid, isiOS]) 70 | return [inputBoxRef, isShow] as [React.MutableRefObject, boolean] 71 | } 72 | -------------------------------------------------------------------------------- /backend/test/api.ts: -------------------------------------------------------------------------------- 1 | import {getMusicInfo, getAlbumInfo, searchMedia, getHighQualityMusicList, getPlayListInfo, } from 'root/lib/api' 2 | import {MediaTypes} from 'root/type' 3 | import redisCli from 'root/lib/redis'; 4 | 5 | beforeAll(() => { 6 | return redisCli.select(1) 7 | }) 8 | 9 | afterAll(() => { 10 | return redisCli.flushdb() 11 | }) 12 | 13 | 14 | describe('test netease api', () => { 15 | it('teat get music info',async () => { 16 | const [info] = await getMusicInfo(['65533']) 17 | expect(info).toMatchObject(expect.objectContaining({ 18 | lyric: expect.any(String), 19 | comments: expect.any(Array), 20 | id: expect.any(String), 21 | name: expect.any(String), 22 | artist: expect.any(String), 23 | album: expect.any(String), 24 | pic: expect.any(String), 25 | duration: expect.any(Number), 26 | free: expect.any(Boolean), 27 | })) 28 | }) 29 | 30 | it('test get album info', () => { 31 | return expect(getAlbumInfo('2301158')).resolves.toMatchObject(expect.objectContaining({ 32 | id: expect.any(String), 33 | name: expect.any(String), 34 | desc: expect.any(String), 35 | pic: expect.any(String), 36 | musicList: expect.any(Array), 37 | })) 38 | }) 39 | 40 | it('search media/song',async () => { 41 | const list = await searchMedia('陈奕迅', MediaTypes.song) 42 | list.forEach(item => expect(item).toMatchObject( expect.objectContaining({ 43 | id: expect.any(String), 44 | title: expect.any(String), 45 | desc: expect.any(String), 46 | }))) 47 | }) 48 | 49 | it('search media/album',async () => { 50 | const list = await searchMedia('陈奕迅', MediaTypes.album) 51 | list.forEach(item => expect(item).toMatchObject( expect.objectContaining({ 52 | id: expect.any(String), 53 | title: expect.any(String), 54 | desc: expect.any(String), 55 | }))) 56 | }) 57 | 58 | it('getHighQualityMusicList', async () => { 59 | const list = await getHighQualityMusicList('流行') 60 | list.forEach(item => expect(item).toMatchObject( expect.objectContaining({ 61 | id: expect.any(Number), 62 | name: expect.any(String), 63 | description: expect.any(String), 64 | }))) 65 | }) 66 | 67 | it('playlist info', () => { 68 | return expect(getPlayListInfo('3136952023')).resolves.toMatchObject( 69 | expect.objectContaining({ 70 | id: expect.any(Number), 71 | name: expect.any(String), 72 | description: expect.any(String), 73 | musicList: expect.any(Array), 74 | }) 75 | ) 76 | }) 77 | 78 | }) 79 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "music-radio", 3 | "version": "0.0.1", 4 | "description": "Inspired by [SyncMusic](https://github.com/kasuganosoras/SyncMusic)", 5 | "scripts": { 6 | "dev": "cross-env NODE_ENV=development SESSION_TYPE=token nodemon -e js,mjs,json,ts", 7 | "debug": "cross-env NODE_ENV=development SESSION_TYPE=token node --inspect -r tsconfig-paths/register -r ts-node/register app.ts", 8 | "start": "ts-node -r tsconfig-paths/register app.ts", 9 | "build": "tsc --build tsconfig.json", 10 | "test": "jest -i --coverage --forceExit", 11 | "server": "cross-env STATIC_PATH=./static NODE_ENV=production node ./build/backend/app.js", 12 | "runts": "ts-node -r tsconfig-paths/register" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git@github.com:sanmmm/MusicRadio.git" 17 | }, 18 | "keywords": [ 19 | "music", 20 | "radio", 21 | "nodejs", 22 | "typescript" 23 | ], 24 | "author": "sanmmm", 25 | "license": "MIT", 26 | "dependencies": { 27 | "@jest/types": "^25.5.0", 28 | "classnames": "^2.2.6", 29 | "colors": "^1.4.0", 30 | "compression": "^1.7.4", 31 | "cookie": "^0.4.0", 32 | "cookie-parser": "^1.4.4", 33 | "cors": "^2.8.5", 34 | "cron": "^1.7.2", 35 | "cross-env": "^7.0.2", 36 | "deep-equal": "^2.0.3", 37 | "express": "^4.17.1", 38 | "express-react-views": "^0.11.0", 39 | "got": "^9.6.0", 40 | "ioredis": "^4.14.1", 41 | "jss": "^10.1.1", 42 | "jss-preset-default": "^10.1.1", 43 | "mint-filter": "^3.0.0", 44 | "polished": "^3.5.2", 45 | "react": "^16.13.1", 46 | "react-dom": "^16.13.1", 47 | "socket.io": "^2.3.0", 48 | "uuid": "^3.3.3" 49 | }, 50 | "devDependencies": { 51 | "@types/cors": "^2.8.6", 52 | "@types/express": "^4.17.2", 53 | "@types/got": "^9.6.9", 54 | "@types/ioredis": "^4.16.0", 55 | "@types/jest": "^25.2.1", 56 | "@types/jquery": "^3.3.35", 57 | "@types/react": "^16.9.34", 58 | "@types/socket.io": "^2.1.4", 59 | "@types/typescript": "^2.0.0", 60 | "cheerio": "^1.0.0-rc.3", 61 | "form-data": "^3.0.0", 62 | "jest": "^25.5.4", 63 | "nodemon": "^1.19.4", 64 | "ts-jest": "^25.5.1", 65 | "ts-node": "^8.5.2", 66 | "tsconfig-paths": "^3.9.0", 67 | "typescript": "^3.7.2" 68 | }, 69 | "jest": { 70 | "preset": "ts-jest", 71 | "testMatch": [ 72 | "**/test/*.ts", 73 | "!**/_*.ts" 74 | ], 75 | "testPathIgnorePatterns": [ 76 | "/node_modules/", 77 | "/lib/", 78 | "/static/", 79 | "/views/" 80 | ], 81 | "collectCoverageFrom": [ 82 | "lib/**/*.ts" 83 | ], 84 | "coverageReporters": [ 85 | "text-summary", 86 | "lcov" 87 | ], 88 | "moduleNameMapper": { 89 | "^root/(.*)$": "/$1", 90 | "^global/(.*)$": "/../$1" 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /.github/actions/deploy/index.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | const core = require('@actions/core') 3 | const shell = require('@actions/exec') 4 | const github = require('@actions/github') 5 | 6 | function wait (time = 1000) { 7 | return new Promise((resolve, reject) => { 8 | setTimeout(resolve, time) 9 | }) 10 | } 11 | 12 | async function getInputArgs() { 13 | const serverUrl = core.getInput('server').replace(/\/$/, '') 14 | const accessToken = core.getInput('access_token') 15 | const imageName = core.getInput('image_name') 16 | let shellOut = '', shellErr = '' 17 | console.log(github.context.payload.client_payload) 18 | await shell.exec(`git rev-parse --short ${github.context.payload.client_payload.sha}`, [], 19 | { 20 | listeners: { 21 | stdout: (data) => { 22 | shellOut += data.toString() 23 | }, 24 | stderr: (data) => { 25 | shellErr += data.toString() 26 | } 27 | } 28 | } 29 | ) 30 | 31 | return { 32 | serverUrl, 33 | accessToken, 34 | imageName, 35 | tag: shellOut.replace(/\n/g, '') 36 | } 37 | } 38 | 39 | 40 | async function main() { 41 | try { 42 | const args = await getInputArgs() 43 | // console.log(args) 44 | const reqData = { 45 | token: args.accessToken, 46 | imageName: args.imageName, 47 | imageTag: args.tag 48 | } 49 | const res = await got(args.serverUrl + '/updateImage', { 50 | method: 'POST', 51 | json: reqData, 52 | responseType: 'json', 53 | timeout: 10000 54 | }) 55 | if (res.body.code !== 0) { 56 | throw new Error(`req update image failed: ${res.body.msg || '未知原因'}`) 57 | } 58 | let i = 0, isSuccess = false 59 | do { 60 | i++ 61 | console.log(`check image tag/ time: ${i}`) 62 | const reqData = { 63 | token: args.accessToken, 64 | imageName: args.imageName, 65 | } 66 | const checkRes = await got(args.serverUrl + '/getImageTag', { 67 | method: 'POST', 68 | json: reqData, 69 | responseType: 'json', 70 | timeout: 10000 71 | }) 72 | const {code, tag} = checkRes.body 73 | if (code === 0 && tag === args.tag) { 74 | isSuccess = true 75 | break 76 | } 77 | if (i >= 30) { 78 | throw new Error(`请求更新失败,请求次数:${i}`) 79 | } 80 | await wait(1000 * 10) 81 | } while (true) 82 | 83 | if (isSuccess) { 84 | console.log('deploy success!!') 85 | } else { 86 | core.setFailed('部署失败') 87 | } 88 | } catch (e) { 89 | core.setFailed(e.message) 90 | } 91 | } 92 | 93 | main() -------------------------------------------------------------------------------- /frontend/src/components/musicList/index.less: -------------------------------------------------------------------------------- 1 | @import '~@/base.less'; 2 | 3 | .actionBtnBase { 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | max-width: 100px; 8 | margin: 0 .3rem; 9 | border: 1px solid rgba(225,225,225,.3); 10 | border-radius: 2px; 11 | text-align: center; 12 | line-height: 1.8em; 13 | > :global(.iconfont) { 14 | display: inline-block; 15 | margin: 0 .3rem; 16 | } 17 | > * { 18 | white-space: nowrap; 19 | } 20 | &:hover { 21 | cursor: pointer; 22 | color: white; 23 | border-color: white; 24 | } 25 | } 26 | 27 | .musicList { 28 | height: 100%; 29 | box-sizing: border-box; 30 | padding: 1rem 0; 31 | display: flex; 32 | flex-direction: column; 33 | .tip { 34 | margin-bottom: 1rem; 35 | } 36 | .actionsBox { 37 | display: flex; 38 | flex-wrap: nowrap; 39 | align-items: center; 40 | justify-content: flex-start; 41 | margin-bottom: 1rem; 42 | &.normal { 43 | > * { 44 | .actionBtnBase(); 45 | flex-basis: 0; 46 | flex-grow: 1; 47 | } 48 | } 49 | &.mobile { 50 | text-align: center; 51 | justify-content: space-around; 52 | > * { 53 | > * { 54 | display: block; 55 | line-height: 1.4em; 56 | color: white; 57 | } 58 | } 59 | } 60 | } 61 | .selectActionsBox { 62 | display: flex; 63 | align-items: center; 64 | justify-content: flex-end; 65 | padding: .5rem; 66 | > * { 67 | .actionBtnBase(); 68 | flex-basis: 0; 69 | flex-grow: 1; 70 | } 71 | } 72 | .tableContainer { 73 | flex-grow: 1; 74 | height: 0; 75 | } 76 | } 77 | 78 | .focusMusicRow { 79 | color: white !important; 80 | } 81 | 82 | 83 | .popoverActions { 84 | padding: .5rem; 85 | .item { 86 | cursor: pointer; 87 | text-align: center; 88 | color: black; 89 | & + .item { 90 | margin-top: .5rem; 91 | } 92 | > * { 93 | margin: 0 .3rem; 94 | white-space: nowrap; 95 | } 96 | } 97 | } 98 | 99 | .musicNameCell { 100 | display: flex; 101 | align-items: center; 102 | @iconMargin: .7rem; 103 | > .name { 104 | display: inline-block; 105 | flex-basis: 0; 106 | flex-grow: 1; 107 | text-overflow: ellipsis; 108 | overflow: hidden; 109 | white-space: nowrap; 110 | } 111 | > * { 112 | & + * { 113 | margin-left: @iconMargin; 114 | } 115 | } 116 | :global(.icon-block) { 117 | color: red !important; 118 | font-size: .9rem !important; 119 | line-height: 1; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /backend/app.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import registerPath from './registerPath' 3 | import {injectedConfigs} from './getSettings' 4 | 5 | if (injectedConfigs.isProductionMode) { 6 | registerPath() 7 | } 8 | 9 | import colors from 'colors/safe' 10 | import express from 'express' 11 | import compression from 'compression' 12 | import cookieParser from 'cookie-parser' 13 | import http from 'http' 14 | import socketIo from 'socket.io'; 15 | import reactViews from 'express-react-views' 16 | import cors from 'cors' 17 | 18 | import session from 'root/lib/session' 19 | import Handler from 'root/lib/handler' 20 | import settings, {clientSettings} from 'root/getSettings' 21 | import globalConfigs from 'global/common/config' 22 | import { SessionTypes } from 'root/type' 23 | import {cookieMiddleware, dispatchClientSettings, umiFileHandler} from 'root/lib/middlewares' 24 | import {fillVaraibleToFile} from 'root/lib/tools' 25 | 26 | global.hallRoomId = globalConfigs.hallRoomId 27 | const sessionType = SessionTypes[injectedConfigs.sessionType] as SessionTypes 28 | 29 | fillVaraibleToFile({ 30 | filePath: path.join(injectedConfigs.staticPath, 'index.html'), 31 | exportTo: path.join(injectedConfigs.staticPath, 'index_server.html'), 32 | vars: { 33 | HTTP_SERVER: settings.httpServer.replace(/\/$/, ''), 34 | WEBSITE_TITLE: clientSettings.websiteName, 35 | } 36 | }) 37 | 38 | const app = express() 39 | app.use(compression()) 40 | app.use(cookieParser(settings.sessionSecret)) 41 | app.use(cookieMiddleware) 42 | app.use((req, res, next) => { 43 | console.log(colors.green(`http req: [${req.method}]${req.originalUrl}`)) 44 | next() 45 | }) 46 | 47 | app.use('/static', express.static(injectedConfigs.staticPath)) 48 | app.use(express.urlencoded({ 49 | extended: true 50 | })) 51 | app.use(express.json()) 52 | 53 | const needSetCors = !!(settings.corsOrigin && settings.corsOrigin.length) 54 | if (needSetCors) { 55 | app.use(cors({ 56 | origin: settings.corsOrigin, 57 | credentials: true, 58 | })) 59 | } 60 | app.set('views', path.join(__dirname, 'views')); 61 | if (!injectedConfigs.isProductionMode) { 62 | app.set('view engine', 'tsx') 63 | app.engine('tsx', reactViews.createEngine({ 64 | transformViews: false, 65 | })); 66 | } else { 67 | app.set('view engine', 'js') 68 | app.engine('js', reactViews.createEngine({ 69 | transformViews: false, 70 | })); 71 | } 72 | // 放在sesion中间件前面 73 | app.get('/client/settings', dispatchClientSettings) 74 | app.get(/^\/umi\..+\.(js|css)/, umiFileHandler) 75 | app.use(session(sessionType)) 76 | 77 | const server = new http.Server(app) 78 | const io = socketIo(server, { 79 | origins: needSetCors ? (settings.corsOrigin).map(url => { 80 | if (url.startsWith('https') && !url.endsWith(':443')) { 81 | const pureUrlStr = new URL(url).toString() 82 | return pureUrlStr.replace(/\/$/, ':443') 83 | } 84 | return url 85 | }) : '*:*' 86 | }) 87 | io.use(session(sessionType)) 88 | 89 | Handler(io, app, () => { 90 | server.listen(settings.port, () => { 91 | console.log(`the server is listening ${settings.port}`) 92 | }) 93 | }) -------------------------------------------------------------------------------- /frontend/src/components/adminActionManage/listContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import bindClass from 'classnames' 3 | import { Input, IconButton, Button } from '@material-ui/core' 4 | import { makeStyles } from '@material-ui/core/styles' 5 | import { Search as SearchIcon, Refresh as RefreshIcon } from '@material-ui/icons' 6 | import ScrollBar from 'react-perfect-scrollbar' 7 | 8 | import styles from './style.less' 9 | import styleConfig from 'config/baseStyle.conf' 10 | 11 | const useStyle = makeStyles({ 12 | root: { 13 | color: styleConfig.normalTextColor, 14 | }, 15 | input: { 16 | fontSize: '1.2rem', 17 | lineHeight: '1.6em', 18 | }, 19 | underline: { 20 | '&::before': { 21 | borderColor: styleConfig.normalTextColor, 22 | }, 23 | '&::after': { 24 | borderColor: styleConfig.themeColor, 25 | } 26 | } 27 | }) 28 | export default React.memo<{ 29 | onSearch: (str: string) => any; 30 | searchLoading: boolean; 31 | loadMoreLoading: boolean; 32 | onLoadMore: () => any; 33 | onRefresh: () => any; 34 | hasMore: boolean; 35 | children: React.ReactNode; 36 | }>((props) => { 37 | const { children, hasMore, loadMoreLoading, searchLoading } = props 38 | const hasData = !!React.Children.count(children) 39 | const [searchStr, onSearchStrChange] = useState('') 40 | const classObj = useStyle({}) 41 | 42 | const onRefresh = () => { 43 | onSearchStrChange('') 44 | props.onRefresh() 45 | } 46 | return
47 |
48 | onSearchStrChange(e.target.value)} value={searchStr} 49 | placeholder="请输入搜索内容" 50 | endAdornment={ 51 | 52 | 53 | 54 | 55 | 56 | 57 | } 58 | /> 59 |
60 |
61 | 62 | { 63 | hasData ? 64 | {children} 65 |
66 | { 67 | hasMore ? : 没有更多了} 68 |
69 |
: 70 |
71 | 暂无数据 72 |
73 | } 74 |
75 |
76 |
77 | }) 78 | -------------------------------------------------------------------------------- /.github/workflows/docker_release.yml: -------------------------------------------------------------------------------- 1 | name: docker release 2 | 3 | on: 4 | push: 5 | branches: master 6 | paths: 7 | - "common/**" 8 | - "frontend/**" 9 | - "backend/**" 10 | - "Dockerfile" 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | defaults: 16 | run: 17 | shell: bash 18 | working-directory: backend 19 | services: 20 | redis: 21 | image: redis 22 | ports: 23 | - 6379:6379 24 | neteaseapi: 25 | image: binaryify/netease_cloud_music_api 26 | ports: 27 | - 3000:3000 28 | strategy: 29 | matrix: 30 | node-version: [12.x, 14.x] 31 | 32 | steps: 33 | - uses: actions/checkout@v2 34 | - 35 | name: Use Node.js ${{ matrix.node-version }} 36 | uses: actions/setup-node@v1 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | - run: npm ci 40 | - 41 | name: run test 42 | run: npm test 43 | env: 44 | REDIS_URL: redis://localhost:6379 45 | NETEASE_API_URL: http://localhost:3000 46 | release: 47 | runs-on: ubuntu-latest 48 | needs: test 49 | steps: 50 | - uses: actions/checkout@v2 51 | - 52 | name: docker login 53 | env: 54 | DOCKER_USERNAME: ${{secrets.DOCKER_USERNAME}} 55 | DOCKER_PASSWORD: ${{secrets.DOCKER_PASSWORD}} 56 | run: | 57 | echo "${DOCKER_PASSWORD}" | docker login --username ${DOCKER_USERNAME} --password-stdin 58 | - 59 | name: Set up Docker Buildx 60 | id: buildx 61 | uses: crazy-max/ghaction-docker-buildx@v1 62 | with: 63 | buildx-version: latest 64 | - 65 | name: Build dockerfile (with push) 66 | env: 67 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 68 | run: | 69 | docker buildx build \ 70 | --platform=linux/amd64 \ 71 | --output "type=image,push=true" \ 72 | --file ./Dockerfile . \ 73 | --tag $(echo "${DOCKER_USERNAME}" | tr '[:upper:]' '[:lower:]')/music_radio:latest \ 74 | --tag $(echo "${DOCKER_USERNAME}" | tr '[:upper:]' '[:lower:]')/music_radio:$(git rev-parse --short $GITHUB_SHA) 75 | deployment: 76 | needs: release 77 | runs-on: ubuntu-latest 78 | steps: 79 | - 80 | name: Repository Dispatch 81 | uses: peter-evans/repository-dispatch@v1.1.0 82 | with: 83 | event-type: deployment 84 | token: ${{secrets.REPO_ACCESS_TOKEN}} 85 | client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}"}' 86 | 87 | -------------------------------------------------------------------------------- /frontend/src/components/notification/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useCallback } from 'react' 2 | import bindClass from 'classnames' 3 | import { useMediaQuery } from 'react-responsive' 4 | import { connect } from 'dva' 5 | import Alert from '@material-ui/lab/Alert' 6 | import { Snackbar, Fade, Slide } from '@material-ui/core' 7 | 8 | import styles from './style.less' 9 | import configs from 'config/base.conf' 10 | import { CenterModelState, ConnectState, ConnectProps } from '@/models/connect' 11 | 12 | interface NotificationProps extends ConnectProps { 13 | list: CenterModelState['notifications']; 14 | } 15 | 16 | const duration = 3000 17 | 18 | const Notification: React.FC = React.memo(function (props) { 19 | const { list = [], dispatch } = props 20 | const isMobile = useMediaQuery({ query: configs.mobileMediaQuery }) 21 | 22 | const handleClose = useCallback((timestamp) => { 23 | dispatch({ 24 | type: 'center/removeNotification', 25 | payload: { 26 | timestamp 27 | } 28 | }) 29 | }, [dispatch]) 30 | 31 | if (isMobile) { 32 | const lastItem = list.length ? list[0] : null 33 | return lastItem ? 40 | 41 | {lastItem.content} 42 | 43 | : null 44 | } else { 45 | return
46 | { 47 | list.map(item => )} 48 |
49 | } 50 | 51 | }) 52 | 53 | export default connect(({ center }: ConnectState) => { 54 | return { 55 | list: center.notifications, 56 | } 57 | })(Notification) 58 | 59 | 60 | interface ItemProps { 61 | onClose: (timestamp: number) => void; 62 | item: CenterModelState['notifications'][0]; 63 | autoCloseDuration?: number; 64 | } 65 | 66 | const NotificationItem = React.memo((props) => { 67 | const [isShow, setIsShow] = useState(true) 68 | 69 | useEffect(() => { 70 | if (!props.autoCloseDuration) { 71 | return 72 | } 73 | const timer = setTimeout(() => { 74 | handleClickClose() 75 | }, props.autoCloseDuration) 76 | return () => { 77 | clearTimeout(timer) 78 | } 79 | }, []) 80 | 81 | const handleClickClose = () => { 82 | setIsShow(false) 83 | } 84 | 85 | const handleFadeExisted = () => { 86 | props.onClose(props.item.timestamp) 87 | } 88 | 89 | return 90 | 91 | {props.item.content} 92 | 93 | 94 | }) 95 | 96 | const SlideDown = (props) => 97 | -------------------------------------------------------------------------------- /frontend/src/layouts/index.less: -------------------------------------------------------------------------------- 1 | @import '~@/base.less'; 2 | 3 | .normal { 4 | font-family: poppin, Tahoma, Arial, sans-serif; 5 | color : @normalTextColor; 6 | height: 100%; 7 | width: 100%; 8 | .header { 9 | position: fixed; 10 | top : 0; 11 | left : 0; 12 | height : @headerHeight; 13 | z-index : 200; 14 | width: 100%; 15 | color: @themeColor; 16 | 17 | > .content { 18 | display: flex; 19 | align-items: center; 20 | width: 90%; 21 | height: 100%; 22 | box-sizing: border-box; 23 | min-width: @minContentWidth; 24 | > * { 25 | & + * { 26 | padding-left: 1rem; 27 | } 28 | } 29 | .logo { 30 | max-width: 20vw; 31 | min-width: calc(@headerHeight * 0.9); 32 | margin-left: 20px; 33 | overflow: hidden; 34 | word-break: break-all; 35 | word-wrap: break-word; 36 | font-family: 'Long Cang', cursive; 37 | font-size: 2.4rem; 38 | margin: 0 1rem 0 2rem; 39 | } 40 | .center { 41 | min-width: 0; 42 | flex-grow: 1; 43 | > * { 44 | max-width: 30vw; 45 | } 46 | } 47 | .right { 48 | display: flex; 49 | align-items: center; 50 | > .onlinePerson { 51 | display: flex; 52 | align-items: center; 53 | margin: 0 2rem; 54 | } 55 | > * { 56 | margin-right: 1rem; 57 | } 58 | } 59 | } 60 | 61 | &.mobile { 62 | height: @mobileHeaderHeight; 63 | background-color: #4B4B4B; 64 | filter: brightness(110%); 65 | > .content { 66 | min-width: 100%; 67 | > .center { 68 | > * { 69 | max-width: none; 70 | } 71 | } 72 | } 73 | } 74 | } 75 | 76 | scroll-behavior:smooth; 77 | } 78 | 79 | .mask { 80 | background-color: rgba(0, 0, 0, .5); 81 | position : absolute; 82 | top : 0; 83 | left : 0; 84 | width : 100%; 85 | height : 100%; 86 | z-index : -2; 87 | } 88 | 89 | .playerBackground { 90 | position : absolute; 91 | top : 0; 92 | left : 0; 93 | z-index : -4; 94 | width : 100%; 95 | height : 100%; 96 | background-position: 50%; 97 | background-size : cover; 98 | background-repeat : no-repeat; 99 | opacity : .6; 100 | filter : blur(60px); 101 | } 102 | 103 | 104 | .pre { 105 | display : flex; 106 | align-items : center; 107 | justify-content: center; 108 | height : 100vh; 109 | color : rgb(155, 155, 155); 110 | font-size : 1.2rem; 111 | 112 | .content { 113 | text-align: center; 114 | 115 | :global(.iconfont) { 116 | font-size: 80px; 117 | color : #99CCCC; 118 | cursor : pointer; 119 | } 120 | 121 | .warning { 122 | color : #CC0033; 123 | cursor: auto; 124 | } 125 | 126 | .label { 127 | margin-top: 2rem; 128 | } 129 | 130 | .action { 131 | margin-top: 1.5rem; 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /frontend/src/components/roomItem/index.less: -------------------------------------------------------------------------------- 1 | @import '~@/base.less'; 2 | 3 | 4 | 5 | @baseIndex: 10; 6 | 7 | .roomItem { 8 | color: @themeColor; 9 | 10 | @mainContentPadding: 8px; 11 | @mainContentFontSize: 0.8rem; 12 | > .main { 13 | width: 100%; 14 | box-sizing: border-box; 15 | border-radius: 8px; 16 | overflow: hidden; 17 | position: relative; 18 | font-size: @mainContentFontSize; 19 | cursor: pointer; 20 | color: white; 21 | &::before { 22 | content: ""; 23 | display: block; 24 | padding-top: 80%; 25 | } 26 | 27 | &:hover { 28 | .background { 29 | filter: brightness(70%); 30 | } 31 | } 32 | 33 | > .background { 34 | position: absolute; 35 | z-index: @baseIndex; 36 | top: 0; 37 | left: 0; 38 | width: 100%; 39 | height: 100%; 40 | overflow: hidden; 41 | > img { 42 | width: 100%; 43 | height: 100%; 44 | filter: brightness(80%); 45 | object-fit: cover; 46 | } 47 | > .noData { 48 | width: 100%; 49 | height: 100%; 50 | box-sizing: border-box; 51 | border: 1px solid white; 52 | display: flex; 53 | align-items: center; 54 | justify-content: center; 55 | color: @highLightColor; 56 | font-size: 1.6rem; 57 | } 58 | } 59 | 60 | > .heat { 61 | position: absolute; 62 | z-index: @baseIndex; 63 | top: @mainContentPadding; 64 | right: @mainContentPadding; 65 | :global(.iconfont) { 66 | color: white !important; 67 | font-size: @mainContentFontSize !important; 68 | } 69 | > * { 70 | + * { 71 | margin-left: 5px; 72 | } 73 | } 74 | } 75 | > .playing { 76 | position: absolute; 77 | z-index: @baseIndex + 1; 78 | left: 0; 79 | bottom: @mainContentPadding; 80 | width: 100%; 81 | padding: 0 @mainContentPadding; 82 | display: flex; 83 | align-items: center; 84 | > * { 85 | & + * { 86 | margin-left: 5px; 87 | } 88 | } 89 | .text { 90 | .textOverflow(); 91 | min-width: 0; 92 | } 93 | } 94 | > .playIcon { 95 | position: absolute; 96 | z-index: @baseIndex; 97 | top: 50%; 98 | left: 50%; 99 | transform: translate(-50%, -50%); 100 | :global(.iconfont) { 101 | color: white !important; 102 | transform: scale(2.5); 103 | } 104 | } 105 | } 106 | > .title { 107 | width: 100%; 108 | line-height: 2em; 109 | margin-top: .5rem; 110 | text-align: center; 111 | .textOverflow(); 112 | } 113 | 114 | } -------------------------------------------------------------------------------- /backend/lib/tools.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | 4 | interface GetConfigOptions { 5 | basePath?: string; //绝对路径 6 | filename: string; // 配置文件名 7 | dir: string[] | string; // 搜索目录 8 | silent?: boolean; // 是否打印错误 9 | } 10 | export function getConfig (options: GetConfigOptions) { 11 | const {filename, dir, basePath = process.cwd(), silent = false} = options 12 | const fileInfo = path.parse(filename) 13 | const fileExt = fileInfo.ext 14 | const fileNameToMatched = [filename, `${fileInfo.name}.default${fileExt}`] // [a.js, a.default.js] 15 | let defaultConfig: Partial = null, config: Partial = null 16 | 17 | if (!['.js', '.json'].includes(fileExt)) { 18 | throw new Error(`invalid ext:${fileExt}`) 19 | } 20 | const readFile = (dirPath: string, filename: string) => { 21 | try { 22 | let exported: T = null, isDefault = filename.endsWith(`default${fileExt}`) 23 | if (fileExt === '.js') { 24 | const fromDir = __dirname 25 | const toDir = path.isAbsolute(dirPath) ? dirPath : path.resolve(basePath, dirPath) 26 | const readFilePath = path.join(path.relative(fromDir, toDir), filename) 27 | exported = require(readFilePath) 28 | } else if (fileExt === '.json') { 29 | const readFilePath = path.isAbsolute(dirPath) ? path.join(dirPath, filename) : path.resolve(basePath, dirPath, filename) 30 | const jsonStr = fs.readFileSync(readFilePath, { 31 | encoding: 'utf-8' 32 | }) 33 | exported = JSON.parse(jsonStr) 34 | } else { 35 | throw new Error(`invalid ext:${fileExt}`) 36 | } 37 | if (isDefault) { 38 | (!defaultConfig || Array.isArray(exported)) ? (defaultConfig = exported) : Object.assign(defaultConfig, exported) 39 | } else { 40 | (!config || Array.isArray(exported)) ? (config = exported) : Object.assign(config, exported) 41 | } 42 | } catch (e) { 43 | if (!silent) { 44 | console.error(e) 45 | } 46 | } 47 | 48 | } 49 | 50 | const readFileFromDir = (dirname) => { 51 | fileNameToMatched.forEach(readFile.bind(null, dirname)) 52 | } 53 | 54 | const dirArr = Array.isArray(dir) ? dir : [dir] 55 | dirArr.forEach(readFileFromDir) 56 | return Array.isArray(defaultConfig || config) ? (config || defaultConfig) : { 57 | ...defaultConfig, 58 | ...config, 59 | } 60 | } 61 | 62 | export function fillVaraibleToFile (options: { 63 | filePath: string; 64 | exportTo: string; 65 | vars: { 66 | [variableName: string]: string; 67 | } 68 | }) { 69 | const {filePath, exportTo, vars} = options 70 | const fileStr = fs.readFileSync(filePath, { 71 | encoding: 'utf-8' 72 | }) 73 | let isMatched = false 74 | const filledFileStr = fileStr.replace(/{{!(\w+)}}/g, (matched, varName) => { 75 | isMatched = true 76 | return vars[varName] 77 | }) 78 | 79 | const exportToFileStr = fs.existsSync(exportTo) ? fs.readFileSync(exportTo, { 80 | encoding: 'utf-8' 81 | }) : '' 82 | if (isMatched && filledFileStr !== exportToFileStr) { 83 | fs.writeFileSync(exportTo, filledFileStr) 84 | } 85 | } 86 | 87 | -------------------------------------------------------------------------------- /frontend/.umirc.ts: -------------------------------------------------------------------------------- 1 | import { IConfig } from '@umijs/types'; 2 | import path from 'path' 3 | 4 | const configInject = { 5 | outPutPath: process.env.OUTPUT_PATH || './dist', 6 | isProductionMode: process.env.NODE_ENV === 'production', 7 | loadSettingsFromServer: process.env.ASYNC_SETTINGS === '1', 8 | } 9 | 10 | function getExternals () { 11 | const obj: IConfig['externals'] = { 12 | 'nprogress': 'NProgress', 13 | } 14 | if (configInject.isProductionMode) { 15 | Object.assign(obj, { 16 | 'react': 'React', 17 | 'react-dom': 'ReactDOM', 18 | }) 19 | } 20 | if (configInject.loadSettingsFromServer) { 21 | Reflect.set(obj, 'config/settings', 'clientSettings') 22 | } 23 | return obj 24 | } 25 | 26 | // ref: https://umijs.org/config/ 27 | const config: IConfig = { 28 | define: { 29 | loadSettingsFromServer: configInject.loadSettingsFromServer, 30 | }, 31 | nodeModulesTransform: { 32 | type: 'none', 33 | exclude: [], 34 | }, 35 | title: false, 36 | externals: getExternals(), 37 | outputPath: configInject.outPutPath, 38 | scripts: configInject.isProductionMode ? [ 39 | '//cdn.jsdelivr.net/npm/react@16.12.0/umd/react.production.min.js', 40 | '//cdn.jsdelivr.net/npm/react-dom@16.13.1/umd/react-dom.production.min.js', 41 | ] : [], 42 | routes: [ 43 | { 44 | path: '/', 45 | component: '../layouts/index', 46 | routes: [ 47 | { path: '/', component: '../pages/index/basic.tsx', exact: true }, 48 | { path: '/:roomToken', component: '../pages/index/basic.tsx', exact: true }, 49 | ] 50 | } 51 | ], 52 | antd: false, 53 | hash: true, 54 | runtimePublicPath: true, 55 | dva: { 56 | immer: false, 57 | hmr: true, 58 | }, 59 | dynamicImport: { 60 | loading: '@/components/pageLoading' 61 | }, 62 | chainWebpack: (config, webpack) => { 63 | config.resolve.alias.set('config', path.join(__dirname, './config')) 64 | config.resolve.alias.set('@', path.join(__dirname, './src')) 65 | config.resolve.alias.set('@global', path.join(__dirname, '../')) 66 | config.module.rule('common').test((validatePath) => { 67 | return validatePath.includes(path.resolve(__dirname, '../common/')) 68 | }). 69 | exclude.add(__dirname).end(). 70 | use('ts').loader('ts-loader').options({ 71 | configFile: path.join(__dirname, 'tsconfig.json') 72 | }) 73 | }, 74 | theme: { 75 | }, 76 | extraBabelPlugins: [ 77 | [ 78 | 'import', 79 | { 80 | 'libraryName': '@material-ui/core', 81 | 'libraryDirectory': 'esm', 82 | 'camel2DashComponentName': false 83 | }, 84 | 'core' 85 | ], 86 | [ 87 | 'import', 88 | { 89 | 'libraryName': '@material-ui/lab', 90 | 'libraryDirectory': 'esm', 91 | 'camel2DashComponentName': false 92 | }, 93 | 'lab' 94 | ], 95 | [ 96 | 'import', 97 | { 98 | 'libraryName': '@material-ui/icons', 99 | 'libraryDirectory': 'esm', 100 | 'camel2DashComponentName': false 101 | }, 102 | 'icon' 103 | ], 104 | ] 105 | } 106 | 107 | if (configInject.isProductionMode && Array.isArray(config.extraBabelPlugins)) { 108 | config.extraBabelPlugins.push([ 109 | 'transform-remove-console', 110 | { "exclude": ["error", "warn"] } 111 | ]) 112 | } 113 | 114 | 115 | export default config; 116 | -------------------------------------------------------------------------------- /backend/test/handler.ts: -------------------------------------------------------------------------------- 1 | import {RoomRoutineLoopTasks, UtilFuncs, DestroyRoom} from 'root/lib/handler' 2 | import * as CronTask from 'root/lib/taskCron' 3 | import {Room} from 'root/lib/models' 4 | import redisCli from 'root/lib/redis'; 5 | import {CronTaskTypes} from 'root/type' 6 | 7 | function wait (time: number) { 8 | return new Promise((resolve) => { 9 | setTimeout(resolve, time) 10 | }) 11 | } 12 | 13 | beforeAll(() => { 14 | return redisCli.select(1) 15 | }) 16 | 17 | afterAll(() => { 18 | return redisCli.flushdb() 19 | }) 20 | 21 | beforeEach(() => { 22 | return redisCli.flushdb() 23 | }) 24 | 25 | 26 | describe('room routine task', () => { 27 | it('start',async () => { 28 | const room = await new Room({}).save() 29 | for (let taskType of Object.values(RoomRoutineLoopTasks.TaskTypes)) { 30 | const cb = jest.fn() 31 | CronTask.listen(CronTaskTypes.roomRoutineTask, cb) 32 | await RoomRoutineLoopTasks.startRoomTask({ 33 | taskType: taskType as any, 34 | period: 0.01, 35 | room, 36 | }) 37 | await wait(100) 38 | expect(cb).toBeCalled() 39 | } 40 | }, 10000) 41 | 42 | it('stop', async () => { 43 | const room = await new Room({}).save() 44 | for (let taskType of Object.values(RoomRoutineLoopTasks.TaskTypes)) { 45 | const cb = jest.fn() 46 | CronTask.listen(CronTaskTypes.roomRoutineTask, cb) 47 | await RoomRoutineLoopTasks.startRoomTask({ 48 | taskType: taskType as any, 49 | period: 0.1, 50 | room, 51 | }) 52 | await wait(50) 53 | await RoomRoutineLoopTasks.stopRoomTask(room, taskType as any) 54 | await wait(100) 55 | expect(cb).not.toBeCalled() 56 | } 57 | }) 58 | }) 59 | 60 | describe('destroyRoomTask', () => { 61 | it('set destroy room task', async () => { 62 | const room = await new Room({}).save() 63 | await DestroyRoom.destroy(room, 2) 64 | await wait(3000) 65 | expect(Room.findOne(room.id)).resolves.toBe(null) 66 | }) 67 | 68 | it('cancel destroy room task', async () => { 69 | const room = await new Room({}).save() 70 | await DestroyRoom.destroy(room, 2) 71 | await wait(1000) 72 | await DestroyRoom.cancelDestroy(room) 73 | await wait(2000) 74 | expect(Room.findOne(room.id)).resolves.not.toBe(null) 75 | }) 76 | }) 77 | 78 | describe('UtilFuncs', () => { 79 | it('recordFuctionArguments', async () => { 80 | const funKey = Date.now().toString() 81 | const isChanged = await UtilFuncs.recordFuctionArguments(funKey, { 82 | taskType: 'ok', 83 | period: 1, 84 | extraData: null, 85 | }) 86 | expect(isChanged === true).toBe(true) 87 | const isChanged2 = await UtilFuncs.recordFuctionArguments(funKey, { 88 | taskType: 'ok', 89 | period: 1, 90 | extraData: null, 91 | }) 92 | expect(isChanged2 === false).toBe(true) 93 | const isChanged3 = await UtilFuncs.recordFuctionArguments(funKey, { 94 | taskType: 'ok', 95 | period: 10, 96 | extraData: null, 97 | }) 98 | expect(isChanged3 === true).toBe(true) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /common/enums.ts: -------------------------------------------------------------------------------- 1 | export enum ClientListenSocketEvents { 2 | disconnect = 'disconnect', 3 | reconnecting = 'reconnecting', 4 | recieveNowPlayingInfo = 'recieveNowPlayingInfo', // 更新当前播放的音乐信息 5 | addChatListMessages = 'addChatListMessages', // 接受消息 6 | withdrawlChatListMessage = 'withdrawlChatListMessage', // 撤回消息 7 | addPlayListItems = 'addPlayListItems', // 播放列表添加音乐 8 | movePlayListItem = 'movePlayListItem', // 播放列表删除音乐 9 | deletePlayListItems = 'deletePlayListItems',// 删除播放列表中的音乐 10 | updateRoomInfo = 'updateRoomInfo', // 客户端更新当前房间的基础信息 11 | updateUserInfo = 'updateUserInfo', // 客户端更新当前用户信息 12 | updateSocketStatus = 'updateSocketStatus', // 客户端更新socket连接状态 13 | updateNowRoomBaseInfo = 'updateNowRoomBaseInfo', // 客户端更新当前房间信息 14 | updateRoomCutMusicVoteInfo = 'updateCutMusicVoteInfo', // 更新切歌投票信息 15 | addAdminActions = 'addAdminActions', // 更新管理操作记录列表 16 | deleteAdminActions = 'deleteAdminActions', // 删除管理操作记录列表 17 | updateOnlineUsers = 'updateOnlineUsers', // 更新在线用户列表信息 18 | notification = 'notification', // 客户端接受通知 19 | } 20 | 21 | export enum ServerListenSocketEvents { 22 | disconnect = 'disconnect', 23 | sendMessage = 'sendMessage', // 发送消息 24 | withdrawlMessage = 'withdrawlMessage', // 撤回消息(admin) 25 | pausePlaying = 'pausePlaying', // 暂停播放(admin) 26 | startPlaying = 'startPlaying', // 开始播放(admin) 27 | changeProgress = 'changeProgress', // 调整播放进度(admin) 28 | cutMusic = 'cutMusic', // 切歌 (admin) 29 | switchPlayMode = 'switchPlayMode', // 切换播放模式 (admin) 30 | voteToCutMusic = 'voteToCutMusic', // 发起切歌投票 31 | addPlayListItems = 'addPlayListItems', // 添加音乐到播放列表 32 | movePlayListItem = 'movePlayListItem', // 移动播放列表中的音乐 (admin) 33 | deletePlayListItems = 'deletePlayListItems', // 删除播放列表中的音乐 (admin) 34 | blockPlayListItems = 'blockPlayListItems', // 个人账号屏蔽音乐(屏蔽之后 轮播到该音乐时 会自动静音) 35 | unblockPlayListItems = 'unblockPlayListItems', // 个人账号取消屏蔽音乐 36 | setNickName = 'setNickName', // 设置昵称 37 | searchMedia = 'searchMedia', // 搜索音乐,专辑 38 | getMediaDetail = 'getMediaDetail', // 获取 专辑详情 39 | banUserComment = 'banUserComment', // 禁言 (admin) 40 | blockUser = 'blockUser', // 封禁用户 (admin) 41 | blockUserIp = 'blockUserIp', // 封禁ip (admin) 42 | revokeAction = 'revokeAction', // 管理员撤回操作 (admin) 43 | createRoom = 'createRoom', // 创建房间 44 | destroyRoom = 'destroyRoom', // 销毁房间 (admin) 45 | joinRoom = 'joinRoom', // 加入房间 46 | quitRoom = 'quitRoom', // 退出房间 47 | getRoomData = 'getRoomData', // 获取房间数据, 用于初始化 48 | recommendRoom = 'recommendRoom', // 获取 推荐房间列表 49 | getEmojiList = 'getEmojiList', // 获取表情包列表 50 | getRoomCoordHotData = 'getRoomCoordHotData', // 获取可视化地图坐标热点数据 51 | getOnlineUserList = 'getOnlineUserList', // 获取房间在线用户 (admin) 52 | getRoomAdminActionList = 'getRoomAdminActionList', // 获取房间管理员操作记录列表 (admin) 53 | manageRoomAdmin = 'manageRoomAdmin', // 撤销、设置房间管理员 54 | cutUserStatus = 'cutUserStatus', //切换 超级管理员角色身份(普通用户视角 <-> 超级管理员) 55 | } 56 | 57 | export enum ScoketStatus { 58 | closed, // 连接关闭 59 | waitting, // 等待连接建立中 60 | invalid, // 认证未通过 61 | roomDestroy, // 房间被销毁 62 | roomBlocked, // 被该房间屏蔽 63 | globalBlocked, // 被全站屏蔽 64 | connected, // 连接已建立 65 | reconnecting, // 断线重连中, 66 | } 67 | 68 | 69 | export enum NowPlayingStatus { 70 | preloading = 'preloading', 71 | playing = 'playing', 72 | paused = 'paused', 73 | } 74 | 75 | export enum RoomMusicPlayMode { 76 | demand = 1, // 点歌播放 77 | auto, // 自动随机播放 78 | } 79 | 80 | export interface RoomPlayModeInfo { 81 | mode: RoomMusicPlayMode; 82 | autoPlayType?: string; 83 | } -------------------------------------------------------------------------------- /frontend/src/pages/document.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <% if (context.config.define.loadSettingsFromServer) { %> 9 | {{!WEBSITE_TITLE}} 10 | <% } %> 11 | 12 | 13 | 14 | 21 | 22 | 23 | 32 | 34 | 36 | 37 | 48 | 95 |
96 | 97 | 98 | -------------------------------------------------------------------------------- /frontend/src/pages/index/handleSelectMessage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from 'react'; 2 | import { useMediaQuery } from 'react-responsive' 3 | import bindClass from 'classnames' 4 | import { connect } from 'dva' 5 | import { Dialog, DialogTitle, List, ListItem, ListItemText } from '@material-ui/core' 6 | 7 | import { ConnectProps, ConnectState, ChatListModelState } from '@/models/connect' 8 | import styles from './style.less' 9 | 10 | interface Props extends ConnectProps { 11 | userId: string; 12 | isRoomAdmin: boolean; 13 | roomId: string; 14 | selectedMessage: ChatListModelState['selectedMessageItem']; 15 | } 16 | 17 | enum ActionTypes { blockAccount, blockIp, ban, atSign } 18 | 19 | const actionTypeMap = { 20 | [ActionTypes.blockAccount]: 'center/blockUser', 21 | [ActionTypes.blockIp]: 'center/blockIP', 22 | [ActionTypes.ban]: 'center/banUserComment', 23 | } 24 | 25 | const HandleSelectMessage: React.FC = React.memo((props) => { 26 | const { selectedMessage, roomId, isRoomAdmin, userId } = props 27 | const blockUser = (type: ActionTypes) => { 28 | clearSelectedMessageItem() 29 | props.dispatch({ 30 | type: actionTypeMap[type], 31 | payload: { 32 | roomId, 33 | userId: selectedMessage.fromId, 34 | } 35 | }) 36 | } 37 | 38 | const clearSelectedMessageItem = () => { 39 | props.dispatch({ 40 | type: 'chatList/clearSelectedMessageItem', 41 | payload: { 42 | } 43 | }) 44 | } 45 | 46 | const handleAtSignAction = () => { 47 | clearSelectedMessageItem() 48 | props.dispatch({ 49 | type: 'chatList/handleAtSignAction', 50 | payload: { 51 | atSignToUserId: selectedMessage.fromId, 52 | atSignToUserName: selectedMessage.from, 53 | } 54 | }) 55 | } 56 | 57 | const withdrawlMessage = () => { 58 | clearSelectedMessageItem() 59 | props.dispatch({ 60 | type: 'chatList/withdrawlMessageItem', 61 | payload: { 62 | messageId: selectedMessage.id, 63 | content: selectedMessage.content.text, 64 | fromId: selectedMessage.fromId, 65 | roomId, 66 | } 67 | }) 68 | } 69 | 70 | return 71 | 对该发言用户:{selectedMessage && selectedMessage.from} 72 | 73 | { 74 | isRoomAdmin && 75 | 76 | blockUser(ActionTypes.ban)}> 77 | 78 | 79 | blockUser(ActionTypes.blockAccount)}> 80 | 81 | 82 | blockUser(ActionTypes.blockIp)}> 83 | 84 | 85 | 86 | } 87 | { 88 | isRoomAdmin && 89 | 90 | 91 | 92 | } 93 | handleAtSignAction()}> 94 | 95 | 96 | 97 | 98 | }) 99 | 100 | export default connect(({ chatList, center: {nowRoomInfo, userInfo, isRoomAdmin} }: ConnectState) => { 101 | return { 102 | userId: userInfo.id, 103 | isRoomAdmin, 104 | roomId: nowRoomInfo && nowRoomInfo.id, 105 | selectedMessage: chatList.selectedMessageItem 106 | } 107 | })(HandleSelectMessage) 108 | -------------------------------------------------------------------------------- /frontend/src/components/hashRouter/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {useMemo, useEffect, useState, useContext} from 'react' 2 | 3 | import {joinPath} from '@/utils' 4 | const RouterCotext = React.createContext({ 5 | url: '', 6 | base: '', 7 | } as { 8 | base: string; 9 | url: string; 10 | }) 11 | 12 | interface RouterProps { 13 | basePath?: string; 14 | children?: React.ReactNode 15 | } 16 | 17 | export const HashRouter: React.FC = props => { 18 | const [hashPath, setHashPath] = useState(location.hash.replace('#', '') || '/') 19 | const providerValue = useMemo(() => { 20 | return { 21 | base: props.basePath || '', 22 | url: hashPath 23 | } 24 | }, [hashPath, props.basePath]) 25 | 26 | useEffect(() => { 27 | const handler = () => { 28 | setHashPath(location.hash.replace('#', '')) 29 | } 30 | window.addEventListener('hashchange', handler) 31 | return () => { 32 | window.removeEventListener('hashchange', handler) 33 | } 34 | }, []) 35 | 36 | return 37 | {props.children} 38 | 39 | } 40 | 41 | export const hashRouter = { 42 | go () { 43 | history.go() 44 | }, 45 | back () { 46 | history.back() 47 | }, 48 | push (hash = '') { 49 | location.hash = hash 50 | }, 51 | replace (hash) { 52 | location.replace(`${location.origin}/#${hash}`) 53 | } 54 | } 55 | 56 | export const useHashRouteStatus = function () { 57 | const {base = '', url = '/'} = useContext(RouterCotext) 58 | return joinPath(base, url) 59 | } 60 | 61 | type childrenFunc = (appendClassName?: string, pathname?: string) => React.ReactNode 62 | 63 | interface RouteProps { 64 | path: string; 65 | exact?: boolean; 66 | children?: React.ReactElement | childrenFunc; 67 | startAniamtiojn?: string; // 动画classname 68 | endAnimation?: string; // 动画classname 69 | animationDuration?: number; // 动画时间 单位s 70 | } 71 | 72 | const normalizePath = (path: string) => { 73 | return path.toLowerCase().replace(/.+\/$/, '') 74 | } 75 | 76 | enum RouteStatus {notMatch, matched, delayDestory} 77 | 78 | const HashRoute: React.FC = (props) => { 79 | const {path = '/', exact = false, children, animationDuration = 0.5} = props 80 | const {base = '', url = '/'} = useContext(RouterCotext) 81 | const [nowStatus, setStatus] = useState(RouteStatus.notMatch) 82 | 83 | const fullPath = useMemo(() => { 84 | return normalizePath(joinPath(base, path)) 85 | }, [base, path]) 86 | const normalizedUrl = useMemo(() => { 87 | return normalizePath(url) 88 | }, [url]) 89 | const isMatch = exact ? fullPath === normalizedUrl : normalizedUrl.startsWith(fullPath) 90 | 91 | useEffect(() => { 92 | let status = nowStatus, timer = null 93 | if (isMatch) { 94 | status = RouteStatus.matched 95 | } 96 | if (!isMatch && nowStatus !== RouteStatus.notMatch) { 97 | status = RouteStatus.notMatch 98 | if (!!props.endAnimation) { 99 | status = RouteStatus.delayDestory 100 | timer = setTimeout(() => { 101 | setStatus(RouteStatus.notMatch) 102 | }, animationDuration * 1000) 103 | } 104 | } 105 | setStatus(status) 106 | return () => { 107 | if (timer) { 108 | clearTimeout(timer) 109 | } 110 | } 111 | }, [isMatch]) 112 | 113 | const isShow = nowStatus > RouteStatus.notMatch 114 | const appendClassName = isShow && (nowStatus === RouteStatus.matched ? props.startAniamtiojn : props.endAnimation) 115 | return isShow ? ( 116 | (typeof children === 'function' ? children(appendClassName, normalizedUrl) : children) as React.ReactElement 117 | ) : null 118 | } 119 | 120 | export default HashRoute 121 | -------------------------------------------------------------------------------- /frontend/src/components/roomName/index.tsx: -------------------------------------------------------------------------------- 1 | import bindClass from 'classnames' 2 | import { useMediaQuery } from 'react-responsive' 3 | import React, { useEffect, useState, useRef } from 'react' 4 | import { connect } from 'dva'; 5 | 6 | import styles from './style.less' 7 | import { ConnectState, CenterModelState } from '@/models/connect'; 8 | import configs from 'config/base.conf'; 9 | import SignalIcon from '@/components/signalIcon'; 10 | import styleConf from 'config/baseStyle.conf'; 11 | 12 | interface Props { 13 | nowRoomInfo: CenterModelState['nowRoomInfo']; 14 | } 15 | 16 | enum Status { 17 | initial, 18 | overflow, 19 | notOverlofw, 20 | } 21 | 22 | const calcNameShowDuration = (contentWidth: number) => { 23 | return (contentWidth / 14) * 0.6 || 6 24 | } 25 | 26 | const RoomInfoShow = React.memo((props) => { 27 | const { nowRoomInfo } = props 28 | const roomName = nowRoomInfo ? nowRoomInfo.name : '' 29 | const containerRef = useRef(null) 30 | const [status, setStatus] = useState(null) 31 | const [units, setUnits] = useState([] as string[]) 32 | const nameShowDurationRef = useRef(6) 33 | const nameShowDuration = nameShowDurationRef.current 34 | const isMobile = useMediaQuery({ query: configs.mobileMediaQuery }) 35 | 36 | useEffect(() => { 37 | setStatus(Status.initial) 38 | }, [roomName]) 39 | 40 | useEffect(() => { 41 | if (status === Status.initial) { 42 | requestAnimationFrame(() => { 43 | const node = containerRef.current 44 | if (node) { 45 | const scrollWidth = node.scrollWidth 46 | const clientWidth = node.clientWidth 47 | const status = scrollWidth > clientWidth ? Status.overflow : Status.notOverlofw 48 | setStatus(status) 49 | if (status === Status.overflow) { 50 | const duration = calcNameShowDuration(scrollWidth) 51 | nameShowDurationRef.current = duration 52 | } 53 | } 54 | }) 55 | } 56 | if (status === Status.overflow) { 57 | setUnits(['1', '2']) 58 | } 59 | }, [status]) 60 | 61 | const signalIconSize = isMobile ? 16 : 20 62 | const signalIconMargin = '1rem' 63 | return
64 |
65 | 66 |
67 | { 68 | status === Status.initial && roomName 69 | } 70 | { 71 | status === Status.notOverlofw &&
{roomName}
} 72 | { 73 | status === Status.overflow && 74 | { 75 | units.map((key, index) => {roomName})} 82 | 83 | } 84 |
85 |
86 | { 87 | isMobile && nowRoomInfo &&
90 | 91 | {[nowRoomInfo.heat, nowRoomInfo.max].filter(v => v >= 0).join('/')}人在线 92 | 93 |
} 94 |
95 | }) 96 | 97 | export default connect(({ center: { nowRoomInfo } }: ConnectState) => { 98 | return { 99 | nowRoomInfo, 100 | } 101 | })(RoomInfoShow) 102 | -------------------------------------------------------------------------------- /backend/prepare/uploadImg.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | const cheerio = require('cheerio') 3 | const fs = require('fs') 4 | const formData = require('form-data') 5 | const tunnel = require('tunnel') 6 | 7 | const pageCount = 21 8 | const baseUrl = 'https://fabiaoqing.com/biaoqing/lists/page/' 9 | 10 | const successFileName = 'success.json' 11 | const originImgFileName = 'originImgs.json' 12 | 13 | const uploadImgs = async (imgs = []) => { 14 | console.log('start upload imgs') 15 | let preData 16 | try { 17 | preData = fs.readFileSync(successFileName) 18 | } catch (e) { 19 | } 20 | const successImgs = preData ? JSON.parse(preData) : [] 21 | const successUrlSet = successImgs.reduce((set, item) => { 22 | set.add(item.originUrl) 23 | return set 24 | }, new Set()) 25 | for (let obj of imgs) { 26 | try { 27 | if (successUrlSet.has(obj.originUrl)) { 28 | continue 29 | } 30 | console.log('upload img:' + obj.originUrl) 31 | const fileName = obj.originUrl.trim().split('/').pop() 32 | const res = await got(obj.originUrl, { 33 | encoding: null 34 | }) 35 | console.log('load img success') 36 | const form = formData() 37 | form.append('smfile', res.body, fileName) 38 | form.append('file_id', fileName) 39 | const uploadRes = await got.post('https://sm.ms/api/upload', { 40 | body: form, 41 | // agent: tunnel.httpOverHttp({ 42 | // proxy: { 43 | // host: 'localhost', 44 | // port: 2020, 45 | // } 46 | // }), 47 | headers: { 48 | // 'Authorization': '' 49 | } 50 | }) 51 | const { success, message, data } = JSON.parse(uploadRes.body) 52 | if (!success) { 53 | throw new Error(message) 54 | } 55 | // upload 56 | successImgs.push({ 57 | ...obj, 58 | url: data.url, 59 | deleteUrl: data.delete 60 | }) 61 | fs.writeFileSync(successFileName, JSON.stringify(successImgs)) 62 | } catch (e) { 63 | console.log('upload error \n') 64 | console.error(e) 65 | } 66 | } 67 | // fs.writeFileSync(successFileName, successImgs) 68 | console.log('upload end') 69 | } 70 | 71 | async function main() { 72 | let imgArr = [] 73 | let preData 74 | try { 75 | preData = fs.readFileSync(originImgFileName) 76 | } catch (e) { 77 | } 78 | imgArr = preData ? JSON.parse(preData) : [] 79 | console.log('start crawl imgs`') 80 | if (!imgArr.length) { 81 | for (let i = 11; i <= pageCount; i++) { 82 | const aimUrl = baseUrl + pageCount 83 | const res = await got(aimUrl) 84 | const $ = cheerio.load(res.body) 85 | const imgs = $('img.image').map((i, ele) => { 86 | return { 87 | title: ele.attribs['title'], 88 | originUrl: ele.attribs['data-original'], 89 | } 90 | }).get() 91 | imgArr = imgArr.concat(imgs) 92 | } 93 | fs.writeFileSync(originImgFileName, JSON.stringify(imgArr)) 94 | } 95 | console.log('crawl img end') 96 | uploadImgs(imgArr) 97 | } 98 | 99 | main() 100 | 101 | async function test() { 102 | const res = await got('http://wx2.sinaimg.cn/large/0068Lfdely1g667lmi7njj30af0aegmz.jpg', { 103 | encoding: null 104 | }) 105 | const form = formData() 106 | form.append('smfile', res.body, '005Me9Ycgy1g7f7b97zwwj305i05i3yv.jpg') 107 | form.append('file_id', '2342323') 108 | const uploadRes = await got.post('https://sm.ms/api/upload', { 109 | body: form, 110 | }) 111 | const { success, message, data } = JSON.parse(uploadRes.body) 112 | if (!success) { 113 | console.log(message) 114 | throw new Error(message) 115 | } 116 | } 117 | 118 | // test() -------------------------------------------------------------------------------- /frontend/src/components/player/lyric.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from 'react' 2 | import bindClass from 'classnames' 3 | 4 | import styles from './index.less' 5 | 6 | interface LyricBoxProps { 7 | id: string, 8 | lyric: string, 9 | nowTime: number, 10 | showItemCount: number 11 | } 12 | 13 | enum LyricItemTypes { 14 | other, 15 | content 16 | } 17 | 18 | interface LyricItem { 19 | time: number, 20 | type: LyricItemTypes, 21 | content: string, 22 | lineHeight?: number, 23 | } 24 | 25 | const itemHeight = '2em' 26 | 27 | const calcOffset = (cursorIndex: number, items: LyricItem[]) => { 28 | let offset = 0 29 | items.some((item, index) => { 30 | if (index < cursorIndex) { 31 | offset += item.lineHeight 32 | return false 33 | } 34 | return true 35 | }) 36 | return offset 37 | } 38 | 39 | const LyricBox: React.FC = React.memo(function (props) { 40 | const boxRef = useRef(null) 41 | const [focusItemIndex, setFocusItemIndex] = useState(-2 as number) 42 | const [freshLyricItems, setLyricItems] = useState([] as LyricItem[]) 43 | const [needCalcItemsLineHeight, setNeedCalcItemLineHeight] = useState(false) 44 | 45 | useEffect(() => { 46 | const { lyric = '' } = props 47 | const items: LyricItem[] = lyric.split('\n').map(s => { 48 | const parsed = /^\[(.*)\](.*)$/.exec(s) 49 | if (!parsed) { 50 | return 51 | } 52 | const [, pre = '', content = ''] = parsed 53 | // const isValidLabel = ['al', 'ti', 'ar'].some(type => label.startsWith(type)) TODO 支持歌曲标签信息 54 | if (!content.trim()) { 55 | return 56 | } 57 | const timeUnitArr = pre.split(':').filter(i => !!i) 58 | const time = timeUnitArr.reduce((time, v, i) => { 59 | const scale = [60, 1, 0.01][i] 60 | return time + Number(v) * scale 61 | }, 0) 62 | return { 63 | type: LyricItemTypes.content, 64 | time, 65 | content, 66 | } 67 | }).filter(i => !!i) 68 | setLyricItems(items) 69 | setNeedCalcItemLineHeight(true) 70 | }, [props.id, props.lyric]) 71 | 72 | useEffect(() => { 73 | if (boxRef.current && needCalcItemsLineHeight) { 74 | const nodeList = boxRef.current.querySelectorAll(`.${styles.item}`) 75 | nodeList.forEach((node, index) => { 76 | const rect = node.getBoundingClientRect() 77 | const lyricItem = freshLyricItems[index] 78 | const eleHeight = rect.height || rect.bottom - rect.top 79 | lyricItem && (lyricItem.lineHeight = eleHeight) 80 | }) 81 | setNeedCalcItemLineHeight(false) 82 | } 83 | }, [needCalcItemsLineHeight]) 84 | 85 | useEffect(() => { 86 | const nowFocusItemIndex = freshLyricItems.findIndex((item, index) => { 87 | if (props.nowTime + 0.5 > item.time) { 88 | const nextItem = freshLyricItems[index + 1] 89 | if (!nextItem || (props.nowTime + 0.5) < nextItem.time) { 90 | return true 91 | } 92 | } 93 | return false 94 | }) 95 | setFocusItemIndex(nowFocusItemIndex) 96 | }, [freshLyricItems, props.nowTime]) 97 | 98 | const lyricOffsetValue = freshLyricItems.length && calcOffset(focusItemIndex, freshLyricItems) 99 | return
103 | { 104 | freshLyricItems.length ? 105 |
106 | { 107 | freshLyricItems.map((item, index) =>
108 | {item.content} 109 |
)} 110 |
: 111 |
112 | 暂无字幕 113 |
114 | } 115 | 116 |
117 | }) 118 | 119 | export default LyricBox 120 | -------------------------------------------------------------------------------- /backend/lib/taskCron.ts: -------------------------------------------------------------------------------- 1 | import uuidV4 from 'uuid/v4' 2 | import Cron from 'cron' 3 | 4 | import {CronTaskTypes} from 'root/type' 5 | import redisCli from 'root/lib/redis' 6 | 7 | const getTaskSetRedisKey = () => { 8 | return 'musicradio:cronTask' 9 | } 10 | 11 | const jobIdToObjMap = new Map() 12 | 13 | interface TaskInfo { 14 | taskId: string; 15 | taskType: CronTaskTypes; 16 | expireAt: number; 17 | data: T; 18 | } 19 | 20 | namespace expiredCronTaskQueue { 21 | const dataQueneMap: Map> = new Map() 22 | const cbMap: Map = new Map() 23 | 24 | function pushToDataQueneMap (type: CronTaskTypes, data: TaskInfo['data']) { 25 | let taskDataSet = dataQueneMap.get(type) 26 | if (!taskDataSet) { 27 | taskDataSet = new Set() 28 | dataQueneMap.set(type, taskDataSet) 29 | } 30 | taskDataSet.add(data) 31 | return taskDataSet 32 | } 33 | 34 | export type cb = (data: TaskInfo['data']) => any 35 | export function publish (type: CronTaskTypes, info: TaskInfo) { 36 | const cb = cbMap.get(type) 37 | if (!cb) { 38 | pushToDataQueneMap(type, info.data) 39 | } 40 | cb(info.data) 41 | } 42 | 43 | export function subscribe (type: CronTaskTypes, cb: cb) { 44 | cbMap.set(type, cb) 45 | const waittingTasks = (dataQueneMap.get(type) || new Set()) 46 | waittingTasks.forEach(taskData => { 47 | cb(taskData) 48 | waittingTasks.delete(taskData) 49 | }) 50 | } 51 | 52 | export function unsubscribe(type: CronTaskTypes) { 53 | cbMap.delete(type) 54 | } 55 | 56 | } 57 | 58 | async function stopCronJob (taskId: string) { 59 | const findJob = jobIdToObjMap.get(taskId) 60 | if (findJob) { 61 | findJob.stop() 62 | } 63 | jobIdToObjMap.delete(taskId) 64 | await redisCli.hdel(getTaskSetRedisKey(), taskId) 65 | } 66 | 67 | function addCronJob(expireAt: number, taskInfo: TaskInfo) { 68 | const job = new Cron.CronJob(new Date(expireAt * 1000), async () => { 69 | const newlyInfo = JSON.parse(await redisCli.hget(getTaskSetRedisKey(), taskInfo.taskId)) 70 | if (newlyInfo) { 71 | console.log(`cron task: ${taskInfo.taskType}/${taskInfo.taskId} arrived`) 72 | expiredCronTaskQueue.publish(taskInfo.taskType, newlyInfo) 73 | } 74 | await stopCronJob(taskInfo.taskId) 75 | }, null, true) 76 | jobIdToObjMap.set(taskInfo.taskId, job) 77 | } 78 | 79 | export async function init () { 80 | let cursor = 0, exisetdTaskList: TaskInfo[] = [] 81 | do { 82 | const [nextCursor, resArr] = await redisCli.hscan(getTaskSetRedisKey(), cursor, 'count', 1000) 83 | cursor = Number(nextCursor) 84 | for (let i=0; i < resArr.length; i+=2) { 85 | const [field, value] = resArr.slice(i, i + 2) 86 | if (value) { 87 | const taskInfo: TaskInfo = JSON.parse(value) 88 | exisetdTaskList.push(taskInfo) 89 | } 90 | } 91 | } while (cursor !== 0 && !isNaN(cursor)) 92 | exisetdTaskList.forEach(async (taskInfo) => { 93 | if (!taskInfo) { 94 | return 95 | } 96 | if (taskInfo.expireAt * 1000 <= Date.now()) { 97 | expiredCronTaskQueue.publish(taskInfo.taskType, taskInfo) 98 | await redisCli.hdel(getTaskSetRedisKey(), taskInfo.taskId) 99 | return 100 | } 101 | addCronJob(taskInfo.expireAt, taskInfo) 102 | }) 103 | } 104 | 105 | export async function pushCronTask(taskType: CronTaskTypes, data: any, expire: number) { 106 | const taskId = uuidV4() 107 | const expireAt = (Date.now() / 1000) + expire 108 | const obj = { 109 | taskId, 110 | expireAt, 111 | data, 112 | taskType, 113 | } 114 | addCronJob(expireAt, obj) 115 | await redisCli.hset(getTaskSetRedisKey(), taskId, JSON.stringify(obj)) 116 | return taskId as string 117 | } 118 | 119 | export async function cancelCaronTask(taskId: string) { 120 | await stopCronJob(taskId) 121 | } 122 | 123 | export function listen(type: CronTaskTypes, cb: expiredCronTaskQueue.cb) { 124 | expiredCronTaskQueue.subscribe(type, cb) 125 | } 126 | 127 | export function offListen (type: CronTaskTypes) { 128 | expiredCronTaskQueue.unsubscribe(type) 129 | } 130 | -------------------------------------------------------------------------------- /frontend/config/type.conf.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | g_app: any; 4 | } 5 | } 6 | 7 | export enum MessageTypes { 8 | notice, // 系统通知 9 | advanced, // 高级弹幕, 房管,或超级管理员所发 10 | normal, // 普通消息 11 | emoji, // 表情消息 12 | notification, // 系统响应 13 | send, // 发送的消息(仅发送者本地可见) 14 | } 15 | 16 | export enum TipTypes { 17 | blockMusic, 18 | unBlockMusic, 19 | } 20 | 21 | export enum ChatListNoticeTypes { 22 | unread = 'unread', 23 | atSign = 'atSign', 24 | vote = 'vote', 25 | } 26 | 27 | export interface MessageItem { 28 | id: string; 29 | fromId?: string; 30 | from: string; 31 | tag?: string; // 发送者的头衔 32 | content: { 33 | text?: string; 34 | title?: string; 35 | img?: string; 36 | atSign?: { // @功能 37 | atSignToUserName: string; // @到的人的姓名 38 | atSignToUserId: string; // @到的人的id 39 | atSignPostion: number; // @符号在消息内容中的位置 40 | }[] 41 | }; 42 | time: string | number; 43 | type: MessageTypes; 44 | } 45 | 46 | 47 | export interface DanmuItem extends MessageItem { 48 | levelValue: number; 49 | offset: number; // 位置偏移量 如:0.34 === 34% 50 | } 51 | 52 | export interface PlayListItem { 53 | id: string; // 歌曲id 54 | name: string; // 歌名 55 | artist: string; // 演唱者 56 | album: string; // 专辑 57 | duration: number; // 时长 58 | from: string; // 点歌人 59 | } 60 | 61 | export interface RoomItem { 62 | pic: string; 63 | playing: string; 64 | title: string; 65 | heat: number; 66 | id: string; 67 | token: string; 68 | } 69 | 70 | export enum MediaTypes { 71 | song = 1, // 单曲 72 | album, // 专辑 73 | } 74 | 75 | export interface searchMediaItem { 76 | type: MediaTypes; 77 | title: string; 78 | desc: string; 79 | pic: string; 80 | id: string; 81 | } 82 | 83 | export interface EmojiItem { 84 | id: string; 85 | title: string; 86 | src: string; 87 | } 88 | 89 | export enum UserStatus { 90 | normal, // 普通用户 91 | superOfNormal, // 管理员普通用户视角 92 | superAdmin, //管理员 93 | } 94 | 95 | export interface UserInfo { 96 | id: string; 97 | status: UserStatus; 98 | isSuperAdmin: boolean; 99 | name?: string; 100 | ip: string; 101 | nowRoomId: string; // 现在所属房间(可以为空) 102 | nowRoomName: string; 103 | nowRoomToken: string; 104 | nowRoomPassword?: string;// 房间密码 105 | isRoomCreator: boolean; // 是否为所属房间的创始人 106 | allowComment: boolean; // 是否允许在房间内发表评论 107 | lastReadMessageTime: string | number; 108 | blockPlayItems: string[]; // 用户个人屏蔽的音乐id列表 109 | userRoomRecordType: UserRoomRecordTypes; 110 | } 111 | 112 | export enum UserRoomRecordTypes { 113 | others, // 普通房间人员 114 | normalAdmin, 115 | creator, 116 | superAdmin, 117 | } 118 | export interface UserRoomRecord { 119 | type: UserRoomRecordTypes; 120 | userId: string; 121 | userName: string; 122 | nowRoomId: string; // 现在所属房间(可以为空) 123 | nowRoomName: string; // 现在所属房间名称 124 | allowComment: boolean; // 是否允许在房间内发表评论 125 | isOffline: boolean; // 是否离线 126 | } 127 | 128 | export enum LocalStorageKeys { 129 | volume = 'volume', 130 | openDanmu = 'openDanmu', 131 | } 132 | 133 | export enum AdminActionTypes { 134 | blockUser, 135 | blockIp, 136 | banUserComment, 137 | withdrwalMessage, // 撤回消息 138 | awardAdmin, // 授予房间管理员权限 139 | removeAdmin, // 撤销房间管理员权限 140 | } 141 | 142 | export interface AdminAction { 143 | id: string; 144 | type: AdminActionTypes; 145 | operator: string; 146 | operatorName: string; 147 | operatorUserRoomType: UserRoomRecordTypes; 148 | isSuperAdmin: boolean; 149 | room: string; 150 | time: string | number; 151 | detail: { 152 | ip?: string; 153 | userId?: string; 154 | userName?: string; 155 | message?: string; 156 | }; 157 | } 158 | 159 | export class MatchedSearchValue { 160 | constructor (props: { 161 | value: string; 162 | startMatched: number; 163 | endMatched: number; 164 | }) { 165 | Object.assign(this, props) 166 | } 167 | value: string; 168 | startMatched: number; 169 | endMatched: number; 170 | } 171 | 172 | export type SearchResValue = { 173 | [key in keyof T]: T[key] & MatchedSearchValue 174 | } 175 | 176 | export type SearchTreeType = Partial<{ 177 | [key in keyof T]: SearchTreeType | boolean 178 | }> 179 | -------------------------------------------------------------------------------- /frontend/src/services/socket.ts: -------------------------------------------------------------------------------- 1 | import socketIoClient from 'socket.io-client' 2 | 3 | import {ClientListenSocketEvents, ServerListenSocketEvents} from '@global/common/enums' 4 | import settings from 'config/settings' 5 | import globalConfigs from '@global/common/config' 6 | import {getAuthToken, CustomAlert} from '@/utils' 7 | 8 | namespace Io { 9 | const authToken = getAuthToken() 10 | export type Listener = (data) => any 11 | export interface SocketRes { 12 | data: any; 13 | } 14 | 15 | export const getScoketActionId = () => Date.now() + Math.random().toString(32).slice(2) 16 | export const getWaittingGetResponseKey = (actionId: string) => `waittingGetResponse:${actionId}` 17 | export const client = socketIoClient.connect(settings.socketServer || '/', { 18 | query: { 19 | [globalConfigs.authTokenFeildName]: authToken 20 | }, 21 | autoConnect: false, 22 | }) 23 | 24 | /** 25 | * 访问时间记录 单位ms 26 | */ 27 | let isSuperAdmin = false 28 | const apiRequestTimeRecord = new Map() 29 | export function checkRequsetFrequency (type: ServerListenSocketEvents) { 30 | if (isSuperAdmin) { 31 | return true 32 | } 33 | const lastRequestTime = apiRequestTimeRecord.get(type) 34 | const limit = globalConfigs.apiFrequeryLimit[type] || globalConfigs.apiFrequeryLimit.default 35 | if (lastRequestTime && (lastRequestTime + limit) > Date.now()) { 36 | return false 37 | } 38 | apiRequestTimeRecord.set(type, Date.now()) 39 | return true 40 | } 41 | 42 | export function setIsSuperAdmin (value: boolean) { 43 | isSuperAdmin = value 44 | } 45 | } 46 | 47 | namespace WaittingQuene { 48 | const listenerMap = new Map() 49 | const failedTimerMap = new Map() 50 | 51 | export const pub = (key: string, ...data) => { 52 | const listener = listenerMap.get(key) 53 | if (listener) { 54 | const timer = failedTimerMap.get(key) 55 | timer && clearTimeout(timer) 56 | listenerMap.delete(key) 57 | failedTimerMap.delete(key) 58 | listener(...data) 59 | } 60 | } 61 | /** 62 | * 一次性订阅, 触发一次后即失效, 多次调用会覆盖之前调用 63 | * @param key 64 | * @param func 回调函数 65 | * @param timeout 等待超时时间 单位毫秒 66 | */ 67 | export const onceSub = (key: string, timeout = 8000) => { 68 | const hasExisted = listenerMap.has(key) 69 | if (hasExisted) { 70 | const timer = failedTimerMap.get(key) 71 | timer && clearTimeout(timer) 72 | listenerMap.delete(key) 73 | failedTimerMap.delete(key) 74 | } 75 | return new Promise((resolve, reject) => { 76 | const failedTimer = setTimeout(() => { 77 | listenerMap.delete(key) 78 | failedTimerMap.delete(key) 79 | reject(`waitting key: ${key} timeout`) 80 | }, timeout) 81 | 82 | listenerMap.set(key, resolve) 83 | failedTimerMap.set(key, failedTimer) 84 | }) 85 | } 86 | } 87 | 88 | export default { 89 | client: Io.client, 90 | connect: () => { 91 | Io.client.connect() 92 | }, 93 | disconnect: () => { 94 | Io.client.disconnect() 95 | }, 96 | on: (eventType: ClientListenSocketEvents, listener: Io.Listener) => { 97 | Io.client.on(eventType, (res: Io.SocketRes) => { 98 | const {data} = res 99 | listener(data) 100 | }) 101 | }, 102 | emit: (evenType: ServerListenSocketEvents, data: any) => { 103 | const flag = Io.checkRequsetFrequency(evenType) 104 | if (!flag) { 105 | CustomAlert('请不要频繁请求') 106 | throw new Error('请不要频繁请求') 107 | } 108 | const actionStampId = Io.getScoketActionId() 109 | Io.client.emit(evenType, data, (res) => { 110 | console.log(res, 'res') 111 | const {data} = res 112 | WaittingQuene.pub(Io.getWaittingGetResponseKey(actionStampId), data) 113 | }) 114 | return actionStampId 115 | }, 116 | /** 117 | * @param timeout 单位毫秒/ 等待超时时间 118 | */ 119 | awaitActionResponse: (actionStampId: string, timeout?: number) => { 120 | return WaittingQuene.onceSub(Io.getWaittingGetResponseKey(actionStampId), timeout) 121 | }, 122 | setIsSuperAdmin (value: boolean) { 123 | Io.setIsSuperAdmin(value) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /frontend/src/components/scrollPage/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect, useCallback, useImperativeHandle, forwardRef } from 'react' 2 | import bindclass from 'classnames' 3 | import { useSwipeable, Swipeable } from 'react-swipeable' 4 | import { useMediaQuery } from 'react-responsive'; 5 | 6 | import { throttle } from '@/utils' 7 | import styles from './style.less' 8 | import configs from 'config/base.conf'; 9 | 10 | interface Props { 11 | children: React.ReactElement[]; 12 | onPageChange?: (page: number) => any; 13 | refObj: React.Ref; 14 | } 15 | 16 | interface RefAttributes { 17 | toPreviousPage: Function, 18 | toNextPage: Function, 19 | } 20 | 21 | const ScrollWrapper: React.FC = function ScrollWrapper (props) { 22 | const {refObj} = props 23 | const [focusPageIndex, setFocusPageIndex] = useState(0) 24 | const [pageHeight, setPageHeight] = useState(0) 25 | const containerRef = useRef(null) 26 | const boxRef = useRef(null) 27 | const isMobile = useMediaQuery({query: configs.mobileMediaQuery}) 28 | 29 | const isInitial = !boxRef.current 30 | useEffect(() => { 31 | getPageHeight() 32 | window.addEventListener('resize', throttle(getPageHeight, 300, true)) 33 | }, []) 34 | 35 | useEffect(() => { 36 | document.body.addEventListener('touchmove', function (e) { 37 | e.preventDefault(); //阻止默认的处理方式(阻止微信浏览器下拉滑动的效果) 38 | }, { passive: false }); 39 | }, []) 40 | 41 | useEffect(() => { 42 | if (!isInitial) { 43 | props.onPageChange && props.onPageChange(focusPageIndex) 44 | } 45 | }, [focusPageIndex]) 46 | 47 | const getPageHeight = () => { 48 | requestAnimationFrame(() => { 49 | if (boxRef.current) { 50 | const client = boxRef.current.getBoundingClientRect() 51 | setPageHeight(client.height) 52 | } 53 | }) 54 | } 55 | 56 | const toPreviousPage = useCallback(throttle(() => { 57 | setFocusPageIndex((prevValue) => { 58 | if (prevValue === 0) { 59 | return prevValue 60 | } 61 | return prevValue - 1 62 | }) 63 | }, 900), []) 64 | const toNextPage = useCallback(throttle(() => { 65 | setFocusPageIndex((prevValue) => { 66 | const childCount = React.Children.count(props.children) 67 | if (prevValue === childCount - 1) { 68 | return prevValue 69 | } 70 | return prevValue + 1 71 | }) 72 | }, 900), []) 73 | const handlers = useSwipeable({ onSwipedUp: toNextPage, onSwipedDown: toPreviousPage }) 74 | useImperativeHandle(refObj, () => { 75 | return { 76 | toPreviousPage, 77 | toNextPage, 78 | } 79 | }) 80 | return
81 |
{ 83 | const { deltaY } = e 84 | if (deltaY < 0) { 85 | toPreviousPage() 86 | } else { 87 | toNextPage() 88 | } 89 | }}> 90 |
95 | { 96 | React.Children.map(props.children, (child, index) => { 97 | return
100 | { 101 | React.cloneElement(child, { 102 | isShow: focusPageIndex === index 103 | }) 104 | } 105 |
106 | }) 107 | } 108 |
109 |
110 |
111 | } 112 | 113 | export default forwardRef>>(function (props, ref) { 114 | return 115 | }) 116 | 117 | interface ItemProps { 118 | isShow?: boolean; 119 | children: React.ReactNode | ((isShow: boolean) => React.ReactNode) 120 | } 121 | 122 | export const ScrollPageItem: React.FC = ({ children, isShow }) => { 123 | return typeof children === 'function' ? (children as Function)(isShow) : children 124 | } -------------------------------------------------------------------------------- /.github/actions/deploy_server/server.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | const express = require('express') 3 | const shell = require('shelljs') 4 | const dotenv = require('dotenv') 5 | dotenv.config() 6 | 7 | const injectedConfigs = { 8 | accessToken: process.env.D_ACCESS_TOKEN || 'token', 9 | port: process.env.D_PORT || 3003, 10 | watchImageNameList: (process.env.D_IMAGES || '').split(',').filter(n => !!n), 11 | workdir: process.env.D_WORD_DIR || __dirname, 12 | routerBasePath: process.env.D_ROUTER_BASE || '/', 13 | } 14 | 15 | function parseItemInfo (itemInfoStr) { 16 | if (!itemInfoStr) { 17 | return null 18 | } 19 | const itemInfoArr = itemInfoStr.trim().split(/\s+/) 20 | const [name, tag, id] = itemInfoArr 21 | return { 22 | name, 23 | tag, 24 | id, 25 | } 26 | } 27 | 28 | function findLocalImageInfo (imageName) { 29 | const output = shell.exec('docker image ls', { 30 | silent: true 31 | }) 32 | const items = output.stdout.split('\n').slice(1).filter(i => !!i) 33 | const findOneStr = items.filter(i => !!i).find(item => { 34 | const info = parseItemInfo(item) 35 | if (info.name === imageName) { 36 | return true 37 | } 38 | }) 39 | return parseItemInfo(findOneStr) 40 | } 41 | 42 | function filterImageName (imageName) { 43 | return injectedConfigs.watchImageNameList.includes(imageName) 44 | } 45 | 46 | function preCheckBeforeStart () { 47 | if (!injectedConfigs.accessToken) { 48 | throw new Error('没有配置访问token') 49 | } 50 | } 51 | 52 | 53 | const app = express() 54 | 55 | app.use(express.json()) 56 | app.use(express.urlencoded()) 57 | app.use(function (req, res, next) { 58 | console.log(`req:[${req.method}] ${req.originalUrl}`) 59 | try { 60 | const {token} = req.body 61 | if (token !== injectedConfigs.accessToken) { 62 | console.log('unauthorized') 63 | return res.json({ 64 | code: -1, 65 | msg: 'unauthorized' 66 | }) 67 | } 68 | next() 69 | } catch (e) { 70 | next(e) 71 | } 72 | }) 73 | const router = express.Router() 74 | router.post('/updateImage', (req, res, next) => { 75 | try { 76 | const {imageName, imageTag} = req.body 77 | if (!imageTag || !imageName) { 78 | throw new Error('invalid params') 79 | } 80 | if (!filterImageName(imageName)) { 81 | throw new Error('不支持更新该镜像') 82 | } 83 | console.log(`inner update image: ${imageName}:${imageTag}`) 84 | const localImageInfo = findLocalImageInfo(imageName) 85 | if (!localImageInfo || localImageInfo.tag !== imageTag) { 86 | console.log(`fetch image: ${imageName}:${imageTag}`) 87 | setImmediate(() => { 88 | try { 89 | shell.exec(`docker pull ${imageName}:${imageTag}`, function (code, stdout, stderr) { 90 | if (code === 0) { 91 | // console.log(stdout) 92 | const pureImageName = imageName.split('/').filter(i => !!i).pop() 93 | const command = `cd ${injectedConfigs.workdir} && export CONFIG_DIR=./config && export ${pureImageName.toUpperCase()}_TAG=${imageTag} && docker-compose down && docker-compose up -d` 94 | console.log(command) 95 | shell.exec(command, function (code, stdout, stderr) { 96 | // console.log(stdout) 97 | // console.error(stderr) 98 | }) 99 | } else { 100 | // console.error(stderr) 101 | } 102 | }) 103 | } catch (e) { 104 | console.error(e) 105 | } 106 | }) 107 | } 108 | res.json({ 109 | code: 0 110 | }) 111 | next() 112 | } catch (e) { 113 | next(e) 114 | } 115 | }) 116 | 117 | router.post('/getImageTag', function (req, res, next) { 118 | try { 119 | const {imageName} = req.body 120 | const localImageInfo = findLocalImageInfo(imageName) 121 | 122 | res.json({ 123 | code: 0, 124 | tag: localImageInfo && localImageInfo.tag 125 | }) 126 | } catch (e) { 127 | next(e) 128 | } 129 | }) 130 | 131 | app.use(injectedConfigs.routerBasePath, router) 132 | app.use(function (error, req, res, next) { 133 | console.error(error) 134 | res.json({ 135 | code: -1, 136 | msg: error.message 137 | }) 138 | }) 139 | 140 | preCheckBeforeStart() 141 | const server = http.createServer(app) 142 | server.listen(injectedConfigs.port) 143 | -------------------------------------------------------------------------------- /backend/test/tools.ts: -------------------------------------------------------------------------------- 1 | import {getConfig} from 'root/lib/tools' 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | import redisCli from 'root/lib/redis'; 5 | 6 | beforeAll(() => { 7 | return redisCli.select(1) 8 | }) 9 | 10 | afterAll(() => { 11 | return redisCli.flushdb() 12 | }) 13 | 14 | 15 | describe('get config tool functon', () => { 16 | const testDirName = '_test_get_config_dir' 17 | const testDirPath = path.join(__dirname, testDirName) 18 | const writeFile = (filePath, value) => fs.writeFileSync(path.resolve(testDirPath, filePath), value) 19 | const genJsFileValue = value => ` 20 | module.exports=${JSON.stringify(value)} 21 | ` 22 | beforeEach(() => { 23 | if (fs.existsSync(testDirPath)) { 24 | fs.rmdirSync(testDirPath, { 25 | recursive: true 26 | }) 27 | } 28 | fs.mkdirSync(testDirPath) 29 | }) 30 | 31 | afterEach(() => { 32 | if (fs.existsSync(testDirPath)) { 33 | fs.rmdirSync(testDirPath, { 34 | recursive: true 35 | }) 36 | } 37 | }) 38 | 39 | it('match default config file', () => { 40 | const d1 = { 41 | a: 1 42 | } 43 | writeFile('./a.default.js', genJsFileValue(d1)) 44 | const d1Config = getConfig({ 45 | filename: 'a.js', 46 | dir: [testDirName], 47 | basePath: __dirname, 48 | silent: true, 49 | }) 50 | expect(d1Config).toEqual(d1) 51 | 52 | writeFile('./a.default.json', JSON.stringify(d1)) 53 | const d1JosnConfig = getConfig({ 54 | filename: 'a.json', 55 | dir: [testDirName], 56 | basePath: __dirname, 57 | silent: true, 58 | }) 59 | expect(d1JosnConfig).toEqual(d1) 60 | }) 61 | 62 | it('match config file', () => { 63 | const d1 = { 64 | b: 2 65 | } 66 | writeFile('./b.js', genJsFileValue(d1)) 67 | const d1Config = getConfig({ 68 | filename: 'b.js', 69 | dir: [testDirName], 70 | basePath: __dirname, 71 | silent: true, 72 | }) 73 | expect(d1Config).toEqual(d1) 74 | 75 | writeFile('./b.json', JSON.stringify(d1)) 76 | const d1JosnConfig = getConfig({ 77 | filename: 'b.json', 78 | dir: [testDirName], 79 | basePath: __dirname, 80 | silent: true, 81 | }) 82 | expect(d1JosnConfig).toEqual(d1) 83 | }) 84 | 85 | it('match config/default config file with object', () => { 86 | const d1 = { 87 | a: 2 88 | } 89 | const d1Default = { 90 | a: 3, 91 | b: 2, 92 | } 93 | writeFile('./c.js', genJsFileValue(d1)) 94 | writeFile('./c.default.js', genJsFileValue(d1Default)) 95 | 96 | const d1Config = getConfig({ 97 | filename: 'c.js', 98 | dir: [testDirName], 99 | basePath: __dirname, 100 | silent: true, 101 | }) 102 | expect(d1Config).toEqual({ 103 | ...d1Default, 104 | ...d1 105 | }) 106 | 107 | writeFile('./c.json', JSON.stringify(d1)) 108 | writeFile('./c.default.json', JSON.stringify(d1Default)) 109 | const d1JosnConfig = getConfig({ 110 | filename: 'c.json', 111 | dir: [testDirName], 112 | basePath: __dirname, 113 | silent: true, 114 | }) 115 | expect(d1JosnConfig).toEqual({ 116 | ...d1Default, 117 | ...d1 118 | }) 119 | }) 120 | 121 | it('match config/default config file with array', () => { 122 | const d1 = [1, 2] 123 | const d1Default = [3, 4] 124 | writeFile('./d.default.js', genJsFileValue(d1Default)) 125 | writeFile('./d.js', genJsFileValue(d1)) 126 | 127 | const d1Config = getConfig({ 128 | filename: 'd.js', 129 | dir: [testDirName], 130 | basePath: __dirname, 131 | silent: true, 132 | }) 133 | expect(d1Config).toEqual(d1) 134 | 135 | writeFile('./d.default.json', JSON.stringify(d1Default)) 136 | const d1JosnConfig = getConfig({ 137 | filename: 'd.json', 138 | dir: [testDirName], 139 | basePath: __dirname, 140 | silent: true, 141 | }) 142 | expect(d1JosnConfig).toEqual(d1Default) 143 | 144 | writeFile('./d.json', JSON.stringify(d1)) 145 | const d1JosnConfig2 = getConfig({ 146 | filename: 'd.json', 147 | dir: [testDirName], 148 | basePath: __dirname, 149 | silent: true, 150 | }) 151 | expect(d1JosnConfig2).toEqual(d1) 152 | }) 153 | 154 | }) -------------------------------------------------------------------------------- /frontend/src/components/createRoom/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from 'react'; 2 | import { useMediaQuery } from 'react-responsive' 3 | import bindClass from 'classnames' 4 | import { connect } from 'dva' 5 | import { history as router } from 'umi' 6 | import { FormControl, Switch, TextField, FormControlLabel, Button, Dialog, DialogTitle, DialogContent } from '@material-ui/core'; 7 | 8 | import FocusInputWrapper from '@/components/focusMobileInput' 9 | import { ConnectProps, ConnectState } from '@/models/connect' 10 | import { copyToClipBoard, gotoRoomPage } from '@/utils'; 11 | 12 | enum DialogTypes { 13 | createRoom, 14 | createSuccess, 15 | } 16 | 17 | interface Props extends ConnectProps { 18 | open: boolean; 19 | onClose: () => any; 20 | isMobile: boolean; 21 | } 22 | 23 | const CreateRoom: React.FC = React.memo((props) => { 24 | const [formData, setFormData] = useState({ 25 | isPrivate: false, 26 | maxMemberCount: 2 27 | } as Partial<{ 28 | name: string; 29 | isPrivate: boolean; 30 | maxMemberCount: number; 31 | }>) 32 | const [dialogType, setDialogType] = useState(null as DialogTypes) 33 | const [roomPassword, setRoomPassword] = useState('') 34 | const divRef = useRef(null) 35 | 36 | useEffect(() => { 37 | if (props.open) { 38 | setDialogType(DialogTypes.createRoom) 39 | setFormData({ 40 | isPrivate: false, 41 | maxMemberCount: 2 42 | }) 43 | } else { 44 | setDialogType(null) 45 | } 46 | }, [props.open]) 47 | 48 | const handleCreate = () => { 49 | props.dispatch({ 50 | type: 'center/createRoom', 51 | payload: { 52 | ...formData 53 | } 54 | }).then(res => { 55 | if (res && res.success) { 56 | gotoRoomPage(res.roomToken) 57 | 58 | if (res.password) { 59 | setDialogType(DialogTypes.createSuccess) 60 | setRoomPassword(res.password) 61 | } else { 62 | props.onClose() 63 | } 64 | } 65 | }) 66 | } 67 | 68 | return 69 | 70 | 创建房间 71 | 72 | 73 | setFormData({ ...formData, name: e.target.value })} 75 | /> 76 | 77 | setFormData({ ...formData, isPrivate: e.target.checked, })} />} 79 | label="不开放" 80 | /> 81 | 82 | 83 | -1} onChange={(e, checked) => { 85 | setFormData({ ...formData, maxMemberCount: checked ? 2 : -1 }) 86 | }} />} 87 | label="人数限制" 88 | /> 89 | 90 | { 91 | formData.maxMemberCount > -1 && { 93 | const value = e.target.value 94 | setFormData({ ...formData, maxMemberCount: value ? Number(value) : null }) 95 | }} 96 | />} 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 创建成功 105 | 106 |
107 | 房间密码为: {roomPassword} 108 | 109 |
110 |
111 | 也可以到右下角查看说明部分查看房间密码 112 |
113 |
114 |
115 |
116 | }) 117 | 118 | export default connect(({ center: { isMobile } }: ConnectState) => { 119 | return { 120 | isMobile 121 | } 122 | })(CreateRoom) 123 | -------------------------------------------------------------------------------- /frontend/src/pages/index/roomList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useEffect, useRef } from 'react'; 2 | import { useMediaQuery } from 'react-responsive' 3 | import bindClass from 'classnames' 4 | import { connect } from 'dva' 5 | import ScrollBar from 'react-perfect-scrollbar' 6 | import { Button, IconButton, Fab, Zoom } from '@material-ui/core'; 7 | import { makeStyles } from '@material-ui/core/styles'; 8 | import { ArrowUpwardRounded as ArrowUp } from '@material-ui/icons' 9 | import { useSwipeable } from 'react-swipeable' 10 | 11 | 12 | import RoomItemRender from '@/components/roomItem' 13 | import { ConnectProps, ConnectState } from '@/models/connect' 14 | import configs from 'config/base.conf' 15 | import { RoomItem } from 'config/type.conf' 16 | import styles from './style.less' 17 | import styleConf from 'config/baseStyle.conf'; 18 | import { throttle } from '@/utils'; 19 | 20 | const useStyle = makeStyles({ 21 | roomItem: { 22 | boxSizing: 'border-box', 23 | width: (props: any) => { 24 | return props.isMobile ? 'calc(100% - 2rem)' : `calc(20% - ${props.itemSpace * 2}rem)` 25 | }, 26 | margin: (props: any) => { 27 | return props.isMobile ? '1rem' : `0 ${props.itemSpace}rem 2rem` 28 | }, 29 | } 30 | }) 31 | interface Props extends ConnectProps { 32 | roomList: RoomItem[]; 33 | hasMore: boolean; 34 | isLoadingList: boolean; 35 | nowRoomId: string; 36 | isInShow: boolean; 37 | toPrevPage: () => any; 38 | } 39 | 40 | const RoomList: React.FC = React.memo((props) => { 41 | const { roomList, hasMore, dispatch, nowRoomId, isLoadingList, isInShow } = props 42 | const isMobile = useMediaQuery({ query: configs.mobileMediaQuery }) 43 | const scrollTopRef = useRef(0) 44 | const classes = useStyle({ 45 | itemSpace: 1, 46 | isMobile, 47 | }) 48 | 49 | useEffect(() => { 50 | loadListItem(roomList.length ? roomList[roomList.length - 1].id : null) 51 | }, []) 52 | 53 | const loadListItem = (lastId) => { 54 | dispatch({ 55 | type: 'center/reqRecommenedRoom', 56 | payload: { 57 | isReplaced: !isMobile, 58 | lastId, 59 | excludeId: nowRoomId 60 | } 61 | }) 62 | } 63 | 64 | const loadMore = () => { 65 | if (isLoadingList) { 66 | return 67 | } 68 | if (isMobile && !hasMore) { 69 | return 70 | } 71 | const lastId = roomList.length ? roomList[roomList.length - 1].id : null 72 | loadListItem(hasMore ? lastId : null) 73 | } 74 | 75 | const handleItemClick = useCallback((roomToken) => { 76 | dispatch({ 77 | type: 'center/joinRoom', 78 | payload: { 79 | token: roomToken, 80 | } 81 | }) 82 | }, [dispatch]) 83 | 84 | const handleScrollY = useCallback(throttle((ele: HTMLElement) => { 85 | requestAnimationFrame(() => { 86 | scrollTopRef.current = ele.scrollTop 87 | }) 88 | }, 500, true), []) 89 | 90 | const handleOnWheelOrSwipeUp = useCallback((e) => { 91 | const event = e.event || e 92 | if (scrollTopRef.current > 10) { 93 | event.stopPropagation() 94 | } 95 | }, []) 96 | 97 | const swipeHandlers = useSwipeable({onSwipedDown: handleOnWheelOrSwipeUp }) 98 | 99 | return 103 |
106 | { 107 | roomList.length ? roomList.map(r => ) 108 | :
暂无数据
} 109 |
110 |
111 | { 112 | !isMobile &&
113 | 114 |
115 | } 116 | { 117 | isMobile && 118 | { 119 | !hasMore && 没有更多了... 120 | } 121 | { 122 | isLoadingList && 加载中... 123 | } 124 | 125 | 126 | } 127 |
128 | 129 | { 130 | (isMobile && isInShow) &&
131 | 132 | 133 | 134 |
135 | } 136 |
137 | }) 138 | 139 | export default connect(({ center: { hasMoreRoomItem, roomList, userInfo }, loading }: ConnectState) => { 140 | return { 141 | nowRoomId: userInfo && userInfo.nowRoomId, 142 | hasMore: hasMoreRoomItem, 143 | isLoadingList: loading.effects['center/reqRecommenedRoom'], 144 | roomList: roomList, 145 | } 146 | })(RoomList) 147 | -------------------------------------------------------------------------------- /backend/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import settings from 'root/getSettings' 2 | import redisCli from 'root/lib/redis' 3 | import {blockKeySet, reqBlockCallBackQueue, ResolveType, RejectType, getBlockWaittingCbQueue} from 'root/lib/store' 4 | import { UserModel } from 'root/type' 5 | 6 | 7 | export function isSuperAdmin(user: UserModel) { 8 | return user.isSuperAdmin 9 | } 10 | 11 | export class PayloadError extends Error { 12 | payload: T 13 | constructor(message: string, data?: any) { 14 | super(message) 15 | this.payload = data 16 | } 17 | } 18 | 19 | /** 20 | * 捕获到api抛出的该类型错误将会讲错误信息返回到前端, 21 | * isGlobal参数用来标识返回信息是否 在前端页面全局弹出显示 22 | */ 23 | export class ResponseError extends Error { 24 | isGlobal: boolean; 25 | constructor(message: string, isGlobal = true) { 26 | super(message) 27 | Object.setPrototypeOf(this, new.target.prototype) 28 | this.isGlobal = isGlobal 29 | } 30 | } 31 | 32 | export function catchError(handler?: (error: Error, ...args: any) => any) { 33 | return function (target, propertyName, descriptor: PropertyDescriptor) { 34 | const func = descriptor.value 35 | descriptor.value = async (...args) => { 36 | try { 37 | await func(...args) 38 | } catch (e) { 39 | console.error(e) 40 | handler && handler(e, ...args) 41 | } 42 | } 43 | return descriptor as TypedPropertyDescriptor 44 | } 45 | } 46 | /** 47 | * 48 | * @param func 49 | * @param time 单位秒 50 | */ 51 | export function throttle (key: string, func: (...args: any) => any, time: number, onThrottle?: () => any) { 52 | return async (...args) => { 53 | if (time > 0) { 54 | const redisKey = `musicradio:apithrottle:${key}` 55 | const rejected = await redisCli.exists(redisKey) 56 | if (rejected) { 57 | onThrottle && await onThrottle 58 | return 59 | } 60 | await redisCli.safeSet(redisKey, true, time) 61 | } 62 | await func(...args) 63 | } 64 | } 65 | 66 | async function execUseBlock (key: string, execFunc, resolve: ResolveType, reject: RejectType) { 67 | blockKeySet.add(key) 68 | let execError = null 69 | try { 70 | await execFunc() 71 | } catch (e) { 72 | execError = e 73 | } 74 | execError ? reject(execError) : resolve() 75 | const waitItemCb = getBlockWaittingCbQueue(key).shift() 76 | if (waitItemCb) { 77 | setImmediate(() => { 78 | execUseBlock(key, waitItemCb.cb, waitItemCb.resolve, waitItemCb.reject) 79 | }) 80 | } else { 81 | blockKeySet.delete(key) 82 | } 83 | } 84 | interface useBlockOptions { 85 | wait: boolean; // 锁被占用时是否等待 86 | success: () => any; 87 | failed?: () => any; 88 | } 89 | export async function useBlock (key: string, option: useBlockOptions) { 90 | const {success: handleSuccess, failed: handleFailed, wait} = option 91 | const hasBlocked = blockKeySet.has(key) 92 | if (hasBlocked) { 93 | if (!wait && handleFailed) { 94 | return await handleFailed() 95 | } else if (wait) { 96 | const cbQueue = getBlockWaittingCbQueue(key) 97 | return new Promise((resolve, reject) => { 98 | cbQueue.push({ 99 | cb: handleSuccess, 100 | resolve, 101 | reject 102 | }) 103 | }) 104 | } 105 | return null 106 | } 107 | return new Promise((resolve, reject) => { 108 | execUseBlock(key, handleSuccess, resolve, reject) 109 | }) 110 | } 111 | 112 | 113 | export function hideIp(ip: string) { 114 | return ip.replace(/(^[0-9a-f]+)|([0-9a-f]+$)/g, '**') 115 | } 116 | 117 | export function safePushArrItem(arr: Array, item: any | any[]) { 118 | const set = new Set(arr) 119 | item instanceof Array ? item.forEach(i => set.add(i)) : set.add(item) 120 | return Array.from(set) 121 | } 122 | 123 | export function safeRemoveArrItem(arr: Array, item: any | any[]) { 124 | const newArr = [] 125 | const delSet = new Set(item instanceof Array ? item : [item]) 126 | arr.forEach(i => { 127 | if (!delSet.has(i)) { 128 | newArr.push(i) 129 | } 130 | }) 131 | return newArr 132 | } 133 | 134 | export function shuffleArr(arr) { 135 | for (let i = arr.length - 1; i > 0; i--) { 136 | const swapIndex = Math.floor(Math.random() * i) 137 | const backUp = arr[i] 138 | arr[i] = arr[swapIndex] 139 | arr[swapIndex] = backUp 140 | } 141 | return arr 142 | } 143 | 144 | export function getRandomIp() { 145 | const ips = [ 146 | "60.13.42.157", 147 | "180.104.63.242", 148 | "219.159.38.200", 149 | "175.42.68.223", 150 | "1.198.73.202", 151 | "125.108.76.226", 152 | "106.75.177.227", 153 | "124.93.201.59", 154 | "121.233.206.211", 155 | "175.44.109.104", 156 | "118.212.104.240", 157 | "163.204.240.107", 158 | "60.13.42.77", 159 | "49.89.86.30", 160 | "106.42.217.26" 161 | ] 162 | return ips[Math.floor(Math.random() * ips.length)] 163 | } 164 | 165 | 166 | export function getArrRandomItem(arr: T[]) { 167 | return arr[Math.floor(Math.random() * arr.length)] as T 168 | } 169 | 170 | export function wait (time: number) { 171 | return new Promise((resolve) => { 172 | setTimeout(resolve, time) 173 | }) 174 | } 175 | 176 | 177 | -------------------------------------------------------------------------------- /backend/lib/session.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import socketIo, {Socket} from 'socket.io' 3 | import cookieParser from 'cookie-parser' 4 | import cookie from 'cookie' 5 | import {URLSearchParams} from 'url' 6 | import http from 'http' 7 | 8 | import settings, { injectedConfigs } from 'root/getSettings' 9 | import {User} from 'root/lib/models' 10 | import redisCli from 'root/lib/redis' 11 | import {SessionTypes, UserModel, Session as SessionDef, SessionStoreData, UserStatus} from 'root/type' 12 | import globalConfigs from 'global/common/config' 13 | import {getRandomIp} from 'root/lib/utils' 14 | const {authTokenFeildName: authTokenKey} = globalConfigs 15 | 16 | const getIp = (req: http.IncomingMessage) => { 17 | if (!injectedConfigs.isProductionMode && settings.openRandomIpMode) { 18 | return getRandomIp() 19 | } 20 | let str = req.headers['x-real-ip'] as string || req.headers['x-forwarded-for'] as string || req.socket.remoteAddress 21 | if (str.startsWith('::ffff:')) { 22 | str = str.replace('::ffff:', '') 23 | } 24 | return str 25 | } 26 | 27 | class Session implements SessionDef { 28 | storeData: SessionStoreData = { 29 | userId: null, 30 | defaultUserId: null, 31 | }; 32 | id: string; 33 | ip: string; 34 | isAuthenticated: boolean = false; 35 | user: UserModel; 36 | type: SessionTypes; 37 | constructor (obj: Pick | Session) { 38 | Object.assign(this, obj) 39 | } 40 | private preCheck () { 41 | if (!this.id) { 42 | throw new Error('invalid session id') 43 | } 44 | } 45 | private getSessionStoreRedisKey () { 46 | this.preCheck() 47 | return `musicradio:sessionstore:${this.id}` 48 | } 49 | private async saveSessionStoreData () { 50 | const redisKey = this.getSessionStoreRedisKey() 51 | await redisCli.safeSet(redisKey, this.storeData, 3600 * 24 * 356) 52 | } 53 | private async getSessionStoreData () { 54 | const redisKey = this.getSessionStoreRedisKey() 55 | const data = await redisCli.safeGet(redisKey) 56 | if (data) { 57 | this.storeData = data 58 | } 59 | return this.storeData 60 | } 61 | async login (user: UserModel) { 62 | this.preCheck() 63 | this.user = user 64 | this.isAuthenticated = true 65 | this.storeData.userId = this.user.id 66 | await this.saveSessionStoreData() 67 | } 68 | async logOut () { 69 | this.preCheck() 70 | this.user = null 71 | this.isAuthenticated = false 72 | this.storeData.userId = null 73 | await this.saveSessionStoreData() 74 | } 75 | async load () { 76 | this.preCheck() 77 | 78 | await this.getSessionStoreData() 79 | const {userId} = this.storeData 80 | let user: UserModel = null, isInitial = false 81 | if (userId) { 82 | user = await User.findOne(userId) 83 | } 84 | if (!user) { 85 | isInitial = true 86 | user = new User({ 87 | id: userId, 88 | ip: this.ip, 89 | name: globalConfigs.initNickNamePerfix + Math.random().toString(32).slice(2), 90 | }) 91 | } 92 | user.ip = this.ip 93 | if (this.type === SessionTypes.token) { 94 | if (settings.superAdminToken.includes(this.id)) { 95 | console.log('dev mode: is super admin') 96 | if (user.status < UserStatus.superOfNormal) { 97 | user.status = UserStatus.superAdmin 98 | } 99 | } else { 100 | user.status = UserStatus.normal 101 | } 102 | } 103 | await user.save() 104 | this.isAuthenticated = true 105 | this.user = user 106 | if (isInitial) { 107 | this.storeData.userId = this.user.id 108 | this.storeData.defaultUserId = this.user.id 109 | await this.saveSessionStoreData() 110 | } 111 | } 112 | } 113 | 114 | export default function session (type: SessionTypes = SessionTypes.cookie) { 115 | return async (...args) => { 116 | const isSocketMode = args.length === 2 117 | const req: http.IncomingMessage = isSocketMode ? (args[0] as Socket).request : args[0] 118 | const next: Function = isSocketMode ? args[1] : args[2] 119 | try { 120 | let sessionId = '' 121 | const ipAddress = getIp(req) 122 | if (type === SessionTypes.ip) { 123 | sessionId = ipAddress 124 | } 125 | if (type === SessionTypes.cookie) { 126 | let preSignedCookies = (req as express.Request).signedCookies 127 | sessionId = !!preSignedCookies && preSignedCookies[settings.sessionKey] 128 | if (!sessionId) { 129 | const cookieStr = req.headers.cookie 130 | const cookies = cookie.parse(cookieStr || '') 131 | const signedCookies = cookieParser.signedCookies(cookies, settings.sessionSecret) 132 | sessionId = signedCookies[settings.sessionKey] 133 | } 134 | } 135 | if (type === SessionTypes.token) { 136 | const searchStr = req.url.split('?').pop() || '' 137 | const searchParams = new URLSearchParams(searchStr) 138 | sessionId = searchParams.get(authTokenKey) as string 139 | } 140 | console.log(`inner session middleware: ${sessionId}`) 141 | const session = new Session({ 142 | id: sessionId, 143 | ip: ipAddress, 144 | type, 145 | isAuthenticated: false, 146 | }) 147 | await session.load() 148 | 149 | if (isSocketMode) { 150 | const socket: Socket = args[0] 151 | socket.session = session 152 | } else { 153 | (req as express.Request).session = session 154 | } 155 | next() 156 | } catch (e) { 157 | console.error(e) 158 | next(e) 159 | } 160 | } 161 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## MsicRadio - 音乐点播平台 2 | 3 | Inspired by [SyncMusic](https://github.com/kasuganosoras/SyncMusic) 4 | 5 | ![test](https://github.com/sanmmm/MusicRadio/workflows/test/badge.svg?branch=master&event=push) 6 | ![docker release](https://github.com/sanmmm/MusicRadio/workflows/docker%20release/badge.svg?branch=master&event=push) 7 | 8 | ![screenshot](./assets/screenshot_hall.png) 9 | 10 | ## 简介 11 | 12 | MusicRadio是一个使用nodejs开发的基于websocket的在线同步播放音乐室,支持多房间(公开/非公开房间) / 多管理员 / 点歌 / 随机播放 /聊天/ 弹幕等功能特性,并提供了基本的管理功能,如:屏蔽用户、屏蔽ip地址、禁言、设置房间管理员等。 13 | 14 | --- 15 | 16 | ## 功能 17 | 18 | 1. :house: 房间 19 | - 大厅 20 | - 创建房间 21 | - 私密(密码加入) 22 | - 公开 23 | - 人数限制 24 | - 设置房间管理员 25 | - 屏蔽ip/用户 26 | - 禁言 27 | 2. :musical_note: 音乐播放器(同一房间操作同步) 28 | - 暂停/播放 29 | - 切歌 30 | - 调整进度 31 | - 屏蔽音乐(默认自动静音) 32 | 3. :speech_balloon: 聊天 33 | - 颜色标识(管理员高亮) 34 | - 表情包支持 35 | - @支持 36 | - 特殊消息悬浮提醒 37 | - 未读消息提醒 38 | - 弹幕 39 | - 消息撤回 40 | 4. :eyes: 搜索、点歌 41 | - 模糊搜索 42 | - 批量点歌 43 | 5. :notes: 播放列表 44 | - 增删(管理员) 45 | - 移动 (管理员) 46 | 6. :chart_with_upwards_trend: 全站数据聚合展示 47 | 7. :star: 超级管理员角色切换(可切换为游客) 48 | 8. :heavy_check_mark: 其他...... 49 | 50 | ## 部署安装 51 | 52 | ### docker-compose 部署 (推荐) 53 | 54 | **注意: 在要部署的服务器上要安装有docker以及docker-compose** 55 | 56 | #### 安装 57 | 58 | ##### 拉取docker-compose文件 59 | 60 | ` 61 | wget https://raw.githubusercontent.com/sanmmm/MusicRadio/master/docker-compose.yml 62 | ` 63 | 64 | ##### 启动 65 | 66 | ` 67 | docker-compose up -d 68 | ` 69 | 70 | 其他配置项可见[配置项说明](#配置项说明) 71 | 72 | ### docker部署 73 | 74 | **注意: 在要部署的服务器上要安装有docker** 75 | 76 | #### 安装 77 | 78 | ##### 拉取docker镜像 79 | 80 | ``` 81 | docker pull sanmmmm/music_radio 82 | ``` 83 | 84 | ##### 安装配置依赖服务 85 | 86 | 关于依赖服务, 见 [依赖服务](#依赖服务) 87 | 88 | ##### 运行 89 | 90 | 在[上述](#安装配置依赖服务)配置好之后,运行 91 | 92 | ``` 93 | docker run -d -p 3001:3001 -e REDIS_URL=redis://examplehost:6379 -e NETEASE_API_URL=http://examplehost:3000 sanmmmm/music_radio 94 | 95 | ``` 96 | 其他配置项见[配置项说明](#配置项说明) 97 | 98 | ### 自行部署 99 | 100 | #### 部署依赖服务 101 | 102 | 详情见[依赖服务](#依赖服务) 103 | 104 | #### 拉取代码 105 | 106 | ``` 107 | git clone git@github.com:sanmmm/MusicRadio.git 108 | ``` 109 | 110 | #### 打包 111 | 112 | 配置前后端的打包所需环境变量, 见[配置项说明-环境变量](#配置项说明-环境变量) 113 | 114 | 前端打包: 115 | 116 | ``` 117 | cd frontend && npm run build 118 | ``` 119 | 120 | 后端打包: 121 | 122 | ``` 123 | cd backend && npm run build 124 | ``` 125 | 126 | #### 运行 127 | 128 | 在设置好[依赖服务](#依赖服务)的环境变量之后,运行 129 | 130 | ``` 131 | node /app.js 132 | ``` 133 | 134 | ### 依赖服务 135 | 136 | MusicRadio依赖的其他三方服务有:`redis`(用来存储数据)以及 [网易云音乐api](https://github.com/Binaryify/NeteaseCloudMusicApi/)。所以在运行`MusicRadio`之前需要先配置启动上述两项服务。然后通过[后端配置文件](#配置文件说明)或者 137 | `环境变量`来配置到`MusicRadio`之中供其与之通信 138 | 139 | #### 配置到环境变量 140 | 141 | 举例: 假设redis的地址为 localhost:6379, 则设置环境变量`REDIS_URL`的值为`redis:localhost:6379`。 而网易云音乐apiServer 的地址为 localhost:3000, 则设置环境变量`NETEASE_API_URL`的值为`http://localhost:3000` 142 | 143 | **注:通过docker部署时则需要在运行docker命令的时候通过 `-e` 参数来把环境变量注入,可见[docker运行](#docker部署)** 144 | 145 | 环境变量具体说明, 见[配置项说明-环境变量](#配置项说明-环境变量) 146 | 147 | ### 配置项说明 148 | 149 | #### 环境变量 150 | 151 | ##### **docker-compose运行所需环境变量** 152 | 153 | **MUSIC_RADIO_TAG**: 154 | 155 | 必须项: `否` 156 | 157 | 默认: `latest` 158 | 159 | 说明: docker 镜像的tag版本,缺省条件下为latest 160 | 161 | **CONFIG_DIR** 162 | 163 | 说明: 见[CONFIG_DIR](#config-dir) 164 | 165 | ##### **前端打包所需环境变量** 166 | 167 | **OUTPUT_PATH**: 168 | 169 | 必须项: `否` 170 | 171 | 默认: `./static` 172 | 173 | 说明: webpack打包输出目录 174 | 175 | 176 | **NODE_ENV**: 177 | 178 | 必须项: `是` 179 | 180 | 说明: 生产环境设置为production 181 | 182 | **ASYNC_SETTINGS**: 183 | 184 | 185 | 必须项: `否` 186 | 187 | 说明: 设置前端配置文件的加载方式,不设置时(默认)为打包时打包配置文件(配置文件为`frontend/config/settings.ts`), 188 | 设置为`1`时则为打包时不讲配置文件打包进去,网页运行时再从后端加载配置文件, 189 | 该配置文件可见 [后端客户端配置文件](#backend-client-settings-file) 190 | 191 | ##### **后端运行所需环境变量** 192 | 193 | **NODE_ENV**: 194 | 195 | 必须项: `是` 196 | 197 | 说明: 生产环境设置为production 198 | 199 | **STATIC_PATH**: 200 | 201 | 必须项: `否` 202 | 203 | 默认: `./static` 204 | 205 | 说明: 后端静态文件夹所在目录,默认为后端根目录 206 | 207 | **CONFIG_DIR**: 208 | 209 | 210 | 必须项: `否` 211 | 212 | 默认: [后端自定义配置存放文件夹](#custom-backend-settings-dir) 213 | 214 | 说明: 添加配置文件目录,见[后端自定义配置存放文件夹](#custom-backend-settings-dir) 215 | 216 | **SESSION_TYPE**: 217 | 218 | 必须项: `否` 219 | 220 | 默认: `cookie` 221 | 222 | 说明: 后端会话管理方式,默认为`cookie`,还可以配置为`ip`, `token` 223 | 224 | **REDIS_URL**: 225 | 226 | 必须项: `否` 227 | 228 | 说明: 后端会话管理方式,默认值配置在[后端默认配置文件中](#backend-settings-default-file)。通过环境变量配置的值优先级最高,将会覆盖在配置文件中配置的值 229 | 230 | **NETEASE_API_URL**: 231 | 232 | 必须项: `否` 233 | 234 | 说明: 后端会话管理方式,默认值配置在[后端默认配置文件中](#backend-settings-default-file)。通过环境变量配置的值优先级最高,将会覆盖在配置文件中配置的值 235 | 236 | 237 | #### 配置文件说明 238 | 239 | **前后端共用配置文件夹:** `/common/` 240 | 241 | **前端配置文件夹:** `/frontend/config/` 242 | 243 | **后端默认配置文件夹:** `/backend/default/` 244 | 245 | 246 | 该文件夹下存放的为系统默认配置,**请不要编辑**该文件夹下内容, 247 | 可以通过在配置[后端自定义配置存放文件夹](#custom-backend-settings-dir)中写入同名配置文件来覆盖default文件夹下的配置文件。如:假设default文件夹下有默认配置文件: `FILE_NAME.default.js`, 可以通过在[后端自定义配置存放文件夹](#custom-backend-settings-dir)中配置 FILE_NAME.js来覆盖上述默认配置。 248 | 249 | 该文件夹下的配置文件有: 250 | 251 | `/backend/default/bitSymbols.default.json` 252 | 253 | `/backend/default/blockMusic.default.json`: 屏蔽的音乐列表 254 | 255 | `/backend/default/blockWords.default.json`: 聊天敏感词列表 256 | 257 | `/backend/default/emojiData.default.json`: 表情包列表配置文件 258 | 259 | `/backend/default/clientSettings.js`: 260 | 前端配置文件(前端从后端加载配置文件时返回的配置文件)具体可见[ASYNC_SETTINGS环境变量](#sync-settings) 261 | 262 | `/backend/default/settings.default.js`: 后端配置文件 263 | 264 | 265 | 266 | **后端自定义配置存放文件夹:** `/backend/config/` 267 | 268 | 269 | 也可以通过[环境变量](#config-dir)添加`自定义配置文件夹`的位置 270 | 271 | 该文件夹存放用户的自定义配置文件(可以覆盖同名默认配置文件的配置) 272 | 273 | 274 | ### 超级管理员 275 | 276 | 超级管理员的注册页面为 `yourhost/admin/register` 277 | 278 | 注册时需要一个`注册码` 可以在[配置文件](#custom-backend-settings-dir)的`superAdminRegisterTokens`字段配置该码,该字段数据类型为字符串数组 279 | 280 | 超级管理员的登录页面为 `yourhost/admin/login` 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | -------------------------------------------------------------------------------- /frontend/src/components/musicSearchList/index.less: -------------------------------------------------------------------------------- 1 | @import '~@/base.less'; 2 | 3 | .searchMediaListBox { 4 | color: @normalTextColor; 5 | line-height: 1; 6 | 7 | .detailItemsList { 8 | width: 100%; 9 | margin-top: .8rem; 10 | > .item { 11 | & + .item { 12 | margin-top: .8rem; 13 | } 14 | padding: .5rem; 15 | display: flex; 16 | align-items: center; 17 | cursor: pointer; 18 | > .left { 19 | width: 40px; 20 | height: 40px; 21 | flex-shrink: 0; 22 | margin-right: 1rem; 23 | img { 24 | width: 100%; 25 | height: 100%; 26 | border-radius: 20%; 27 | overflow: hidden; 28 | object-fit: cover; 29 | } 30 | } 31 | > .right { 32 | flex-grow: 1; 33 | min-width: 0; 34 | display: flex; 35 | align-items: center; 36 | justify-content: space-between; 37 | > * { 38 | & + * { 39 | margin-left: 1rem; 40 | } 41 | } 42 | > .content { 43 | min-width: 0; 44 | > .title { 45 | .textOverflow(); 46 | font-size: 1rem; 47 | margin-bottom: .5rem; 48 | color: @themeColor; 49 | } 50 | > .desc { 51 | .textOverflow(); 52 | min-width: 0; 53 | font-size: .9rem; 54 | } 55 | } 56 | > .actions { 57 | flex-shrink: 0; 58 | :global(.iconfont) { 59 | font-size: 1.5em; 60 | cursor: pointer; 61 | } 62 | .added { 63 | font-size: 0.9rem; 64 | } 65 | } 66 | } 67 | } 68 | } 69 | 70 | .step1Box { 71 | height: 100%; 72 | display: flex; 73 | flex-direction: column; 74 | justify-content: space-between; 75 | &.hide { 76 | display: none; 77 | } 78 | > * { 79 | width: 100%; 80 | } 81 | .searchList { 82 | .subListItem { 83 | box-sizing: border-box; 84 | width: 100%; 85 | padding: .5rem; 86 | & + .subListItem { 87 | margin-top: 1rem; 88 | } 89 | > .header { 90 | font-size: 1.2em; 91 | color: @themeColor; 92 | } 93 | } 94 | } 95 | .searchArea { 96 | width: 100%; 97 | box-sizing: border-box; 98 | padding: .2rem .4rem; 99 | margin-top: 1rem; 100 | display: flex; 101 | align-items: center; 102 | > * { 103 | + * { 104 | margin-left: 1rem; 105 | } 106 | } 107 | } 108 | 109 | } 110 | 111 | .step2Box { 112 | box-sizing: border-box; 113 | width: 100%; 114 | height: 100%; 115 | display: flex; 116 | flex-direction: column; 117 | padding: .5rem; 118 | > * { 119 | width: 100%; 120 | } 121 | > .header { 122 | color: white; 123 | font-size: 1rem; 124 | margin-bottom: 1rem; 125 | > * { 126 | display: inline-block; 127 | cursor: pointer; 128 | padding-right: .5rem; 129 | } 130 | } 131 | 132 | >.detail { 133 | > .header { 134 | display: flex; 135 | align-items: center; 136 | justify-content: space-between; 137 | padding: 0 .5rem; 138 | & + * { 139 | margin-bottom: 1rem; 140 | } 141 | > .name { 142 | min-width: 0; 143 | .textOverflow(); 144 | font-size: 1.1rem; 145 | margin-right: 1rem; 146 | } 147 | > .actions { 148 | text-align: right; 149 | .btn { 150 | display: inline-block; 151 | padding: .3rem .7rem; 152 | border-radius: 5px; 153 | overflow: hidden; 154 | border: 1px solid rgba(225,225,225,.3); 155 | &:hover { 156 | cursor: pointer; 157 | color: white; 158 | border-color: white; 159 | } 160 | &.mobile { 161 | border: none; 162 | > * { 163 | display: block; 164 | text-align: center; 165 | line-height: 1.5em; 166 | } 167 | } 168 | :global(.iconfont) { 169 | padding-right: .3rem; 170 | } 171 | } 172 | } 173 | } 174 | 175 | } 176 | } 177 | } 178 | 179 | 180 | .noData { 181 | width: 100%; 182 | height: 60%; 183 | min-height: 50px; 184 | display: flex; 185 | align-items: center; 186 | justify-content: center; 187 | } 188 | 189 | .loading { 190 | width: 100%; 191 | height: 100%; 192 | display: flex; 193 | align-items: center; 194 | justify-content: center; 195 | > * { 196 | font-size: 1.3rem; 197 | animation: loadingAnimation 1s linear infinite; 198 | } 199 | } 200 | 201 | @keyframes loadingAnimation { 202 | from { 203 | transform: rotate(0deg); 204 | } to { 205 | transform: rotate(360deg); 206 | } 207 | } -------------------------------------------------------------------------------- /backend/default/emojiData.default.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "心脏扑通扑通(印尼小胖子 TATAN)", 4 | "src": "https://i.loli.net/2019/11/20/YhAwOXBMeqnJRGx.gif", 5 | "id": "ceeb653ely1g3gohxsiu1g209v09v7s6" 6 | }, 7 | { 8 | "title": "好了,孩子们,赶紧起床打游戏啦!", 9 | "src": "https://i.loli.net/2019/11/20/e2pXzMKquacAoxP.jpg", 10 | "id": "006fbYi5gy1g3et5w3fq6j305e03j3ym" 11 | }, 12 | { 13 | "title": "先yeah一个,免得你们觉得我不快乐(印尼小胖 TATAN)", 14 | "src": "https://i.loli.net/2019/11/20/zUJhabYyEKtGls1.gif", 15 | "id": "006APoFYly1g3cshwilczg30b70b74nu" 16 | }, 17 | { 18 | "title": "魔仙的疑惑", 19 | "src": "https://i.loli.net/2019/11/20/HbpP4cAqsgXC3Wm.jpg", 20 | "id": "415f82b9ly1g3b6tg5um5j20by0byq3f" 21 | }, 22 | { 23 | "title": "你可真阴啊、竟在背地里喜欢", 24 | "src": "https://i.loli.net/2019/11/20/NwgUyO7AvViSLQW.gif", 25 | "id": "9150e4e5gy1g36bgoumhsg20c80e40ur" 26 | }, 27 | { 28 | "title": "如果你不想泡我请推给有需要的人(印尼小胖子 TATAN)", 29 | "src": "https://i.loli.net/2019/11/20/6hv3lE1FVtXJ2Zy.gif", 30 | "id": "006APoFYly1g386ngtxq4g30870871gu" 31 | }, 32 | { 33 | "title": "双手握住简单的快乐(钱 money)", 34 | "src": "https://i.loli.net/2019/11/20/a9TBQ74zborcVv8.jpg", 35 | "id": "9150e4e5gy1g32bu4yvkoj206o06omyd" 36 | }, 37 | { 38 | "title": "等我穿好了袜子踢死你个臭婊子", 39 | "src": "https://i.loli.net/2019/11/20/oiDmOAUQ1YlGPv7.gif", 40 | "id": "006APoFYly1g331akp9nng30dc0dcwig" 41 | }, 42 | { 43 | "title": "我爱你此心此情天地可鉴", 44 | "src": "https://i.loli.net/2019/11/20/2mAajReZXEtbSnV.jpg", 45 | "id": "78b88159gy1g31q3kjrgaj20j60fpabc" 46 | }, 47 | { 48 | "title": "六一礼物在哪里?(权律二六一儿童节表情包)", 49 | "src": "https://i.loli.net/2019/11/20/MFrQVJ82GuPqjNz.gif", 50 | "id": "006APoFYly1g2m6u68pgcg306606oh7k" 51 | }, 52 | { 53 | "title": "好的 老婆(蜡笔小新)", 54 | "src": "https://i.loli.net/2019/11/20/B61y3mcGjqIfWC5.jpg", 55 | "id": "005TGG6vly1g2m1ns9batj30pw0pwtbt" 56 | }, 57 | { 58 | "title": "收起你那愚蠢的图片(熊猫头)", 59 | "src": "https://i.loli.net/2019/11/20/qhJNm2TyBVI3oR5.jpg", 60 | "id": "006GJQvhly1g2jxwef9p8j309q09qq2z" 61 | }, 62 | { 63 | "title": "做咩野?(印尼小胖 TATAN)", 64 | "src": "https://i.loli.net/2019/11/20/mg7sCleyaZvWL4u.gif", 65 | "id": "ceeb653ely1g2julyw4oeg208c08cn3j" 66 | }, 67 | { 68 | "title": "再亲一口好吗(萌娃表情包)", 69 | "src": "https://i.loli.net/2019/11/20/QvxqD2tneEdhHuk.jpg", 70 | "id": "006mMMLigy1g2hsukbc5kj30sg0slq64" 71 | }, 72 | { 73 | "title": "要人老命(熊猫头抽烟流鼻血表情包)", 74 | "src": "https://i.loli.net/2019/11/20/Xpe9xcQSvyLPglm.gif", 75 | "id": "ceeb653ely1g2ipkekq53g20bg0b4js3" 76 | }, 77 | { 78 | "title": "不跟你们这些基佬玩耍了我要去体验异性恋的生活了", 79 | "src": "https://i.loli.net/2019/11/20/y5dRFV9ur8AjP7G.jpg", 80 | "id": "006APoFYly1g2fa7ll1pfj30a00a0mxn" 81 | }, 82 | { 83 | "title": "就他 不要给我面子死里打(印尼小胖 TATAN)", 84 | "src": "https://i.loli.net/2019/11/20/CPOgQmE9IFpurnT.jpg", 85 | "id": "006APoFYly1g2f7bjrrtej306u06mdg9" 86 | }, 87 | 88 | { 89 | "title": "心脏扑通扑通(印尼小胖子 TATAN)", 90 | "src": "https://i.loli.net/2019/11/20/YhAwOXBMeqnJRGx.gif", 91 | "id": "eeb653ely1g3gohxsiu1g209v09v7s6" 92 | }, 93 | { 94 | "title": "好了,孩子们,赶紧起床打游戏啦!", 95 | "src": "https://i.loli.net/2019/11/20/e2pXzMKquacAoxP.jpg", 96 | "id": "06fbYi5gy1g3et5w3fq6j305e03j3ym" 97 | }, 98 | { 99 | "title": "先yeah一个,免得你们觉得我不快乐(印尼小胖 TATAN)", 100 | "src": "https://i.loli.net/2019/11/20/zUJhabYyEKtGls1.gif", 101 | "id": "06APoFYly1g3cshwilczg30b70b74nu" 102 | }, 103 | { 104 | "title": "魔仙的疑惑", 105 | "src": "https://i.loli.net/2019/11/20/HbpP4cAqsgXC3Wm.jpg", 106 | "id": "15f82b9ly1g3b6tg5um5j20by0byq3f" 107 | }, 108 | { 109 | "title": "你可真阴啊、竟在背地里喜欢", 110 | "src": "https://i.loli.net/2019/11/20/NwgUyO7AvViSLQW.gif", 111 | "id": "150e4e5gy1g36bgoumhsg20c80e40ur" 112 | }, 113 | { 114 | "title": "如果你不想泡我请推给有需要的人(印尼小胖子 TATAN)", 115 | "src": "https://i.loli.net/2019/11/20/6hv3lE1FVtXJ2Zy.gif", 116 | "id": "06APoFYly1g386ngtxq4g30870871gu" 117 | }, 118 | { 119 | "title": "双手握住简单的快乐(钱 money)", 120 | "src": "https://i.loli.net/2019/11/20/a9TBQ74zborcVv8.jpg", 121 | "id": "150e4e5gy1g32bu4yvkoj206o06omyd" 122 | }, 123 | { 124 | "title": "等我穿好了袜子踢死你个臭婊子", 125 | "src": "https://i.loli.net/2019/11/20/oiDmOAUQ1YlGPv7.gif", 126 | "id": "06APoFYly1g331akp9nng30dc0dcwig" 127 | }, 128 | { 129 | "title": "我爱你此心此情天地可鉴", 130 | "src": "https://i.loli.net/2019/11/20/2mAajReZXEtbSnV.jpg", 131 | "id": "8b88159gy1g31q3kjrgaj20j60fpabc" 132 | }, 133 | { 134 | "title": "六一礼物在哪里?(权律二六一儿童节表情包)", 135 | "src": "https://i.loli.net/2019/11/20/MFrQVJ82GuPqjNz.gif", 136 | "id": "06APoFYly1g2m6u68pgcg306606oh7k" 137 | }, 138 | { 139 | "title": "好的 老婆(蜡笔小新)", 140 | "src": "https://i.loli.net/2019/11/20/B61y3mcGjqIfWC5.jpg", 141 | "id": "05TGG6vly1g2m1ns9batj30pw0pwtbt" 142 | }, 143 | { 144 | "title": "收起你那愚蠢的图片(熊猫头)", 145 | "src": "https://i.loli.net/2019/11/20/qhJNm2TyBVI3oR5.jpg", 146 | "id": "06GJQvhly1g2jxwef9p8j309q09qq2z" 147 | }, 148 | { 149 | "title": "做咩野?(印尼小胖 TATAN)", 150 | "src": "https://i.loli.net/2019/11/20/mg7sCleyaZvWL4u.gif", 151 | "id": "eeb653ely1g2julyw4oeg208c08cn3j" 152 | }, 153 | { 154 | "title": "再亲一口好吗(萌娃表情包)", 155 | "src": "https://i.loli.net/2019/11/20/QvxqD2tneEdhHuk.jpg", 156 | "id": "06mMMLigy1g2hsukbc5kj30sg0slq64" 157 | }, 158 | { 159 | "title": "要人老命(熊猫头抽烟流鼻血表情包)", 160 | "src": "https://i.loli.net/2019/11/20/Xpe9xcQSvyLPglm.gif", 161 | "id": "eeb653ely1g2ipkekq53g20bg0b4js3" 162 | }, 163 | { 164 | "title": "不跟你们这些基佬玩耍了我要去体验异性恋的生活了", 165 | "src": "https://i.loli.net/2019/11/20/y5dRFV9ur8AjP7G.jpg", 166 | "id": "06APoFYly1g2fa7ll1pfj30a00a0mxn" 167 | }, 168 | { 169 | "title": "就他 不要给我面子死里打(印尼小胖 TATAN)", 170 | "src": "https://i.loli.net/2019/11/20/CPOgQmE9IFpurnT.jpg", 171 | "id": "06APoFYly1g2f7bjrrtej306u06mdg9" 172 | } 173 | ] -------------------------------------------------------------------------------- /frontend/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { getDvaApp, history as router } from 'umi' 2 | 3 | import { MatchedSearchValue, SearchTreeType } from 'config/type.conf' 4 | import globalConfigs from '@global/common/config' 5 | 6 | enum TipTypes { 7 | blockMusic, 8 | unBlockMusic, 9 | } 10 | 11 | const tipLabelObj = { 12 | [TipTypes.blockMusic]: '屏蔽的音乐在播放期间会自动静音, 可以点击取消屏蔽撤销', 13 | } 14 | 15 | const pastTipRecord = {} 16 | 17 | export function tipWrapper(func: Function, type: TipTypes) { 18 | if (pastTipRecord[type]) { 19 | return func 20 | } 21 | return function (...args) { 22 | if (!pastTipRecord[type]) { 23 | // Modal.info({ 24 | // title: '提示', 25 | // content: tipLabelObj[type], 26 | // onOk: () => { 27 | // func() 28 | // } 29 | // }) 30 | return 31 | } 32 | func(...args) 33 | } 34 | } 35 | 36 | export const throttle = (func, time = 500, delayMode = false) => { 37 | if (delayMode) { // 防抖 38 | let delayTimer = null 39 | return (...args) => { 40 | if (delayTimer) { 41 | clearTimeout(delayTimer) 42 | } 43 | delayTimer = setTimeout(() => { 44 | func(...args) 45 | }, time) 46 | } 47 | } 48 | let isThrottle = false 49 | const func2 = function (...args) { 50 | if (isThrottle) { 51 | return 52 | } 53 | func(...args) 54 | isThrottle = true 55 | setTimeout(() => { 56 | isThrottle = false 57 | }, time) 58 | } 59 | return func2 60 | } 61 | 62 | 63 | export const isPathEqual = (path1: string, path2: string) => path1.toLocaleLowerCase().replace(/\/$/, '') === path2.toLocaleLowerCase().replace('/\/$/', '') 64 | 65 | export const joinPath = (path1, path2) => '/' + path1.split('/').concat(path2.split('/')).filter(s => !!s).join('/') 66 | 67 | export const getAuthToken = () => (new URL(location.href)).searchParams.get(globalConfigs.authTokenFeildName) || '' 68 | 69 | 70 | export function getLocalStorageData(key: string, defaultValue: T = null) { 71 | const dataStr = localStorage.getItem(key) 72 | const value = JSON.parse(dataStr) as T 73 | return value !== null ? value : defaultValue 74 | } 75 | 76 | export function setLocalStorageData(key: string, data: any) { 77 | localStorage.setItem(key, JSON.stringify(data)) 78 | } 79 | 80 | export function CustomAlert(content: string) { 81 | const app = getDvaApp() 82 | app._store.dispatch({ 83 | type: 'center/addNotification', 84 | payload: { 85 | content 86 | } 87 | }) 88 | } 89 | 90 | export function checkReqRes(res, actionName = '请求') { 91 | if (res && res.success) { 92 | CustomAlert(`${actionName}成功`) 93 | } else { 94 | CustomAlert(`${actionName}失败`) 95 | } 96 | } 97 | 98 | 99 | const getFlatSearchTree = (searchTree) => { 100 | const fieldNames = Object.keys(searchTree) 101 | const arr = [] 102 | fieldNames.forEach(fieldName => { 103 | const value = searchTree[fieldName] 104 | const valueType = typeof value 105 | if (valueType === 'object') { 106 | const flatTree = getFlatSearchTree(value) 107 | flatTree.forEach(fieldNameArr => arr.push([fieldName, ...fieldNameArr])) 108 | } else if (value === true) { 109 | arr.push([fieldName]) 110 | } 111 | }) 112 | return arr 113 | } 114 | 115 | export function searchValueFromObjByTree(objs: T[], searchTree: SearchTreeType, searchStr) { 116 | if (typeof searchTree !== 'object') { 117 | throw new Error('invalid searchtree') 118 | } 119 | const flatSearchTree = getFlatSearchTree(searchTree) 120 | objs.forEach(obj => { 121 | flatSearchTree.forEach(keyArr => { 122 | const searchValue = keyArr.reduce((obj, key) => { 123 | return obj && obj[key] 124 | }, obj) 125 | let findIndex = -1 126 | if (!!searchValue && !!searchValue.indexOf) { 127 | findIndex = searchValue.indexOf(searchStr) 128 | } 129 | if (findIndex > -1) { 130 | const leafNodeParent = keyArr.length === 1 ? obj : 131 | keyArr.slice(0, -1).reduce((nodeObj, feildName) => { 132 | return nodeObj[feildName] 133 | }, obj) 134 | const lastKey = keyArr[keyArr.length - 1] 135 | leafNodeParent[lastKey] = new MatchedSearchValue({ 136 | value: searchValue, 137 | startMatched: findIndex, 138 | endMatched: findIndex + searchStr.length 139 | }) 140 | } 141 | }) 142 | }) 143 | } 144 | 145 | export function isMatchedFeildSearchValue(value) { 146 | return value instanceof MatchedSearchValue 147 | } 148 | 149 | export function deduplicateObjArr(arr: Object[], getId: (item) => string) { 150 | const set = new Set() 151 | const newArr = [] 152 | arr.forEach(item => { 153 | const key = getId(item) 154 | if (set.has(key)) { 155 | return 156 | } 157 | set.add(key) 158 | newArr.push(item) 159 | }) 160 | return newArr 161 | } 162 | 163 | export function copyToClipBoard(text = '', container: React.MutableRefObject = null, alert = false) { 164 | const input = document.createElement('input') 165 | input.setAttribute('readonly', 'readonly') 166 | input.setAttribute('value', text) 167 | const box = container ? container.current : document.body 168 | box.appendChild(input) 169 | input.select() 170 | let flag = false 171 | if (document.execCommand) { 172 | document.execCommand('copy') 173 | flag = true 174 | } 175 | box.removeChild(input) 176 | if (alert) { 177 | CustomAlert(flag ? '已复制到剪切板' : '复制失败') 178 | } 179 | return flag 180 | } 181 | 182 | 183 | export function getArrRandomItem(arr: any[]) { 184 | return arr[Math.floor(Math.random() * arr.length)] 185 | } 186 | 187 | export function gotoRoomPage(roomToken) { 188 | const prefix = globalConfigs.roomUrlPrefix || '' 189 | router.push({ 190 | pathname: roomToken === globalConfigs.hallRoomToken ? '/' : `/${prefix}${roomToken}`, 191 | search: location.search, 192 | }) 193 | } 194 | 195 | export function urlCompatible(originUrl: string) { 196 | if (originUrl) { 197 | return originUrl.replace(/^(http|https):/, '') 198 | } 199 | return originUrl 200 | } --------------------------------------------------------------------------------