├── 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
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 |
103 |
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) &&
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 | 
6 | 
7 |
8 | 
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 | }
--------------------------------------------------------------------------------