feat: 增加防多人冲突的编辑检测机制

This commit is contained in:
gaoxiaolin.gao 2018-07-05 11:56:13 +08:00
parent b2ffd72c86
commit 3460ce5e20
10 changed files with 384 additions and 127 deletions

View File

@ -1,18 +1,13 @@
import React, { Component } from 'react';
import { Button, message, Checkbox } from 'antd';
import { message } from 'antd';
import { connect } from 'react-redux';
import axios from 'axios';
import PropTypes from 'prop-types';
import './index.scss';
// deps for editor
require('codemirror/lib/codemirror.css'); // codemirror
require('tui-editor/dist/tui-editor.css'); // editor ui
require('tui-editor/dist/tui-editor-contents.css'); // editor content
require('highlight.js/styles/github.css'); // code block highlight
// require('./editor.css');
var Editor = require('tui-editor');
import { timeago } from '../util.js';
import { Link } from 'react-router-dom';
import WikiView from './View.js';
import WikiEditor from './Editor.js';
@connect(
state => {
@ -30,7 +25,11 @@ class WikiPage extends Component {
isUpload: true,
desc: '',
markdown: '',
notice: props.projectMsg.switch_notice
notice: props.projectMsg.switch_notice,
status: 'INIT',
editUid: '',
editName: '',
curdata: null
};
}
@ -42,22 +41,100 @@ class WikiPage extends Component {
async componentDidMount() {
const currProjectId = this.props.match.params.id;
await this.handleData({ project_id: currProjectId });
this.editor = new Editor({
el: document.querySelector('#desc'),
initialEditType: 'wysiwyg',
height: '500px',
initialValue: this.state.markdown || this.state.desc
});
}
// 点击编辑按钮
onEditor = () => {
this.setState({
isEditor: !this.state.isEditor
});
componentWillUnmount() {
// willUnmount
this.closeWebSocket();
}
this.editor.setHtml(this.state.desc);
// 关闭websocket
closeWebSocket = () => {
try {
if (this.state.status === 'CLOSE') {
this.WebSocket.close();
}
} catch (e) {
return null;
}
};
// 处理多人编辑冲突问题
handleConflict = () => {
let self = this;
return new Promise((resolve, reject) => {
let domain = location.hostname + (location.port !== '' ? ':' + location.port : '');
let s,
initData = false;
//因后端 node 仅支持 ws 暂不支持 wss
let wsProtocol = location.protocol === 'https' ? 'ws' : 'ws';
setTimeout(() => {
if (initData === false) {
self.setState({
status: 'CLOSE'
});
initData = true;
}
}, 3000);
s = new WebSocket(
wsProtocol +
'://' +
domain +
'/api/ws_plugin/wiki_desc/solve_conflict?id=' +
this.props.match.params.id
);
s.onopen = () => {
self.WebSocket = s;
};
s.onmessage = e => {
initData = true;
let result = JSON.parse(e.data);
if (result.errno === 0) {
self.setState({
curdata: result.data,
status: 'CLOSE'
});
} else {
self.setState({
editUid: result.data.uid,
editName: result.data.username,
status: 'EDITOR'
});
}
resolve();
};
s.onerror = () => {
self.setState({
// curdata: this.props.curdata,
status: 'CLOSE'
});
console.warn('websocket 连接失败,将导致多人编辑同一个接口冲突。');
reject('websocket 连接失败,将导致多人编辑同一个接口冲突。');
};
});
};
// 点击编辑按钮
onEditor = async () => {
this.setState({isEditor: !this.state.isEditor})
// 多人冲突编辑判断
await this.handleConflict();
// 获取最新的编辑数据
// let curDesc = this.state.curdata ? this.state.curdata.desc : this.state.desc;
if(this.state.curdata) {
this.setState({
desc: this.state.curdata.desc,
username: this.state.curdata.username,
uid: this.state.curdata.uid,
editorTime: timeago(this.state.curdata.up_time)
});
}
};
// 获取数据
@ -79,9 +156,8 @@ class WikiPage extends Component {
}
};
onUpload = async () => {
let desc = this.editor.getHtml();
let markdown = this.editor.getMarkdown();
onUpload = async (desc, markdown) => {
const currProjectId = this.props.match.params.id;
let option = {
project_id: currProjectId,
@ -96,10 +172,12 @@ class WikiPage extends Component {
} else {
message.error(`更新失败: ${result.data.errmsg}`);
}
this.closeWebSocket();
};
// 取消编辑
onCancel = () => {
this.setState({ isEditor: false });
this.setState({ isEditor: false, status: 'CLOSE' });
this.closeWebSocket();
};
// 邮件通知
@ -110,51 +188,46 @@ class WikiPage extends Component {
};
render() {
const { isEditor, username, editorTime, notice, uid } = this.state;
const { isEditor, username, editorTime, notice, uid, status, editUid, editName } = this.state;
const editorEable =
this.props.projectMsg.role === 'admin' ||
this.props.projectMsg.role === 'owner' ||
this.props.projectMsg.role === 'dev';
const isConflict = status === 'EDITOR';
return (
<div className="g-row">
<div className="m-panel wiki-content">
<div className="wiki-title">
{!isEditor ? (
<Button icon="edit" onClick={this.onEditor} disabled={!editorEable}>
编辑
</Button>
) : (
<div>
<Button icon="upload" type="primary" className="upload-btn" onClick={this.onUpload}>
更新
</Button>
<Button onClick={this.onCancel} className="upload-btn">
取消
</Button>
<Checkbox checked={notice} onChange={this.onEmailNotice}>
通知相关人员
</Checkbox>
{!isEditor ? (
<WikiView
editorEable={editorEable}
onEditor={this.onEditor}
uid={uid}
username={username}
editorTime={editorTime}
desc={this.state.desc}
/>
) : (
<WikiEditor
isConflict={isConflict}
onUpload={this.onUpload}
onCancel={this.onCancel}
notice={notice}
onEmailNotice={this.onEmailNotice}
desc={this.state.desc}
/>
)}
<div className="wiki-content">
{isConflict && (
<div className="wiki-conflict">
<Link to={`/user/profile/${editUid || uid}`}>
<b>{editName || username}</b>
</Link>
<span>正在编辑该wiki请稍后再试...</span>
</div>
)}
</div>
{!isEditor &&
username && (
<div className="wiki-user">
{/* 由 {username} */}
{' '}
<Link className="user-name" to={`/user/profile/${uid || 11}`}>
{/* <img src={'/api/user/avatar?uid=' + this.props.curData.uid} className="user-img" /> */}
{username}
</Link>{' '}
修改于 {editorTime}
</div>
)}
<div id="desc" className="wiki-editor" style={{ display: isEditor ? 'block' : 'none' }} />
<div
className="tui-editor-contents"
style={{ display: isEditor ? 'none' : 'block' }}
dangerouslySetInnerHTML={{ __html: this.state.desc }}
/>
</div>
</div>
);

View File

@ -10,4 +10,14 @@
.upload-btn {
margin-right: 16px;
}
.wiki-conflict {
text-align: center;
font-size: 14px;
padding-top: 10px;
}
.wiki-up {
text-align: right;
padding-top: 16px;
}
}

View File

@ -1,4 +1,5 @@
import WikiPage from './WikiPage/index'
import WikiPage from './wikiPage/index'
// const WikiPage = require('./wikiPage/index')
module.exports = function(){
this.bindHook('sub_nav', function(app){

View File

@ -1,7 +1,7 @@
const baseController = require('controllers/base.js');
const wikiModel = require('./wikiModel.js');
const projectModel = require('models/project.js');
const userModel = require('models/user.js');
const jsondiffpatch = require('jsondiffpatch');
const formattersHtml = jsondiffpatch.formatters.html;
const yapi = require('yapi.js');
@ -14,7 +14,6 @@ class wikiController extends baseController {
super(ctx);
this.Model = yapi.getInst(wikiModel);
this.projectModel = yapi.getInst(projectModel);
}
/**
@ -29,10 +28,10 @@ class wikiController extends baseController {
try {
let project_id = ctx.request.query.project_id;
if (!project_id) {
return ctx.body = yapi.commons.resReturn(null, 400, '项目id不能为空');
return (ctx.body = yapi.commons.resReturn(null, 400, '项目id不能为空'));
}
let result = await this.Model.get(project_id)
return ctx.body = yapi.commons.resReturn(result);
let result = await this.Model.get(project_id);
return (ctx.body = yapi.commons.resReturn(result));
} catch (err) {
ctx.body = yapi.commons.resReturn(null, 400, err.message);
}
@ -55,14 +54,14 @@ class wikiController extends baseController {
desc: 'string',
markdown: 'string'
});
if (!params.project_id) {
return ctx.body = yapi.commons.resReturn(null, 400, '项目id不能为空');
return (ctx.body = yapi.commons.resReturn(null, 400, '项目id不能为空'));
}
if(!this.$tokenAuth){
let auth = await this.checkAuth(params.project_id, 'project', 'edit')
if (!this.$tokenAuth) {
let auth = await this.checkAuth(params.project_id, 'project', 'edit');
if (!auth) {
return ctx.body = yapi.commons.resReturn(null, 400, '没有权限');
return (ctx.body = yapi.commons.resReturn(null, 400, '没有权限'));
}
}
@ -72,8 +71,8 @@ class wikiController extends baseController {
const uid = this.getUid();
// 如果当前数据库里面没有数据
let result = await this.Model.get(params.project_id)
if(!result) {
let result = await this.Model.get(params.project_id);
if (!result) {
let data = Object.assign(params, {
username,
uid,
@ -82,33 +81,46 @@ class wikiController extends baseController {
});
let res = await this.Model.save(data);
return ctx.body = yapi.commons.resReturn(res);
ctx.body = yapi.commons.resReturn(res);
} else {
let data = Object.assign(params, {
username,
uid,
up_time: yapi.commons.time()
});
let upRes = await this.Model.up(result._id, data);
ctx.body = yapi.commons.resReturn(upRes);
}
// console.log('result', result);
let data = Object.assign(params, {
username,
uid,
up_time: yapi.commons.time()
});
let upRes = await this.Model.up(result._id, data);
ctx.body = yapi.commons.resReturn(upRes)
let logData = {
type: 'wiki',
project_id: params.project_id,
current: params.desc,
old: result ? result.toObject().desc : ''
}
let wikiUrl = `http://${ctx.request.host}/project/${params.project_id}/wiki`
};
let wikiUrl = `http://${ctx.request.host}/project/${params.project_id}/wiki`;
if(notice) {
let diffView = showDiffMsg(jsondiffpatch, formattersHtml, logData);
let annotatedCss = fs.readFileSync(path.resolve(yapi.WEBROOT, 'node_modules/jsondiffpatch/public/formatters-styles/annotated.css'), 'utf8');
let htmlCss = fs.readFileSync(path.resolve(yapi.WEBROOT, 'node_modules/jsondiffpatch/public/formatters-styles/html.css'), 'utf8');
if (notice) {
let diffView = showDiffMsg(jsondiffpatch, formattersHtml, logData);
let annotatedCss = fs.readFileSync(
path.resolve(
yapi.WEBROOT,
'node_modules/jsondiffpatch/public/formatters-styles/annotated.css'
),
'utf8'
);
let htmlCss = fs.readFileSync(
path.resolve(
yapi.WEBROOT,
'node_modules/jsondiffpatch/public/formatters-styles/html.css'
),
'utf8'
);
let project = await this.projectModel.getBaseInfo(params.project_id);
yapi.commons.sendNotice(params.project_id, {
title: `${username} 更新了wiki说明`,
content: `<html>
@ -126,7 +138,7 @@ class wikiController extends baseController {
<p>详细改动日志: ${this.diffHTML(diffView)}</p></div>
</body>
</html>`
})
});
}
// 保存修改日志信息
@ -143,21 +155,54 @@ class wikiController extends baseController {
} catch (err) {
ctx.body = yapi.commons.resReturn(null, 400, err.message);
}
}
diffHTML(html) {
if (html.length === 0) {
return `<span style="color: #555">没有改动该操作未改动wiki数据</span>`
return `<span style="color: #555">没有改动该操作未改动wiki数据</span>`;
}
return html.map(item => {
return (`<div>
return `<div>
<h4 class="title">${item.title}</h4>
<div>${item.content}</div>
</div>`)
})
</div>`;
});
}
// 处理编辑冲突
async wikiConflict(ctx) {
try {
let id = parseInt(ctx.query.id, 10),
result,
userInst,
userinfo,
data;
if (!id) return ctx.websocket.send('id 参数有误');
result = await this.Model.get(id);
if (result.edit_uid !== 0 && result.edit_uid !== this.getUid()) {
userInst = yapi.getInst(userModel);
userinfo = await userInst.findById(result.edit_uid);
data = {
errno: result.edit_uid,
data: { uid: result.edit_uid, username: userinfo.username }
};
} else {
await this.Model.upEditUid(result._id, this.getUid());
data = {
errno: 0,
data: result
};
}
ctx.websocket.send(JSON.stringify(data));
ctx.websocket.on('close', async () => {
console.log('close');
await this.Model.upEditUid(result._id, 0);
});
} catch (err) {
yapi.commons.log(err, 'error');
}
}
}
module.exports = wikiController;

View File

@ -1,35 +1,39 @@
const yapi = require('yapi.js')
const yapi = require('yapi.js');
const mongoose = require('mongoose');
const controller = require('./controller');
module.exports = function() {
yapi.connect.then(function() {
let Col = mongoose.connection.db.collection('wiki');
Col.createIndex({
project_id: 1
});
});
module.exports = function () {
yapi.connect.then(function () {
let Col = mongoose.connection.db.collection('wiki');
Col.createIndex({
project_id: 1
})
this.bindHook('add_router', function(addRouter) {
addRouter({
// 获取wiki信息
controller: controller,
method: 'get',
path: 'wiki_desc/get',
action: 'getWikiDesc'
});
this.bindHook('add_router', function (addRouter) {
addRouter({
// 获取wiki信息
controller: controller,
method: 'get',
path: 'wiki_desc/get',
action: 'getWikiDesc'
})
addRouter({
// 更新wiki信息
controller: controller,
method: 'post',
path: 'wiki_desc/up',
action: 'uplodaWikiDesc'
});
});
addRouter({
// 更新wiki信息
controller: controller,
method: 'post',
path: 'wiki_desc/up',
action: 'uplodaWikiDesc'
})
})
};
this.bindHook('add_ws_router', function(wsRouter) {
wsRouter({
controller: controller,
method: 'get',
path: 'wiki_desc/solve_conflict',
action: 'wikiConflict'
});
});
};

View File

@ -10,7 +10,8 @@ class statisMockModel extends baseModel {
return {
project_id: { type: Number, required: true },
username: String,
uid: Number,
uid: { type: Number, required: true },
edit_uid: { type: Number, default: 0 },
desc: String,
markdown: String,
add_time: Number,
@ -44,6 +45,14 @@ class statisMockModel extends baseModel {
{ runValidators: true }
);
}
upEditUid(id, uid) {
return this.model.update({
_id: id
},
{ edit_uid: uid },
{ runValidators: true });
}
}
module.exports = statisMockModel;

View File

@ -0,0 +1,71 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Button, Checkbox } from 'antd';
// deps for editor
require('codemirror/lib/codemirror.css'); // codemirror
require('tui-editor/dist/tui-editor.css'); // editor ui
require('tui-editor/dist/tui-editor-contents.css'); // editor content
require('highlight.js/styles/github.css'); // code block highlight
var Editor = require('tui-editor');
class WikiEditor extends Component {
constructor(props) {
super(props);
}
static propTypes = {
isConflict: PropTypes.bool,
onUpload: PropTypes.func,
onCancel: PropTypes.func,
notice: PropTypes.bool,
onEmailNotice: PropTypes.func,
desc: PropTypes.string
};
componentDidMount() {
this.editor = new Editor({
el: document.querySelector('#desc'),
initialEditType: 'wysiwyg',
height: '500px',
initialValue: this.props.desc
});
}
onUpload = () => {
let desc = this.editor.getHtml();
let markdown = this.editor.getMarkdown();
this.props.onUpload(desc, markdown)
}
render() {
const {isConflict, onCancel, notice, onEmailNotice } = this.props;
return (
<div>
<div
id="desc"
className="wiki-editor"
style={{ display: !isConflict ? 'block' : 'none' }}
/>
<div className="wiki-title wiki-up">
<Button
icon="upload"
type="primary"
className="upload-btn"
disabled={isConflict}
onClick={this.onUpload}
>
更新
</Button>
<Button onClick={onCancel} className="upload-btn">
取消
</Button>
<Checkbox checked={notice} onChange={onEmailNotice}>
通知相关人员
</Checkbox>
</div>
</div>
);
}
}
export default WikiEditor;

View File

@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from 'antd';
import { Link } from 'react-router-dom';
const WikiView = props => {
const { editorEable, onEditor, uid, username, editorTime, desc } = props;
return (
<div>
<div className="wiki-title">
<Button icon="edit" onClick={onEditor} disabled={!editorEable}>
编辑
</Button>
{username && (
<div className="wiki-user">
{' '}
<Link className="user-name" to={`/user/profile/${uid || 11}`}>
{username}
</Link>{' '}
修改于 {editorTime}
</div>
)}
</div>
<div
className="tui-editor-contents"
dangerouslySetInnerHTML={{ __html: desc }}
/>
</div>
);
};
WikiView.propTypes = {
editorEable: PropTypes.bool,
onEditor: PropTypes.func,
uid: PropTypes.number,
username: PropTypes.string,
editorTime: PropTypes.string,
desc: PropTypes.string
};
export default WikiView;

View File

@ -679,7 +679,7 @@ class interfaceController extends baseController {
ctx.body = yapi.commons.resReturn(null, 402, err.message);
}
}
// 处理编辑冲突
async solveConflict(ctx) {
try {
let id = parseInt(ctx.query.id, 10), result, userInst, userinfo, data;

View File

@ -20,11 +20,14 @@ function addPluginRouter(config) {
pluginsRouterPath.push(routerPath);
createAction(router, "/api", config.controller, config.action, routerPath, method, true);
}
function websocket(app) {
createAction(router, "/api", interfaceController, "solveConflict", "/interface/solve_conflict", "get")
yapi.emitHookSync('add_ws_router', addPluginRouter);
app.ws.use(router.routes())
app.ws.use(router.allowedMethods());
app.ws.use(function (ctx, next) {