├── .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 | 
12 |
13 | ## im-client im客户端
14 | 
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 |
2 |
3 |
4 |
5 |
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 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
{{item.content}}
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
![]()
23 |
24 |
25 |
28 |
29 |
30 |
![]()
31 |
32 |
33 |
34 |
35 |
36 |
37 |
{{getFileName(item.fileName)}}
38 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | 当前没有配置机器人,
50 | 转接客服
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
94 |
95 |
96 |
97 |
100 |
101 |
![]()
102 |
103 |
104 |
105 |
106 |
107 |
521 |
952 |
953 |
--------------------------------------------------------------------------------
/src/components/common/common_chat_emoji.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
14 |
15 |
81 |
82 |
550 |
--------------------------------------------------------------------------------
/src/components/imClient/imClient.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | 结束本次会话?
74 |
78 |
79 |
80 |
81 |
82 |
371 |
372 |
499 |
--------------------------------------------------------------------------------
/src/components/imClient/imLeave.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
当前人工客服不在线,如需帮助请留言
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | 提 交
18 |
19 |
20 |
21 |
22 |
23 |
24 |
留 言 提 交 成 功
25 |
我们会很快与您联系
26 |
27 |
28 |
29 |
30 |
111 |
112 |
154 |
--------------------------------------------------------------------------------
/src/components/imClient/imRate.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 感谢你的咨询,请对我们的服务进行评价
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
确定
16 |
17 |
18 |
22 |
23 |
24 |
25 |
58 |
59 |
110 |
--------------------------------------------------------------------------------
/src/components/imClient/imTransfer.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {{item.serverChatName}}
8 |
9 |
10 |
11 |
14 |
15 |
16 |
17 |
53 |
54 |
87 |
--------------------------------------------------------------------------------
/src/components/imServer/imChat.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
87 |
112 |
113 |
--------------------------------------------------------------------------------
/src/components/imServer/imRecord.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | {{tmpEn.clientChatName}}
27 | {{getLastMsgTimeStr(tmpEn.lastMsgTime)}}
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
42 |
43 |
44 |
45 |
46 |
106 |
107 |
284 |
--------------------------------------------------------------------------------
/src/components/imServer/imServer.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
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
--------------------------------------------------------------------------------