├── packages ├── frontend │ ├── README.md │ ├── src │ │ ├── assets │ │ │ ├── img │ │ │ │ ├── bg │ │ │ │ │ ├── 0.png │ │ │ │ │ └── 1.png │ │ │ │ ├── favicon@16.png │ │ │ │ ├── favicon@48.png │ │ │ │ ├── favicon@128.png │ │ │ │ └── favicon.svg │ │ │ ├── scroll-bar.scss │ │ │ ├── css-var.scss │ │ │ ├── element.scss │ │ │ └── nord.scss │ │ ├── hooks │ │ │ ├── useModelWrapper.ts │ │ │ └── useWsClient.ts │ │ ├── global.d.ts │ │ ├── views │ │ │ ├── home │ │ │ │ ├── Channels.vue │ │ │ │ ├── Groups.vue │ │ │ │ └── Friends.vue │ │ │ ├── Theme.vue │ │ │ ├── ChatRoom.vue │ │ │ ├── Login.vue │ │ │ ├── Register.vue │ │ │ ├── CreateChannel.vue │ │ │ └── EditPersonnel.vue │ │ ├── env.d.ts │ │ ├── main.ts │ │ ├── components │ │ │ ├── ColorsCard.vue │ │ │ ├── Avatar.vue │ │ │ ├── TitleBar.vue │ │ │ ├── SearchChannel.vue │ │ │ ├── SearchUser.vue │ │ │ ├── Emoticons.vue │ │ │ ├── SelMembers.vue │ │ │ ├── ChannelCard.vue │ │ │ ├── UploaderAvatar.vue │ │ │ ├── Message.vue │ │ │ ├── Channel.vue │ │ │ ├── Boiling.vue │ │ │ ├── Group.vue │ │ │ ├── ConfigureGroup.vue │ │ │ └── PanelSelector.vue │ │ ├── router.ts │ │ ├── App.vue │ │ ├── store.ts │ │ └── api.ts │ ├── public │ │ ├── flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2 │ │ └── material-icons.css │ ├── tsconfig.json │ ├── index.html │ ├── vite.config.ts │ └── package.json ├── backend │ ├── static │ │ ├── uploads │ │ │ └── .gitkeep │ │ └── img │ │ │ └── avatar │ │ │ ├── 0.jpg │ │ │ ├── 1.jpg │ │ │ ├── 10.jpg │ │ │ ├── 11.jpg │ │ │ ├── 12.jpg │ │ │ ├── 13.jpg │ │ │ ├── 14.jpg │ │ │ ├── 15.jpg │ │ │ ├── 2.jpg │ │ │ ├── 3.jpg │ │ │ ├── 4.jpg │ │ │ ├── 5.jpg │ │ │ ├── 6.jpg │ │ │ ├── 7.jpg │ │ │ ├── 8.jpg │ │ │ └── 9.jpg │ ├── tsconfig.json │ ├── tsconfig.pro.json │ ├── README.md │ ├── src │ │ ├── hooks │ │ │ ├── useCurUser.ts │ │ │ ├── useTarget.ts │ │ │ ├── usePagination.ts │ │ │ └── extendService.ts │ │ ├── dao │ │ │ ├── seq.ts │ │ │ ├── index.ts │ │ │ ├── chatRoom.ts │ │ │ ├── channel.ts │ │ │ ├── user.ts │ │ │ └── message.ts │ │ ├── global │ │ │ ├── index.ts │ │ │ └── HttpError.ts │ │ ├── middlewares.ts │ │ ├── routes │ │ │ ├── common.ts │ │ │ ├── channels.ts │ │ │ └── chat-rooms.ts │ │ ├── index.ts │ │ ├── utils.ts │ │ └── services │ │ │ └── channels.ts │ ├── start.ts │ ├── .mocharc.js │ ├── package.json │ └── tests │ │ ├── utils.spec.ts │ │ ├── hooks │ │ └── usePagination.spec.ts │ │ └── services │ │ └── channels.spec.ts ├── utils │ ├── src │ │ ├── index.ts │ │ ├── config-dotenv.ts │ │ └── do-command.ts │ ├── tsconfig.json │ └── package.json ├── electron │ ├── static │ │ ├── favicon.icns │ │ ├── favicon.ico │ │ ├── favicon@16.png │ │ ├── favicon@48.png │ │ ├── favicon@128.png │ │ └── favicon.svg │ ├── tsconfig.json │ ├── tsconfig.pro.json │ ├── src │ │ ├── preload.js │ │ └── runner.ts │ ├── esbuild.ts │ └── package.json └── core │ ├── tsconfig.json │ ├── tests │ ├── utils.spec.ts │ ├── schemastery-interface.spec.ts │ ├── schemastery-ext.spec.ts │ ├── ws-client.spec.ts │ └── api.spec.ts │ ├── tsconfig.pro.json │ ├── src │ ├── utils.ts │ ├── index.ts │ ├── chat-rooms.ts │ ├── channels.ts │ ├── schemastery-ext.ts │ ├── users.ts │ ├── ws-client.ts │ ├── messages.ts │ ├── schemastery-interface.ts │ └── api.ts │ └── package.json ├── .eslintignore ├── rules ├── fix.d.ts └── stylelint-plugin.cjs ├── .husky ├── pre-push └── commit-msg ├── .idea └── icon.png ├── public ├── favicon.ico ├── favicon@128.png ├── favicon@16.png ├── favicon@48.png └── favicon.svg ├── TODOS.md ├── .mocharc.js ├── tsconfig.pro.json ├── .editorconfig ├── commitlint.config.js ├── .gitignore ├── scripts └── pre-build.ts ├── tsconfig.json ├── .github ├── actions │ ├── prepare-env │ │ └── action.yml │ ├── gen-pem │ │ └── action.yml │ ├── remote-command │ │ └── action.yml │ └── rsync │ │ └── action.yml └── workflows │ └── build-pro.yml ├── README.md ├── LICENSE ├── package.json ├── .eslintrc.js └── .stylelintrc.js /packages/frontend/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /packages/backend/static/uploads/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rules/fix.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint 5 | -------------------------------------------------------------------------------- /.idea/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/.idea/icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /packages/utils/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config-dotenv' 2 | export * from './do-command' 3 | -------------------------------------------------------------------------------- /public/favicon@128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/public/favicon@128.png -------------------------------------------------------------------------------- /public/favicon@16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/public/favicon@16.png -------------------------------------------------------------------------------- /public/favicon@48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/public/favicon@48.png -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /packages/electron/static/favicon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/electron/static/favicon.icns -------------------------------------------------------------------------------- /packages/electron/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/electron/static/favicon.ico -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": [ "src/**/*.ts", "tests/**/*.ts" ] 4 | } 5 | -------------------------------------------------------------------------------- /packages/electron/static/favicon@16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/electron/static/favicon@16.png -------------------------------------------------------------------------------- /packages/electron/static/favicon@48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/electron/static/favicon@48.png -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/0.jpg -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/1.jpg -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/10.jpg -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/11.jpg -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/12.jpg -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/13.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/13.jpg -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/14.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/14.jpg -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/15.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/15.jpg -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/2.jpg -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/3.jpg -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/4.jpg -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/5.jpg -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/6.jpg -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/7.jpg -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/8.jpg -------------------------------------------------------------------------------- /packages/backend/static/img/avatar/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/backend/static/img/avatar/9.jpg -------------------------------------------------------------------------------- /packages/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": [ "src/**/*.ts", "tests/**/*.ts" ] 4 | } 5 | -------------------------------------------------------------------------------- /packages/electron/static/favicon@128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/electron/static/favicon@128.png -------------------------------------------------------------------------------- /packages/frontend/src/assets/img/bg/0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/frontend/src/assets/img/bg/0.png -------------------------------------------------------------------------------- /packages/frontend/src/assets/img/bg/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/frontend/src/assets/img/bg/1.png -------------------------------------------------------------------------------- /packages/frontend/src/assets/img/favicon@16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/frontend/src/assets/img/favicon@16.png -------------------------------------------------------------------------------- /packages/frontend/src/assets/img/favicon@48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/frontend/src/assets/img/favicon@48.png -------------------------------------------------------------------------------- /packages/frontend/src/assets/img/favicon@128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/frontend/src/assets/img/favicon@128.png -------------------------------------------------------------------------------- /TODOS.md: -------------------------------------------------------------------------------- 1 | # TODOS 2 | 3 | 实现一个开放的自由聊天平台 4 | 5 | ## 初期目标 6 | 7 | * [ ] 注册登录 8 | * [ ] 聊天 9 | 10 | ## 中期目标 11 | 12 | * [ ] 好友 13 | * [ ] 支持多种消息类型 14 | -------------------------------------------------------------------------------- /packages/core/tests/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { Utils } from '../src/utils' 4 | 5 | describe('Utils', function () { 6 | }) 7 | -------------------------------------------------------------------------------- /packages/electron/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src/**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": [ "src/**/*.ts" ] 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/tsconfig.pro.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.pro.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src/**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/backend/tsconfig.pro.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.pro.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src/**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/frontend/public/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boiling-js/boiling/HEAD/packages/frontend/public/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2 -------------------------------------------------------------------------------- /packages/core/src/utils.ts: -------------------------------------------------------------------------------- 1 | export namespace Utils { 2 | export namespace String { 3 | export function pluralize(word: string) { 4 | return word + 's' 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.mocharc.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test' 2 | 3 | module.exports = { 4 | extension: [ 'ts', 'tsx' ], 5 | require: [ 6 | 'dotenv/config', 7 | 'ts-node/register' 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /packages/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "outDir": "dist" 6 | }, 7 | "include": ["src/**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.pro.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": true, 5 | "paths": { 6 | "@boiling/*": [ "packages/*/dist" ] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | indent_size = 2 5 | indent_style = space 6 | 7 | [*.{js,jsx,ts,tsx,vue}] 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /packages/backend/README.md: -------------------------------------------------------------------------------- 1 | # @boiling/backend 2 | 3 | ## 技术栈 4 | 5 | * framework koa 6 | * database mongodb、redis 7 | * utils 8 | * orm mongoose 9 | 10 | ## 功能 11 | 12 | * ws 消息推送 13 | * 登陆注册 14 | * 好友管理 15 | * 聊天管理 16 | -------------------------------------------------------------------------------- /packages/electron/tsconfig.pro.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.pro.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "sourceMap": false, 6 | "declaration": false 7 | }, 8 | "include": ["src/**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/backend/src/hooks/useCurUser.ts: -------------------------------------------------------------------------------- 1 | import { Session } from 'koa-session' 2 | 3 | export default function useCurUser(session: Session | null) { 4 | if (session?.curUser) 5 | return session.curUser 6 | 7 | throw new HttpError('UNAUTHORIZED', '未登录') 8 | } 9 | -------------------------------------------------------------------------------- /packages/backend/src/hooks/useTarget.ts: -------------------------------------------------------------------------------- 1 | import useCurUser from './useCurUser' 2 | import { Session } from 'koa-session' 3 | 4 | export default function useTarget(session: Session | null, id: number | '@me') { 5 | return id === '@me' ? useCurUser(session).id : id 6 | } 7 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boiling/utils", 3 | "version": "0.1.0", 4 | "license": "MIT", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc" 8 | }, 9 | "dependencies": { 10 | "dotenv": "^10.0.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/electron/src/preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge, ipcRenderer } = require('electron') 2 | 3 | contextBridge.exposeInMainWorld('desktop', { 4 | min: () => ipcRenderer.send('window', 'min'), 5 | max: () => ipcRenderer.send('window', 'max'), 6 | type: require('os').type() 7 | }) 8 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useModelWrapper.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue' 2 | 3 | export default function useModelWrapper< 4 | Props extends Record 5 | >(props: Props, emit: any, name: keyof Props) { 6 | return computed({ 7 | get: () => props[name], 8 | set: val => emit(`update:${ name }`, val) 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /packages/backend/src/dao/seq.ts: -------------------------------------------------------------------------------- 1 | import { model, Schema } from 'mongoose' 2 | 3 | export const SeqModel = model('Seq', new Schema({ 4 | collectionName: { 5 | type: String, 6 | unique: true, 7 | required: true 8 | }, 9 | initIndent: { 10 | type: Number, 11 | default: 0 12 | }, 13 | seq: { 14 | type: Number, 15 | required: true 16 | } 17 | })) 18 | -------------------------------------------------------------------------------- /packages/utils/src/config-dotenv.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | import path from 'path' 3 | 4 | const cwd = path.resolve(process.cwd(), '../../') 5 | 6 | export const configDotenv = () => { 7 | dotenv.config({ 8 | path: path.join(cwd, '.env') 9 | }) 10 | !process.env.NODE_ENV && dotenv.config({ 11 | path: path.join(cwd, `.${ process.env.NODE_ENV }.env`) 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /packages/backend/src/global/index.ts: -------------------------------------------------------------------------------- 1 | import { Users } from '@boiling/core' 2 | import { HttpError as _HttpError } from './HttpError' 3 | import 'koa-session' 4 | 5 | declare global { 6 | const HttpError: _HttpError 7 | } 8 | 9 | // @ts-ignore 10 | global.HttpError = _HttpError 11 | 12 | declare module 'koa-session' { 13 | interface Session { 14 | curUser?: Users.Out 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/frontend/src/global.d.ts: -------------------------------------------------------------------------------- 1 | import { ElMessage, ElMessageBox } from 'element-plus' 2 | import { Store } from 'vuex' 3 | import { State } from './store' 4 | 5 | declare module '@vue/runtime-core' { 6 | interface ComponentCustomProperties { 7 | $store: Store 8 | $logger: Console 9 | $prompt: typeof ElMessageBox.prompt 10 | $message: typeof ElMessage 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Boiling 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'type-enum': [2, 'always', [ 5 | 'upd', 'feat', 'fix', 'refactor', 'docs', 'chore', 'style', 'revert', 'release' 6 | ]], 7 | 'type-case': [0], 8 | 'type-empty': [0], 9 | 'scope-empty': [0], 10 | 'scope-case': [0], 11 | 'subject-full-stop': [0, 'never'], 12 | 'subject-case': [0, 'never'], 13 | 'header-max-length': [0, 'always', 72] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | dist 3 | releases 4 | temp 5 | .temp 6 | .cache 7 | .env 8 | 9 | packages/backend/static/uploads 10 | packages/electron/static/assets 11 | packages/electron/static/index.html 12 | 13 | yarn.lock 14 | node_modules/ 15 | npm-debug.log 16 | yarn-debug.log 17 | yarn-error.log 18 | package-lock.json 19 | tsconfig.tsbuildinfo 20 | report.*.json 21 | 22 | .eslintcache 23 | .DS_Store 24 | .vscode 25 | .yarn 26 | *.suo 27 | *.ntvs* 28 | *.njsproj 29 | *.sln 30 | 31 | .idea/* 32 | !.idea/icon.png 33 | -------------------------------------------------------------------------------- /packages/utils/src/do-command.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process' 2 | 3 | export const doCommand = (cmd: string, args: readonly string[], options?: Parameters[2]) => new Promise(resolve => { 4 | if (process.platform === 'win32') { 5 | cmd += '.cmd' 6 | } 7 | const execCmd = spawn(cmd, args, Object.assign({}, { encoding: 'utf-8' }, options)) 8 | execCmd.stdout?.pipe(process.stdout) 9 | execCmd.stderr?.pipe(process.stderr) 10 | execCmd.on('exit', resolve) 11 | }) 12 | -------------------------------------------------------------------------------- /packages/backend/start.ts: -------------------------------------------------------------------------------- 1 | import { doCommand } from '@boiling/utils' 2 | 3 | async function main() { 4 | let cmd: string | undefined 5 | let target: string | undefined 6 | 7 | switch (process.env.NODE_ENV) { 8 | case 'production': 9 | if (await doCommand('yarn', [ 'build' ]) !== 0) 10 | return 11 | 12 | cmd = 'node' 13 | target = 'dist/index.js' 14 | break 15 | case 'development': 16 | cmd = 'nodemon' 17 | target = 'src/index.ts' 18 | break 19 | } 20 | cmd && target && await doCommand(cmd, [ target ]) 21 | } 22 | main().then(undefined) 23 | -------------------------------------------------------------------------------- /packages/backend/src/middlewares.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa' 2 | import { AppContext } from './' 3 | 4 | export namespace Middlewares { 5 | export async function handleErrors(ctx: AppContext, next: Koa.Next) { 6 | try { 7 | await next() 8 | } catch (e) { 9 | if (e instanceof HttpError) { 10 | ctx.body = e.msg 11 | ctx.status = e.code 12 | return 13 | } 14 | throw e 15 | } 16 | } 17 | export async function returnBody(ctx: AppContext, next: Koa.Next) { 18 | const body = await next() 19 | if (!ctx.body) 20 | ctx.body = body 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boiling/core", 3 | "version": "0.1.0", 4 | "main": "./dist/index.js", 5 | "module": "./src/index.ts", 6 | "types": "./dist/index.d.ts", 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "tsc -p ./tsconfig.pro.json", 10 | "test": "mocha tests" 11 | }, 12 | "dependencies": { 13 | "@types/koa": "^2.13.4", 14 | "qs": "^6.10.2", 15 | "schemastery": "2.1.3" 16 | }, 17 | "devDependencies": { 18 | "@types/chai-as-promised": "^7.1.4", 19 | "@types/qs": "^6.9.7", 20 | "axios-mock-adapter": "^1.20.0", 21 | "chai-as-promised": "^7.1.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /scripts/pre-build.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import { doCommand } from '@boiling/utils' 3 | 4 | const cwd = process.cwd() 5 | 6 | const pkg = JSON.parse(fs.readFileSync(`${cwd}/package.json`, 'utf8')) as { 7 | dependencies: Record 8 | } 9 | 10 | Promise.all(Object.keys(pkg.dependencies) 11 | .filter(name => name.startsWith('@boiling/')) 12 | .map(async name => { 13 | const code = await doCommand('yarn', [ 'workspace', name, 'build' ]) 14 | if (code !== 0) 15 | throw new Error(`${name} build failed`) 16 | })) 17 | .catch(err => { 18 | console.error(err) 19 | process.exit(1) 20 | }) 21 | -------------------------------------------------------------------------------- /packages/frontend/public/material-icons.css: -------------------------------------------------------------------------------- 1 | /* fallback */ 2 | @font-face { 3 | font-family: 'Material Icons'; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: url(./flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2) format('woff2'); 7 | } 8 | 9 | .material-icons { 10 | font-family: 'Material Icons'; 11 | font-weight: normal; 12 | font-style: normal; 13 | font-size: 24px; 14 | line-height: 1; 15 | letter-spacing: normal; 16 | text-transform: none; 17 | display: inline-block; 18 | white-space: nowrap; 19 | word-wrap: normal; 20 | direction: ltr; 21 | -webkit-font-feature-settings: 'liga'; 22 | -webkit-font-smoothing: antialiased; 23 | } 24 | -------------------------------------------------------------------------------- /packages/electron/esbuild.ts: -------------------------------------------------------------------------------- 1 | import { build, BuildOptions } from 'esbuild' 2 | import { configDotenv } from '@boiling/utils' 3 | 4 | configDotenv() 5 | 6 | const options = { 7 | entryPoints: ['./src/runner.ts'], 8 | bundle: true, 9 | target: 'node14', 10 | platform: 'node', 11 | sourcemap: false, 12 | external: [ '@boiling/utils' ], 13 | define: { 14 | 'process.env.PRODUCT_URL': JSON.stringify(process.env.PRODUCT_URL) 15 | } 16 | } as BuildOptions 17 | 18 | Promise.all([ 19 | build({ 20 | ...options, 21 | format: 'cjs', 22 | outfile: './dist/index.js' 23 | }) 24 | ]).catch(error => { 25 | console.error(error) 26 | process.exit(1) 27 | }) 28 | -------------------------------------------------------------------------------- /packages/backend/src/dao/index.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | 3 | export default async function main(reconnectTimes = 1) { 4 | const connectUrl = `mongodb://127.0.0.1:27017/${{ 5 | 'production': 'boiling', 6 | 'development': 'boiling-dev', 7 | 'test': 'boiling-test' 8 | }[process.env.NODE_ENV ?? 'development']}` 9 | for (let i = 0; i < reconnectTimes; i++) { 10 | console.log('connecting mongoDB server.') 11 | try { 12 | await mongoose.connect(connectUrl) 13 | return 14 | } catch (e) { 15 | console.warn(e) 16 | } 17 | } 18 | throw new Error( 19 | `Unable connect to mongodb server, please confirm server "${ connectUrl }" is running.`) 20 | } 21 | -------------------------------------------------------------------------------- /packages/backend/.mocharc.js: -------------------------------------------------------------------------------- 1 | const root = require('../../.mocharc.js') 2 | 3 | global = new Proxy(global, { 4 | set(target, p, value, receiver) { 5 | if (p === 'describe') { 6 | target[p] = function (...args) { 7 | const [title, cb] = args 8 | if (title.endsWith(' Service')) { 9 | before(async () => { 10 | await require('./src/dao/index.ts').default() 11 | }) 12 | } 13 | return value(title, cb) 14 | } 15 | } else { 16 | target[p] = value 17 | } 18 | return true 19 | } 20 | }) 21 | 22 | module.exports = Object.assign(root, { 23 | require: [ 24 | ...root.require, 25 | './src/global/index.ts' 26 | ] 27 | }) 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "lib": [ 6 | "esnext", "dom" 7 | ], 8 | "declaration": true, 9 | "outDir": "./dist", 10 | "removeComments": false, 11 | "strict": true, 12 | "moduleResolution": "node", 13 | "baseUrl": "./", 14 | "types": [ "node", "mocha", "chai" ], 15 | "paths": { 16 | "@boiling/*": [ 17 | "packages/*/src" 18 | ] 19 | }, 20 | "esModuleInterop": true, 21 | "skipLibCheck": true, 22 | "forceConsistentCasingInFileNames": true 23 | }, 24 | "exclude": [ "node_modules" ], 25 | "ts-node": { 26 | "require": [ "tsconfig-paths/register" ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/actions/prepare-env/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Prepare Enviroment' 2 | description: 'Prepare and cache project Node.js enviroment.' 3 | author: 'YiJie' 4 | 5 | runs: 6 | using: composite 7 | steps: 8 | - uses: actions/setup-node@v2 9 | with: 10 | node-version: 16.15.0 11 | - uses: actions/cache@v2 12 | env: 13 | cache-name: cache-node-modules 14 | with: 15 | path: ./node_modules 16 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('./yarn.lock') }} 17 | restore-keys: | 18 | ${{ runner.os }}-build-${{ env.cache-name }}- 19 | ${{ runner.os }}-build- 20 | ${{ runner.os }}- 21 | - shell: bash 22 | run: yarn 23 | -------------------------------------------------------------------------------- /packages/backend/src/dao/chatRoom.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose' 2 | import { ChatRooms } from '@boiling/core' 3 | 4 | export const chatRoomSchema = new Schema({ 5 | channelId: { 6 | type: String 7 | }, 8 | name: { 9 | type: String 10 | }, 11 | avatar: { 12 | type: String 13 | }, 14 | members: [{ 15 | type: [Number], 16 | required: true 17 | }], 18 | createdAt: { 19 | type: Date, 20 | required: true 21 | } 22 | }) 23 | 24 | chatRoomSchema.set('toJSON', { 25 | transform: (doc, ret) => { 26 | ret.id = ret._id 27 | delete ret._id 28 | delete ret.__v 29 | } 30 | }) 31 | 32 | export const ChatRoomModel = model('ChatRoom', chatRoomSchema) 33 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/scroll-bar.scss: -------------------------------------------------------------------------------- 1 | @mixin scroll-bar { 2 | $scrollbar-thumb-background: #ccc; 3 | $scrollbar-track-background: #fff0; 4 | 5 | overflow-y: overlay; 6 | &::-webkit-scrollbar { 7 | z-index: 11; 8 | width: 6px; 9 | &:horizontal { 10 | height: 6px; 11 | } 12 | &-thumb { 13 | width: 6px; 14 | background: $scrollbar-thumb-background; 15 | border-radius: 5px; 16 | } 17 | &-corner { 18 | background: $scrollbar-track-background; 19 | } 20 | &-track { 21 | background: $scrollbar-track-background; 22 | &-piece { 23 | width: 6px; 24 | background: $scrollbar-track-background; 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Boiling 3 |

4 |

Boiling

5 | 6 |

7 | lang 8 | version 9 |

10 | 11 | 应用名字取自人声鼎沸的“沸”。 12 | 13 | ## FAQ 14 | 15 | * Q: 需要什么环境以及工具? 16 | * A: `Node.js 16.x`, `yarn`, `Mongodb 5.x` 17 | * Q: 如何启动应用? 18 | * A: 在项目根目录下 19 | * 确认已经启动 mongodb 以及安装了 yarn 20 | * 安装依赖 `yarn` 21 | * 在 `./packages/backend` 创建环境变量文件 `.env` 22 | * 在 `.env` 中添加如下内容 23 | ``` 24 | PORT=32141 25 | ``` 26 | * 启动应用服务端 `start:dev:server` 27 | * 启动应用客户端 `start:dev:client` 28 | -------------------------------------------------------------------------------- /packages/frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import { configDotenv } from '@boiling/utils' 4 | 5 | configDotenv() 6 | 7 | export default defineConfig(env => ({ 8 | define: { 9 | API_HOST: JSON.stringify(env.command === 'build' ? process.env.PRODUCT_URL : '') 10 | }, 11 | publicDir: 'public', 12 | server: { 13 | port: Number(process.env.VITE_PORT) || 3000, 14 | proxy: { 15 | '/api': { 16 | ws: true, 17 | target: `http://${ process.env.BACKEND_HOST }:${ process.env.BACKEND_PORT }`, 18 | changeOrigin: true, 19 | rewrite: path => path.replace(/^\/api/, '') 20 | } 21 | } 22 | }, 23 | plugins: [ vue() ] 24 | })) 25 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | import Schema from 'schemastery' 2 | import './schemastery-ext' 3 | 4 | export const Pagination = (item: Item) => Schema.interface({ 5 | count: Schema.number(), 6 | items: Schema.array(item) 7 | }) 8 | export interface Pagination { 9 | count: number 10 | items: Item[] 11 | } 12 | export interface Paginate { 13 | num?: number 14 | page?: number 15 | } 16 | export interface SearchQuery extends Paginate { 17 | key: string 18 | } 19 | 20 | export * from './channels' 21 | export * from './users' 22 | export * from './messages' 23 | export * from './chat-rooms' 24 | export * from './api' 25 | export * from './router' 26 | export { Utils } from './utils' 27 | export { WsClient, resolveMessage } from './ws-client' 28 | -------------------------------------------------------------------------------- /packages/backend/src/global/HttpError.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes' 2 | 3 | type Status = keyof typeof StatusCodes 4 | 5 | export interface HttpError { 6 | new(key: number, msg: string): this 7 | new(key: Status, msg: string): this 8 | } 9 | 10 | function genCode(key: any) { 11 | switch (typeof key) { 12 | case 'string': 13 | return StatusCodes[key as Status] 14 | case 'number': 15 | return key as StatusCodes 16 | default: 17 | throw new Error('Not support type for key.') 18 | } 19 | } 20 | 21 | export class HttpError extends Error { 22 | msg: string 23 | code: StatusCodes 24 | constructor(key: any, msg: string) { 25 | super(`[${ genCode(key) }] ${ msg }`) 26 | this.msg = msg 27 | this.code = genCode(key) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/backend/src/hooks/usePagination.ts: -------------------------------------------------------------------------------- 1 | import { Paginate, Pagination } from '@boiling/core' 2 | import mongoose from 'mongoose' 3 | 4 | export interface SearchService> { 5 | Model: M 6 | search(...args: any[]): ReturnType 7 | } 8 | 9 | export default function usePagination< 10 | M extends mongoose.Model, S extends SearchService 11 | >(searchService: S, paginate: Paginate, order: [string, -1 | 0 | 1][] = []) { 12 | const { page = 0, num = 10 } = paginate 13 | return (...args: Parameters) => { 14 | const s = searchService.search(...args) 15 | return Promise.all([ 16 | s.clone().count(), s.limit(num).sort(order).skip(page*num) 17 | ]).then(([count, items]) => >{ count, items }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boiling/frontend", 3 | "version": "0.1.0", 4 | "main": "dist/index.html", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "vite", 8 | "build": "vite build" 9 | }, 10 | "dependencies": { 11 | "axios": "^0.24.0", 12 | "element-plus": "^2.1.8", 13 | "highlight.js": "^11.5.1", 14 | "marked": "^4.0.15", 15 | "node-emoji": "^1.11.0", 16 | "vue": "^3.2.26", 17 | "vue-router": "^4.0.12", 18 | "vuex": "^4.0.2", 19 | "vuex-persistedstate": "^4.1.0" 20 | }, 21 | "devDependencies": { 22 | "@element-plus/icons-vue": "^1.1.4", 23 | "@types/marked": "^4.0.3", 24 | "@types/node-emoji": "^1.8.1", 25 | "@vitejs/plugin-vue": "^2.0.1", 26 | "sass": "^1.45.1", 27 | "vite": "^2.7.6" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/frontend/src/views/home/Channels.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 27 | 28 | 33 | -------------------------------------------------------------------------------- /packages/backend/src/hooks/extendService.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | 3 | interface Service> { 4 | Model: M 5 | [k: string]: any 6 | } 7 | 8 | type PickTargetType = { 9 | [key in keyof T]: T[key] extends Type ? key : never 10 | } 11 | 12 | export default function extendService< 13 | S extends Service, 14 | M extends keyof PickTargetType any>, 15 | R = ReturnType 16 | >(service: S, property: M, callback: (pre: R) => R): S { 17 | return new Proxy(service, { 18 | // @ts-ignore 19 | get(target, prop: M) { 20 | const pre = target[prop] 21 | if (prop === property && typeof pre === 'function') { 22 | return (...args: Parameters) => callback(pre(...args)) 23 | } 24 | return target[prop] 25 | } 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /packages/core/src/chat-rooms.ts: -------------------------------------------------------------------------------- 1 | import Schema from 'schemastery' 2 | 3 | export namespace ChatRooms { 4 | /** 5 | * 聊天室 6 | */ 7 | export const Model = Schema.interface({ 8 | /** 9 | * id 10 | */ 11 | id: Schema.string(), 12 | /** 13 | * 频道id 不存在则不属于频道聊天室 14 | * */ 15 | channelId: Schema.string().optional(), 16 | /** 17 | * 名称 18 | * 19 | * 当聊天室为私聊时,名称为空 20 | */ 21 | name: Schema.string().optional(), 22 | /** 23 | * 头像 24 | * 25 | * 当聊天室为私聊时,头像为空 26 | * 当聊天室讨论组时,该结构 avatar 为成员头像组合 27 | * 当聊天室为 channel 时,该结构 avatar 为 guild 头像 28 | */ 29 | avatar: Schema.string().optional(), 30 | /** 成员列表 */ 31 | members: Schema.array(Number), 32 | /** 创建时间 */ 33 | createdAt: Schema.from(Date).optional() 34 | }) 35 | export type Model = Schema.InferS 36 | } 37 | -------------------------------------------------------------------------------- /.github/actions/gen-pem/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Generate Pem' 2 | description: 'Generate pem file and set known_hosts.' 3 | author: 'YiJie' 4 | 5 | inputs: 6 | user: 7 | description: The remote server username 8 | required: true 9 | pem: 10 | description: The remote server key 11 | required: true 12 | pem-type: 13 | description: The remote server key type 14 | required: true 15 | host: 16 | description: The remote server rsync host 17 | required: true 18 | 19 | runs: 20 | using: composite 21 | steps: 22 | - shell: bash 23 | env: 24 | DSN: ${{ inputs.user }}@${{ inputs.host }} 25 | run: | 26 | cat > k.pem <> ~/.ssh/known_hosts 33 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/css-var.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | // 辅助色 3 | --color-auxi-primary: #202225; 4 | --color-auxi-regular: #282c34; 5 | --color-auxi-secondary: #2c313a; 6 | --color-auxi-placeholder: #333842; 7 | // 文字色 8 | --color-text-primary: var(--el-text-color-primary); 9 | --color-text-regular: var(--el-text-color-regular); 10 | --color-text-secondary: var(--el-text-color-secondary); 11 | --color-text-placeholder: var(--el-text-color-placeholder); 12 | // 主题色 13 | --color-primary: var(--el-color-primary); 14 | // 圆角 15 | --border-radius: 5px; 16 | // 阴影 17 | --box-shadow: 0 0 16px #959598; 18 | } 19 | .material-icons.md-light { color: rgb(255 255 255 / 100%); } 20 | // 用户状态色 21 | .noDisturb { 22 | background-color: #ed4245; 23 | } 24 | .online { 25 | background-color: #3ba55d; 26 | } 27 | .leave { 28 | background-color: #faa81a; 29 | } 30 | .offline { 31 | background-color: #747f8d; 32 | } 33 | -------------------------------------------------------------------------------- /packages/backend/src/dao/channel.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose' 2 | import { Channels } from '@boiling/core' 3 | 4 | export const channelSchema = new Schema({ 5 | name: { 6 | type: String 7 | }, 8 | avatar: { 9 | type: String 10 | }, 11 | members: [{ 12 | id: { type: Number, required: true }, 13 | name: { type: String } 14 | }], 15 | subChannels: [{ 16 | title: { type: String }, 17 | chatRooms: [{ 18 | id: { type: String, required: true }, 19 | title: { type: String }, 20 | desc: { type: String } 21 | }] 22 | }], 23 | description: { 24 | type: String 25 | }, 26 | createdAt: { 27 | type: Date, 28 | default: Date.now 29 | } 30 | }) 31 | 32 | channelSchema.set('toJSON', { 33 | transform: (doc, ret) => { 34 | ret.id = ret._id 35 | delete ret._id 36 | delete ret.__v 37 | } 38 | }) 39 | 40 | export const ChannelModel = model('Channel', channelSchema) 41 | -------------------------------------------------------------------------------- /packages/frontend/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | type OSType = 'Linux' | 'Darwin' | 'Windows_NT'; 4 | 5 | interface Desktop { 6 | min?: () => void 7 | max?: () => void 8 | type?: OSType 9 | } 10 | 11 | interface OSMeta { 12 | isDesktop?: boolean 13 | type?: OSType 14 | } 15 | 16 | export declare global { 17 | declare const API_HOST: string 18 | export declare const desktop: Desktop | undefined 19 | export declare const osMeta: OSMeta | undefined 20 | interface Window { 21 | desktop: Desktop | undefined 22 | osMeta: OSMeta | undefined 23 | } 24 | } 25 | 26 | declare module '*.vue' { 27 | import { DefineComponent } from 'vue' 28 | const component: DefineComponent<{}, {}, any> 29 | export default component 30 | } 31 | 32 | interface ImportMetaEnv { 33 | readonly VITE_LOGIN_UID: string 34 | readonly VITE_LOGIN_PWD: string 35 | } 36 | 37 | interface ImportMeta { 38 | readonly env: ImportMetaEnv 39 | } 40 | -------------------------------------------------------------------------------- /packages/backend/src/dao/user.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose' 2 | import { Users } from '@boiling/core' 3 | 4 | export const userSchema = new Schema({ 5 | id: { 6 | type: Number, 7 | unique: true, 8 | required: true 9 | }, 10 | username: { 11 | type: String, 12 | required: true 13 | }, 14 | passwordHash: { 15 | type: String, 16 | required: true 17 | }, 18 | avatar: { 19 | type: String, 20 | required: true 21 | }, 22 | tags: { 23 | type: [String], 24 | default: [] 25 | }, 26 | status: { 27 | type: String, 28 | default: 'offline' 29 | }, 30 | birthday: { 31 | type: String 32 | }, 33 | sex: { 34 | type: String, 35 | default: 'female' 36 | }, 37 | desc: { 38 | type: String 39 | }, 40 | friends: { 41 | type: [{ 42 | id: Number, 43 | tags: [String], 44 | remark: String 45 | }], 46 | default: [] 47 | } 48 | }) 49 | 50 | export const UserModel = model('User', userSchema) 51 | -------------------------------------------------------------------------------- /.github/actions/remote-command/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Remote Command' 2 | description: 'Run remote command by ssh.' 3 | author: 'YiJie' 4 | 5 | inputs: 6 | user: 7 | description: The remote server username 8 | required: true 9 | pem: 10 | description: The remote server key 11 | required: true 12 | pem-type: 13 | description: The remote server key type 14 | required: true 15 | host: 16 | description: The remote server rsync host 17 | required: true 18 | cmds: 19 | description: Cmds that need to be run 20 | required: true 21 | 22 | runs: 23 | using: composite 24 | steps: 25 | - uses: boiling-js/boiling/.github/actions/gen-pem@master 26 | with: 27 | user: ${{ inputs.user }} 28 | host: ${{ inputs.host }} 29 | pem: ${{ inputs.pem }} 30 | pem-type: ${{ inputs.pem-type }} 31 | - shell: bash 32 | env: 33 | DSN: ${{ inputs.user }}@${{ inputs.host }} 34 | run: | 35 | ssh -i k.pem $DSN < {} 28 | } 29 | // @ts-ignore 30 | return target[prop] 31 | } 32 | }), 33 | $message: ElMessage, 34 | $prompt: ElMessageBox.prompt 35 | } 36 | 37 | app 38 | .use(router) 39 | .use(store) 40 | .mount('#app') 41 | -------------------------------------------------------------------------------- /packages/backend/src/dao/message.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose' 2 | import { Messages, Users } from '@boiling/core' 3 | 4 | const messageSchema = new Schema({ 5 | sender: { 6 | type: new Schema({ 7 | id: { 8 | type: Number, 9 | required: true 10 | }, 11 | username: { 12 | type: String, 13 | required: true 14 | }, 15 | avatar: { 16 | type: String, 17 | required: true 18 | }, 19 | status: { 20 | type: String, 21 | default: 'offline' 22 | } 23 | }), 24 | required: true 25 | }, 26 | content: { 27 | type: String, 28 | required: true 29 | }, 30 | createdAt: { 31 | type: Date, 32 | required: true 33 | }, 34 | chatRoomId: { 35 | type: String, 36 | required: true 37 | } 38 | }) 39 | 40 | messageSchema.set('toJSON', { 41 | transform: (doc, ret) => { 42 | ret.id = ret._id 43 | delete ret._id 44 | delete ret.__v 45 | } 46 | }) 47 | 48 | export const MessageModel = model('Message', messageSchema) 49 | -------------------------------------------------------------------------------- /packages/backend/src/routes/common.ts: -------------------------------------------------------------------------------- 1 | import { Router } from '@boiling/core' 2 | import { File } from 'formidable' 3 | import { cp, pathExists } from 'fs-extra' 4 | import * as path from 'path' 5 | import { staticPath } from '../index' 6 | 7 | export const router = new Router({ 8 | prefix: '/common' 9 | }) 10 | .post('/upload', async ctx => { 11 | const files = Object.entries(ctx.request.files ?? {}).reduce((acc, [_, arg]) => { 12 | return acc.concat(Array.isArray(arg) ? arg : [ arg ]) 13 | }, [] as File[]) 14 | return await Promise.all(files.map(async file => { 15 | const ext = require('mime-types').extension(file.mimetype) 16 | const filename = `${ file.hash }.${ ext }` 17 | const targetPath = path.resolve(staticPath, 'uploads', filename) 18 | if (await pathExists(targetPath)) 19 | return filename 20 | 21 | return new Promise((resolve, reject) => { 22 | if (!file.hash) 23 | throw new Error('file.hash is not defined') 24 | cp(file.filepath, targetPath, err => err ? reject(err) : resolve(filename)) 25 | }) 26 | })) 27 | }) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-present Winna 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 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ColorsCard.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 27 | 28 | 52 | -------------------------------------------------------------------------------- /.github/actions/rsync/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Rsync Action' 2 | description: 'Upload directory by rsync.' 3 | author: 'YiJie' 4 | 5 | inputs: 6 | user: 7 | description: The remote server username 8 | required: true 9 | pem: 10 | description: The remote server key 11 | required: true 12 | pem-type: 13 | description: The remote server key type 14 | required: true 15 | host: 16 | description: The remote server rsync host 17 | required: true 18 | path: 19 | description: The local path 20 | required: false 21 | default: '' 22 | remote-path: 23 | description: The remote server rsync path 24 | required: false 25 | default: '' 26 | 27 | runs: 28 | using: composite 29 | steps: 30 | - uses: boiling-js/boiling/.github/actions/gen-pem@master 31 | with: 32 | user: ${{ inputs.user }} 33 | host: ${{ inputs.host }} 34 | pem: ${{ inputs.pem }} 35 | pem-type: ${{ inputs.pem-type }} 36 | - shell: bash 37 | env: 38 | DSN: ${{ inputs.user }}@${{ inputs.host }} 39 | LOCAL_PATH: ${{ github.workspace }}/${{ inputs.path }} 40 | run: rsync -e "ssh -i k.pem" -av $LOCAL_PATH $DSN:${{ inputs.remote-path }} 41 | -------------------------------------------------------------------------------- /packages/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boiling/backend", 3 | "version": "0.1.0", 4 | "main": "dist/index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "ts-node ../../scripts/pre-build.ts && tsc -p tsconfig.pro.json", 8 | "start": "ts-node start.ts" 9 | }, 10 | "dependencies": { 11 | "@boiling/core": "^0.1.0", 12 | "@koa/router": "^10.1.1", 13 | "fs-extra": "^10.0.0", 14 | "http-status-codes": "^2.1.4", 15 | "koa": "^2.13.4", 16 | "koa-body": "^5.0.0", 17 | "koa-bodyparser": "^4.3.0", 18 | "koa-logger": "^3.2.1", 19 | "koa-session": "^6.2.0", 20 | "koa-static": "^5.0.0", 21 | "koa-websocket": "^6.0.0", 22 | "mongoose": "6.1.3", 23 | "redis": "^4.1.0", 24 | "uuid": "^8.3.2" 25 | }, 26 | "devDependencies": { 27 | "@types/fs-extra": "^9.0.13", 28 | "@types/koa": "^2.13.4", 29 | "@types/koa-bodyparser": "^4.3.4", 30 | "@types/koa-logger": "^3.1.2", 31 | "@types/koa-session": "^5.10.4", 32 | "@types/koa-static": "^4.0.2", 33 | "@types/koa-websocket": "^5.0.7", 34 | "@types/koa__router": "^8.0.11", 35 | "@types/mock-fs": "^4.13.1", 36 | "@types/uuid": "^8.3.4", 37 | "mock-fs": "^5.1.2", 38 | "nodemon": "^2.0.15" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/backend/tests/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { Seq, Security } from '../src/utils' 2 | import DAOMain from '../src/dao' 3 | import { expect } from 'chai' 4 | import { SeqModel } from '../src/dao/seq' 5 | 6 | after(() => { 7 | process.exit(0) 8 | }) 9 | 10 | describe('Utils', () => { 11 | describe('Security', () => { 12 | it('should password is match passwordHash.', () => { 13 | expect( 14 | Security.match('123', Security.encrypt('123')) 15 | ).to.be.eq(true) 16 | expect( 17 | Security.match('456', Security.encrypt('123')) 18 | ).to.be.eq(false) 19 | }) 20 | }) 21 | describe('Seq', () => { 22 | before(async () => { 23 | await DAOMain() 24 | }) 25 | afterEach(async () => { 26 | await SeqModel.deleteMany({}) 27 | }) 28 | it('return value should auto increase.', async () => { 29 | const name = 'test' 30 | const v = await Seq.auto(name) 31 | expect(await Seq.auto(name)).to.be.eq(v + 1) 32 | }) 33 | it('should auto increase on the basis of `1000`.', async () => { 34 | expect(await Seq.auto('test', 1000)) 35 | .to.be.eq(1001) 36 | }) 37 | it('should increase target step.', async () => { 38 | const name = 'test' 39 | const v = await Seq.auto(name) 40 | expect(await Seq.auto(name, 0, 2)).to.be.eq(v + 2) 41 | }) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /packages/core/tests/schemastery-interface.spec.ts: -------------------------------------------------------------------------------- 1 | import Schema from 'schemastery' 2 | import { expect } from 'chai' 3 | import '../src/schemastery-interface' 4 | 5 | it('should use `optional` method.', function () { 6 | const O = Schema.interface({ 7 | foo: Schema.string().optional(), 8 | bar: Schema.string() 9 | }) 10 | O({ bar: 'bar' }) 11 | // @ts-ignore 12 | expect(O.bind(null, { })) 13 | .to.be.throw(Error, 'bar is required but not exist') 14 | }) 15 | 16 | it('should use schemastry interface.', () => { 17 | const UserOut = Schema.interface({ 18 | name: Schema.string(), 19 | tags: Schema.array(Schema.string()).default([]), 20 | friend: Schema.interface({ 21 | name: Schema.string() 22 | }) 23 | }) 24 | // @ts-ignore 25 | expect(UserOut({ 26 | id: 1, name: 'John', tags: [], 27 | friend: { name: 'John' } 28 | })).to.be.not.have.property('id') 29 | expect(UserOut({ 30 | name: 'John', tags: ['John'], 31 | friend: { name: 'John' } 32 | })).property('tags').to.be.contain('John') 33 | // @ts-ignore 34 | expect(UserOut({ 35 | name: 'John', tags: ['John'], 36 | friend: { 37 | id: 1, 38 | name: 'John' 39 | } 40 | })).property('friend').to.be.not.have.property('id') 41 | // @ts-ignore 42 | expect(UserOut.bind(null, { 43 | name: 'John', tags: ['John'] 44 | })).to.be.throw('friend is required but not exist') 45 | }) 46 | -------------------------------------------------------------------------------- /packages/electron/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boiling/electron", 3 | "version": "0.1.0", 4 | "license": "MIT", 5 | "author": "Yijie", 6 | "description": "Boiling Desktop Application", 7 | "main": "dist/index.js", 8 | "scripts": { 9 | "start": "electron -r ts-node/register src/runner.ts", 10 | "cp-frontend": "copyfiles ../frontend/dist/* -u 3 ../frontend/dist/**/* static", 11 | "build:src": "ts-node esbuild.ts && yarn cp-frontend && copyfiles src/*.js dist --flat", 12 | "build:app": "yarn build:src && electron-builder -c.extraMetadata.name=boiling", 13 | "build:app:mac": "yarn build:app -m", 14 | "build:app:win": "yarn build:app -w", 15 | "build:app:linux": "yarn build:app -l" 16 | }, 17 | "devDependencies": { 18 | "asar": "^3.1.0", 19 | "electron": "16.0.9", 20 | "electron-builder": "^23.0.3" 21 | }, 22 | "dependencies": { 23 | "@boiling/utils": "file:../utils" 24 | }, 25 | "build": { 26 | "appId": "com.boiling.app", 27 | "productName": "boiling", 28 | "directories": { 29 | "output": "releases" 30 | }, 31 | "files": [ 32 | "dist/**/*", 33 | "static/**/*" 34 | ], 35 | "mac": { 36 | "icon": "static/favicon.icns", 37 | "target": [ 38 | "dmg", 39 | "zip" 40 | ] 41 | }, 42 | "win": { 43 | "icon": "static/favicon.png", 44 | "target": [ 45 | "nsis", 46 | "zip" 47 | ] 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/frontend/src/components/Avatar.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 38 | 39 | 58 | -------------------------------------------------------------------------------- /packages/core/src/channels.ts: -------------------------------------------------------------------------------- 1 | import Schema from 'schemastery' 2 | 3 | export namespace Channels { 4 | export const MemberMeta = Schema.interface({ 5 | /** id */ 6 | id: Schema.number(), 7 | /** name */ 8 | name: Schema.string().optional(), 9 | /** rules */ 10 | rules: Schema.array(Schema.string()).default([]) 11 | }) 12 | export type MemberMeta = Schema.InferS 13 | export const ChatRoomMeta = Schema.interface({ 14 | /** id */ 15 | id: Schema.string(), 16 | /** 群名 */ 17 | title: Schema.string().optional(), 18 | /** desc */ 19 | desc: Schema.string().optional() 20 | }) 21 | export type ChatRoomMeta = Schema.InferS 22 | export const SubChannelMeta = Schema.interface({ 23 | /** 标题 */ 24 | title: Schema.string(), 25 | /** 聊天室列表 */ 26 | chatRooms: Schema.array(ChatRoomMeta).default([]).optional() 27 | }) 28 | export type SubChannelMeta = Schema.InferS 29 | export const Model = Schema.interface({ 30 | /** id */ 31 | id: Schema.string(), 32 | /** 名称 */ 33 | name: Schema.string(), 34 | /** 头像 */ 35 | avatar: Schema.string(), 36 | /** 成员列表 */ 37 | members: Schema.array(MemberMeta), 38 | /** 子频道 */ 39 | subChannels: Schema.array(SubChannelMeta), 40 | /** 介绍信息 */ 41 | description: Schema.string().optional(), 42 | /** 创建时间 */ 43 | createdAt: Schema.from(Date).optional() 44 | }) 45 | export type Model = Schema.InferS 46 | } 47 | -------------------------------------------------------------------------------- /packages/backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa' 2 | import session from 'koa-session' 3 | import websockify from 'koa-websocket' 4 | import koaBody from 'koa-body' 5 | import staticMiddleware from 'koa-static' 6 | import logger from 'koa-logger' 7 | import { resolve } from 'path' 8 | 9 | import './global' 10 | import { configDotenv } from '@boiling/utils' 11 | 12 | import { router as WSRouter } from './routes/ws' 13 | import { router as UsersRouter } from './routes/users' 14 | import { router as ChannelsRouter } from './routes/channels' 15 | import { router as ChatRoomsRouter } from './routes/chat-rooms' 16 | import { router as CommonRouter } from './routes/common' 17 | import { Middlewares } from './middlewares' 18 | import { initApp } from './utils' 19 | 20 | configDotenv() 21 | 22 | const app = websockify(new Koa()) 23 | 24 | export const staticPath = resolve(__dirname, '../static') 25 | 26 | app.ws.use(WSRouter) 27 | app 28 | .use(staticMiddleware(staticPath)) 29 | .use(logger()) 30 | .use(koaBody({ 31 | multipart: true, 32 | formidable: { 33 | hashAlgorithm: 'md5' 34 | } 35 | })) 36 | .use(session(app)) 37 | .use(Middlewares.handleErrors) 38 | .use(Middlewares.returnBody) 39 | .use(UsersRouter.middleware()) 40 | .use(ChannelsRouter.middleware()) 41 | .use(ChatRoomsRouter.middleware()) 42 | .use(CommonRouter.middleware()) 43 | 44 | export type AppContext = typeof app.context 45 | 46 | async function main() { 47 | await initApp(app) 48 | } 49 | 50 | main().catch(e => { 51 | console.error(e) 52 | process.exit(1) 53 | }) 54 | -------------------------------------------------------------------------------- /packages/frontend/src/views/home/Groups.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 45 | 46 | 63 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/element.scss: -------------------------------------------------------------------------------- 1 | @forward "element-plus/theme-chalk/src/common/var.scss" with ( 2 | $colors: ( 3 | "white": #202225, 4 | "black": #ffffff, 5 | "primary": ( 6 | "base": #5c6bc0 7 | ), 8 | "success": ( 9 | "base": #1abf73 10 | ), 11 | "warning": ( 12 | "base": #f5a623 13 | ), 14 | "danger": ( 15 | "base": #f55a4e 16 | ), 17 | "error": ( 18 | "base": #f55a4e 19 | ), 20 | "info": ( 21 | "base": #8f8f8f 22 | ) 23 | ), 24 | $text-color: ( 25 | "primary": #7f7f7f, 26 | "regular": #afafaf, 27 | "secondary": #dfdfdf, 28 | "placeholder": #ffffff 29 | ), 30 | $fill-color: ( 31 | "blank": #202225, 32 | ), 33 | $bg-color: ( 34 | '': #202225, 35 | 'page': #282c34, 36 | 'overlay': #2c313a, 37 | ), 38 | ); 39 | 40 | @use "element-plus/theme-chalk/src/index.scss" as *; 41 | @media screen and (max-width: 800px) { 42 | .el-dialog { 43 | width: 90%; 44 | } 45 | } 46 | .el-icon { 47 | font-size: var(--font-size); 48 | } 49 | .el-message-box { 50 | width: 300px; 51 | } 52 | body { 53 | .el-overlay { 54 | background-color: rgb(0 0 0 / 80%); 55 | border-radius: 0 0 6px 6px; 56 | } 57 | &.is-desktop { 58 | .el-overlay, .el-overlay-dialog { 59 | top: 31px; 60 | right: 5px; 61 | bottom: 5px; 62 | left: 5px; 63 | height: calc(100% - 36px); 64 | } 65 | &.Darwin { 66 | .el-overlay, .el-overlay-dialog { 67 | top: 26px; 68 | right: 0; 69 | bottom: 0; 70 | left: 0; 71 | height: calc(100% - 26px); 72 | border-radius: 0; 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "boiling-workspace", 4 | "version": "0.1.0", 5 | "license": "MIT", 6 | "workspaces": [ 7 | "packages/*" 8 | ], 9 | "scripts": { 10 | "prepare": "husky install", 11 | "pro": "yarn cross-env NODE_ENV=production", 12 | "dev": "yarn cross-env NODE_ENV=development", 13 | "lint": "eslint --fix ./packages/*/src/**/*.{ts,vue}", 14 | "core": "yarn workspace @boiling/core", 15 | "b": "yarn workspace @boiling/backend", 16 | "f": "yarn workspace @boiling/frontend", 17 | "build:app": "yarn f build && yarn workspace @boiling/electron build:app" 18 | }, 19 | "devDependencies": { 20 | "@commitlint/config-conventional": "^15.0.0", 21 | "@types/chai": "^4.3.0", 22 | "@types/mocha": "^9.0.0", 23 | "@typescript-eslint/eslint-plugin": "^4.33.0", 24 | "@typescript-eslint/parser": "^4.21.0", 25 | "chai": "^4.3.4", 26 | "commitlint": "^15.0.0", 27 | "copyfiles": "^2.4.1", 28 | "cross-env": "^7.0.3", 29 | "dotenv": "^10.0.0", 30 | "esbuild": "^0.14.42", 31 | "eslint": "^7.24.0", 32 | "eslint-config-prettier": "^8.1.0", 33 | "eslint-plugin-prettier": "^3.3.1", 34 | "eslint-plugin-vue": "^7.8.0", 35 | "husky": "^7.0.4", 36 | "mocha": "^9.1.3", 37 | "mockdate": "^3.0.5", 38 | "postcss": "^8.3.11", 39 | "postcss-html": "^1.2.0", 40 | "postcss-scss": "^4.0.2", 41 | "style-loader": "^3.3.1", 42 | "stylelint": "^14.1.0", 43 | "stylelint-config-standard": "^24.0.0", 44 | "stylelint-order": "^5.0.0", 45 | "stylelint-scss": "^4.0.0", 46 | "ts-node": "^10.4.0", 47 | "tsconfig-paths": "^3.12.0", 48 | "typescript": "^4.5.4" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/img/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/electron/static/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/electron/src/runner.ts: -------------------------------------------------------------------------------- 1 | import { app, ipcMain, nativeImage, BrowserWindow } from 'electron' 2 | import * as path from 'path' 3 | import { configDotenv } from '@boiling/utils' 4 | 5 | async function createWindow() { 6 | const mainWindow = new BrowserWindow({ 7 | icon: nativeImage.createFromPath(path.join(__dirname, '../static/favicon.ico')), 8 | title: 'Boiling', 9 | width: 1200, 10 | height: 800, 11 | minWidth: 940, 12 | minHeight: 500, 13 | transparent: true, 14 | titleBarStyle: 'hidden', 15 | webPreferences: { 16 | preload: path.join(__dirname, 'preload.js') 17 | } 18 | }) 19 | if (process.env.NODE_ENV === 'development') { 20 | if (!process.env.VITE_PORT) 21 | throw new Error('Not configured VITE_PORT.') 22 | 23 | await mainWindow.loadURL(`http://127.0.0.1:${process.env.VITE_PORT}`) 24 | mainWindow.webContents.openDevTools({ 25 | mode: 'detach' 26 | }) 27 | } else { 28 | if (!process.env.PRODUCT_URL) 29 | throw new Error('Not configured PRODUCT_URL.') 30 | 31 | await mainWindow.loadURL(process.env.PRODUCT_URL) 32 | } 33 | ipcMain.on('window', (e, ...args) => { 34 | const [ action ] = args 35 | switch (action) { 36 | case 'min': 37 | mainWindow.minimize() 38 | break 39 | case 'max': 40 | mainWindow.maximize() 41 | break 42 | } 43 | }) 44 | } 45 | 46 | async function main() { 47 | configDotenv() 48 | await app.whenReady() 49 | await createWindow() 50 | app.on('activate', () => { 51 | if (BrowserWindow.getAllWindows().length === 0) createWindow() 52 | }) 53 | app.on('window-all-closed', () => { 54 | if (process.platform !== 'darwin') 55 | app.quit() 56 | }) 57 | } 58 | 59 | main().catch(e => { 60 | console.error(e) 61 | console.error('Failed to start the application.') 62 | process.exit(1) 63 | }) 64 | -------------------------------------------------------------------------------- /packages/frontend/src/components/TitleBar.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 24 | 25 | 76 | -------------------------------------------------------------------------------- /packages/frontend/src/components/SearchChannel.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 50 | 51 | 68 | -------------------------------------------------------------------------------- /packages/backend/tests/hooks/usePagination.spec.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose' 2 | import { expect } from 'chai' 3 | 4 | import usePagination from '../../src/hooks/usePagination' 5 | 6 | const testSchema = new Schema<{ name: string, index: number }>({ 7 | name: { 8 | type: String, 9 | required: true 10 | }, 11 | index: { 12 | type: Number, 13 | required: true 14 | } 15 | }) 16 | 17 | export const TestModel = model('Test', testSchema) 18 | 19 | namespace TestService { 20 | export const Model = TestModel 21 | export function search(key: string) { 22 | return Model.find({ name: new RegExp(`.*${key}.*`) }) 23 | } 24 | } 25 | 26 | after(() => process.exit(0)) 27 | 28 | describe('use Pagination', () => { 29 | before(async () => { 30 | await require('../../src/dao/index.ts').default() 31 | for (let i = 0; i < 48; i++) { 32 | await TestModel.create({ name: `test${i}`, index: i }) 33 | } 34 | }) 35 | after(async () => { 36 | await TestModel.deleteMany({}) 37 | }) 38 | it('should get target test data by use pagination.', async () => { 39 | let p = await usePagination(TestService, {})<{ name: string }>('') 40 | expect(p.count).to.be.eq(48) 41 | expect(p.items.length).to.be.eq(10) 42 | expect(p.items[0].name).to.be.eq('test0') 43 | p = await usePagination(TestService, { page: 1 })<{ name: string }>('') 44 | expect(p.items.length).to.be.eq(10) 45 | expect(p.items[0].name).to.be.eq('test10') 46 | p = await usePagination(TestService, { page: 1, num: 15 })<{ name: string }>('') 47 | expect(p.items.length).to.be.eq(15) 48 | expect(p.items[0].name).to.be.eq('test15') 49 | }) 50 | it('should get target test data by order.', async () => { 51 | let p = await usePagination( 52 | TestService, {} 53 | )<{ name: string }>('') 54 | expect(p.items[0].name).to.be.eq('test0') 55 | p = await usePagination( 56 | TestService, {}, [[ 'index', -1 ]] 57 | )<{ name: string }>('') 58 | expect(p.items[0].name).to.be.eq('test47') 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: 'vue-eslint-parser', 3 | parserOptions: { 4 | parser: '@typescript-eslint/parser', 5 | ecmaVersion: 2020, 6 | sourceType: 'module', 7 | ecmaFeatures: { 8 | jsx: true 9 | } 10 | }, 11 | extends: [ 12 | 'plugin:vue/vue3-recommended', 13 | 'plugin:@typescript-eslint/recommended' 14 | ], 15 | rules: { 16 | 'no-use-before-define': 'off', 17 | 'semi': ['error', 'never'], 18 | 'quotes': ['error', 'single'], 19 | 'object-curly-spacing': ['error', 'always'], 20 | 'padding-line-between-statements': [ 21 | 'error', 22 | { blankLine: 'any', prev: ['const', 'let', 'var'], next: ['const', 'let', 'var'] } 23 | ], 24 | 'no-multiple-empty-lines': ['error', { 'max': 1 }], 25 | 'space-in-parens': ['error', 'never'], 26 | 'space-before-function-paren': 'off', 27 | 'keyword-spacing': ['error', { before: true, after: true }], 28 | 'comma-dangle': ['error', 'never'], 29 | 'function-paren-newline': 'off', 30 | 'vue/custom-event-name-casing': 'off', 31 | 'vue/html-closing-bracket-newline': 'off', 32 | 'vue/html-closing-bracket-spacing': 'off', 33 | 'vue/singleline-html-element-content-newline': 'off', 34 | 'vue/max-attributes-per-line': 'off', 35 | '@typescript-eslint/prefer-as-const': 'off', 36 | '@typescript-eslint/no-namespace': 'off', 37 | '@typescript-eslint/ban-ts-ignore': 'off', 38 | '@typescript-eslint/explicit-function-return-type': 'off', 39 | '@typescript-eslint/no-explicit-any': 'off', 40 | '@typescript-eslint/no-var-requires': 'off', 41 | '@typescript-eslint/no-empty-function': 'off', 42 | '@typescript-eslint/no-use-before-define': 'off', 43 | '@typescript-eslint/ban-ts-comment': 'off', 44 | '@typescript-eslint/ban-types': 'off', 45 | '@typescript-eslint/no-non-null-assertion': 'off', 46 | '@typescript-eslint/explicit-module-boundary-types': 'off', 47 | '@typescript-eslint/no-unused-vars': [ 48 | 'error', 49 | { 50 | argsIgnorePattern: '^_.*$', 51 | varsIgnorePattern: '^_.*$' 52 | } 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/frontend/src/components/SearchUser.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 62 | 63 | 79 | -------------------------------------------------------------------------------- /packages/frontend/src/components/Emoticons.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 26 | 27 | 79 | -------------------------------------------------------------------------------- /packages/core/tests/schemastery-ext.spec.ts: -------------------------------------------------------------------------------- 1 | import Schema from 'schemastery' 2 | import { expect } from 'chai' 3 | import '../src/schemastery-ext' 4 | 5 | describe('Schemastery ext', function () { 6 | const User = Schema.interface({ 7 | id: Schema.number(), 8 | username: Schema.string(), 9 | password: Schema.string() 10 | }) 11 | it('should use `and` method.', function () { 12 | const U = User.and(Schema.interface({ 13 | avatar: Schema.string() 14 | })) 15 | expect(U({ 16 | id: 1, 17 | username: 'foo', 18 | password: 'bar', 19 | avatar: 'http://example.com/foo.png' 20 | })).to.be.deep.equal({ 21 | id: 1, 22 | username: 'foo', 23 | password: 'bar', 24 | avatar: 'http://example.com/foo.png' 25 | }) 26 | // @ts-ignore 27 | expect(U.bind(null, { 28 | id: 1, 29 | username: 'foo', 30 | password: 'bar' 31 | })).to.be.throw('avatar is required but not exist') 32 | }) 33 | it('should use `or` method.', function () { 34 | const U = User.or(Schema.interface({ 35 | avatar: Schema.string() 36 | })) 37 | expect(U({ 38 | id: 1, 39 | username: 'foo', 40 | password: 'bar' 41 | })).to.be.deep.equal({ 42 | id: 1, 43 | username: 'foo', 44 | password: 'bar' 45 | }) 46 | expect(U({ 47 | id: 1, 48 | username: 'foo', 49 | password: 'bar', 50 | avatar: 'http://example.com/foo.png' 51 | })).to.be.not.have.property('avatar') 52 | expect(U({ 53 | avatar: 'http://example.com/foo.png' 54 | })).to.be.not.have.property('id') 55 | }) 56 | it('should use pick target keys.', function () { 57 | const UserOut = Schema.Pick(User, ['id', 'username']) 58 | // @ts-ignore 59 | expect(UserOut({ 60 | id: 1, 61 | username: 'test', 62 | password: 'test' 63 | })).to.be.not.have.property('password') 64 | }) 65 | it('should use omit target keys.', function () { 66 | const UserOut = Schema.Omit(User, ['password']) 67 | // @ts-ignore 68 | expect(UserOut({ 69 | id: 1, 70 | username: 'test', 71 | password: 'test' 72 | })).to.be.not.have.property('password') 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /packages/core/tests/ws-client.spec.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws' 2 | import { Messages, WsClient } from '@boiling/core' 3 | import { expect } from 'chai' 4 | import { resolveMessage } from '@boiling/core' 5 | 6 | after(() => { 7 | process.exit(0) 8 | }) 9 | 10 | describe('Ws Client', function () { 11 | let ws: WebSocket 12 | const wss = new WebSocket.Server({ port: 8080 }) 13 | wss.on('connection', _ => { 14 | ws = _ 15 | }) 16 | // @ts-ignore 17 | const wsClient = new WsClient(new WebSocket('ws://localhost:8080')) 18 | 19 | it('should wait once message.', async function () { 20 | ws && ws.send('hi') 21 | expect(await wsClient.waitOnceMessage()) 22 | .to.be.equal('hi') 23 | }) 24 | it('should wait once message and resolve it.', async function () { 25 | ws && ws.send(JSON.stringify({ 26 | op: Messages.Opcodes.HELLO, 27 | d: { heartbeatInterval: 1000 } 28 | })) 29 | const m = await wsClient.waitOnceMessage().resolve([ Messages.Opcodes.HELLO ]) 30 | expect(m.op).to.be.equal(Messages.Opcodes.HELLO) 31 | expect(m.d.heartbeatInterval).to.be.equal(1000) 32 | }) 33 | it('should wait once message and throw error.', async function () { 34 | ws && ws.send(JSON.stringify({ 35 | op: Messages.Opcodes.HELLO, 36 | d: { heartbeatInterval: 1000 } 37 | })) 38 | try { 39 | await wsClient.waitOnceMessage().resolve([ Messages.Opcodes.DISPATCH ]) 40 | } catch (e) { 41 | if (e instanceof Error) { 42 | expect(e.message).to.be.equal('Expected opcodes: [DISPATCH], but got HELLO') 43 | } else { 44 | throw e 45 | } 46 | } 47 | }) 48 | it('should wait each messages.', async function () { 49 | const messages = [{ 50 | op: Messages.Opcodes.HELLO, 51 | d: { heartbeatInterval: 1000 } 52 | }, { 53 | op: Messages.Opcodes.HEARTBEAT_ACK 54 | }] 55 | ws && messages.forEach(m => ws.send(JSON.stringify(m))) 56 | 57 | let i = 0 58 | for await (const _ of wsClient.waitMessage()) { 59 | const m = resolveMessage(_, [ Messages.Opcodes.HELLO, Messages.Opcodes.HEARTBEAT_ACK ]) 60 | expect(m.op).to.be.equal(messages[i++].op) 61 | if (i === messages.length) { 62 | break 63 | } 64 | } 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /packages/frontend/src/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | 3 | const router = createRouter({ 4 | history: createWebHistory(), 5 | routes: [{ 6 | path: '/', redirect: '/home' 7 | }, { 8 | path: '/home', 9 | name: 'home', 10 | redirect: '/home/friends', 11 | component: () => import('./views/home/Index.vue'), 12 | children: [{ 13 | path: '/home/friends', 14 | component: () => import('./views/home/Friends.vue') 15 | },{ 16 | path: '/home/channels', 17 | component: () => import('./views/home/Channels.vue') 18 | },{ 19 | path: '/home/groups', 20 | component: () => import('./views/home/Groups.vue') 21 | }, { 22 | path: '/home/chat-rooms/:id', 23 | component: () => import('./views/ChatRoom.vue'), 24 | props: route => ({ 25 | id: route.params.id, 26 | title: route.query.title 27 | }) 28 | }] 29 | }, { 30 | path: '/login', 31 | name: 'login', 32 | component: () => import('./views/Login.vue') 33 | }, { 34 | path: '/register', 35 | name: 'register', 36 | component: () => import('./views/Register.vue') 37 | }, { 38 | path: '/theme', 39 | component: () => import('./views/Theme.vue') 40 | }, { 41 | name: 'channel', 42 | path: '/channel/:id', 43 | props: true, 44 | component: () => import('./views/Channel.vue') 45 | }, { 46 | path: '/edit-personnel', 47 | component: () => import('./views/EditPersonnel.vue') 48 | }, { 49 | path: '/create-channel', 50 | component: () => import('./views/CreateChannel.vue'), 51 | props: route => ({ 52 | type: route.query.type, 53 | info: route.query.info 54 | }) 55 | }, { 56 | path: '/search-channel', 57 | component: () => import('./components/SearchChannel.vue') 58 | },{ 59 | path: '/create-chatRoom', 60 | component: () => import('./views/CreateChatRoom.vue'), 61 | props: route => ({ 62 | type: route.query.type, 63 | info: route.query.info, 64 | channelId: route.query.channelId, 65 | channelTitle: route.query.channelTitle 66 | }) 67 | }, { 68 | path: '/:pathMatch(.*)*', 69 | component: () => import('element-plus').then(({ ElEmpty }) => ElEmpty), 70 | props: { description: '访问到了不存在的页面' } 71 | }] 72 | }) 73 | 74 | export default router 75 | -------------------------------------------------------------------------------- /packages/core/src/schemastery-ext.ts: -------------------------------------------------------------------------------- 1 | import Schema from 'schemastery' 2 | import './schemastery-interface' 3 | 4 | declare module 'schemastery' { 5 | interface Schema { 6 | or(x: X): Schema< 7 | Schema.TypeS | Schema.TypeS, 8 | Schema.TypeT | Schema.TypeT 9 | > 10 | and(x: X): Schema< 11 | Schema.TypeS & Schema.TypeS, 12 | Schema.TypeT & Schema.TypeT 13 | > 14 | } 15 | namespace Schema { 16 | interface Static { 17 | Pick>(s: S, keys: Keys[]): Schema< 18 | Pick, Keys>, Pick, Keys> 19 | > 20 | Omit>(s: S, keys: Keys[]): Schema< 21 | Omit, Keys>, Omit, Keys> 22 | > 23 | } 24 | } 25 | } 26 | Schema.prototype.and = function(x) { 27 | const result = >{} 28 | if (x.type !== 'interface' || !x.dict) 29 | throw new Error('Schema.and: s must be an interface') 30 | for (const key in this.dict) { 31 | result[key] = this.dict[key] 32 | } 33 | for (const key in x.dict) { 34 | if (!result[key]) 35 | result[key] = x.dict[key] 36 | } 37 | return Schema.interface(result) 38 | } 39 | Schema.prototype.or = function (x) { 40 | return Schema.union([this, x]) 41 | } 42 | // @ts-ignore 43 | Schema.Pick = function(s, keys) { 44 | const result = >{} 45 | if (!s.dict) 46 | throw new Error('pick() can only be used on dictionaries') 47 | 48 | const objKeys = Object.keys(s.dict) 49 | for (const key of keys) { 50 | if (typeof key !== 'string') 51 | throw new Error('pick() keys must be strings') 52 | 53 | if (!objKeys.includes(key)) 54 | throw new Error(`pick() key ${key} not found in schema`) 55 | 56 | result[key] = s.dict[key] 57 | } 58 | return Schema.interface(result) 59 | } 60 | // @ts-ignore 61 | Schema.Omit = function(s, keys) { 62 | const result = >{} 63 | if (!s.dict) 64 | throw new Error('omit() can only be used on dictionaries') 65 | 66 | const objKeys = Object.keys(s.dict) 67 | for (const key of objKeys) { 68 | // @ts-ignore 69 | if (!keys.includes(key)) 70 | result[key] = s.dict[key] 71 | } 72 | return Schema.interface(result) 73 | } 74 | -------------------------------------------------------------------------------- /packages/frontend/src/components/SelMembers.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 65 | 66 | 87 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ChannelCard.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 55 | 56 | 91 | -------------------------------------------------------------------------------- /packages/frontend/src/components/UploaderAvatar.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 66 | 67 | 85 | -------------------------------------------------------------------------------- /packages/frontend/src/components/Message.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 46 | 47 | 94 | -------------------------------------------------------------------------------- /packages/core/src/users.ts: -------------------------------------------------------------------------------- 1 | import Schema from 'schemastery' 2 | 3 | export namespace Users { 4 | export const Status = Schema.union<'online' | 'leave' | 'offline' | 'noDisturb'>([ 5 | 'online', 'leave', 'offline', 'noDisturb' 6 | ]) 7 | export const Sex = Schema.union<'female' | 'male'>(['female', 'male']) 8 | export type Status = Schema.InferS 9 | export type Sex = Schema.InferS 10 | /** 基础数据 */ 11 | export const Base = Schema.interface({ 12 | /** 用户唯一 id */ 13 | id: Schema.number(), 14 | /** 用户名 */ 15 | username: Schema.string(), 16 | /** 用户头像 */ 17 | avatar: Schema.string().optional(), 18 | /** 加密的用户密码 */ 19 | passwordHash: Schema.string(), 20 | /** 用户状态 */ 21 | status: Status.optional(), 22 | /** 性别 */ 23 | sex: Sex.optional(), 24 | /** 出生日期 */ 25 | birthday: Schema.string().optional(), 26 | /** 描述 */ 27 | desc: Schema.string().optional() 28 | }) 29 | export type Base = Schema.InferS 30 | /** 基础数据出口 */ 31 | export const BaseOut = Schema.Omit(Base, ['passwordHash']) 32 | export type BaseOut = Schema.InferS 33 | 34 | /** 可更新数据 */ 35 | export const UpdateOut = Schema.Pick(Base, ['username', 'avatar', 'sex', 'birthday', 'desc', 'status']) 36 | export type UpdateOut = Schema.InferS 37 | 38 | /** 数据库模型 */ 39 | export const Model = Users.Base.and(Schema.interface({ 40 | /** 用户好友 */ 41 | friends: Schema.array(Users.Friend), 42 | /** 用户标签 */ 43 | tags: Schema.array(Schema.string()) 44 | })) 45 | export type Model = Schema.InferS 46 | /** 用户数据出口 */ 47 | export const Out = Schema.Omit(Model, ['passwordHash']) 48 | export type Out = Schema.InferS 49 | 50 | /** 好友 */ 51 | export const Friend = Schema.Pick(Base, ['id']).and(Schema.interface({ 52 | /** 好友备注 */ 53 | remark: Schema.string(), 54 | /** 好友标签 */ 55 | tags: Schema.array(Schema.string()) 56 | })) 57 | export type Friend = Schema.InferS 58 | /** 好友数据出口 */ 59 | export const FriendOut = BaseOut.and(Friend) 60 | export type FriendOut = Schema.InferS 61 | 62 | export const Register = Schema.Omit(Base, ['id', 'passwordHash', 'avatar']).and(Schema.interface({ 63 | /** 用户密码 */ 64 | password: Schema.string() 65 | })) 66 | export type Register = Schema.InferS 67 | /** 登录 */ 68 | export const Login = Schema.interface({ 69 | /** 用户状态 */ 70 | status: Status, 71 | /** 用户密码 */ 72 | password: Schema.string() 73 | }) 74 | export type Login = Schema.InferS 75 | } 76 | -------------------------------------------------------------------------------- /packages/backend/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from 'redis' 2 | import { createHash } from 'crypto' 3 | import { SeqModel } from './dao/seq' 4 | import { App } from 'koa-websocket' 5 | import DAOMain from './dao' 6 | import { clientManager } from './routes/ws' 7 | 8 | namespace Utils { 9 | export namespace Security { 10 | export function encrypt(plaintext: string) { 11 | return createHash('md5') 12 | .update(plaintext).digest('hex') 13 | } 14 | export function match(waitMatch: string, origin: string) { 15 | return encrypt(waitMatch) === origin 16 | } 17 | } 18 | export namespace Redis { 19 | export const client = createClient({ 20 | url: 'redis://127.0.0.1:6379', 21 | database: 5 22 | }) 23 | export async function init() { 24 | client.once('error', console.error) 25 | await client.connect() 26 | client.removeListener('error', console.error) 27 | } 28 | } 29 | export function initApp(app: App) { 30 | app.keys = ['hker92hjkugfkerbl.e[gewkg68'] 31 | const { 32 | BACKEND_PORT: PORT = '8080', 33 | BACKEND_HOST: HOST = 'localhost' 34 | } = process.env 35 | return new Promise<{ 36 | HOST: string 37 | PORT: string 38 | server: ReturnType 39 | }>((resolve, reject) => { 40 | const server = app.listen(+PORT, HOST, async () => { 41 | // connect mongodb database 42 | try { 43 | await DAOMain() 44 | await Redis.init() 45 | await clientManager.initFromRedis() 46 | } catch (e) { 47 | reject(e) 48 | } 49 | console.log(`server is running on http://${ HOST }:${ PORT }`) 50 | resolve({ HOST, PORT, server }) 51 | }) 52 | }) 53 | } 54 | export namespace Seq { 55 | export async function auto(n: string, initIndent = 0, step = 1) { 56 | return SeqModel 57 | .findOneAndUpdate({ collectionName: n }, { 58 | $setOnInsert: { initIndent }, 59 | $inc: { seq: step } 60 | }, { new: true, upsert: true }) 61 | .then(m => m.initIndent + m.seq) 62 | } 63 | } 64 | export type Period = [Date | undefined, Date | undefined] 65 | export function periodQuery(key: string, period?: Period) { 66 | if (!period) return {} 67 | 68 | let query 69 | const [start, end] = period 70 | if (start && end) { 71 | query = { $gte: start, $lte: end } 72 | } else if (start) { 73 | query = { $gte: start } 74 | } else if (end) { 75 | query = { $lte: end } 76 | } else { 77 | query = {} 78 | } 79 | return { [key]: query } 80 | } 81 | } 82 | 83 | export = Utils 84 | -------------------------------------------------------------------------------- /packages/frontend/src/views/home/Friends.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 57 | 58 | 95 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useWsClient.ts: -------------------------------------------------------------------------------- 1 | import { Messages, resolveMessage, WsClient } from '@boiling/core' 2 | 3 | export const S_ID_KEY = 'session:id' 4 | export const S_TK_KEY = 'session:tk' 5 | export const S_NUM_KEY = 'session:s' 6 | 7 | let wsClient: WsClient | null = null 8 | 9 | type DispatchListener = 10 | (message: Messages.PickTarget) => void 11 | 12 | const dispatchListeners = new Set() 13 | 14 | export const onDispatch = (listener: DispatchListener) => { 15 | dispatchListeners.add(listener) 16 | return () => dispatchListeners.delete(listener) 17 | } 18 | 19 | export const identifyWS = async (wsClient: WsClient, token: string, option = { 20 | resume: false 21 | }) => { 22 | let s = Number(localStorage.getItem(S_NUM_KEY)) || 0 23 | let sessionId = localStorage.getItem(S_ID_KEY) 24 | 25 | const helloPkg = await wsClient.waitOnceMessage().resolve([Messages.Opcodes.HELLO]) 26 | if (helloPkg.op !== Messages.Opcodes.HELLO) { 27 | throw new Error('未成功连接服务器') 28 | } 29 | const heartbeatInterval = helloPkg.d.heartbeatInterval 30 | 31 | if (!option.resume) { 32 | wsClient.send({ 33 | op: Messages.Opcodes.IDENTIFY, 34 | d: { token } 35 | }) 36 | } else { 37 | if (!sessionId) 38 | throw new Error('没有持久化的 sessionId') 39 | 40 | wsClient.send({ 41 | op: Messages.Opcodes.RESUME, 42 | d: { 43 | s, 44 | token, 45 | sessionId 46 | } 47 | }) 48 | } 49 | 50 | const redyPkg = await wsClient.waitOnceMessage().resolve([Messages.Opcodes.DISPATCH]) 51 | if (redyPkg.t === 'READY') { 52 | sessionId = redyPkg.d.sessionId 53 | localStorage.setItem(S_ID_KEY, sessionId) 54 | localStorage.setItem(S_TK_KEY, token) 55 | } 56 | 57 | setInterval(() => { 58 | wsClient.send({ 59 | op: Messages.Opcodes.HEARTBEAT 60 | }) 61 | }, heartbeatInterval) 62 | 63 | for await (const _ of wsClient.waitMessage()) { 64 | const m = resolveMessage(_, [ 65 | Messages.Opcodes.HEARTBEAT_ACK, 66 | Messages.Opcodes.DISPATCH 67 | ]) 68 | switch (m.op) { 69 | case Messages.Opcodes.DISPATCH: 70 | s = m.s 71 | localStorage.setItem('session:s', String(m.s)) 72 | dispatchListeners.forEach(listener => listener(m)) 73 | break 74 | case Messages.Opcodes.HEARTBEAT_ACK: 75 | break 76 | } 77 | } 78 | } 79 | 80 | export const useWsClient = (): [WsClient, (v: typeof wsClient) => void] => { 81 | if (wsClient === null) { 82 | wsClient = new WsClient(new WebSocket(`ws://${location.host}/api/ws`)) 83 | } 84 | return [wsClient, v => wsClient = v] 85 | } 86 | 87 | export const resetWsClient = () => { 88 | const [_, r] = useWsClient() 89 | _.ws.close(1000) 90 | r(null) 91 | return useWsClient() 92 | } 93 | -------------------------------------------------------------------------------- /packages/frontend/src/components/Channel.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 46 | 47 | 106 | -------------------------------------------------------------------------------- /packages/core/src/ws-client.ts: -------------------------------------------------------------------------------- 1 | import { Messages } from './messages' 2 | 3 | const genOnClose = (reject: (reason?: any) => void) => 4 | (ev: WebSocketEventMap['close']) => reject(new Error(`ws close: ${ ev.code } ${ ev.reason }`)) 5 | 6 | export const resolveMessage = ( 7 | message: string, opcodes: T[] | undefined = undefined 8 | ) => { 9 | const msg = JSON.parse(message) as Messages.PickTarget 10 | if (opcodes && !opcodes.includes(msg.op)) 11 | throw new Error(`Expected opcodes: [${ opcodes.map(c => Messages.Opcodes[c]).join(', ') }], but got ${ Messages.Opcodes[msg.op] }`) 12 | return msg 13 | } 14 | 15 | const createMessageResolver = (p: Promise) => new Proxy(p as Promise & { 16 | resolve(opcodes: T[]): Promise> 17 | }, { 18 | get(target, p) { 19 | if (p === 'resolve') 20 | return new Proxy(() => {}, { 21 | apply(_, __, args: any[]): any { 22 | return target.then(m => resolveMessage(m, ...args)) 23 | } 24 | }) 25 | // @ts-ignore 26 | return target[p].bind(target) 27 | } 28 | }) 29 | 30 | export class WsClient { 31 | constructor(public ws: WebSocket) { 32 | this.ws = ws 33 | } 34 | once(type: K, listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, options?: boolean | AddEventListenerOptions) { 35 | this.ws.addEventListener(type, function _(...args) { 36 | listener.call(this, ...args) 37 | this.removeEventListener(type, _) 38 | }, options) 39 | } 40 | send(message: Messages.Client) { 41 | this.ws.send(JSON.stringify(message)) 42 | } 43 | waitOnceMessage() { 44 | return createMessageResolver(new Promise((resolve, reject) => { 45 | const onClose = genOnClose(reject) 46 | 47 | this.once('message', d => { 48 | resolve(d.data.toString()) 49 | this.ws.removeEventListener('close', onClose) 50 | }) 51 | this.once('close', onClose) 52 | })) 53 | } 54 | waitMessage() { 55 | let reject: undefined | ((reason?: any) => void) 56 | let resolve: undefined | ((r: { value: string }) => void) 57 | 58 | this.ws.onmessage = function onMessage(d) { 59 | if (resolve) { 60 | resolve({ value: d.data.toString() }) 61 | resolve = undefined 62 | } else { 63 | setTimeout(onMessage.bind(this, d), 10) 64 | } 65 | } 66 | this.ws.onclose = (code) => { 67 | reject && reject(new Error(`ws close: ${ code }`)) 68 | } 69 | return { 70 | [Symbol.asyncIterator]: () => ({ 71 | next: () => new Promise<{ value: string }>((_, __) => { 72 | resolve = _ 73 | reject = __ 74 | }) 75 | }) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /rules/stylelint-plugin.cjs: -------------------------------------------------------------------------------- 1 | const { utils, createPlugin } = require('stylelint') 2 | const 3 | parseSelector = require('stylelint/lib/utils/parseSelector'), 4 | whitespaceChecker = require('stylelint/lib/utils/whitespaceChecker'), 5 | isStandardSyntaxRule = require('stylelint/lib/utils/isStandardSyntaxRule') 6 | 7 | const ruleName = 'in/selector-combinator-space-after' 8 | const messages = utils.ruleMessages(ruleName, { 9 | expected: combinator => `Expected single space after "${ combinator }"` 10 | }) 11 | module.exports = createPlugin(ruleName, function rule(expectation, options, context) { 12 | const checker = whitespaceChecker('space', expectation, messages) 13 | 14 | return (root, result) => { 15 | const validOptions = utils.validateOptions(result, ruleName, { 16 | actual: expectation, 17 | possible: ['always', 'never'] 18 | }) 19 | if (!validOptions) { 20 | return 21 | } 22 | const isAutoFixing = Boolean(context.fix) 23 | 24 | let hasFixed 25 | root.walkRules(rule => { 26 | if (!isStandardSyntaxRule(rule)) { 27 | return 28 | } 29 | 30 | const selector = rule.raws.selector ? rule.raws.selector.raw : rule.selector 31 | 32 | const fixedSelector = parseSelector(selector, result, rule, selectorTree => { 33 | selectorTree.walkCombinators(node => { 34 | if (/\s/.test(node.value)) { 35 | return 36 | } 37 | const parentParentNode = node.parent && node.parent.parent 38 | 39 | // Ignore pseudo-classes selector like `.foo:nth-child(2n + 1) {}` 40 | if (parentParentNode && parentParentNode.type === 'pseudo') { 41 | return 42 | } 43 | 44 | const sourceIndex = node.sourceIndex 45 | const index = node.value.length > 1 46 | ? sourceIndex 47 | : sourceIndex + node.value.length - 1 48 | 49 | if (node.spaces.after === ' ') { 50 | return 51 | } 52 | 53 | if (isAutoFixing) { 54 | const nSelector = rule.selector.slice(0, index + 1) + ' ' + rule.selector.slice(index + 1) 55 | if (!rule.raws.selector) { 56 | rule.selector = nSelector 57 | } else { 58 | rule.raws.selector.raw = nSelector 59 | } 60 | } else { 61 | utils.report({ 62 | ruleName, 63 | node: rule, 64 | message: messages.expected(node.value), 65 | index: sourceIndex, 66 | result: result 67 | }) 68 | } 69 | }) 70 | }) 71 | 72 | if (hasFixed) { 73 | if (!rule.raws.selector) { 74 | rule.selector = fixedSelector 75 | } else { 76 | rule.raws.selector.raw = fixedSelector 77 | } 78 | } 79 | }) 80 | } 81 | }) 82 | module.exports.messages = messages 83 | -------------------------------------------------------------------------------- /packages/backend/src/routes/channels.ts: -------------------------------------------------------------------------------- 1 | import { Channels, Pagination, Router } from '@boiling/core' 2 | import { ChannelsService } from '../services/channels' 3 | import Schema from 'schemastery' 4 | import useCurUser from '../hooks/useCurUser' 5 | import usePagination from '../hooks/usePagination' 6 | import extendService from '../hooks/extendService' 7 | 8 | export const router = new Router({ 9 | prefix: '/channels' 10 | }) 11 | /** 12 | * 获取频道列表 13 | */ 14 | .get(Pagination(Channels.Model), '?key&page(number)&num(number)', ctx => { 15 | // @ts-ignore 16 | ctx.query.key = '' 17 | return usePagination( 18 | extendService(ChannelsService, 'search', m => m), ctx.query 19 | )(decodeURI(ctx.query.key)) 20 | }) 21 | /** 22 | * 创建讨论组 23 | */ 24 | .post(Schema.Pick(Channels.Model, ['members', 'name', 'avatar']), Schema.any(), '', async ctx => { 25 | const { name, avatar, description } = ctx.request.body 26 | return ChannelsService.create(useCurUser(ctx.session).id,{ 27 | name, 28 | avatar, 29 | description 30 | }) 31 | }) 32 | /** 33 | * 通过频道id获取频道信息 34 | */ 35 | .get('/:channelId', async ctx => { 36 | const { channelId } = ctx.params 37 | return ChannelsService.get(channelId) 38 | }) 39 | /** 40 | * 更新频道信息 41 | */ 42 | .patch('/:channelId', async ctx => { 43 | const { channelId } = ctx.params 44 | const { name, avatar, description, members } = ctx.request.body 45 | return ChannelsService.update(channelId, { 46 | name, 47 | avatar, 48 | description, 49 | members 50 | }) 51 | }) 52 | /** 53 | * 删除频道 54 | */ 55 | .delete('/:channelId', async ctx => { 56 | const { channelId } = ctx.params 57 | const channel = await ChannelsService.getOrThrow(channelId) 58 | if ( 59 | !channel.members 60 | .every(m => m.id === useCurUser(ctx.session).id && m.rules?.includes('owner')) 61 | ) throw new HttpError('UNAUTHORIZED', '无权限删除该频道') 62 | return ChannelsService.del(channelId) 63 | }) 64 | /** 65 | * 添加子频道 66 | */ 67 | .post(Schema.Pick(Channels.SubChannelMeta, ['title']), Schema.any(), '/:channelId/subChannels', async ctx => { 68 | const { channelId } = ctx.params 69 | const { title } = ctx.request.body 70 | return ChannelsService.addSubChannel(channelId, title) 71 | }) 72 | /** 73 | * 添加成员 74 | */ 75 | .post('/:channelId/members', async ctx => { 76 | const { channelId } = ctx.params 77 | const { members } = ctx.request.body 78 | return ChannelsService.addMember(channelId, members) 79 | }) 80 | /** 81 | * 添加聊天室 82 | */ 83 | .post('/:channelId/chatRooms/:chatRoomId', async ctx => { 84 | const { channelId, chatRoomId } = ctx.params 85 | const { title, chatRoomTitle, description } = ctx.request.body 86 | return ChannelsService.addChatRoom(channelId, title, chatRoomId, chatRoomTitle, description) 87 | }) 88 | -------------------------------------------------------------------------------- /packages/backend/src/routes/chat-rooms.ts: -------------------------------------------------------------------------------- 1 | import Schema from 'schemastery' 2 | import { ChatRooms, Messages, Pagination, Router } from '@boiling/core' 3 | import { ChatRoomsService } from '../services/chat-rooms' 4 | import usePagination from '../hooks/usePagination' 5 | import { clientManager } from './ws' 6 | import useCurUser from '../hooks/useCurUser' 7 | import extendService from '../hooks/extendService' 8 | import useTarget from '../hooks/useTarget' 9 | 10 | export const router = new Router({ 11 | prefix: '/chat-rooms' as '/chat-rooms' 12 | }) 13 | /** 14 | * 获取聊天室 15 | */ 16 | .get(Pagination(ChatRooms.Model), '?key&page(number)&num(number)', ctx => { 17 | return usePagination( 18 | extendService(ChatRoomsService, 'search', m => m), ctx.query 19 | )(decodeURI(ctx.query.key)) 20 | }) 21 | /** 22 | * 创建聊天室 23 | */ 24 | .post(Schema.Pick(ChatRooms.Model, ['members', 'name', 'avatar']), Schema.any(), '', async ctx => { 25 | const { members, ...opts } = ctx.request.body 26 | return ChatRoomsService.create([ 27 | ...new Set(members.concat(useCurUser(ctx.session).id)) 28 | ], opts) 29 | }) 30 | /** 31 | * 添加消息 32 | */ 33 | .post(Schema.Pick(Messages.Model, ['content']), Schema.any(), '/:chatRoomId/messages', async ctx => { 34 | const { content } = ctx.request.body 35 | const senderId = useCurUser(ctx.session).id 36 | const m = await ChatRoomsService.Message.create(ctx.params.chatRoomId, senderId, content) 37 | const { members } = await ChatRoomsService.get(ctx.params.chatRoomId) || {} 38 | members 39 | ?.filter(id => id !== senderId) 40 | ?.forEach( 41 | memberId => clientManager 42 | .proxyTo(memberId) 43 | ?.map(client => client?.dispatch('MESSAGE', m)) 44 | ) 45 | return m 46 | }) 47 | /** 48 | * 获取聊天室消息列表 49 | * 50 | * 排序 51 | * 按照时间升降序 52 | * 数量 53 | * 分页器:页码、单页数目 54 | * 时间 55 | * 消息创建时间范围 56 | * 内容 57 | * 发送者信息、消息内容 58 | */ 59 | .get('/:chatRoomId/messages?key&page(number)&num(number)', async ctx => { 60 | const { chatRoomId } = ctx.params 61 | return usePagination(ChatRoomsService.Message, ctx.query, [ 62 | ['createdAt', -1] 63 | ])(chatRoomId) 64 | }) 65 | /** 66 | * 获取聊天室成员列表 67 | */ 68 | .get('/:chatRoomId/members', async ctx => { 69 | const { chatRoomId } = ctx.params 70 | return ChatRoomsService.User.get(chatRoomId) 71 | }) 72 | /** 73 | * 更新聊天室 74 | */ 75 | .patch('/:chatRoomId', async ctx => { 76 | const { chatRoomId } = ctx.params 77 | const { name, avatar, members } = ctx.request.body 78 | return ChatRoomsService.update(chatRoomId, { name, avatar, members }) 79 | }) 80 | /** 81 | * 删除聊天室成员 82 | */ 83 | .delete('/:chatRoomId/members/:userId(uid)', async ctx => { 84 | console.log('del') 85 | return ChatRoomsService.User.del(ctx.params.chatRoomId, useTarget(ctx.session, ctx.params.userId)) 86 | }) 87 | -------------------------------------------------------------------------------- /packages/frontend/src/views/Theme.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 70 | 71 | 106 | -------------------------------------------------------------------------------- /packages/frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 43 | 44 | 88 | 89 | 113 | -------------------------------------------------------------------------------- /packages/core/src/messages.ts: -------------------------------------------------------------------------------- 1 | import Schema from 'schemastery' 2 | import { Users } from './users' 3 | 4 | export namespace Messages { 5 | /** 储存在数据库的消息 */ 6 | export const Model = Schema.interface({ 7 | /** id */ 8 | id: Schema.string(), 9 | /** 发送者 */ 10 | sender: Users.BaseOut, 11 | /** 内容 */ 12 | content: Schema.string(), 13 | /** 发送时间 */ 14 | createdAt: Schema.from(Date), 15 | /** 16 | * id 17 | * 18 | * 聊天室id 19 | */ 20 | chatRoomId: Schema.string() 21 | }) 22 | export type Model = Schema.InferS 23 | export enum Opcodes { 24 | /** 25 | * Server 26 | * 当客户端与网关建立 ws 连接之后,网关下发的第一条消息 27 | */ 28 | HELLO = 0, 29 | /** 30 | * Client 31 | * 客户端发送心跳 32 | */ 33 | HEARTBEAT = 1, 34 | /** 35 | * Server 36 | * 当发送心跳成功之后,就会收到该消息 37 | */ 38 | HEARTBEAT_ACK = 2, 39 | /** 40 | * Server 41 | * 服务端进行消息推送 42 | */ 43 | DISPATCH = 3, 44 | /** 45 | * Client 46 | * 客户端发送鉴权 47 | */ 48 | IDENTIFY = 4, 49 | /** 50 | * Client 51 | * 客户端断连恢复连接 52 | */ 53 | RESUME = 5, 54 | } 55 | type DP = { 56 | op: Opcodes.DISPATCH 57 | s: number 58 | t: T 59 | d: D 60 | } 61 | interface Events { 62 | User: 63 | // 好友请求 64 | DP<'FRIEND_REQUEST', Users.BaseOut> | 65 | // 好友接受了请求 66 | DP<'FRIEND_REQUEST_ACCEPTED', Users.BaseOut> | 67 | // 好友拒绝了请求 68 | DP<'FRIEND_REQUEST_REJECTED', Users.BaseOut> 69 | Guilds: 70 | // 加入频道 71 | DP<'GUILD_CREATE', {}> | 72 | // 退出频道 73 | DP<'GUILD_DELETE', {}> | 74 | // 频道信息更新 75 | DP<'GUILD_UPDATE', {}> | 76 | // 新增频道成员 77 | DP<'GUILD_MEMBER_CREATE', {}> | 78 | // 频道成员退出 79 | DP<'GUILD_MEMBER_DELETE', {}> | 80 | // 频道成员更新 81 | DP<'GUILD_MEMBER_UPDATE', {}> 82 | ChatRoom: 83 | // 用户开始输入 84 | DP<'USER_INPUT_START', Users.BaseOut> | 85 | // 用户停止输入 86 | DP<'USER_INPUT_END', Users.BaseOut> | 87 | // 聊天室接收到新消息 88 | DP<'MESSAGE', Model> 89 | } 90 | export type Server = { 91 | op: Opcodes.HELLO 92 | d: { 93 | /** 94 | * 客户端需要按照该心跳周期发送心跳包来保持连接 95 | * 96 | * 单位 ms 97 | */ 98 | heartbeatInterval: number 99 | } 100 | } | { 101 | op: Opcodes.HEARTBEAT_ACK 102 | } | DP<'READY', { 103 | sessionId: string 104 | user: Users.BaseOut 105 | }> 106 | | Events['ChatRoom'] | Events['User'] | Events['Guilds'] 107 | | DP<'RESUMED', {}> 108 | export type Client = { 109 | op: Opcodes.HEARTBEAT 110 | } | { 111 | op: Opcodes.IDENTIFY 112 | d: { 113 | token: string 114 | } 115 | } | { 116 | op: Opcodes.RESUME 117 | d: { 118 | /** 验证密钥 */ 119 | token: string 120 | /** 客户端上次断开连接时的 sessionId */ 121 | sessionId: string 122 | /** 客户端上次断开连接时的最后一条消息的序列号 */ 123 | s: number 124 | } 125 | } 126 | export type PickTarget = E extends { op: T } ? E : never 127 | } 128 | -------------------------------------------------------------------------------- /packages/frontend/src/components/Boiling.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 72 | 73 | 77 | -------------------------------------------------------------------------------- /packages/backend/src/services/channels.ts: -------------------------------------------------------------------------------- 1 | import { ChannelModel } from '../dao/channel' 2 | import { Channels } from '@boiling/core' 3 | import { UsersService } from './users' 4 | 5 | export namespace ChannelsService { 6 | export const Model = ChannelModel 7 | type M = Channels.Model 8 | 9 | /** 10 | * 创建频道 11 | */ 12 | export async function create( 13 | creatorId: number, m: Pick 14 | ) { 15 | await UsersService.existsOrThrow(creatorId) 16 | return new Model({ 17 | ...m, 18 | members: [{ 19 | id: creatorId, 20 | rules: ['owner'] 21 | }] 22 | }).save() 23 | } 24 | /** 25 | * 判断频道是否存在 26 | */ 27 | export function exists(id: string) { 28 | return Model.exists({ _id: id }) 29 | } 30 | /** 31 | * 判断频道是否存在,不存在抛出异常 32 | */ 33 | export async function existsOrThrow(id: string) { 34 | if (!await exists(id)) 35 | throw new HttpError( 36 | 'NOT_FOUND', `id 为 '${ id }' 的频道不存在` 37 | ) 38 | } 39 | /** 40 | * 通过频道 id 获取频道 41 | */ 42 | export async function get(id: string) { 43 | return Model.findOne({ _id: id }) 44 | } 45 | /** 46 | * 通过频道 id 获取频道,不存在抛出异常 47 | */ 48 | export async function getOrThrow(id: string) { 49 | await existsOrThrow(id) 50 | return get(id) as Promise>, null>> 51 | } 52 | /** 53 | * 通过频道名称或者简介搜索频道 54 | * 为空时返回全部频道 55 | */ 56 | export function search(key = '') { 57 | return Model.find({ 58 | $or: [ 59 | { name: { $regex: new RegExp(`.*${key}.*`) } }, 60 | { description: { $regex: new RegExp(`.*${key}.*`) } } 61 | ] 62 | }) 63 | } 64 | 65 | /** 66 | * 通过用户id获取频道 67 | */ 68 | export async function getByUserId(uid: number) { 69 | return Model.find({ 70 | members: { 71 | $elemMatch: { 72 | id: uid 73 | } 74 | } 75 | }) 76 | } 77 | /** 78 | * 删除解散频道 79 | */ 80 | export async function del(id: string) { 81 | await existsOrThrow(id) 82 | await Model.deleteOne({ _id: id }) 83 | } 84 | /** 85 | * 更新频道基础信息 86 | */ 87 | export async function update(id: string, options: Partial>) { 88 | await existsOrThrow(id) 89 | await Model.updateOne({ _id: id }, options) 90 | } 91 | /** 92 | * 添加子频道 93 | */ 94 | export async function addSubChannel(id: string, title: string) { 95 | await existsOrThrow(id) 96 | await Model.updateOne({ _id: id }, { $push: { subChannels: { title: title } } }) 97 | } 98 | /** 99 | * 为子频道添加聊天室 100 | * */ 101 | export async function addChatRoom(id: string, title: string, chatRoomId: string, chatRoomTitle?: string, description?: string) { 102 | const channel = await getOrThrow(id) 103 | const subChannel = channel.subChannels.find(item => item.title === title) 104 | if (!subChannel) 105 | throw new HttpError('NOT_FOUND', `频道不存在标题为 ${ title } 的子频道`) 106 | // @ts-ignore 107 | subChannel.chatRooms?.push?.({ id: chatRoomId, title: chatRoomTitle, description }) 108 | await channel.save() 109 | } 110 | /** 111 | * 添加成员 112 | */ 113 | export async function addMember(id: string, members: Channels.MemberMeta[]) { 114 | await existsOrThrow(id) 115 | await Model.updateOne({ _id: id }, { $push: { members: { $each: members } } }) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /packages/frontend/src/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'vuex' 2 | import { Users } from '@boiling/core' 3 | import CreatePersistedState from 'vuex-persistedstate' 4 | import { api } from './api' 5 | import { S_ID_KEY, S_NUM_KEY, S_TK_KEY } from './hooks/useWsClient' 6 | 7 | export interface State { 8 | sidebarVisiable: boolean 9 | sidebarCrtlVisiable: boolean 10 | isHiddenLeftSelector: boolean 11 | user: Users.Out 12 | } 13 | 14 | const uDefault: State['user'] = { 15 | id: 0, 16 | username: '', 17 | avatar: '', 18 | tags: [], 19 | friends: [], 20 | status: 'online' 21 | } 22 | 23 | export default createStore({ 24 | state: { 25 | sidebarVisiable: false, 26 | sidebarCrtlVisiable: false, 27 | isHiddenLeftSelector: false, 28 | user: uDefault 29 | }, 30 | mutations: { 31 | toggleSidebarVisiable(state) { 32 | state.sidebarVisiable = !state.sidebarVisiable 33 | }, 34 | setSidebarCrtlVisiable(state, isHidden) { 35 | state.sidebarCrtlVisiable = isHidden 36 | }, 37 | setLeftSelectorHidden(state, isHidden) { 38 | state.isHiddenLeftSelector = isHidden 39 | }, 40 | toggleLeftSelector(state) { 41 | state.isHiddenLeftSelector = !state.isHiddenLeftSelector 42 | }, 43 | setUser(state, user: Users.Out) { 44 | state.user = user 45 | }, 46 | addFriend(state, friend: Users.Friend) { 47 | state.user.friends.push(friend) 48 | }, 49 | updFriend(state, friend: Users.Friend) { 50 | const index = state.user.friends.findIndex(iFriend => iFriend.id === friend.id) 51 | state.user.friends[index] = friend 52 | }, 53 | delFriend(state, id: number) { 54 | const index = state.user.friends.findIndex(iFriend => iFriend.id === id) 55 | state.user.friends.splice(index, 1) 56 | }, 57 | updAvatar(state, avatar: string) { 58 | state.user.avatar = avatar 59 | }, 60 | updStatus(state, status: Users.Status) { 61 | state.user.status = status 62 | }, 63 | update(state, user: Users.UpdateOut) { 64 | state.user = { ...state.user, ...user } 65 | }, 66 | clear(state) { 67 | state.user = uDefault 68 | localStorage.removeItem(S_ID_KEY) 69 | localStorage.removeItem(S_TK_KEY) 70 | localStorage.removeItem(S_NUM_KEY) 71 | } 72 | }, 73 | actions: { 74 | async addFriend(context, friend: Users.Friend) { 75 | await api.user('@me').friends.add(friend) 76 | context.commit('addFriend', friend) 77 | }, 78 | async delFriend(context, id: number) { 79 | await api.user('@me').friend(id).del() 80 | context.commit('delFriend', id) 81 | }, 82 | async updFriend(context, friend: Users.Friend) { 83 | await api.user('@me').friend(friend.id).upd({ 84 | tags: friend.tags, 85 | remark: friend.remark 86 | }) 87 | context.commit('updFriend', friend) 88 | }, 89 | async updAvatar(context, avatar: string) { 90 | await api.user('@me').avatar.upd({ 91 | avatar 92 | }) 93 | context.commit('updAvatar', avatar) 94 | }, 95 | async updStatus(context, status: Users.Status) { 96 | await api.user('@me').status.upd({ status }) 97 | context.commit('updStatus', status) 98 | }, 99 | async update(context, user: Users.UpdateOut) { 100 | await api.user('@me').upd(user) 101 | context.commit('update', user) 102 | } 103 | }, 104 | plugins: [CreatePersistedState()] 105 | }) 106 | -------------------------------------------------------------------------------- /packages/core/src/schemastery-interface.ts: -------------------------------------------------------------------------------- 1 | import Schema from 'schemastery' 2 | 3 | type Dict = { 4 | [key in K]?: T 5 | } 6 | type KeysOfType = Exclude<{ 7 | [key in keyof T]: SelectedType extends T[key] ? key : never 8 | }[keyof T], undefined> 9 | export type OptionalUndefined< 10 | T, 11 | UndefiendKeys extends keyof T = KeysOfType 12 | > = 13 | & Partial> 14 | & Omit 15 | 16 | declare module 'schemastery' { 17 | interface Schema { 18 | processor(processor: (o: any) => S): Schema 19 | optional(): Schema< 20 | Schema.TypeS | undefined, Schema.TypeT | undefined 21 | > 22 | default(value: T): Schema 23 | } 24 | namespace Schema { 25 | type InferS = X extends Schema ? S : never 26 | type InferT = X extends Schema ? T : never 27 | interface Meta { 28 | optional?: boolean 29 | } 30 | type InterfaceS = OptionalUndefined<{ 31 | [K in keyof X]: Schema.TypeS 32 | }> 33 | type InterfaceT = OptionalUndefined<{ 34 | [K in keyof X]: Schema.TypeT 35 | }> 36 | interface Static { 37 | interface(dict: X): Schema, Schema.InterfaceT> 38 | } 39 | } 40 | } 41 | 42 | Schema.prototype.optional = function () { 43 | if (this.meta === undefined) 44 | this.meta = {} 45 | this.meta.optional = true 46 | return this 47 | } 48 | const oldDefault = Schema.prototype.default 49 | Schema.prototype.default = function (value) { 50 | if (this.meta === undefined) 51 | this.meta = {} 52 | this.meta.optional = true 53 | return oldDefault.call(this, value) 54 | } 55 | 56 | function isNullable(value: any) { 57 | return value === null || value === undefined 58 | } 59 | 60 | Schema.extend('interface', (data, schema, _strict) => { 61 | if (!schema) 62 | throw new Error('schema is not exist') 63 | 64 | const { dict = {} } = schema 65 | const result = >{} 66 | Object.entries(dict).forEach(([key, propSchema]) => { 67 | if (data[key] === undefined) { 68 | if (propSchema?.meta?.default !== undefined) 69 | result[key] = propSchema.meta.default 70 | else { 71 | if (!propSchema?.meta?.optional) 72 | throw new Error(`${key} is required but not exist`) 73 | } 74 | } else 75 | result[key] = data[key] 76 | 77 | try { 78 | const [value, adapted] = Schema.resolve(data[key], propSchema) 79 | if (!isNullable(adapted)) 80 | data[key] = adapted 81 | result[key] = value 82 | } catch (e) { 83 | throw new Error(`${key}.${ e }`) 84 | } 85 | }) 86 | return [result] 87 | }) 88 | 89 | Object.assign(Schema, { 90 | interface(inn: Dict, string>) { 91 | const schema = new Schema({ type: 'interface' }) 92 | schema.toString = (({ dict }: Schema) => { 93 | if (!dict) 94 | throw new Error('Schema.interface: dict is not exist') 95 | 96 | if (Object.keys(dict).length === 0) return '{}' 97 | return `{ ${Object.entries(dict).map(([key, inner]) => { 98 | return `${key}: ${ inner ? inner.toString() : undefined }` 99 | }).join(', ')} }` 100 | }).bind(null, schema) 101 | schema.dict = inn 102 | return schema 103 | } 104 | }) 105 | -------------------------------------------------------------------------------- /packages/frontend/src/components/Group.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 67 | 68 | 138 | -------------------------------------------------------------------------------- /packages/frontend/src/views/ChatRoom.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 79 | 80 | 133 | -------------------------------------------------------------------------------- /.github/workflows/build-pro.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Build development enviroment 5 | 6 | on: 7 | push: 8 | tags: 9 | - '*.*.*' 10 | 11 | jobs: 12 | prepare-env: 13 | name: Prepare enviroment 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ ubuntu-latest, windows-latest, macos-latest ] 18 | steps: 19 | - uses: actions/checkout@v2 20 | with: 21 | ref: ${{ github.ref }} 22 | - name: prepare enviroment 23 | uses: boiling-js/boiling/.github/actions/prepare-env@master 24 | 25 | build-win-app: 26 | name: Build Windows app 27 | runs-on: windows-latest 28 | env: 29 | PRODUCT_URL: http://${{ secrets.PRO_HOST }} 30 | steps: 31 | - uses: actions/checkout@v2 32 | with: 33 | ref: ${{ github.ref }} 34 | - name: prepare enviroment 35 | uses: boiling-js/boiling/.github/actions/prepare-env@master 36 | - name: Build utils 37 | run: yarn workspace @boiling/utils build 38 | - name: build win app 39 | run: yarn build:app -w 40 | - name: Upload artifact 41 | uses: actions/upload-artifact@v2 42 | with: 43 | name: app-windows 44 | path: | 45 | ./packages/electron/releases/boiling Setup ${{ github.ref_name }}.exe 46 | ./packages/electron/releases/boiling-${{ github.ref_name }}-win.zip 47 | 48 | 49 | build-mac-app: 50 | name: Build MacOS app 51 | runs-on: macos-latest 52 | env: 53 | PRODUCT_URL: http://${{ secrets.PRO_HOST }} 54 | steps: 55 | - uses: actions/checkout@v2 56 | with: 57 | ref: ${{ github.ref }} 58 | - name: prepare enviroment 59 | uses: boiling-js/boiling/.github/actions/prepare-env@master 60 | - name: Build utils 61 | run: yarn workspace @boiling/utils build 62 | - name: build mac app 63 | run: yarn build:app -m 64 | - name: Upload artifact 65 | uses: actions/upload-artifact@v2 66 | with: 67 | name: app-macos 68 | path: ./packages/electron/releases/boiling-${{ github.ref_name }}.dmg 69 | 70 | package-frontend: 71 | needs: [prepare-env] 72 | name: Package Frontend 73 | runs-on: ubuntu-latest 74 | steps: 75 | - uses: actions/checkout@v2 76 | with: 77 | ref: ${{ github.ref }} 78 | - name: prepare enviroment 79 | uses: boiling-js/boiling/.github/actions/prepare-env@master 80 | - name: Build utils 81 | run: yarn workspace @boiling/utils build 82 | - name: Build frontend 83 | run: yarn f build 84 | - name: Tar dist 85 | run: tar -cvf frontend-pack-dist.tar ./packages/frontend/dist 86 | - name: Upload artifact 87 | uses: actions/upload-artifact@v2 88 | with: 89 | name: frontend-pack-dist 90 | path: frontend-pack-dist.tar 91 | 92 | upload-frontend: 93 | needs: [package-frontend] 94 | name: Upload Frontend 95 | runs-on: ubuntu-latest 96 | steps: 97 | - name: Download a single artifact 98 | uses: actions/download-artifact@v2 99 | with: 100 | name: frontend-pack-dist 101 | - name: Untar frontend-pack-dist 102 | run: tar -xvf frontend-pack-dist.tar 103 | - name: rsync deployments 104 | uses: boiling-js/boiling/.github/actions/rsync@master 105 | with: 106 | path: ./packages/frontend/dist 107 | remote-path: /www/ 108 | host: ${{ secrets.PRO_HOST }} 109 | user: developer 110 | pem: ${{ secrets.PRO_PEM }} 111 | pem-type: ssh-ed25519 112 | -------------------------------------------------------------------------------- /packages/core/src/api.ts: -------------------------------------------------------------------------------- 1 | import { Utils } from './utils' 2 | import axios, { AxiosResponse, AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios' 3 | import qs from 'qs' 4 | 5 | type TwoParamsMethod = 'get' | 'delete' | 'head' | 'options' 6 | type ThreeParamsMethod = 'post' | 'put' | 'patch' 7 | interface TwoParamsRequest { 8 | (url: string, config?: AxiosRequestConfig): Promise 9 | } 10 | interface ThreeParamsRequest { 11 | (url: string, data?: D, config?: AxiosRequestConfig): Promise 12 | } 13 | export type InnerAxiosInstance = Omit & { 14 | request(config: AxiosRequestConfig): Promise 15 | } & { 16 | [K in TwoParamsMethod]: TwoParamsRequest 17 | } & { 18 | [K in ThreeParamsMethod]: ThreeParamsRequest 19 | } 20 | export type QueryPromise = Promise & { 21 | query(q: Q): Promise 22 | } 23 | 24 | type promiseMethod = 'then' | 'catch' | 'finally' 25 | const promiseMethods = [ 'then', 'catch', 'finally' ] 26 | 27 | const getPromiseProp = (p: Promise, prop: promiseMethod) => p[prop].bind(p) 28 | 29 | const requestProxy = (a: Api, path: string, cPath = ''): Function => new Proxy(() => {}, { 30 | get(_, prop: string) { 31 | if (promiseMethods.includes(prop)) 32 | return getPromiseProp( 33 | a.$request.get(path + cPath), prop as promiseMethod) 34 | else { 35 | switch (prop as 'add' | 'del' | 'upd' | 'query') { 36 | case 'add': 37 | return (d: any) => a.$request.post(path + cPath, d) 38 | case 'del': 39 | return () => a.$request.delete(path + cPath) 40 | case 'upd': 41 | return (d: any) => a.$request.patch(path + cPath, d) 42 | case 'query': 43 | return new Proxy(() => {}, { 44 | apply(target, thisArg, [ query ]): any { 45 | return a.$request.get(`${path}${cPath}?${qs.stringify(query, { encode: false })}`) 46 | } 47 | }) 48 | } 49 | return requestProxy(a, path, `/${prop}`) 50 | } 51 | }, 52 | apply(_, __, [id, ..._args]) { 53 | return requestProxy(a, `${path + Utils.String.pluralize(cPath)}/${id}`) 54 | } 55 | }) 56 | 57 | export const attachApi = (a: T) => new Proxy(a, { 58 | get(target, path: keyof Api) { 59 | if (path in target) return target[path] 60 | return requestProxy(a, `/${path}`) 61 | } 62 | }) 63 | 64 | export class Api { 65 | readonly host: string 66 | readonly events = <{ 67 | [K in keyof Api.EventMap]: Api.EventMap[K] 68 | }>{} 69 | $request: InnerAxiosInstance 70 | 71 | constructor(host: string) { 72 | this.host = host 73 | this.$request = this.getRequest() 74 | } 75 | 76 | on(event: E, cb: Api.EventMap[E]) { 77 | this.events[event] = cb 78 | } 79 | 80 | off(event: E) { 81 | delete this.events[event] 82 | } 83 | 84 | emit(event: E, ...args: Parameters) { 85 | if (event in this.events) 86 | // @ts-ignore 87 | return this.events[event](...args) 88 | switch (event) { 89 | case 'resp.fulfilled': 90 | return (args[0]).data 91 | case 'resp.rejected': 92 | throw args[0] 93 | default: 94 | return 95 | } 96 | } 97 | 98 | protected getRequest() { 99 | const a = axios.create({ 100 | baseURL: this.host, 101 | headers: { 102 | 'Content-Type': 'application/json' 103 | } 104 | }) 105 | // @ts-ignore 106 | a.interceptors.response.use(this.emit.bind(this, 'resp.fulfilled'), this.emit.bind(this, 'resp.rejected')) 107 | return a 108 | } 109 | } 110 | 111 | export namespace Api { 112 | export interface EventMap { 113 | 'resp.fulfilled': (resp: AxiosResponse) => void 114 | 'resp.rejected': (error: AxiosError<{}>) => void 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /packages/frontend/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 78 | 79 | 137 | 138 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ConfigureGroup.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 101 | 102 | 136 | -------------------------------------------------------------------------------- /packages/core/tests/api.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter' 2 | import { AxiosInstance } from 'axios' 3 | import { expect, use } from 'chai' 4 | import cap from 'chai-as-promised' 5 | 6 | import { Api, attachApi, QueryPromise } from '../src/api' 7 | 8 | use(cap) 9 | 10 | describe('Api', function () { 11 | interface Foo { 12 | get guilds(): QueryPromise & { 13 | add(nGuild: string): Promise 14 | } 15 | guild(id: string): Promise & { 16 | upd(nGuild: string): Promise 17 | del(): Promise 18 | 19 | get members(): Promise 20 | member(id: string): Promise 21 | } 22 | } 23 | class Foo extends Api { 24 | constructor() { 25 | super('http://www.example.com') 26 | 27 | return attachApi(this) 28 | } 29 | } 30 | const foo = new Foo() 31 | 32 | it('should travel simple property and simple method.', async () => { 33 | const id = '123' 34 | new MockAdapter(foo.$request as AxiosInstance) 35 | .onGet('/guilds') 36 | .replyOnce(200, 'test0') 37 | .onGet(`/guilds/${ id }`) 38 | .replyOnce(200, 'test1') 39 | 40 | expect(await foo.guilds) 41 | .to.equal('test0') 42 | expect(await foo.guild(id)) 43 | .to.equal('test1') 44 | }) 45 | 46 | it('should travel nest property and nest method.', async () => { 47 | const guildId = '123', memberId = '456' 48 | new MockAdapter(foo.$request as AxiosInstance) 49 | .onGet(`/guilds/${ guildId }/members`) 50 | .replyOnce(200, 'test0') 51 | .onGet(`/guilds/${ guildId }/members/${ memberId }`) 52 | .replyOnce(200, 'test1') 53 | 54 | expect(await foo.guild(guildId).members) 55 | .to.equal('test0') 56 | expect(await foo.guild(guildId).member(memberId)) 57 | .to.equal('test1') 58 | }) 59 | 60 | it('should travel other method request.', async () => { 61 | const reqAdd = 'content' 62 | const guildId = '123' 63 | new MockAdapter(foo.$request as AxiosInstance) 64 | .onPost('/guilds', reqAdd) 65 | .replyOnce(200, 'test0') 66 | .onPatch(`/guilds/${ guildId }`, reqAdd) 67 | .replyOnce(200, 'test1') 68 | .onDelete(`/guilds/${ guildId }`) 69 | .replyOnce(200, 'test2') 70 | 71 | expect(await foo.guilds.add(reqAdd)) 72 | .to.equal('test0') 73 | expect(await foo.guild(guildId).upd(reqAdd)) 74 | .to.equal('test1') 75 | expect(await foo.guild(guildId).del()) 76 | .to.equal('test2') 77 | }) 78 | 79 | it('should listen `resp.fulfilled` and `resp.rejected` event.', async () => { 80 | new MockAdapter(foo.$request as AxiosInstance) 81 | .onGet('/guilds') 82 | .replyOnce(200, 'test0') 83 | .onGet('/guilds') 84 | .replyOnce(403, 'test1') 85 | .onGet('/guilds') 86 | .replyOnce(404, 'test2') 87 | .onGet('/guilds') 88 | .replyOnce(404, 'test2') 89 | 90 | foo.on('resp.fulfilled', resp => { 91 | return resp.data + '-event' 92 | }) 93 | foo.on('resp.rejected', error => { 94 | switch (error.response?.status) { 95 | case 403: 96 | return error.response.data + '-403event' 97 | case 404: 98 | throw new Error(error.response.data + '-404event') 99 | } 100 | }) 101 | 102 | expect(await foo.guilds) 103 | .to.equal('test0-event') 104 | expect(await foo.guilds) 105 | .to.equal('test1-403event') 106 | try { 107 | await foo.guilds 108 | } catch (error) { 109 | if (error instanceof Error) 110 | expect(error.message) 111 | .to.equal('test2-404event') 112 | else 113 | throw error 114 | } 115 | await foo.guilds.catch(error => { 116 | expect(error.message).to.equal('test2-404event') 117 | }) 118 | 119 | foo.off('resp.fulfilled') 120 | foo.off('resp.rejected') 121 | }) 122 | 123 | it('should parse query string.', async () => { 124 | new MockAdapter(foo.$request as AxiosInstance) 125 | .onGet('/guilds?key=test') 126 | .replyOnce(200, 'test') 127 | expect(await foo.guilds.query({ key: 'test' })) 128 | .to.equal('test') 129 | }) 130 | }) 131 | -------------------------------------------------------------------------------- /packages/frontend/src/views/Register.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 109 | 110 | 145 | -------------------------------------------------------------------------------- /packages/backend/tests/services/channels.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, use } from 'chai' 2 | import cap from 'chai-as-promised' 3 | import { ChannelsService } from '../../src/services/channels' 4 | import { Users } from '@boiling/core' 5 | import { UsersService } from '../../src/services/users' 6 | import { ChatRoomsService } from '../../src/services/chat-rooms' 7 | 8 | use(cap) 9 | 10 | after(() => { 11 | process.exit(0) 12 | }) 13 | 14 | describe('Channels Service', () => { 15 | let u0: Users.Base, u1: Users.Base, u2: Users.Base 16 | after(async () => { 17 | await UsersService.Model.deleteMany({}) 18 | }) 19 | 20 | before(async () => { 21 | u0 = await UsersService.add({ username: '001', passwordHash: '001', avatar: '001' }) 22 | u1 = await UsersService.add({ username: '002', passwordHash: '002', avatar: '002' }) 23 | u2 = await UsersService.add({ username: '003', passwordHash: '003', avatar: '003' }) 24 | }) 25 | 26 | afterEach(async () => { 27 | await ChannelsService.Model.deleteMany({}) 28 | await ChatRoomsService.Model.deleteMany({}) 29 | }) 30 | it('should create a channel', async () => { 31 | const channel = await ChannelsService.create(u0.id, { name: 'test', avatar: 'test', description: 'test' }) 32 | expect(channel.name).to.equal('test') 33 | expect(channel.avatar).to.equal('test') 34 | expect(channel.description).to.equal('test') 35 | }) 36 | it('should exits channel', async () => { 37 | const channel = await ChannelsService.create(u0.id, { name: 'test', avatar: 'test', description: 'test' }) 38 | expect(await ChannelsService.exists(channel.id)).to.equal(true) 39 | expect(await ChannelsService.exists('62820e4c8d68d496c4e8ce97')).to.equal(false) 40 | }) 41 | it('should exits channel and throw error', async () => { 42 | expect(await ChannelsService.exists('62820e4c8d68d496c4e8ce97')).to.equal(false) 43 | await expect(ChannelsService.existsOrThrow('62820e4c8d68d496c4e8ce97'), 44 | 'throw a `NOT_FOUND` error' 45 | ).to.be.eventually.rejectedWith('[404] id 为 \'62820e4c8d68d496c4e8ce97\' 的频道不存在') 46 | }) 47 | it('should search channel', async () => { 48 | await Promise.all([ 49 | ChannelsService.create(u0.id, { name: 'name', avatar: 'avatar', description: 'des' }), 50 | ChannelsService.create(u0.id, { name: 'name1', avatar: 'avatar1', description: 'description1' }), 51 | ChannelsService.create(u0.id, { name: 'name2', avatar: 'avatar2', description: 'description2' }), 52 | ChannelsService.create(u0.id, { name: 'nb111', avatar: 'avatar2', description: 'description2' }) 53 | ]) 54 | expect(await ChannelsService.search('test')).to.be.have.lengthOf(0) 55 | expect(await ChannelsService.search('name')).to.be.have.lengthOf(3) 56 | expect(await ChannelsService.search('')).to.be.have.lengthOf(4) 57 | }) 58 | it('should add subChannel', async () => { 59 | const channel = await ChannelsService.create(u0.id, { name: 'test', avatar: 'test', description: 'test' }) 60 | await ChannelsService.addSubChannel(channel.id, 'test subChannel') 61 | expect((await ChannelsService.get(channel.id))?.subChannels[0].title).to.equal('test subChannel') 62 | }) 63 | it('should add member for channel', async () => { 64 | const channel = await ChannelsService.create(u0.id, { name: 'test', avatar: 'test', description: 'test' }) 65 | await ChannelsService.addMember(channel.id, [{ 66 | id: u1.id, 67 | name: u1.username, 68 | rules: ['admin'] 69 | },{ 70 | id: u2.id, 71 | name: u2.username, 72 | rules: ['admin'] 73 | }]) 74 | expect((await ChannelsService.get(channel.id))?.members[1].id).to.equal(u1.id) 75 | expect((await ChannelsService.get(channel.id))?.members[2].id).to.equal(u2.id) 76 | }) 77 | it('should add chatRoom for channel', async () => { 78 | const channel = await ChannelsService.create(u0.id, { name: 'test', avatar: 'test', description: 'test' }) 79 | await ChannelsService.addSubChannel(channel.id, 'foo') 80 | const chatRoom = await ChatRoomsService.create([u0.id, u1.id, u2.id], { name: 'test subChannel chatRoom' }, channel.id) 81 | await ChannelsService.addChatRoom(channel.id, 'foo', chatRoom.id) 82 | expect(await ChannelsService.get(channel.id)) 83 | .property('subChannels') 84 | .property('0') 85 | .property('chatRooms') 86 | .property('0') 87 | .property('id') 88 | .to.equal(chatRoom.id) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /packages/frontend/src/api.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Api, 3 | attachApi, 4 | Channels, 5 | ChatRooms, 6 | Messages, 7 | Pagination, 8 | QueryPromise, 9 | SearchQuery, 10 | Users 11 | } from '@boiling/core' 12 | import { ElMessage } from 'element-plus' 13 | 14 | interface OfficialApi { 15 | /** 搜索用户 */ 16 | users: QueryPromise, SearchQuery> & { 17 | /** 注册新用户 */ 18 | add(d: Users.Register): Promise 19 | /** 获取头像*/ 20 | avatars: Promise 21 | } 22 | /** 获取指定用户 */ 23 | user(id: number | '@me'): Promise & { 24 | upd(d: Users.UpdateOut): Promise 25 | password: { 26 | upd(d: { oldPwd: string, newPwd: string }): Promise 27 | } 28 | status: { 29 | /** 登录用户并设置用户状态 */ 30 | add(d: Users.Login): Promise 31 | /** 更改用户状态 */ 32 | upd(d: { status: Users.Status }): Promise 33 | 34 | } 35 | /** 获取讨论组 */ 36 | groups: Promise 37 | /** 获取聊天室 */ 38 | 'chat-rooms': Promise 39 | /** 获取好友列表 */ 40 | friends: Promise & { 41 | /** 添加好友 */ 42 | add(d: Users.Friend): Promise 43 | } 44 | tag: { 45 | /** 创建好友标签 */ 46 | add(d: { tag: string }): Promise 47 | } 48 | friend(fUid: Pick['id']): { 49 | del(): Promise 50 | upd(d: Omit): Promise 51 | } 52 | avatar: { 53 | upd(d: { avatar: string }): Promise 54 | } 55 | /** 获取频道 */ 56 | channels: Promise 57 | } 58 | 'chat-rooms': QueryPromise, SearchQuery & { 59 | disableToast?: boolean 60 | }> & { 61 | /** 创建聊天室 */ 62 | add(d: Pick): Promise 63 | } 64 | /** 聊天室 */ 65 | 'chat-room'(chatRoomId: string): { 66 | upd(d: { 67 | name?: string 68 | avatar?: string 69 | members?: number[] 70 | }): Promise 71 | messages: QueryPromise, SearchQuery> & { 72 | /** 发送消息 */ 73 | add(d: { content: string }): Promise 74 | } 75 | members: Promise 76 | member(uid: number| '@me'): { 77 | del(): Promise 78 | } 79 | files: Promise 80 | } 81 | /** 频道 */ 82 | channels: QueryPromise, SearchQuery> & { 83 | add(d: Pick): Promise 84 | } 85 | channel(channelId: string): Promise & { 86 | /** 更新频道 */ 87 | upd(d: { 88 | name?: string 89 | avatar?: string 90 | description?: string 91 | members?: Channels.MemberMeta[] 92 | }): Promise 93 | /** 删除频道 */ 94 | del(): Promise 95 | /** 子频道 */ 96 | subChannels: { 97 | /** 创建子频道 */ 98 | add(d: {title: string}): Promise 99 | } 100 | /** 聊天室 */ 101 | chatRoom(chatRoomId: string): { 102 | /** 创建聊天室 */ 103 | add(d: {title: string, chatRoomTitle?: string, description?: string}): Promise 104 | } 105 | members: { 106 | /** 添加成员 */ 107 | add(d: { members: Channels.MemberMeta[] }): Promise 108 | } 109 | } 110 | } 111 | 112 | class OfficialApi extends Api { 113 | constructor() { 114 | super(`${ API_HOST ?? '' }/api`) 115 | return attachApi(this) 116 | } 117 | } 118 | 119 | export const api = new OfficialApi() 120 | 121 | api.on('resp.rejected', async error => { 122 | const response = error?.response 123 | let msg = response?.data as string | undefined 124 | // TODO 处理全局异常 125 | switch (response?.status) { 126 | case 401: 127 | msg = msg || '登陆过期' 128 | location.href = '/login' 129 | break 130 | case 403: 131 | msg = msg || '没有权限' 132 | break 133 | case 404: 134 | msg = msg || '资源不存在' 135 | break 136 | case 500: 137 | msg = msg || '服务器错误' 138 | break 139 | default: 140 | msg = msg || '未知错误' 141 | } 142 | if (!/disableToast=true/.test(response?.request.responseURL)) 143 | ElMessage.error(msg) 144 | const config = response?.config 145 | throw new Error(`[${ response?.status }-${ config?.method }]${ config?.url }("${ msg }")`) 146 | }) 147 | -------------------------------------------------------------------------------- /packages/frontend/src/views/CreateChannel.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 91 | 92 | 151 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/nord.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017-present Arctic Ice Studio 3 | * Copyright (c) 2017-present Sven Greb 4 | * 5 | * Project: Nord highlight.js 6 | * Version: 0.1.0 7 | * Repository: https://github.com/arcticicestudio/nord-highlightjs 8 | * License: MIT 9 | * References: 10 | * https://github.com/arcticicestudio/nord 11 | */ 12 | .hljs { 13 | display: block; 14 | padding: 0.5em; 15 | overflow-x: auto; 16 | background: #2e3440; 17 | } 18 | .hljs, 19 | .hljs-subst { 20 | color: #d8dee9; 21 | } 22 | .hljs-selector-tag { 23 | color: #81a1c1; 24 | } 25 | .hljs-selector-id { 26 | color: #8fbcbb; 27 | font-weight: bold; 28 | } 29 | .hljs-selector-class { 30 | color: #8fbcbb; 31 | } 32 | .hljs-selector-attr { 33 | color: #8fbcbb; 34 | } 35 | .hljs-selector-pseudo { 36 | color: #88c0d0; 37 | } 38 | .hljs-addition { 39 | background-color: rgb(163 190 140 / 50%); 40 | } 41 | .hljs-deletion { 42 | background-color: rgb(191 97 106 / 50%); 43 | } 44 | .hljs-built_in, 45 | .hljs-type { 46 | color: #8fbcbb; 47 | } 48 | .hljs-class { 49 | color: #8fbcbb; 50 | } 51 | .hljs-function { 52 | color: #88c0d0; 53 | } 54 | .hljs-title { 55 | color: #8fbcbb; 56 | } 57 | .hljs-function > .hljs-title { 58 | color: #88c0d0; 59 | } 60 | .hljs-keyword, 61 | .hljs-literal, 62 | .hljs-symbol { 63 | color: #81a1c1; 64 | } 65 | .hljs-number { 66 | color: #b48ead; 67 | } 68 | .hljs-regexp { 69 | color: #ebcb8b; 70 | } 71 | .hljs-string { 72 | color: #a3be8c; 73 | } 74 | .hljs-params { 75 | color: #d8dee9; 76 | } 77 | .hljs-bullet { 78 | color: #81a1c1; 79 | } 80 | .hljs-code { 81 | color: #8fbcbb; 82 | } 83 | .hljs-emphasis { 84 | font-style: italic; 85 | } 86 | .hljs-formula { 87 | color: #8fbcbb; 88 | } 89 | .hljs-strong { 90 | font-weight: bold; 91 | } 92 | .hljs-link:hover { 93 | text-decoration: underline; 94 | } 95 | .hljs-quote { 96 | color: #4c566a; 97 | } 98 | .hljs-comment { 99 | color: #4c566a; 100 | } 101 | .hljs-doctag { 102 | color: #8fbcbb; 103 | } 104 | .hljs-meta, 105 | .hljs-meta-keyword { 106 | color: #5e81ac; 107 | } 108 | .hljs-meta-string { 109 | color: #a3be8c; 110 | } 111 | .hljs-attr { 112 | color: #8fbcbb; 113 | } 114 | .hljs-attribute { 115 | color: #d8dee9; 116 | } 117 | .hljs-builtin-name { 118 | color: #81a1c1; 119 | } 120 | .hljs-name { 121 | color: #81a1c1; 122 | } 123 | .hljs-section { 124 | color: #88c0d0; 125 | } 126 | .hljs-tag { 127 | color: #81a1c1; 128 | } 129 | .hljs-variable { 130 | color: #d8dee9; 131 | } 132 | .hljs-template-variable { 133 | color: #d8dee9; 134 | } 135 | .hljs-template-tag { 136 | color: #5e81ac; 137 | } 138 | .abnf .hljs-attribute { 139 | color: #88c0d0; 140 | } 141 | .abnf .hljs-symbol { 142 | color: #ebcb8b; 143 | } 144 | .apache .hljs-attribute { 145 | color: #88c0d0; 146 | } 147 | .apache .hljs-section { 148 | color: #81a1c1; 149 | } 150 | .arduino .hljs-built_in { 151 | color: #88c0d0; 152 | } 153 | .aspectj .hljs-meta { 154 | color: #d08770; 155 | } 156 | .aspectj > .hljs-title { 157 | color: #88c0d0; 158 | } 159 | .bnf .hljs-attribute { 160 | color: #8fbcbb; 161 | } 162 | .clojure .hljs-name { 163 | color: #88c0d0; 164 | } 165 | .clojure .hljs-symbol { 166 | color: #ebcb8b; 167 | } 168 | .coq .hljs-built_in { 169 | color: #88c0d0; 170 | } 171 | .cpp .hljs-meta-string { 172 | color: #8fbcbb; 173 | } 174 | .css .hljs-built_in { 175 | color: #88c0d0; 176 | } 177 | .css .hljs-keyword { 178 | color: #d08770; 179 | } 180 | .diff .hljs-meta { 181 | color: #8fbcbb; 182 | } 183 | .ebnf .hljs-attribute { 184 | color: #8fbcbb; 185 | } 186 | .glsl .hljs-built_in { 187 | color: #88c0d0; 188 | } 189 | .haxe .hljs-meta { 190 | color: #d08770; 191 | } 192 | .java .hljs-meta { 193 | color: #d08770; 194 | } 195 | .yaml .hljs-meta { 196 | color: #d08770; 197 | } 198 | .swift .hljs-meta { 199 | color: #d08770; 200 | } 201 | .groovy .hljs-meta:not(:first-child) { 202 | color: #d08770; 203 | } 204 | .ldif .hljs-attribute { 205 | color: #8fbcbb; 206 | } 207 | .lisp .hljs-name { 208 | color: #88c0d0; 209 | } 210 | .lua .hljs-built_in { 211 | color: #88c0d0; 212 | } 213 | .moonscript .hljs-built_in { 214 | color: #88c0d0; 215 | } 216 | .nginx .hljs-attribute { 217 | color: #88c0d0; 218 | } 219 | .nginx .hljs-section { 220 | color: #5e81ac; 221 | } 222 | .pf .hljs-built_in { 223 | color: #88c0d0; 224 | } 225 | .processing .hljs-built_in { 226 | color: #88c0d0; 227 | } 228 | .scss .hljs-keyword { 229 | color: #81a1c1; 230 | } 231 | .stylus .hljs-keyword { 232 | color: #81a1c1; 233 | } 234 | .vim .hljs-built_in { 235 | color: #88c0d0; 236 | font-style: italic; 237 | } 238 | -------------------------------------------------------------------------------- /packages/frontend/src/components/PanelSelector.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 81 | 82 | 155 | -------------------------------------------------------------------------------- /packages/frontend/src/views/EditPersonnel.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 150 | 151 | 178 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'stylelint-config-standard' 4 | ], 5 | plugins: [ 6 | 'stylelint-scss', 7 | 'stylelint-order', 8 | './rules/stylelint-plugin.cjs' 9 | ], 10 | customSyntax: 'postcss-html', 11 | overrides: [{ 12 | files: ['**/*.scss', '**/*.sass'], 13 | customSyntax: 'postcss-scss' 14 | }], 15 | rules: { 16 | 'in/selector-combinator-space-after': ['always'], 17 | 'font-family-name-quotes': ['always-unless-keyword'], 18 | 'at-rule-no-unknown': [undefined, { 19 | ignoreAtRules: ['include'] 20 | }], 21 | 'selector-class-pattern': [undefined, { 22 | resolveNestedSelectors: true 23 | }], 24 | 'selector-combinator-space-after': ['always'], 25 | 'selector-pseudo-class-no-unknown': [true, { 26 | ignorePseudoClasses: ['deep', 'horizontal'] 27 | }], 28 | 'selector-list-comma-newline-after': ['always-multi-line'], 29 | 'rule-empty-line-before': ['never-multi-line'], 30 | 'custom-property-empty-line-before': ['never'], 31 | 'order/order': [ 32 | 'custom-properties', 33 | 'dollar-variables', 34 | 'at-variables', 35 | 'at-rules', 36 | 'declarations', 37 | 'rules', 38 | ], 39 | 'order/properties-order': [ 40 | 'z-index', 41 | 'position', 42 | 'top', 43 | 'right', 44 | 'bottom', 45 | 'left', 46 | 'display', 47 | 'float', 48 | 'padding', 49 | 'padding-top', 50 | 'padding-right', 51 | 'padding-bottom', 52 | 'padding-left', 53 | 'margin', 54 | 'margin-top', 55 | 'margin-right', 56 | 'margin-bottom', 57 | 'margin-left', 58 | 'margin-collapse', 59 | 'margin-top-collapse', 60 | 'margin-right-collapse', 61 | 'margin-bottom-collapse', 62 | 'margin-left-collapse', 63 | 'width', 64 | 'height', 65 | 'max-width', 66 | 'max-height', 67 | 'min-width', 68 | 'min-height', 69 | 'overflow', 70 | 'overflow-x', 71 | 'overflow-y', 72 | 'clip', 73 | 'clear', 74 | 'hyphens', 75 | 'src', 76 | 'line-height', 77 | 'letter-spacing', 78 | 'word-spacing', 79 | 'color', 80 | 'font', 81 | 'font-family', 82 | 'font-size', 83 | 'font-smoothing', 84 | 'osx-font-smoothing', 85 | 'font-style', 86 | 'font-weight', 87 | 'text-align', 88 | 'text-decoration', 89 | 'text-indent', 90 | 'text-overflow', 91 | 'text-rendering', 92 | 'text-size-adjust', 93 | 'text-shadow', 94 | 'text-transform', 95 | 'word-break', 96 | 'word-wrap', 97 | 'white-space', 98 | 'vertical-align', 99 | 'list-style', 100 | 'list-style-type', 101 | 'list-style-position', 102 | 'list-style-image', 103 | 'pointer-events', 104 | 'cursor', 105 | 'background', 106 | 'background-attachment', 107 | 'background-color', 108 | 'background-image', 109 | 'background-position', 110 | 'background-repeat', 111 | 'background-size', 112 | 'border', 113 | 'border-collapse', 114 | 'border-top', 115 | 'border-right', 116 | 'border-bottom', 117 | 'border-left', 118 | 'border-color', 119 | 'border-image', 120 | 'border-top-color', 121 | 'border-right-color', 122 | 'border-bottom-color', 123 | 'border-left-color', 124 | 'border-spacing', 125 | 'border-style', 126 | 'border-top-style', 127 | 'border-right-style', 128 | 'border-bottom-style', 129 | 'border-left-style', 130 | 'border-width', 131 | 'border-top-width', 132 | 'border-right-width', 133 | 'border-bottom-width', 134 | 'border-left-width', 135 | 'border-radius', 136 | 'border-top-right-radius', 137 | 'border-bottom-right-radius', 138 | 'border-bottom-left-radius', 139 | 'border-top-left-radius', 140 | 'border-radius-topright', 141 | 'border-radius-bottomright', 142 | 'border-radius-bottomleft', 143 | 'border-radius-topleft', 144 | 'content', 145 | 'quotes', 146 | 'outline', 147 | 'outline-offset', 148 | 'opacity', 149 | 'filter', 150 | 'visibility', 151 | 'size', 152 | 'zoom', 153 | 'transform', 154 | 'box-align', 155 | 'box-flex', 156 | 'box-orient', 157 | 'box-pack', 158 | 'box-shadow', 159 | 'box-sizing', 160 | 'table-layout', 161 | 'animation', 162 | 'animation-delay', 163 | 'animation-duration', 164 | 'animation-iteration-count', 165 | 'animation-name', 166 | 'animation-play-state', 167 | 'animation-timing-function', 168 | 'animation-fill-mode', 169 | 'transition', 170 | 'transition-delay', 171 | 'transition-duration', 172 | 'transition-property', 173 | 'transition-timing-function', 174 | 'background-clip', 175 | 'backface-visibility', 176 | 'resize', 177 | 'appearance', 178 | 'user-select', 179 | 'interpolation-mode', 180 | 'direction', 181 | 'marks', 182 | 'page', 183 | 'set-link-source', 184 | 'unicode-bidi', 185 | 'speak', 186 | ], 187 | } 188 | } 189 | --------------------------------------------------------------------------------