├── .babelrc ├── .editorconfig ├── .gitignore ├── .postcssrc.js ├── LICENSE ├── README.md ├── build ├── build.js ├── check-versions.js ├── utils.js ├── vue-loader.conf.js ├── webpack.base.conf.js ├── webpack.dev.conf.js └── webpack.prod.conf.js ├── config ├── dev.env.js ├── index.js ├── prod.env.js └── test.env.js ├── index.html ├── package-lock.json ├── package.json ├── src ├── App.vue ├── assets │ └── qqEmoji.png ├── common │ ├── ak.js │ ├── css │ │ └── base.less │ └── http.js ├── components │ ├── common │ │ ├── common_chat.vue │ │ └── common_chat_emoji.vue │ ├── imClient │ │ ├── imClient.vue │ │ ├── imLeave.vue │ │ ├── imRate.vue │ │ └── imTransfer.vue │ └── imServer │ │ ├── imChat.vue │ │ ├── imRecord.vue │ │ └── imServer.vue ├── main.js ├── router │ └── index.js └── store │ └── imServerStore.js └── static ├── css └── reset.css ├── image ├── im_client_avatar.png ├── im_emoji_spacer.gif ├── im_robot_avatar.png └── im_server_avatar.png ├── js └── socket.io.js └── upload └── .gitignore /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "modules": false, 5 | "targets": { 6 | "browsers": ["> 1%", "last 2 versions", "not ie <= 8"] 7 | } 8 | }], 9 | "stage-2" 10 | ], 11 | "plugins": ["transform-vue-jsx", "transform-runtime"] 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | /dist/ 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | /test/e2e/reports/ 8 | selenium-debug.log 9 | /static/upload/* 10 | 11 | # Editor directories and files 12 | .idea 13 | .vscode 14 | *.suo 15 | *.ntvs* 16 | *.njsproj 17 | *.sln 18 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | // https://github.com/michael-ciniawsky/postcss-load-config 2 | 3 | module.exports = { 4 | "plugins": { 5 | "postcss-import": {}, 6 | "postcss-url": {}, 7 | // to edit target browsers: use "browserslist" field in package.json 8 | "autoprefixer": {} 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 polk6 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-im 2 | 一个基于Vue2.0的在线客服系统。包括服务端和客户端。 3 | 4 | # Features 5 | * 支持1客服对多用户 6 | * 支持客户选择客服 7 | * 输入框支持文本、图片、表情、文件传输 8 | * 输入框支持粘贴图片、文本表情混合 9 | 10 | ## im-server im服务端 11 | ![image](https://user-images.githubusercontent.com/3334204/54471439-e1a7b400-47f3-11e9-8a97-819ef99a0fb5.png) 12 | 13 | ## im-client im客户端 14 | ![image](https://user-images.githubusercontent.com/3334204/54471440-e704fe80-47f3-11e9-9454-96a2fb27b122.png) 15 | 16 | ## Usage 17 | ``` 18 | npm install . 19 | 20 | npm run dev 21 | ``` 22 | ## Express-server 23 | ./build/webpack.dev.conf.js 内置了一个Express服务,后台接口都在此处 24 | 25 | ## Blog 26 | [https://www.cnblogs.com/polk6/p/vue-im.html](https://www.cnblogs.com/polk6/p/vue-im.html) 27 | 28 | ## Browser 29 | 目前只适配了Chrome浏览器 30 | 31 | ## LICENSE 32 | [MIT](https://zh.wikipedia.org/wiki/MIT%E8%A8%B1%E5%8F%AF%E8%AD%89) -------------------------------------------------------------------------------- /build/build.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | require('./check-versions')() 3 | 4 | process.env.NODE_ENV = 'production' 5 | 6 | const ora = require('ora') 7 | const rm = require('rimraf') 8 | const path = require('path') 9 | const chalk = require('chalk') 10 | const webpack = require('webpack') 11 | const config = require('../config') 12 | const webpackConfig = require('./webpack.prod.conf') 13 | 14 | const spinner = ora('building for production...') 15 | spinner.start() 16 | 17 | rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => { 18 | if (err) throw err 19 | webpack(webpackConfig, (err, stats) => { 20 | spinner.stop() 21 | if (err) throw err 22 | process.stdout.write(stats.toString({ 23 | colors: true, 24 | modules: false, 25 | children: false, // If you are using ts-loader, setting this to true will make TypeScript errors show up during build. 26 | chunks: false, 27 | chunkModules: false 28 | }) + '\n\n') 29 | 30 | if (stats.hasErrors()) { 31 | console.log(chalk.red(' Build failed with errors.\n')) 32 | process.exit(1) 33 | } 34 | 35 | console.log(chalk.cyan(' Build complete.\n')) 36 | console.log(chalk.yellow( 37 | ' Tip: built files are meant to be served over an HTTP server.\n' + 38 | ' Opening index.html over file:// won\'t work.\n' 39 | )) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /build/check-versions.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const chalk = require('chalk') 3 | const semver = require('semver') 4 | const packageConfig = require('../package.json') 5 | const shell = require('shelljs') 6 | 7 | function exec (cmd) { 8 | return require('child_process').execSync(cmd).toString().trim() 9 | } 10 | 11 | const versionRequirements = [ 12 | { 13 | name: 'node', 14 | currentVersion: semver.clean(process.version), 15 | versionRequirement: packageConfig.engines.node 16 | } 17 | ] 18 | 19 | if (shell.which('npm')) { 20 | versionRequirements.push({ 21 | name: 'npm', 22 | currentVersion: exec('npm --version'), 23 | versionRequirement: packageConfig.engines.npm 24 | }) 25 | } 26 | 27 | module.exports = function () { 28 | const warnings = [] 29 | 30 | for (let i = 0; i < versionRequirements.length; i++) { 31 | const mod = versionRequirements[i] 32 | 33 | if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) { 34 | warnings.push(mod.name + ': ' + 35 | chalk.red(mod.currentVersion) + ' should be ' + 36 | chalk.green(mod.versionRequirement) 37 | ) 38 | } 39 | } 40 | 41 | if (warnings.length) { 42 | console.log('') 43 | console.log(chalk.yellow('To use this template, you must update following to modules:')) 44 | console.log() 45 | 46 | for (let i = 0; i < warnings.length; i++) { 47 | const warning = warnings[i] 48 | console.log(' ' + warning) 49 | } 50 | 51 | console.log() 52 | process.exit(1) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /build/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const config = require('../config') 4 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 5 | const packageConfig = require('../package.json') 6 | 7 | exports.assetsPath = function (_path) { 8 | const assetsSubDirectory = process.env.NODE_ENV === 'production' 9 | ? config.build.assetsSubDirectory 10 | : config.dev.assetsSubDirectory 11 | 12 | return path.posix.join(assetsSubDirectory, _path) 13 | } 14 | 15 | exports.cssLoaders = function (options) { 16 | options = options || {} 17 | 18 | const cssLoader = { 19 | loader: 'css-loader', 20 | options: { 21 | sourceMap: options.sourceMap 22 | } 23 | } 24 | 25 | const postcssLoader = { 26 | loader: 'postcss-loader', 27 | options: { 28 | sourceMap: options.sourceMap 29 | } 30 | } 31 | 32 | // generate loader string to be used with extract text plugin 33 | function generateLoaders (loader, loaderOptions) { 34 | const loaders = options.usePostCSS ? [cssLoader, postcssLoader] : [cssLoader] 35 | 36 | if (loader) { 37 | loaders.push({ 38 | loader: loader + '-loader', 39 | options: Object.assign({}, loaderOptions, { 40 | sourceMap: options.sourceMap 41 | }) 42 | }) 43 | } 44 | 45 | // Extract CSS when that option is specified 46 | // (which is the case during production build) 47 | if (options.extract) { 48 | return ExtractTextPlugin.extract({ 49 | use: loaders, 50 | fallback: 'vue-style-loader' 51 | }) 52 | } else { 53 | return ['vue-style-loader'].concat(loaders) 54 | } 55 | } 56 | 57 | // https://vue-loader.vuejs.org/en/configurations/extract-css.html 58 | return { 59 | css: generateLoaders(), 60 | postcss: generateLoaders(), 61 | less: generateLoaders('less'), 62 | sass: generateLoaders('sass', { indentedSyntax: true }), 63 | scss: generateLoaders('sass'), 64 | stylus: generateLoaders('stylus'), 65 | styl: generateLoaders('stylus') 66 | } 67 | } 68 | 69 | // Generate loaders for standalone style files (outside of .vue) 70 | exports.styleLoaders = function (options) { 71 | const output = [] 72 | const loaders = exports.cssLoaders(options) 73 | 74 | for (const extension in loaders) { 75 | const loader = loaders[extension] 76 | output.push({ 77 | test: new RegExp('\\.' + extension + '$'), 78 | use: loader 79 | }) 80 | } 81 | 82 | return output 83 | } 84 | 85 | exports.createNotifierCallback = () => { 86 | const notifier = require('node-notifier') 87 | 88 | return (severity, errors) => { 89 | if (severity !== 'error') return 90 | 91 | const error = errors[0] 92 | const filename = error.file && error.file.split('!').pop() 93 | 94 | notifier.notify({ 95 | title: packageConfig.name, 96 | message: severity + ': ' + error.name, 97 | subtitle: filename || '', 98 | icon: path.join(__dirname, 'logo.png') 99 | }) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /build/vue-loader.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const utils = require('./utils') 3 | const config = require('../config') 4 | const isProduction = process.env.NODE_ENV === 'production' 5 | const sourceMapEnabled = isProduction 6 | ? config.build.productionSourceMap 7 | : config.dev.cssSourceMap 8 | 9 | module.exports = { 10 | loaders: utils.cssLoaders({ 11 | sourceMap: sourceMapEnabled, 12 | extract: isProduction 13 | }), 14 | cssSourceMap: sourceMapEnabled, 15 | cacheBusting: config.dev.cacheBusting, 16 | transformToRequire: { 17 | video: ['src', 'poster'], 18 | source: 'src', 19 | img: 'src', 20 | image: 'xlink:href' 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /build/webpack.base.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const utils = require('./utils') 4 | const config = require('../config') 5 | const vueLoaderConfig = require('./vue-loader.conf') 6 | 7 | function resolve (dir) { 8 | return path.join(__dirname, '..', dir) 9 | } 10 | 11 | const createLintingRule = () => ({ 12 | test: /\.(js|vue)$/, 13 | loader: 'eslint-loader', 14 | enforce: 'pre', 15 | include: [resolve('src'), resolve('test')], 16 | options: { 17 | formatter: require('eslint-friendly-formatter'), 18 | emitWarning: !config.dev.showEslintErrorsInOverlay 19 | } 20 | }) 21 | 22 | module.exports = { 23 | context: path.resolve(__dirname, '../'), 24 | entry: { app: [ 'babel-polyfill', './src/main.js' ] }, 25 | output: { 26 | path: config.build.assetsRoot, 27 | filename: '[name].js', 28 | publicPath: process.env.NODE_ENV === 'production' 29 | ? config.build.assetsPublicPath 30 | : config.dev.assetsPublicPath 31 | }, 32 | resolve: { 33 | extensions: ['.js', '.vue', '.json'], 34 | alias: { 35 | 'vue$': 'vue/dist/vue.esm.js', 36 | '@': resolve('src'), 37 | '@@': resolve('static'), 38 | } 39 | }, 40 | module: { 41 | rules: [ 42 | ...(config.dev.useEslint ? [createLintingRule()] : []), 43 | { 44 | test: /\.vue$/, 45 | loader: 'vue-loader', 46 | options: vueLoaderConfig 47 | }, 48 | { 49 | test: /\.js$/, 50 | loader: 'babel-loader', 51 | include: [resolve('src'), resolve('test'), resolve('node_modules/webpack-dev-server/client')] 52 | }, 53 | { 54 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 55 | loader: 'url-loader', 56 | options: { 57 | limit: 10000, 58 | name: utils.assetsPath('img/[name].[hash:7].[ext]') 59 | } 60 | }, 61 | { 62 | test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, 63 | loader: 'url-loader', 64 | options: { 65 | limit: 10000, 66 | name: utils.assetsPath('media/[name].[hash:7].[ext]') 67 | } 68 | }, 69 | { 70 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 71 | loader: 'url-loader', 72 | options: { 73 | limit: 10000, 74 | name: utils.assetsPath('fonts/[name].[hash:7].[ext]') 75 | } 76 | } 77 | ] 78 | }, 79 | node: { 80 | // prevent webpack from injecting useless setImmediate polyfill because Vue 81 | // source contains it (although only uses it if it's native). 82 | setImmediate: false, 83 | // prevent webpack from injecting mocks to Node native modules 84 | // that does not make sense for the client 85 | dgram: 'empty', 86 | fs: 'empty', 87 | net: 'empty', 88 | tls: 'empty', 89 | child_process: 'empty' 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /build/webpack.dev.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const utils = require('./utils'); 3 | const webpack = require('webpack'); 4 | const config = require('../config'); 5 | const merge = require('webpack-merge'); 6 | const path = require('path'); 7 | const baseWebpackConfig = require('./webpack.base.conf'); 8 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 9 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 10 | const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin'); 11 | const portfinder = require('portfinder'); 12 | 13 | const HOST = process.env.HOST; 14 | const PORT = process.env.PORT && Number(process.env.PORT); 15 | 16 | const devWebpackConfig = merge(baseWebpackConfig, { 17 | module: { 18 | rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, usePostCSS: true }) 19 | }, 20 | // cheap-module-eval-source-map is faster for development 21 | devtool: config.dev.devtool, 22 | 23 | // these devServer options should be customized in /config/index.js 24 | devServer: { 25 | clientLogLevel: 'warning', 26 | historyApiFallback: { 27 | rewrites: [{ from: /.*/, to: path.posix.join(config.dev.assetsPublicPath, 'index.html') }] 28 | }, 29 | hot: true, 30 | contentBase: false, // since we use CopyWebpackPlugin. 31 | compress: true, 32 | host: HOST || config.dev.host, 33 | port: PORT || config.dev.port, 34 | open: config.dev.autoOpenBrowser, 35 | overlay: config.dev.errorOverlay ? { warnings: false, errors: true } : false, 36 | publicPath: config.dev.assetsPublicPath, 37 | proxy: config.dev.proxyTable, 38 | quiet: true, // necessary for FriendlyErrorsPlugin 39 | watchOptions: { 40 | poll: config.dev.poll 41 | } 42 | }, 43 | plugins: [ 44 | new webpack.DefinePlugin({ 45 | 'process.env': require('../config/dev.env') 46 | }), 47 | new webpack.HotModuleReplacementPlugin(), 48 | new webpack.NamedModulesPlugin(), // HMR shows correct file names in console on update. 49 | new webpack.NoEmitOnErrorsPlugin(), 50 | // https://github.com/ampedandwired/html-webpack-plugin 51 | new HtmlWebpackPlugin({ 52 | filename: 'index.html', 53 | template: 'index.html', 54 | inject: true 55 | }), 56 | // copy custom static assets 57 | new CopyWebpackPlugin([ 58 | { 59 | from: path.resolve(__dirname, '../static'), 60 | to: config.dev.assetsSubDirectory, 61 | ignore: ['.*'] 62 | } 63 | ]) 64 | ] 65 | }); 66 | 67 | module.exports = new Promise((resolve, reject) => { 68 | portfinder.basePort = process.env.PORT || config.dev.port; 69 | portfinder.getPort((err, port) => { 70 | if (err) { 71 | reject(err); 72 | } else { 73 | // publish the new Port, necessary for e2e tests 74 | process.env.PORT = port; 75 | // add port to devServer config 76 | devWebpackConfig.devServer.port = port; 77 | 78 | // Add FriendlyErrorsPlugin 79 | devWebpackConfig.plugins.push( 80 | new FriendlyErrorsPlugin({ 81 | compilationSuccessInfo: { 82 | messages: [ 83 | ` 84 | Your application is running here: 85 | im-server: http://localhost:${port}/#/imServer 86 | im-client: http://localhost:${port}/#/imclient 87 | ` 88 | ] 89 | }, 90 | onErrors: config.dev.notifyOnErrors ? utils.createNotifierCallback() : undefined 91 | }) 92 | ); 93 | 94 | resolve(devWebpackConfig); 95 | } 96 | }); 97 | }); 98 | 99 | // express 100 | const app = require('express')(); 101 | const fileUpload = require('express-fileupload'); 102 | app.use(fileUpload()); // for parsing multipart/form-data 103 | app.use(function(req, res, next) { 104 | res.header('Access-Control-Allow-Origin', '*'); 105 | res.header('Access-Control-Allow-Headers', 'X-Requested-With'); 106 | res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control, Pragma'); 107 | res.header('Access-Control-Allow-Methods', 'PUT, GET, POST, DELETE, OPTIONS'); 108 | if (req.method === 'OPTIONS') { 109 | res.sendStatus(204); 110 | } else { 111 | next(); 112 | } 113 | }); 114 | // 上传文件 115 | app.post('/upload', function(req, res) { 116 | if (!req.files) { 117 | return res.status(400).send('No files were uploaded.'); 118 | } 119 | // save file 120 | // 121 | let file = req.files.uploadFile; 122 | let encodeFileName = Number.parseInt(Date.now() + Math.random()) + file.name; 123 | file.mv(path.resolve(__dirname, '../static/upload/') + '/' + encodeFileName, function(err) { 124 | if (err) { 125 | return res.status(500).send({ 126 | code: err.code, 127 | data: err, 128 | message: '文件上传失败' 129 | }); 130 | } 131 | res.send({ 132 | code: 0, 133 | data: { 134 | fileName: file.name, 135 | fileUrl: `http://${devWebpackConfig.devServer.host}:3000/static/upload/${encodeFileName}` 136 | }, 137 | message: '文件上传成功' 138 | }); 139 | }); 140 | }); 141 | 142 | // 获取文件 143 | app.get('/static/upload/:fileName', function(req, res) { 144 | res.sendFile(path.resolve(__dirname, '../static/upload') + '/' + req.params.fileName); 145 | }); 146 | // 获取im客服列表 147 | app.get('/getIMServerList', function(req, res) { 148 | res.json({ 149 | code: 0, 150 | data: Array.from(serverChatDic.values()).map((item) => { 151 | return item.serverChatEn; 152 | }) // 只需要serverChatDic.values内的serverChatEn 153 | }); 154 | }); 155 | app.listen(3000); 156 | 157 | // socket 158 | var server = require('http').createServer(); 159 | var io = require('socket.io')(server); 160 | var serverChatDic = new Map(); // 服务端 161 | var clientChatDic = new Map(); // 客户端 162 | io.on('connection', function(socket) { 163 | // 服务端上线 164 | socket.on('SERVER_ON', function(data) { 165 | let serverChatEn = data.serverChatEn; 166 | console.log(`有新的服务端socket连接了,服务端Id:${serverChatEn.serverChatId}`); 167 | serverChatDic.set(serverChatEn.serverChatId, { 168 | serverChatEn: serverChatEn, 169 | socket: socket 170 | }); 171 | }); 172 | 173 | // 服务端下线 174 | socket.on('SERVER_OFF', function(data) { 175 | let serverChatEn = data.serverChatEn; 176 | serverChatDic.delete(serverChatEn.serverChatId); 177 | }); 178 | 179 | // 服务端发送了信息 180 | socket.on('SERVER_SEND_MSG', function(data) { 181 | if (clientChatDic.has(data.clientChatId)) { 182 | clientChatDic.get(data.clientChatId).socket.emit('SERVER_SEND_MSG', { msg: data.msg }); 183 | } 184 | }); 185 | 186 | // 客户端事件;'CLIENT_ON'(上线), 'CLIENT_OFF'(离线), 'CLIENT_SEND_MSG'(发送消息) 187 | ['CLIENT_ON', 'CLIENT_OFF', 'CLIENT_SEND_MSG'].forEach((eventName) => { 188 | socket.on(eventName, (data) => { 189 | let clientChatEn = data.clientChatEn; 190 | let serverChatId = data.serverChatId; 191 | // 1.通知服务端 192 | if (serverChatDic.has(serverChatId)) { 193 | serverChatDic.get(serverChatId).socket.emit(eventName, { 194 | clientChatEn: clientChatEn, 195 | msg: data.msg 196 | }); 197 | } else { 198 | socket.emit('SERVER_SEND_MSG', { 199 | msg: { 200 | content: '未找到客服' 201 | } 202 | }); 203 | } 204 | 205 | // 2.对不同的事件特殊处理 206 | if (eventName === 'CLIENT_ON') { 207 | // 1)'CLIENT_ON',通知客户端正确连接 208 | console.log(`有新的客户端socket连接了,客户端Id:${clientChatEn.clientChatId}`); 209 | clientChatDic.set(clientChatEn.clientChatId, { 210 | clientChatEn: clientChatEn, 211 | socket: socket 212 | }); 213 | serverChatDic.has(serverChatId) && 214 | socket.emit('SERVER_CONNECTED', { 215 | serverChatEn: serverChatDic.get(serverChatId).serverChatEn 216 | }); 217 | } else if (eventName === 'CLIENT_OFF') { 218 | // 2)'CLIENT_OFF',删除连接 219 | clientChatDic.delete(clientChatEn.clientChatId); 220 | } 221 | }); 222 | }); 223 | }); 224 | server.listen(3001); 225 | -------------------------------------------------------------------------------- /build/webpack.prod.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const utils = require('./utils') 4 | const webpack = require('webpack') 5 | const config = require('../config') 6 | const merge = require('webpack-merge') 7 | const baseWebpackConfig = require('./webpack.base.conf') 8 | const CopyWebpackPlugin = require('copy-webpack-plugin') 9 | const HtmlWebpackPlugin = require('html-webpack-plugin') 10 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 11 | const OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin') 12 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin') 13 | 14 | const env = require('../config/prod.env') 15 | 16 | const webpackConfig = merge(baseWebpackConfig, { 17 | module: { 18 | rules: utils.styleLoaders({ 19 | sourceMap: config.build.productionSourceMap, 20 | extract: true, 21 | usePostCSS: true 22 | }) 23 | }, 24 | devtool: config.build.productionSourceMap ? config.build.devtool : false, 25 | output: { 26 | path: config.build.assetsRoot, 27 | filename: utils.assetsPath('js/[name].[chunkhash].js'), 28 | chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') 29 | }, 30 | plugins: [ 31 | // http://vuejs.github.io/vue-loader/en/workflow/production.html 32 | new webpack.DefinePlugin({ 33 | 'process.env': env 34 | }), 35 | new UglifyJsPlugin({ 36 | uglifyOptions: { 37 | compress: { 38 | warnings: false 39 | } 40 | }, 41 | sourceMap: config.build.productionSourceMap, 42 | parallel: true 43 | }), 44 | // extract css into its own file 45 | new ExtractTextPlugin({ 46 | filename: utils.assetsPath('css/[name].[contenthash].css'), 47 | // Setting the following option to `false` will not extract CSS from codesplit chunks. 48 | // Their CSS will instead be inserted dynamically with style-loader when the codesplit chunk has been loaded by webpack. 49 | // It's currently set to `true` because we are seeing that sourcemaps are included in the codesplit bundle as well when it's `false`, 50 | // increasing file size: https://github.com/vuejs-templates/webpack/issues/1110 51 | allChunks: true, 52 | }), 53 | // Compress extracted CSS. We are using this plugin so that possible 54 | // duplicated CSS from different components can be deduped. 55 | new OptimizeCSSPlugin({ 56 | cssProcessorOptions: config.build.productionSourceMap 57 | ? { safe: true, map: { inline: false } } 58 | : { safe: true } 59 | }), 60 | // generate dist index.html with correct asset hash for caching. 61 | // you can customize output by editing /index.html 62 | // see https://github.com/ampedandwired/html-webpack-plugin 63 | new HtmlWebpackPlugin({ 64 | filename: config.build.index, 65 | template: 'index.html', 66 | inject: true, 67 | minify: { 68 | removeComments: true, 69 | collapseWhitespace: true, 70 | removeAttributeQuotes: true 71 | // more options: 72 | // https://github.com/kangax/html-minifier#options-quick-reference 73 | }, 74 | // necessary to consistently work with multiple chunks via CommonsChunkPlugin 75 | chunksSortMode: 'dependency' 76 | }), 77 | // keep module.id stable when vendor modules does not change 78 | new webpack.HashedModuleIdsPlugin(), 79 | // enable scope hoisting 80 | new webpack.optimize.ModuleConcatenationPlugin(), 81 | // split vendor js into its own file 82 | new webpack.optimize.CommonsChunkPlugin({ 83 | name: 'vendor', 84 | minChunks (module) { 85 | // any required modules inside node_modules are extracted to vendor 86 | return ( 87 | module.resource && 88 | /\.js$/.test(module.resource) && 89 | module.resource.indexOf( 90 | path.join(__dirname, '../node_modules') 91 | ) === 0 92 | ) 93 | } 94 | }), 95 | // extract webpack runtime and module manifest to its own file in order to 96 | // prevent vendor hash from being updated whenever app bundle is updated 97 | new webpack.optimize.CommonsChunkPlugin({ 98 | name: 'manifest', 99 | minChunks: Infinity 100 | }), 101 | // This instance extracts shared chunks from code splitted chunks and bundles them 102 | // in a separate chunk, similar to the vendor chunk 103 | // see: https://webpack.js.org/plugins/commons-chunk-plugin/#extra-async-commons-chunk 104 | new webpack.optimize.CommonsChunkPlugin({ 105 | name: 'app', 106 | async: 'vendor-async', 107 | children: true, 108 | minChunks: 3 109 | }), 110 | 111 | // copy custom static assets 112 | new CopyWebpackPlugin([ 113 | { 114 | from: path.resolve(__dirname, '../static'), 115 | to: config.build.assetsSubDirectory, 116 | ignore: ['.*'] 117 | } 118 | ]) 119 | ] 120 | }) 121 | 122 | if (config.build.productionGzip) { 123 | const CompressionWebpackPlugin = require('compression-webpack-plugin') 124 | 125 | webpackConfig.plugins.push( 126 | new CompressionWebpackPlugin({ 127 | asset: '[path].gz[query]', 128 | algorithm: 'gzip', 129 | test: new RegExp( 130 | '\\.(' + 131 | config.build.productionGzipExtensions.join('|') + 132 | ')$' 133 | ), 134 | threshold: 10240, 135 | minRatio: 0.8 136 | }) 137 | ) 138 | } 139 | 140 | if (config.build.bundleAnalyzerReport) { 141 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin 142 | webpackConfig.plugins.push(new BundleAnalyzerPlugin()) 143 | } 144 | 145 | module.exports = webpackConfig 146 | -------------------------------------------------------------------------------- /config/dev.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const merge = require('webpack-merge') 3 | const prodEnv = require('./prod.env') 4 | 5 | module.exports = merge(prodEnv, { 6 | NODE_ENV: '"development"' 7 | }) 8 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | // Template version: 1.3.1 3 | // see http://vuejs-templates.github.io/webpack for documentation. 4 | 5 | const path = require('path') 6 | 7 | module.exports = { 8 | dev: { 9 | 10 | // Paths 11 | assetsSubDirectory: 'static', 12 | assetsPublicPath: '/', 13 | proxyTable: {}, 14 | 15 | // Various Dev Server settings 16 | host: '', // can be overwritten by process.env.HOST 17 | port: 8080, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined 18 | autoOpenBrowser: false, 19 | errorOverlay: true, 20 | notifyOnErrors: true, 21 | poll: false, // https://webpack.js.org/configuration/dev-server/#devserver-watchoptions- 22 | 23 | // Use Eslint Loader? 24 | // If true, your code will be linted during bundling and 25 | // linting errors and warnings will be shown in the console. 26 | useEslint: false, 27 | // If true, eslint errors and warnings will also be shown in the error overlay 28 | // in the browser. 29 | showEslintErrorsInOverlay: false, 30 | 31 | /** 32 | * Source Maps 33 | */ 34 | 35 | // https://webpack.js.org/configuration/devtool/#development 36 | devtool: 'cheap-module-eval-source-map', 37 | 38 | // If you have problems debugging vue-files in devtools, 39 | // set this to false - it *may* help 40 | // https://vue-loader.vuejs.org/en/options.html#cachebusting 41 | cacheBusting: true, 42 | 43 | cssSourceMap: true 44 | }, 45 | 46 | build: { 47 | // Template for index.html 48 | index: path.resolve(__dirname, '../dist/index.html'), 49 | 50 | // Paths 51 | assetsRoot: path.resolve(__dirname, '../dist'), 52 | assetsSubDirectory: 'static', 53 | assetsPublicPath: '/', 54 | 55 | /** 56 | * Source Maps 57 | */ 58 | 59 | productionSourceMap: true, 60 | // https://webpack.js.org/configuration/devtool/#production 61 | devtool: '#source-map', 62 | 63 | // Gzip off by default as many popular static hosts such as 64 | // Surge or Netlify already gzip all static assets for you. 65 | // Before setting to `true`, make sure to: 66 | // npm install --save-dev compression-webpack-plugin 67 | productionGzip: false, 68 | productionGzipExtensions: ['js', 'css'], 69 | 70 | // Run the build command with an extra argument to 71 | // View the bundle analyzer report after build finishes: 72 | // `npm run build --report` 73 | // Set to `true` or `false` to always turn it on or off 74 | bundleAnalyzerReport: process.env.npm_config_report 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | module.exports = { 3 | NODE_ENV: '"production"' 4 | } 5 | -------------------------------------------------------------------------------- /config/test.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const merge = require('webpack-merge') 3 | const devEnv = require('./dev.env') 4 | 5 | module.exports = merge(devEnv, { 6 | NODE_ENV: '"testing"' 7 | }) 8 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | vue-im 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-im", 3 | "version": "1.0.0", 4 | "description": "A Vue.js project", 5 | "author": "polk6", 6 | "private": true, 7 | "scripts": { 8 | "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js", 9 | "start": "npm run dev", 10 | "build": "node build/build.js" 11 | }, 12 | "dependencies": { 13 | "karma-chai": "^0.1.0", 14 | "vue": "^2.5.2", 15 | "vue-router": "^3.0.1" 16 | }, 17 | "devDependencies": { 18 | "autoprefixer": "^7.2.6", 19 | "axios": "^0.18.1", 20 | "babel-core": "^6.22.1", 21 | "babel-helper-vue-jsx-merge-props": "^2.0.3", 22 | "babel-loader": "^7.1.4", 23 | "babel-plugin-syntax-jsx": "^6.18.0", 24 | "babel-plugin-transform-runtime": "^6.22.0", 25 | "babel-plugin-transform-vue-jsx": "^3.7.0", 26 | "babel-polyfill": "^6.26.0", 27 | "babel-preset-env": "^1.3.2", 28 | "babel-preset-stage-2": "^6.22.0", 29 | "chalk": "^2.3.2", 30 | "copy-webpack-plugin": "^4.5.0", 31 | "css-loader": "^0.28.10", 32 | "element-ui": "^2.2.1", 33 | "express": "^4.16.2", 34 | "express-fileupload": "^1.1.9", 35 | "extract-text-webpack-plugin": "^3.0.0", 36 | "file-loader": "^1.1.11", 37 | "font-awesome": "^4.7.0", 38 | "friendly-errors-webpack-plugin": "^1.6.1", 39 | "html-webpack-plugin": "^2.30.1", 40 | "less": "^2.7.3", 41 | "less-loader": "^4.0.6", 42 | "node-notifier": "^5.1.2", 43 | "optimize-css-assets-webpack-plugin": "^3.2.0", 44 | "ora": "^1.2.0", 45 | "portfinder": "^1.0.13", 46 | "postcss-import": "^11.1.0", 47 | "postcss-loader": "^2.1.1", 48 | "postcss-url": "^7.3.1", 49 | "rimraf": "^2.6.0", 50 | "semver": "^5.3.0", 51 | "shelljs": "^0.7.6", 52 | "socket.io": "^2.1.0", 53 | "socket.io-client": "^2.1.0", 54 | "uglifyjs-webpack-plugin": "^1.2.2", 55 | "url-loader": "^0.5.8", 56 | "vue-loader": "^13.3.0", 57 | "vue-style-loader": "^3.0.1", 58 | "vue-template-compiler": "^2.5.2", 59 | "vuex": "^3.0.1", 60 | "webpack": "^3.11.0", 61 | "webpack-bundle-analyzer": "^3.3.2", 62 | "webpack-dev-server": "^2.11.2", 63 | "webpack-merge": "^4.1.2" 64 | }, 65 | "engines": { 66 | "node": ">= 6.0.0", 67 | "npm": ">= 3.0.0" 68 | }, 69 | "browserslist": [ 70 | "> 1%", 71 | "last 2 versions", 72 | "not ie <= 8" 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 23 | -------------------------------------------------------------------------------- /src/assets/qqEmoji.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polk6/vue-im/a9c9817855aeff4087b25890742f036034dbe04b/src/assets/qqEmoji.png -------------------------------------------------------------------------------- /src/common/ak.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 工具模块,不依赖第三方代码 3 | */ 4 | var ak = ak || {}; 5 | 6 | ak.Base_URL = location.host; 7 | 8 | /** 9 | * 工具模块,不依赖第三方代码 10 | * 包含:类型判断 11 | */ 12 | ak.Utils = { 13 | /** 14 | * 是否为JSON字符串 15 | * @param {String} 16 | * @return {Boolean} 17 | */ 18 | 19 | isJSON(str) { 20 | if (typeof str == 'string') { 21 | try { 22 | var obj = JSON.parse(str); 23 | if (str.indexOf('{') > -1) { 24 | return true; 25 | } else { 26 | return false; 27 | } 28 | } catch (e) { 29 | return false; 30 | } 31 | } 32 | return false; 33 | }, 34 | /** 35 | * 去除字符串首尾两端空格 36 | * @param {String} str 37 | * @return {String} 38 | */ 39 | trim(str) { 40 | if (str) { 41 | return str.replace(/(^\s*)|(\s*$)/g, ''); 42 | } else { 43 | return ''; 44 | } 45 | }, 46 | /** 47 | * 脱敏 48 | * @param {String} value 脱敏的对象 49 | * @return {String} 50 | */ 51 | desensitization: function(value) { 52 | if (value) { 53 | var valueNew = ''; 54 | const length = value.length; 55 | valueNew = value 56 | .split('') 57 | .map((number, index) => { 58 | // 脱敏:从倒数第五位开始向前四位脱敏 59 | const indexMin = length - 8; 60 | const indexMax = length - 5; 61 | 62 | if (index >= indexMin && index <= indexMax) { 63 | return '*'; 64 | } else { 65 | return number; 66 | } 67 | }) 68 | .join(''); 69 | return valueNew; 70 | } else { 71 | return ''; 72 | } 73 | }, 74 | 75 | /** 76 | * 判断是否Array对象 77 | * @param {Object} value 判断的对象 78 | * @return {Boolean} 79 | */ 80 | isArray: function(value) { 81 | return toString.call(value) === '[object Array]'; 82 | }, 83 | 84 | /** 85 | * 判断是否日期对象 86 | * @param {Object} value 判断的对象 87 | * @return {Boolean} 88 | */ 89 | isDate: function(value) { 90 | return toString.call(value) === '[object Date]'; 91 | }, 92 | 93 | /** 94 | * 判断是否Object对象 95 | * @param {Object} value 判断的对象 96 | * @return {Boolean} 97 | */ 98 | isObject: function(value) { 99 | return toString.call(value) === '[object Object]'; 100 | }, 101 | 102 | /** 103 | * 判断是否为空 104 | * @param {Object} value 判断的对象 105 | * @return {Boolean} 106 | */ 107 | isEmpty: function(value) { 108 | return value === null || value === undefined || value === '' || (this.isArray(value) && value.length === 0); 109 | }, 110 | 111 | /** 112 | * 判断是否移动电话 113 | * @param {Number} value 判断的值 114 | * @return {Boolean} 115 | */ 116 | isMobilePhone: function(value) { 117 | value = Number.parseInt(value); 118 | // 1)是否非数字 119 | if (Number.isNaN(value)) { 120 | return false; 121 | } 122 | 123 | // 2)时候移动电话 124 | return /^1[3|4|5|7|8|9|6][0-9]\d{4,8}$/.test(value); 125 | }, 126 | 127 | /** 128 | * 判断是否为邮箱 129 | * @param {String} value 判断的值 130 | * @return {Boolean} 131 | */ 132 | isEmail: function(value) { 133 | return /^[a-zA-Z\-_0-9]+@[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)+$/.test(value); 134 | }, 135 | 136 | /** 137 | * 转换服务器请求的对象为Js的对象:包含首字母转换为小写;属性格式转换为Js支持的格式 138 | * @param {Object} en 服务器的获取的数据对象 139 | */ 140 | transWebServerObj: function(en) { 141 | if (toString.call(en) == '[object Array]') { 142 | for (var i = 0, len = en.length; i < len; i++) { 143 | ak.Utils.transWebServerObj(en[i]); 144 | } 145 | } else { 146 | for (propertyName in en) { 147 | /* 148 | // 1.创建一个小写的首字母属性并赋值:ABC => aBC 149 | var newPropertyName = propertyName.charAt(0).toLowerCase() + propertyName.substr(1); 150 | en[newPropertyName] = en[propertyName]; 151 | */ 152 | var tmpName = propertyName; 153 | // 2.判断此属性是否为数组,若是就执行递归 154 | if (toString.call(en[tmpName]) == '[object Array]') { 155 | for (var i = 0, len = en[tmpName].length; i < len; i++) { 156 | ak.Utils.transWebServerObj(en[tmpName][i]); // 数组里的每个对象再依次进行转换 157 | } 158 | } else if (toString.call(en[tmpName]) == '[object Object]') { 159 | ak.Utils.transWebServerObj(en[tmpName]); // 若属性的值是一个对象,也要进行转换 160 | } else { 161 | // 3.若不是其他类型,把此属性的值转换为Js的数据格式 162 | // 3.1)日期格式:后台为2015-12-08T09:23:23.917 => 2015-12-08 09:23:23 163 | if (new RegExp(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/).test(en[propertyName])) { 164 | // en[propertyName] = new RegExp(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/).exec(en[propertyName])[0].replace('T', ' '); 165 | 166 | // 若为0001年,表示时间为空,就返回''空字符串 167 | if (en[propertyName].indexOf('0001') >= 0) { 168 | en[propertyName] = ''; 169 | } 170 | } else if (toString.call(en[propertyName]) == '[object Number]' && new RegExp(/\d+[.]\d{3}/).test(en[propertyName])) { 171 | // 3.2)溢出的float格式:1.33333 = > 1.33 172 | en[propertyName] = en[propertyName].toFixed(2); 173 | } else if (en[propertyName] == null) { 174 | // 3.3)null值返回空 175 | en[propertyName] = ''; 176 | } else if ( 177 | ['imgPath', 'loopImgPath', 'clubIcon', 'headImgPath'].indexOf(propertyName) >= 0 && 178 | en[propertyName] && 179 | en[propertyName].length > 0 180 | ) { 181 | en[propertyName] = ak.Base_URL + en[propertyName].replace('..', ''); 182 | } 183 | } 184 | } 185 | } 186 | return en; 187 | }, 188 | 189 | /** 190 | *设置SessionStorage的值 191 | * @param key:要存的键 192 | * @param value :要存的值 193 | */ 194 | setSessionStorage: function(key, value) { 195 | if (this.isObject(value) || this.isArray(value)) { 196 | value = this.toJsonStr(value); 197 | } 198 | sessionStorage[key] = value; 199 | }, 200 | 201 | /** 202 | *获取SessionStorage的值 203 | * @param key:存的键 204 | */ 205 | getSessionStorage: function(key) { 206 | var rs = sessionStorage[key]; 207 | try { 208 | if (rs != undefined) { 209 | var obj = this.toJson(rs); 210 | rs = obj; 211 | } 212 | } catch (error) {} 213 | return rs; 214 | }, 215 | 216 | /** 217 | * 清除SessionStorage的值 218 | * @param key:存的键 219 | */ 220 | removeSessionStorage: function(key) { 221 | return sessionStorage.removeItem(key); 222 | }, 223 | 224 | /** 225 | *设置LocalStorage的值 226 | * @param key:要存的键 227 | * @param value :要存的值 228 | */ 229 | setLocalStorage: function(key, value) { 230 | if (this.isObject(value) || this.isArray(value)) { 231 | value = this.toJsonStr(value); 232 | } 233 | localStorage[key] = value; 234 | }, 235 | 236 | /** 237 | *获取LocalStorage的值 238 | * @param key:存的键 239 | */ 240 | getLocalStorage: function(key) { 241 | var rs = localStorage[key]; 242 | try { 243 | if (rs != undefined) { 244 | var obj = this.toJson(rs); 245 | rs = obj; 246 | } 247 | } catch (error) {} 248 | return rs; 249 | }, 250 | 251 | /** 252 | * 对传入的时间值进行格式化。后台传入前台的时间有两种个是:Sql时间和.Net时间 253 | * @param {String|Date} sValue 传入的时间字符串 254 | * @param {dateFormat | bool} dateFormat 日期格式,日期格式:eg:'Y-m-d H:i:s' 255 | * @return {String} 2014-03-01 这种格式 256 | * @example 257 | * 1) Sql时间格式:2015-02-24T00:00:00 258 | * 2) .Net时间格式:/Date(1410744626000)/ 259 | */ 260 | getDateTimeStr: function(sValue, dateFormat) { 261 | if (dateFormat == undefined) { 262 | dateFormat = 'Y-m-d'; // 默认显示年月日 263 | } 264 | 265 | var dt; 266 | // 1.先解析传入的时间对象, 267 | if (sValue) { 268 | if (toString.call(sValue) !== '[object Date]') { 269 | // 不为Date格式,就转换为DateTime类型 270 | sValue = sValue + ''; 271 | if (sValue.indexOf('T') > 0) { 272 | // 1)格式:2015-02-24T00:00:00 273 | var timestr = sValue.replace('T', ' ').replace(/-/g, '/'); //=> 2015/02/24 00:00:00 274 | dt = new Date(timestr); 275 | } else if (sValue.indexOf('Date') >= 0) { 276 | // 2).Net格式:/Date(1410744626000)/ 277 | //Convert date type that .NET can bind to DateTime 278 | //var date = new Date(parseInt(sValue.substr(6))); 279 | var timestr = sValue.toString().replace(/\/Date\((\d+)\)\//gi, '$1'); // 280 | dt = new Date(Math.abs(timestr)); 281 | } else { 282 | dt = new Date(sValue); 283 | } 284 | } else { 285 | dt = sValue; 286 | } 287 | } 288 | 289 | // 2.转换 290 | // 1)转换成对象 'Y-m-d H:i:s' 291 | var obj = {}; //返回的对象,包含了 year(年)、month(月)、day(日) 292 | obj.Y = dt.getFullYear(); //年 293 | obj.m = dt.getMonth() + 1; //月 294 | obj.d = dt.getDate(); //日期 295 | obj.H = dt.getHours(); 296 | obj.i = dt.getMinutes(); 297 | obj.s = dt.getSeconds(); 298 | //2.2单位的月、日都转换成双位 299 | if (obj.m < 10) { 300 | obj.m = '0' + obj.m; 301 | } 302 | if (obj.d < 10) { 303 | obj.d = '0' + obj.d; 304 | } 305 | if (obj.H < 10) { 306 | obj.H = '0' + obj.H; 307 | } 308 | if (obj.i < 10) { 309 | obj.i = '0' + obj.i; 310 | } 311 | if (obj.s < 10) { 312 | obj.s = '0' + obj.s; 313 | } 314 | // 3.解析 315 | var rs = dateFormat 316 | .replace('Y', obj.Y) 317 | .replace('m', obj.m) 318 | .replace('d', obj.d) 319 | .replace('H', obj.H) 320 | .replace('i', obj.i) 321 | .replace('s', obj.s); 322 | 323 | return rs; 324 | }, 325 | 326 | /** 327 | * 把总秒数转换为时分秒 328 | */ 329 | getSFM: function(seconds, dateFormat) { 330 | if (dateFormat == undefined) { 331 | dateFormat = 'H:i:s'; // 默认格式 332 | } 333 | var obj = {}; 334 | obj.H = Number.parseInt(seconds / 3600); 335 | obj.i = Number.parseInt((seconds - obj.H * 3600) / 60); 336 | obj.s = Number.parseInt(seconds - obj.H * 3600 - obj.i * 60); 337 | if (obj.H < 10) { 338 | obj.H = '0' + obj.H; 339 | } 340 | if (obj.i < 10) { 341 | obj.i = '0' + obj.i; 342 | } 343 | if (obj.s < 10) { 344 | obj.s = '0' + obj.s; 345 | } 346 | 347 | // 3.解析 348 | var rs = dateFormat 349 | .replace('H', obj.H) 350 | .replace('i', obj.i) 351 | .replace('s', obj.s); 352 | return rs; 353 | }, 354 | 355 | /** 356 | * 是否同一天 357 | */ 358 | isSomeDay: function(dt1, dt2) { 359 | if (dt1.getFullYear() == dt2.getFullYear() && dt1.getMonth() == dt2.getMonth() && dt1.getDate() == dt2.getDate()) { 360 | return true; 361 | } 362 | return false; 363 | }, 364 | 365 | /** 366 | * 对象转换为json字符串 367 | * @param {jsonObj} jsonObj Json对象 368 | * @return {jsonStr} Json字符串 369 | */ 370 | toJsonStr: function(jsonObj) { 371 | return JSON.stringify(jsonObj); 372 | }, 373 | 374 | /** 375 | * 讲json字符串转换为json对象 376 | * @param {String} jsonStr Json对象字符串 377 | * @return {jsonObj} Json对象 378 | */ 379 | toJson: function(jsonStr) { 380 | return JSON.parse(jsonStr); 381 | }, 382 | 383 | /** 384 | * @private 385 | */ 386 | getCookieVal: function(offset) { 387 | var endstr = document.cookie.indexOf(';', offset); 388 | if (endstr == -1) { 389 | endstr = document.cookie.length; 390 | } 391 | return unescape(document.cookie.substring(offset, endstr)); 392 | }, 393 | 394 | /** 395 | * 获取指定key的cookie 396 | * @param {String} key cookie的key 397 | */ 398 | getCookie: function(key) { 399 | var arg = key + '=', 400 | alen = arg.length, 401 | clen = document.cookie.length, 402 | i = 0, 403 | j = 0; 404 | 405 | while (i < clen) { 406 | j = i + alen; 407 | if (document.cookie.substring(i, j) == arg) { 408 | return this.getCookieVal(j); 409 | } 410 | i = document.cookie.indexOf(' ', i) + 1; 411 | if (i === 0) { 412 | break; 413 | } 414 | } 415 | return null; 416 | }, 417 | 418 | /** 419 | * 设置cookie 420 | * @param {String} key cookie的key 421 | * @param {String} value cookie的value 422 | */ 423 | setCookie: function(key, value) { 424 | var argv = arguments, 425 | argc = arguments.length, 426 | expires = argc > 2 ? argv[2] : null, 427 | path = argc > 3 ? argv[3] : '/', 428 | domain = argc > 4 ? argv[4] : null, 429 | secure = argc > 5 ? argv[5] : false; 430 | 431 | document.cookie = 432 | key + 433 | '=' + 434 | escape(value) + 435 | (expires === null ? '' : '; expires=' + expires.toGMTString()) + 436 | (path === null ? '' : '; path=' + path) + 437 | (domain === null ? '' : '; domain=' + domain) + 438 | (secure === true ? '; secure' : ''); 439 | }, 440 | 441 | /** 442 | * 是否含有特殊字符 443 | * @param {String} value 传入的值 444 | * @return {Boolean} true 含有特殊符号;false 不含有特殊符号 445 | */ 446 | isHaveSpecialChar: function(value) { 447 | var oldLength = value.length; 448 | var newLength = value.replace(/[`~!@#$%^&*_+=\\{}:"<>?\[\];',.\/~!@#¥%……&*——+『』:“”《》?【】;‘’,。? \[\]()()]/g, '').length; 449 | if (newLength < oldLength) { 450 | return true; 451 | } 452 | return false; 453 | }, 454 | 455 | /** 456 | * 合并数组内成员的某个对象 457 | * @param {Array} arr 需要合并的数组 458 | * @param {String} fieldName 数组成员内的指定字段 459 | * @param {String} split 分隔符,默认为',' 460 | * @example 461 | * var arr = [{name:'tom',age:13},{name:'jack',age:13}] => (arr, 'name') => tom,jack 462 | */ 463 | joinArray: function(arr, fieldName, split) { 464 | split = split == undefined ? ',' : split; 465 | var rs = arr 466 | .map((item) => { 467 | return item[fieldName]; 468 | }) 469 | .join(split); 470 | return rs; 471 | } 472 | }; 473 | 474 | /** 475 | * http交互模块 476 | * 包含:ajax 477 | */ 478 | ak.Http = { 479 | /** 480 | * 将`name` - `value`对转换为支持嵌套结构的对象数组 481 | * 482 | * var objects = toQueryObjects('hobbies', ['reading', 'cooking', 'swimming']); 483 | * 484 | * // objects then equals: 485 | * [ 486 | * { name: 'hobbies', value: 'reading' }, 487 | * { name: 'hobbies', value: 'cooking' }, 488 | * { name: 'hobbies', value: 'swimming' }, 489 | * ]; 490 | * 491 | * var objects = toQueryObjects('dateOfBirth', { 492 | * day: 3, 493 | * month: 8, 494 | * year: 1987, 495 | * extra: { 496 | * hour: 4 497 | * minute: 30 498 | * } 499 | * }, true); // Recursive 500 | * 501 | * // objects then equals: 502 | * [ 503 | * { name: 'dateOfBirth[day]', value: 3 }, 504 | * { name: 'dateOfBirth[month]', value: 8 }, 505 | * { name: 'dateOfBirth[year]', value: 1987 }, 506 | * { name: 'dateOfBirth[extra][hour]', value: 4 }, 507 | * { name: 'dateOfBirth[extra][minute]', value: 30 }, 508 | * ]; 509 | * 510 | * @param {String} name 511 | * @param {object | Array} value 512 | * @param {boolean} [recursive=false] 是否递归 513 | * @return {array} 514 | */ 515 | toQueryObjects: function(name, value, recursive) { 516 | var objects = [], 517 | i, 518 | ln; 519 | 520 | if (ak.Utils.isArray(value)) { 521 | for (i = 0, ln = value.length; i < ln; i++) { 522 | if (recursive) { 523 | objects = objects.concat(toQueryObjects(name + '[' + i + ']', value[i], true)); 524 | } else { 525 | objects.push({ 526 | name: name, 527 | value: value[i] 528 | }); 529 | } 530 | } 531 | } else if (ak.Utils.isObject(value)) { 532 | for (i in value) { 533 | if (value.hasOwnProperty(i)) { 534 | if (recursive) { 535 | objects = objects.concat(toQueryObjects(name + '[' + i + ']', value[i], true)); 536 | } else { 537 | objects.push({ 538 | name: name, 539 | value: value[i] 540 | }); 541 | } 542 | } 543 | } 544 | } else { 545 | objects.push({ 546 | name: name, 547 | value: value 548 | }); 549 | } 550 | 551 | return objects; 552 | }, 553 | 554 | /** 555 | * 把对象转换为查询字符串 556 | * e.g.: 557 | * toQueryString({foo: 1, bar: 2}); // returns "foo=1&bar=2" 558 | * toQueryString({foo: null, bar: 2}); // returns "foo=&bar=2" 559 | * toQueryString({date: new Date(2011, 0, 1)}); // returns "date=%222011-01-01T00%3A00%3A00%22" 560 | * @param {Object} object 需要转换的对象 561 | * @param {Boolean} [recursive=false] 是否递归 562 | * @return {String} queryString 563 | */ 564 | toQueryString: function(object, recursive) { 565 | var paramObjects = [], 566 | params = [], 567 | i, 568 | j, 569 | ln, 570 | paramObject, 571 | value; 572 | 573 | for (i in object) { 574 | if (object.hasOwnProperty(i)) { 575 | paramObjects = paramObjects.concat(this.toQueryObjects(i, object[i], recursive)); 576 | } 577 | } 578 | 579 | for (j = 0, ln = paramObjects.length; j < ln; j++) { 580 | paramObject = paramObjects[j]; 581 | value = paramObject.value; 582 | 583 | if (ak.Utils.isEmpty(value)) { 584 | value = ''; 585 | } else if (ak.Utils.isDate(value)) { 586 | value = 587 | value.getFullYear() + 588 | '-' + 589 | Ext.String.leftPad(value.getMonth() + 1, 2, '0') + 590 | '-' + 591 | Ext.String.leftPad(value.getDate(), 2, '0') + 592 | 'T' + 593 | Ext.String.leftPad(value.getHours(), 2, '0') + 594 | ':' + 595 | Ext.String.leftPad(value.getMinutes(), 2, '0') + 596 | ':' + 597 | Ext.String.leftPad(value.getSeconds(), 2, '0'); 598 | } 599 | 600 | params.push(encodeURIComponent(paramObject.name) + '=' + encodeURIComponent(String(value))); 601 | } 602 | 603 | return params.join('&'); 604 | }, 605 | 606 | /** 607 | * 以get方式请求获取JSON数据 608 | * @param {Object} opts 配置项,可包含以下成员: 609 | * @param {String} opts.url 请求地址 610 | * @param {Object} opts.params 附加的请求参数 611 | * @param {Boolean} opts.isHideLoading 是否关闭'载入中'提示框,默认false 612 | * @param {String} opts.loadingTitle '载入中'提示框title,e.g. 提交中、上传中 613 | * @param {Function} opts.successCallback 成功接收内容时的回调函数 614 | * @param {Function} opts.failCallback 失败的回调函数 615 | */ 616 | get: function(opts) { 617 | if (!opts.isHideLoading) { 618 | ak.Msg.showLoading(opts.loadingTitle); 619 | } 620 | if (opts.url.substr(0, 1) == '/') { 621 | opts.url = opts.url.substr(1); 622 | } 623 | opts.url = ak.Base_URL + opts.url; 624 | if (opts.params) { 625 | opts.url = opts.url + '?' + this.toQueryString(opts.params); 626 | } 627 | // Jquery、Zepto 628 | $.getJSON( 629 | opts.url, 630 | function(res, status, xhr) { 631 | ak.Msg.hideLoading(); 632 | if (res.resultCode == '0') { 633 | if (opts.successCallback) { 634 | opts.successCallback(res); 635 | } 636 | } else { 637 | ak.Msg.toast(res.resultText, 'error'); 638 | if (opts.failCallback) { 639 | opts.failCallback(res); 640 | } 641 | } 642 | }, 643 | 'json' 644 | ); 645 | }, 646 | 647 | /** 648 | * 以get方式请求获取JSON数据 649 | * @param {Object} opts 配置项,可包含以下成员: 650 | * @param {String} opts.url 请求地址 651 | * @param {Object} opts.params 附加的请求参数 652 | * @param {Boolean} opts.ignoreFail 忽略错误,默认false,不管返回的结果如何,都执行 successCallback 653 | * @param {Boolean} opts.ignoreEmptyParam 忽略空值,默认true 654 | * @param {Boolean} opts.isHideLoading 是否关闭'载入中'提示框,默认false 655 | * @param {String} opts.loadingTitle '载入中'提示框title,e.g. 提交中、上传中 656 | * @param {Function} opts.successCallback 成功接收内容时的回调函数 657 | * @param {Function} opts.failCallback 失败的回调函数 658 | */ 659 | post: function(opts) { 660 | opts.ignoreFail = opts.ignoreFail == undefined ? false : opts.ignoreFail; 661 | opts.ignoreEmptyParam = opts.ignoreEmptyParam == undefined ? true : opts.ignoreEmptyParam; 662 | if (!opts.isHideLoading) { 663 | ak.Msg.showLoading(opts.loadingTitle); 664 | } 665 | if (opts.url.substr(0, 1) == '/') { 666 | opts.url = opts.url.substr(1); 667 | } 668 | opts.url = ak.Base_URL + opts.url; // test 669 | 670 | // 去除params的空值 671 | if (opts.ignoreEmptyParam) { 672 | for (var key in opts.params) { 673 | if (opts.params[key] == undefined || opts.params[key] == '') { 674 | delete opts.params[key]; 675 | } 676 | } 677 | } 678 | // Jquery、Zepto 679 | $.post( 680 | opts.url, 681 | opts.params, 682 | function(res, status, xhr) { 683 | ak.Msg.hideLoading(); 684 | if (res.resultCode == '0' || opts.ignoreFail) { 685 | if (opts.successCallback) { 686 | opts.successCallback(res); 687 | } 688 | } else { 689 | ak.Msg.toast(res.resultText, 'error'); 690 | if (opts.failCallback) { 691 | opts.failCallback(res); 692 | } 693 | } 694 | }, 695 | 'json' 696 | ); 697 | }, 698 | 699 | /** 700 | * 上传文件 701 | * @param {Object} opts 配置项,可包含以下成员: 702 | * @param {Object} opts.params 上传的参数 703 | * @param {Object} opts.fileParams 上传文件参数 704 | * @param {String} opts.url 请求地址 705 | * @param {Function} opts.successCallback 成功接收内容时的回调函数 706 | * @param {Function} opts.failCallback 失败的回调函数 707 | */ 708 | uploadFile: function(opts) { 709 | // 1.解析url 710 | if (opts.url.substr(0, 1) == '/') { 711 | opts.url = opts.url.substr(1); 712 | } 713 | opts.url = ak.Base_URL + opts.url; 714 | if (opts.params) { 715 | opts.url = opts.url + '?' + this.toQueryString(opts.params); 716 | } 717 | 718 | // 2.文件参数 719 | var formData = new FormData(); 720 | for (var key in opts.fileParams) { 721 | formData.append(key, opts.fileParams[key]); 722 | } 723 | 724 | // 3.发起ajax 725 | $.ajax({ 726 | url: opts.url, 727 | type: 'POST', 728 | cache: false, 729 | data: formData, 730 | processData: false, 731 | contentType: false, 732 | dataType: 'json' 733 | }) 734 | .done(function(res) { 735 | if (res.resultCode != '0') { 736 | ak.Msg.toast(res.resultText, 'error'); 737 | } 738 | if (opts.successCallback) { 739 | opts.successCallback(res); 740 | } 741 | }) 742 | .fail(function(res) { 743 | if (opts.failCallback) { 744 | opts.failCallback(res); 745 | } 746 | }); 747 | } 748 | }; 749 | 750 | /** 751 | * 消息模块 752 | * 包含:确认框、信息提示框 753 | */ 754 | ak.Msg = { 755 | /** 756 | * 提示框 757 | * msg {string} :信息内容 758 | */ 759 | alert: function(msg) {}, 760 | 761 | /** 762 | * 确认框 763 | * msg {string} :信息内容 764 | * callback {function} :点击'确定'时的回调函数。 765 | */ 766 | confirm: function(msg, callback) { 767 | 768 | }, 769 | 770 | /** 771 | * 显示正在加载 772 | * @param {String} title 显示的title 773 | */ 774 | showLoading: function(title) { 775 | 776 | }, 777 | 778 | /** 779 | * 关闭正在加载 780 | */ 781 | hideLoading: function() {}, 782 | 783 | /** 784 | * 自动消失的提示框 785 | * @param {String} msg 信息内容 786 | */ 787 | toast: function(msg) {} 788 | }; 789 | 790 | /** 791 | * 业务相关逻辑 792 | */ 793 | ak.BLL = {}; 794 | 795 | export default ak; -------------------------------------------------------------------------------- /src/common/css/base.less: -------------------------------------------------------------------------------- 1 | // 公共类 2 | #common-wrapper { 3 | .hide { 4 | display: none !important; 5 | } 6 | .show { 7 | display: initial !important; 8 | } 9 | .float-left { 10 | float: left; 11 | } 12 | .float-right { 13 | float: right; 14 | } 15 | .text-right { 16 | text-align: right; 17 | } 18 | .text-center { 19 | text-align: center; 20 | } 21 | .text-left { 22 | text-align: left; 23 | } 24 | .red { 25 | color: red; 26 | } 27 | ::-webkit-scrollbar { 28 | width: 10px; 29 | background: transparent; 30 | } 31 | ::-webkit-scrollbar-track-piece { 32 | background: none; 33 | } 34 | ::-webkit-scrollbar-thumb { 35 | height: 50px; 36 | border: 2px solid rgba(0, 0, 0, 0); 37 | border-radius: 12px; 38 | background-clip: padding-box; 39 | background-color: #ccd4d4; 40 | box-shadow: inset -1px -1px 0px #ccd4d4, inset 1px 1px 0px #ccd4d4; 41 | } 42 | .position-h-mid { 43 | position: absolute; 44 | left: 50%; 45 | transform: translate(-50%, 0); 46 | } 47 | .position-v-mid { 48 | position: absolute; 49 | top: 50%; 50 | transform: translate(0, -50%); 51 | } 52 | .position-h-v-mid { 53 | position: absolute; 54 | left: 50%; 55 | top: 50%; 56 | transform: translate(-50%, -50%); 57 | } 58 | } 59 | 60 | // elemUI相关 61 | #common-wrapper { 62 | .el-button--text { 63 | margin: 0px; 64 | padding: 0px; 65 | color: #00a8d7; 66 | } 67 | .el-button--primary { 68 | background-color: #00a8d7; 69 | border-color: #00a8d7; 70 | &.is-disabled { 71 | color: #ffffff; 72 | cursor: not-allowed; 73 | background-image: none; 74 | background-color: #b8e9f8; 75 | border-color: #b8e9f8; 76 | } 77 | } 78 | .el-textarea__inner { 79 | resize: none; 80 | } 81 | .el-select, 82 | .el-slider__runway { 83 | z-index: 0; 84 | } 85 | .el-input__inner { 86 | &:hover { 87 | border-color: #00a8d7; 88 | } 89 | } 90 | .el-select { 91 | .el-tag--primary { 92 | background-color: #f4f4f4; 93 | border-color: #dfe4e6; 94 | color: #6e6e6e; 95 | } 96 | &:hover { 97 | .el-input__inner { 98 | border-color: #00a8d7; 99 | } 100 | } 101 | } 102 | .el-dropdown { 103 | .el-icon-caret-bottom { 104 | font-size: 12px; 105 | margin-left: 16px; 106 | } 107 | } 108 | .el-tag { 109 | background-color: #f4f4f4; 110 | color: #454545; 111 | border-color: #e6e6e6; 112 | padding: 0px 10px; 113 | } 114 | .el-pager { 115 | li.active { 116 | border-color: #00a8d7; 117 | background-color: #00a8d7; 118 | } 119 | } 120 | .el-dialog__wrapper { 121 | .el-dialog__body { 122 | padding: 0px; 123 | } 124 | } 125 | } 126 | 127 | body { 128 | font-family: 'Microsoft YaHei', 'CaviarDreams Bold', Helvetica, Arial, sans-serif, 'STHeiti'; 129 | } 130 | -------------------------------------------------------------------------------- /src/common/http.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import axios from 'axios'; 3 | 4 | var axiosInstance = axios.create({ 5 | baseURL: location.origin.replace(/:\d+/, ':3000'), 6 | timeout: 1000 * 5 7 | }); 8 | 9 | axiosInstance.interceptors.request.use( 10 | function(config) { 11 | // Do something before request is sent 12 | return config; 13 | }, 14 | function(error) { 15 | // Do something with request error 16 | return Promise.reject(error); 17 | } 18 | ); 19 | 20 | /** 21 | * http请求响应处理函数 22 | */ 23 | var httpResponseHandle = function() { 24 | var self = this; 25 | if (self.res.code == '0') { 26 | self.successCallback && self.successCallback(self.res.data); 27 | } else { 28 | self.failCallback && self.failCallback(self.res.data); 29 | } 30 | }; 31 | 32 | var http = { 33 | /** 34 | * 以get方式请求获取JSON数据 35 | * @param {Object} opts 配置项,可包含以下成员: 36 | * @param {String} opts.url 请求地址 37 | * @param {Object} opts.params 附加的请求参数 38 | * @param {Function} opts.successCallback 成功接收内容时的回调函数 39 | */ 40 | get: function(opts) { 41 | if (opts.params) { 42 | opts.url = opts.url + '?' + this.toQueryString(opts.params); 43 | } 44 | axiosInstance 45 | .get(opts.url, { params: opts.params }) 46 | .then(function(res) { 47 | opts.res = res.data; 48 | httpResponseHandle.call(opts); 49 | }) 50 | .catch(function(err) {}); 51 | }, 52 | 53 | /** 54 | * 以get方式请求获取JSON数据 55 | * @param {Object} opts 配置项,可包含以下成员: 56 | * @param {String} opts.url 请求地址 57 | * @param {Object} opts.params 附加的请求参数 58 | * @param {Function} opts.successCallback 成功接收内容时的回调函数 59 | */ 60 | post: function(opts) { 61 | axiosInstance 62 | .post(opts.url, opts.params) 63 | .then(function(res) { 64 | opts.res = res.data; 65 | httpResponseHandle.call(opts); 66 | }) 67 | .catch(function(err) {}); 68 | }, 69 | 70 | /** 71 | * 上传文件 72 | * @param {Object} opts 配置项,可包含以下成员: 73 | * @param {String} opts.url 请求地址 74 | * @param {Object} opts.params 上传的参数 75 | * @param {Function} opts.successCallback 成功接收内容时的回调函数 76 | */ 77 | uploadFile: function(opts) { 78 | axiosInstance 79 | .post('/upload', opts.params, { 80 | headers: { 81 | 'Content-Type': 'multipart/form-data' 82 | } 83 | }) 84 | .then(function(res) { 85 | opts.res = res.data; 86 | httpResponseHandle.call(opts); 87 | }) 88 | .catch(function() {}); 89 | } 90 | }; 91 | 92 | export default http; 93 | -------------------------------------------------------------------------------- /src/components/common/common_chat.vue: -------------------------------------------------------------------------------- 1 | 2 | 106 | 107 | 521 | 952 | 953 | -------------------------------------------------------------------------------- /src/components/common/common_chat_emoji.vue: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 81 | 82 | 550 | -------------------------------------------------------------------------------- /src/components/imClient/imClient.vue: -------------------------------------------------------------------------------- 1 | 2 | 81 | 82 | 371 | 372 | 499 | -------------------------------------------------------------------------------- /src/components/imClient/imLeave.vue: -------------------------------------------------------------------------------- 1 | 2 | 29 | 30 | 111 | 112 | 154 | -------------------------------------------------------------------------------- /src/components/imClient/imRate.vue: -------------------------------------------------------------------------------- 1 | 2 | 24 | 25 | 58 | 59 | 110 | -------------------------------------------------------------------------------- /src/components/imClient/imTransfer.vue: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 53 | 54 | 87 | -------------------------------------------------------------------------------- /src/components/imServer/imChat.vue: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 87 | 112 | 113 | -------------------------------------------------------------------------------- /src/components/imServer/imRecord.vue: -------------------------------------------------------------------------------- 1 | 2 | 45 | 46 | 106 | 107 | 284 | -------------------------------------------------------------------------------- /src/components/imServer/imServer.vue: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 45 | 46 | 76 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // The Vue build version to load with the `import` command 2 | // (runtime-only or standalone) has been set in webpack.base.conf with an alias. 3 | import Vue from 'vue'; 4 | import App from './App'; 5 | import router from './router'; 6 | import { imServerStore } from './store/imServerStore.js'; 7 | // axios 8 | import http from '@/common/http.js'; 9 | Vue.prototype.$http = http; 10 | // ak 11 | import ak from '@/common/ak.js'; 12 | Vue.prototype.$ak = ak; 13 | // element-ui 14 | import ElementUI from 'element-ui'; 15 | import 'element-ui/lib/theme-chalk/index.css'; 16 | Vue.use(ElementUI); 17 | // font-awesome 18 | import 'font-awesome/css/font-awesome.min.css' 19 | 20 | // config 21 | Vue.config.productionTip = false; 22 | 23 | /* eslint-disable no-new */ 24 | window.polkVue = new Vue({ 25 | el: '#app', 26 | router, 27 | components: { App }, 28 | store: { 29 | imServerStore: imServerStore 30 | }, 31 | template: '' 32 | }); 33 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import imServer from '@/components/imServer/imServer' 4 | import imClient from '@/components/imClient/imClient' 5 | 6 | Vue.use(Router) 7 | 8 | export default new Router({ 9 | routes: [ 10 | { path: '/', redirect: 'imServer' }, 11 | { path: '/imServer', name: 'imServer', component: imServer }, 12 | { path: '/imClient', name: 'imClient', component: imClient }, 13 | ] 14 | }) -------------------------------------------------------------------------------- /src/store/imServerStore.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * im服务端Store 3 | */ 4 | 5 | import Vue from 'vue'; 6 | import Vuex from 'vuex'; 7 | import ak from '@/common/ak.js'; 8 | 9 | Vue.use(Vuex); 10 | export const imServerStore = new Vuex.Store({ 11 | state: { 12 | serverChatEn: { 13 | serverChatId: Number.parseInt(Date.now() + Math.random()), 14 | serverChatName: '小P', 15 | avatarUrl: '/static/image/im_server_avatar.png' 16 | }, 17 | selectedChatEn: null, // 选取的会话对象 18 | currentChatEnlist: [], // 当前chat实体集合 19 | notificationChatEnlist: [], // 通知chat实体集合 20 | haveNewMsgDelegate: null, // 当前已选中的用户含有新消息 21 | socket: null 22 | }, 23 | mutations: { 24 | /** 25 | * 触发当前选择的chat含有新的消息 26 | * @param {Object} payload 载荷对象 27 | */ 28 | triggerHaveNewMsgDelegate: function(state, payload) { 29 | state.haveNewMsgDelegate = Date.now(); 30 | }, 31 | 32 | /** 33 | * 排序当前会话列表 34 | */ 35 | sortCurrentChatEnlist: function(state, payload) { 36 | var enlist = state.currentChatEnlist.concat(); 37 | 38 | // 排序规则: 39 | // 1)已关注放最前面,关注状态下按最后一条获取时间正序 40 | // 2)非关注状态下,按最后一条获取时间正序 41 | 42 | // 1.首先按最后一次更新时间排序 43 | for (var i = 0; i < enlist.length; i++) { 44 | for (var j = i; j < enlist.length; j++) { 45 | var iTimeSpan = Date.parse(enlist[i].lastMsgTime); 46 | var jTimeSpan = Date.parse(enlist[j].lastMsgTime); 47 | if (iTimeSpan < jTimeSpan) { 48 | var tmp = enlist[i]; 49 | enlist[i] = enlist[j]; 50 | enlist[j] = tmp; 51 | } 52 | } 53 | } 54 | 55 | // 2.已关注的排在最前面并按最后一次时间倒序 56 | var followEnlist = []; 57 | var unfollowEnlist = []; 58 | for (var i = 0; i < enlist.length; i++) { 59 | var en = enlist[i]; 60 | if (en.isFollow) { 61 | followEnlist.push(en); 62 | } else { 63 | unfollowEnlist.push(en); 64 | } 65 | } 66 | 67 | // 3.合并 68 | state.currentChatEnlist = followEnlist.concat(unfollowEnlist); 69 | }, 70 | 71 | /** 72 | * 清除通知chat 73 | */ 74 | clearNotificationChat: function(state) { 75 | state.notificationChatEnlist = []; 76 | } 77 | }, 78 | actions: { 79 | /** 80 | * 添加访客端chat对象 81 | * @param {Object} payload 载荷对象 82 | * @param {String} payload.newChatEn 新的chat对象 83 | */ 84 | addClientChat: function(context, { newChatEn }) { 85 | context.dispatch('getChatEnByChatId', { clientChatId: newChatEn.clientChatId }).then((chatEn) => { 86 | if (chatEn == null) { 87 | // 1)公共属性 88 | newChatEn.msgList = []; 89 | newChatEn.state = 'on'; 90 | newChatEn.accessTime = new Date(); // 访问时间 91 | newChatEn.inputContent = ''; // 输入框内容 92 | newChatEn.newMsgCount = 0; 93 | newChatEn.isFollow = false; // 是否关注 94 | newChatEn.lastMsgTime = null; 95 | newChatEn.lastMsgShowTime = null; // 最后一个消息的显示时间 96 | context.state.currentChatEnlist.push(newChatEn); 97 | } 98 | 99 | // 2)增加消息 100 | context.dispatch('addChatMsg', { 101 | clientChatId: newChatEn.clientChatId, 102 | msg: { 103 | role: 'sys', 104 | contentType: 'text', 105 | content: chatEn == null ? '新客户接入' : '重新连接' 106 | } 107 | }); 108 | }); 109 | }, 110 | /** 111 | * 根据jobId获取chat对象 112 | * @param {String} clientChatId 需要修改的chatEn的id,根据此id匹配当前集合或历史集合 113 | * @param {String} listName 指定的集合名称;e.g. currentChatEnlist、historyChatEnlist、allHistoryChatEnlist 114 | */ 115 | getChatEnByChatId: function(context, { clientChatId, listName }) { 116 | var chatEn = null; 117 | 118 | if (listName) { 119 | // 1.指定了列表 120 | var targetList = context.state[listName]; 121 | for (var i = 0; i < targetList.length; i++) { 122 | var tmpEn = targetList[i]; 123 | if (tmpEn.clientChatId == clientChatId) { 124 | chatEn = tmpEn; 125 | break; 126 | } 127 | } 128 | } else { 129 | // 2.未指定列表 130 | // 1)从当前会话列表查找 131 | for (var i = 0; i < context.state.currentChatEnlist.length; i++) { 132 | var tmpEn = context.state.currentChatEnlist[i]; 133 | if (tmpEn.clientChatId == clientChatId) { 134 | chatEn = tmpEn; 135 | break; 136 | } 137 | } 138 | } 139 | 140 | return chatEn; 141 | }, 142 | 143 | /** 144 | * 修改Chat对象的属性 145 | * @param {Object} payload 载荷对象 146 | * @param {Object} payload.clientChatId 需要修改的chatEn的id,根据此id匹配当前集合或历史集合 147 | * @param {Array} payload.extends Chat需要变更的属性对象数组 148 | */ 149 | extendChatEn: function(context, payload) { 150 | return context.dispatch('getChatEnByChatId', { clientChatId: payload.clientChatId }).then((chatEn) => { 151 | // 1.若没有,就附加到当前会话列表里 152 | if (chatEn == null) { 153 | return; 154 | } 155 | 156 | // 2.extend属性 157 | for (var key in payload.extends) { 158 | Vue.set(chatEn, key, payload.extends[key]); 159 | } 160 | 161 | // 3.若选中的当前chatEn 与 传入的一直,更新选中额chatEn 162 | if (context.state.selectedChatEn && context.state.selectedChatEn.clientChatId == chatEn.clientChatId) { 163 | context.state.selectedChatEn = Object.assign({}, chatEn); 164 | Vue.nextTick(function() {}); 165 | } 166 | return chatEn; 167 | }); 168 | }, 169 | 170 | /** 171 | * 添加chat对象的msg 172 | * @param {String} clientChatId 会话Id 173 | * @param {Object} msg 消息对象;eg:{role:'sys',content:'含有新的消息'} 174 | * @param {String} msg.role 消息所有者身份;eg:'sys'系统消息; 175 | * @param {String} msg.contentType 消息类型;text:文本(默认);image:图片 176 | * @param {String} msg.content 消息内容 177 | * @param {Function} successCallback 添加消息后的回调 178 | */ 179 | addChatMsg: function(context, { clientChatId, msg, successCallback }) { 180 | context.dispatch('getChatEnByChatId', { clientChatId: clientChatId }).then((chatEn) => { 181 | if (chatEn == null) { 182 | return; 183 | } 184 | 185 | // 1.设定默认值 186 | msg.createTime = msg.createTime == undefined ? new Date() : msg.createTime; 187 | 188 | var msgList = chatEn.msgList ? chatEn.msgList : []; 189 | 190 | // 2.插入消息 191 | // 1)插入日期 192 | // 实际场景中,在消息上方是否显示时间是由后台传递给前台的消息中附加上的,可参考 微信Web版 193 | // 此处进行手动设置,5分钟之内的消息,只显示一次消息 194 | msg.createTime = new Date(msg.createTime); 195 | if (chatEn.lastMsgShowTime == null || msg.createTime.getTime() - chatEn.lastMsgShowTime.getTime() > 1000 * 60 * 5) { 196 | msgList.push({ 197 | role: 'sys', 198 | contentType: 'text', 199 | content: ak.Utils.getDateTimeStr(msg.createTime, 'H:i') 200 | }); 201 | chatEn.lastMsgShowTime = msg.createTime; 202 | } 203 | 204 | // 2)插入消息 205 | msgList.push(msg); 206 | 207 | // 3.设置chat对象相关属性 208 | chatEn.msgList = msgList; 209 | chatEn.lastMsgTime = msg.createTime; 210 | switch (msg.contentType) { 211 | case 'text': 212 | chatEn.lastMsgContent = msg.content; 213 | break; 214 | case 'image': 215 | chatEn.lastMsgContent = '[图片]'; 216 | break; 217 | case 'file': 218 | chatEn.lastMsgContent = '[文件]'; 219 | break; 220 | case 'sound': 221 | chatEn.lastMsgContent = '[语音]'; 222 | break; 223 | } 224 | // 更新列表 225 | if (context.state.selectedChatEn && chatEn.clientChatId == context.state.selectedChatEn.clientChatId) { 226 | chatEn.newMsgCount = 0; 227 | context.state.selectedChatEn = Object.assign({}, chatEn); 228 | context.commit('triggerHaveNewMsgDelegate'); 229 | } else { 230 | chatEn.newMsgCount++; 231 | } 232 | 233 | // 4.排序 234 | context.commit('sortCurrentChatEnlist', {}); 235 | 236 | // 5.加入通知 237 | if (msg.isNewMsg && msg.role == 'client' && msg.contentType != 'preInput') { 238 | context.dispatch('addNotificationChat', { 239 | chatEn: chatEn, 240 | oprType: 'msg' 241 | }); 242 | } 243 | 244 | // 6.回调 245 | successCallback && successCallback(); 246 | }); 247 | }, 248 | 249 | /** 250 | * 选中会话 251 | * @param {String} clientChatId 选中会话Id 252 | */ 253 | selectChat: function(context, { clientChatId }) { 254 | context.dispatch('getChatEnByChatId', { clientChatId: clientChatId }).then((chatEn) => { 255 | var state = context.state; 256 | chatEn.newMsgCount = 0; // 设置新消息为0 257 | // 1.设置当前选中的会话 258 | context.state.selectedChatEn = Object.assign({}, chatEn); 259 | 260 | // 2.刷新当前会话集合 261 | for (var i = 0; i < state.currentChatEnlist.length; i++) { 262 | var tmpEn = state.currentChatEnlist[i]; 263 | if (tmpEn.clientChatId == chatEn.clientChatId) { 264 | state.currentChatEnlist[i] = state.selectedChatEn; 265 | break; 266 | } 267 | } 268 | }); 269 | }, 270 | 271 | /** 272 | * 添加通知chat 273 | * @param {Object} chatEn 会话对象 274 | * @param {String} oprType 操作类型;eg:chat(添加会话)、msg(添加消息) 275 | */ 276 | addNotificationChat: function(context, { chatEn, oprType }) { 277 | var state = context.state; 278 | // 当前的路由是否在im模块里,若不在im模块里,才显示通知 279 | if (window.polkVue.$route.name == 'im') { 280 | return; 281 | } 282 | 283 | // 1.判断当前通知集合里是否已存在次会话,若已存在去除此会话 284 | for (var i = 0; i < state.notificationChatEnlist.length; i++) { 285 | if (state.notificationChatEnlist[i].clientChatId == chatEn.clientChatId) { 286 | state.notificationChatEnlist.splice(i, 1); 287 | break; 288 | } 289 | } 290 | 291 | // 2.集合最多只能有5个 292 | if (state.notificationChatEnlist.length > 5) { 293 | state.notificationChatEnlist = state.notificationChatEnlist.splice(4); 294 | } 295 | 296 | // 3.转换后加入到当前通知集合里 297 | var tmpChatEn = { 298 | clientChatId: chatEn.clientChatId, 299 | sourceInfo_way: chatEn.sourceInfo_way, 300 | site: window.location.host 301 | }; 302 | if (oprType == 'chat') { 303 | tmpChatEn.title = '新用户'; 304 | tmpChatEn.content = '客户 ' + chatEn.clientChatName + ' 接入新会话'; 305 | } else if (oprType == 'msg') { 306 | tmpChatEn.title = '客户 ' + chatEn.clientChatName + ' ' + chatEn.newMsgCount + '条新消息'; 307 | tmpChatEn.content = chatEn.lastMsgContent; 308 | } 309 | 310 | // 4.内容大于25个截断 311 | if (tmpChatEn.content.length > 25) { 312 | tmpChatEn.content = tmpChatEn.content.substr(0, 24) + '...'; 313 | } 314 | 315 | // 5.加入到集合里 316 | state.notificationChatEnlist.push(tmpChatEn); 317 | 318 | // 6.当通知数量大于5个时清除通知 319 | window.imServerStore_notificationList = window.imServerStore_notificationList || []; 320 | if (window.imServerStore_notificationList.length > 5) { 321 | window.imServerStore_notificationList.forEach((item, index) => { 322 | item.close(); 323 | }); 324 | window.imServerStore_notificationList = []; 325 | } 326 | 327 | // 7.显示通知 328 | for (var i = 0; i < state.notificationChatEnlist.length; i++) { 329 | const item = state.notificationChatEnlist[i]; 330 | // 1)已存在的通知列表是否包含此会话,若存在就关闭并移除 331 | for (var j = 0; j < window.imServerStore_notificationList.length; j++) { 332 | if (window.imServerStore_notificationList[j].data == item.clientChatId) { 333 | window.imServerStore_notificationList[j].close(); 334 | break; 335 | } 336 | } 337 | 338 | // 2)创建新的通知 339 | const notification = new Notification(item.title, { 340 | body: item.content, 341 | data: item.clientChatId, 342 | tag: Date.now(), 343 | icon: ak.BLL.getPngFromWay(item.sourceInfo_way) 344 | }); 345 | notification.onclick = function(e) { 346 | window.focus(); 347 | window.polkVue.$router.push('im'); 348 | context.commit('clearNotificationChat'); 349 | context.dispatch('selectChat', { clientChatId: item.clientChatId }); 350 | notification.close(); 351 | imServerStore_notificationList = []; 352 | }; 353 | 354 | notification.onclose = function(e) { 355 | // remove en 356 | for (var i = 0; i < state.notificationChatEnlist.length; i++) { 357 | if (state.notificationChatEnlist[i].clientChatId == item.clientChatId) { 358 | state.notificationChatEnlist.splice(i, 1); 359 | break; 360 | } 361 | } 362 | // remove notification 363 | for (var i = 0; i < window.imServerStore_notificationList.length; i++) { 364 | if (window.imServerStore_notificationList[i].tag == notification.tag) { 365 | window.imServerStore_notificationList.splice(i, 1); 366 | break; 367 | } 368 | } 369 | }; 370 | 371 | setTimeout(function() { 372 | notification && notification.close(); 373 | }, 1000 * 10); 374 | 375 | window.imServerStore_notificationList.push(notification); 376 | } 377 | }, 378 | 379 | /** 380 | * 服务端上线 381 | */ 382 | SERVER_ON: function(context, payload) { 383 | context.state.socket = require('socket.io-client')('http://localhost:3001'); 384 | context.state.socket.on('connect', function() { 385 | // 服务端上线 386 | context.state.socket.emit('SERVER_ON', { 387 | serverChatEn: { 388 | serverChatId: context.state.serverChatEn.serverChatId, 389 | serverChatName: context.state.serverChatEn.serverChatName, 390 | avatarUrl:context.state.serverChatEn.avatarUrl 391 | } 392 | }); 393 | 394 | // 访客端上线 395 | context.state.socket.on('CLIENT_ON', function(data) { 396 | // 1)增加客户列表 397 | context.dispatch('addClientChat', { 398 | newChatEn: { 399 | clientChatId: data.clientChatEn.clientChatId, 400 | clientChatName: data.clientChatEn.clientChatName 401 | } 402 | }); 403 | }); 404 | 405 | // 访客端离线 406 | context.state.socket.on('CLIENT_OFF', function(data) { 407 | // 1)修改客户状态为离线 408 | context.dispatch('extendChatEn', { 409 | clientChatId: data.clientChatEn.clientChatId, 410 | extends: { 411 | state: 'off' 412 | } 413 | }); 414 | 415 | // 2)增加消息 416 | context.dispatch('addChatMsg', { 417 | clientChatId: data.clientChatEn.clientChatId, 418 | msg: { 419 | role: 'sys', 420 | contentType: 'text', 421 | content: '客户断开连接' 422 | } 423 | }); 424 | }); 425 | 426 | // 访客端发送了信息 427 | context.state.socket.on('CLIENT_SEND_MSG', function(data) { 428 | context.dispatch('addChatMsg', { 429 | clientChatId: data.clientChatEn.clientChatId, 430 | msg: data.msg 431 | }); 432 | }); 433 | 434 | // 离开 435 | window.addEventListener('beforeunload', () => { 436 | context.dispatch('SERVER_OFF'); 437 | }); 438 | }); 439 | }, 440 | 441 | /** 442 | * 服务端离线 443 | */ 444 | SERVER_OFF: function(context, payload) { 445 | context.state.socket.emit('SERVER_OFF', { 446 | serverChatEn: { 447 | serverChatId: context.state.serverChatEn.serverChatId, 448 | serverChatName: context.state.serverChatEn.serverChatName 449 | } 450 | }); 451 | context.state.socket.close(); 452 | context.state.socket = null; 453 | }, 454 | 455 | /** 456 | * 发送消息 457 | */ 458 | sendMsg: function(context, { clientChatId, msg }) { 459 | console.log(clientChatId); 460 | context.state.socket.emit('SERVER_SEND_MSG', { 461 | clientChatId: clientChatId, 462 | msg: msg 463 | }); 464 | } 465 | }, 466 | getters: { 467 | /** 468 | * 获取选中的会话对象 469 | */ 470 | selectedChatEn: function(state) { 471 | return state.selectedChatEn; 472 | }, 473 | 474 | /** 475 | * 当前会话集合 476 | */ 477 | currentChatEnlist: function(state) { 478 | return state.currentChatEnlist; 479 | }, 480 | 481 | /** 482 | * 选中的chat含有新消息 483 | */ 484 | haveNewMsgDelegate: function(state) { 485 | return state.haveNewMsgDelegate; 486 | }, 487 | 488 | /** 489 | * 客服chat信息 490 | */ 491 | serverChatEn: function(state) { 492 | return state.serverChatEn; 493 | } 494 | } 495 | }); 496 | -------------------------------------------------------------------------------- /static/css/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, 7 | body, 8 | div, 9 | span, 10 | applet, 11 | object, 12 | iframe, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | p, 20 | blockquote, 21 | pre, 22 | a, 23 | abbr, 24 | acronym, 25 | address, 26 | big, 27 | cite, 28 | code, 29 | del, 30 | dfn, 31 | em, 32 | img, 33 | ins, 34 | kbd, 35 | q, 36 | s, 37 | samp, 38 | small, 39 | strike, 40 | strong, 41 | sub, 42 | sup, 43 | tt, 44 | var, 45 | b, 46 | u, 47 | i, 48 | center, 49 | dl, 50 | dt, 51 | dd, 52 | ol, 53 | ul, 54 | li, 55 | fieldset, 56 | form, 57 | label, 58 | legend, 59 | table, 60 | caption, 61 | tbody, 62 | tfoot, 63 | thead, 64 | tr, 65 | th, 66 | td, 67 | article, 68 | aside, 69 | canvas, 70 | details, 71 | embed, 72 | figure, 73 | figcaption, 74 | footer, 75 | header, 76 | hgroup, 77 | menu, 78 | nav, 79 | output, 80 | ruby, 81 | section, 82 | summary, 83 | time, 84 | mark, 85 | audio, 86 | video { 87 | margin: 0; 88 | padding: 0; 89 | border: 0; 90 | font-size: 100%; 91 | font: inherit; 92 | vertical-align: baseline; 93 | } 94 | 95 | 96 | /* HTML5 display-role reset for older browsers */ 97 | 98 | article, 99 | aside, 100 | details, 101 | figcaption, 102 | figure, 103 | footer, 104 | header, 105 | hgroup, 106 | menu, 107 | nav, 108 | section { 109 | display: block; 110 | } 111 | 112 | body { 113 | width: 100%; 114 | height: 100%; 115 | line-height: 1; 116 | position: absolute; 117 | } 118 | 119 | ol, 120 | ul { 121 | list-style: none; 122 | } 123 | 124 | blockquote, 125 | q { 126 | quotes: none; 127 | } 128 | 129 | blockquote:before, 130 | blockquote:after, 131 | q:before, 132 | q:after { 133 | content: ''; 134 | content: none; 135 | } 136 | 137 | table { 138 | border-collapse: collapse; 139 | border-spacing: 0; 140 | } -------------------------------------------------------------------------------- /static/image/im_client_avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polk6/vue-im/a9c9817855aeff4087b25890742f036034dbe04b/static/image/im_client_avatar.png -------------------------------------------------------------------------------- /static/image/im_emoji_spacer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polk6/vue-im/a9c9817855aeff4087b25890742f036034dbe04b/static/image/im_emoji_spacer.gif -------------------------------------------------------------------------------- /static/image/im_robot_avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polk6/vue-im/a9c9817855aeff4087b25890742f036034dbe04b/static/image/im_robot_avatar.png -------------------------------------------------------------------------------- /static/image/im_server_avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polk6/vue-im/a9c9817855aeff4087b25890742f036034dbe04b/static/image/im_server_avatar.png -------------------------------------------------------------------------------- /static/upload/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore --------------------------------------------------------------------------------