feat: 通过链接邀请加入项目

This commit is contained in:
kuaifan 2021-12-26 23:38:25 +08:00
parent 0665f2de5f
commit dd86bb88c6
10 changed files with 386 additions and 11 deletions

View File

@ -6,6 +6,7 @@ use App\Exceptions\ApiException;
use App\Models\AbstractModel;
use App\Models\Project;
use App\Models\ProjectColumn;
use App\Models\ProjectInvite;
use App\Models\ProjectLog;
use App\Models\ProjectTask;
use App\Models\ProjectTaskFile;
@ -258,6 +259,110 @@ class ProjectController extends AbstractController
return Base::retSuccess('修改成功', ['id' => $project->id]);
}
/**
* 获取邀请链接(限:项目负责人)
*
* @apiParam {Number} project_id 项目ID
* @apiParam {String} refresh 刷新链接
* - no: 只获取(默认)
* - yes: 刷新链接,之前的将失效
*/
public function invite()
{
User::auth();
//
$project_id = intval(Request::input('project_id'));
$refresh = Request::input('refresh', 'no');
//
$project = Project::userProject($project_id);
if (!$project->owner) {
return Base::retError('仅限项目负责人查看');
}
//
$invite = Base::settingFind('system', 'project_invite');
if ($invite == 'close') {
return Base::retError('未开放此功能');
}
//
$projectInvite = ProjectInvite::whereProjectId($project->id)->first();
if (empty($projectInvite)) {
$projectInvite = ProjectInvite::createInstance([
'project_id' => $project->id,
'code' => Base::generatePassword(64),
]);
$projectInvite->save();
} else {
if ($refresh == 'yes') {
$projectInvite->code = Base::generatePassword(64);
$projectInvite->save();
}
}
return Base::retSuccess('success', [
'url' => Base::fillUrl('manage/project/invite?code=' . $projectInvite->code),
'num' => $projectInvite->num
]);
}
/**
* 通过邀请链接code获取项目信息
*
* @apiParam {String} code
*/
public function invite__info()
{
User::auth();
//
$code = Request::input('code');
//
$invite = Base::settingFind('system', 'project_invite');
if ($invite == 'close') {
return Base::retError('未开放此功能');
}
//
$projectInvite = ProjectInvite::with(['project'])->whereCode($code)->first();
if (empty($projectInvite)) {
return Base::retError('邀请code不存在');
}
return Base::retSuccess('success', $projectInvite);
}
/**
* 通过邀请链接code加入项目
*
* @apiParam {String} code
*/
public function invite__join()
{
$user = User::auth();
//
$code = Request::input('code');
//
$invite = Base::settingFind('system', 'project_invite');
if ($invite == 'close') {
return Base::retError('未开放此功能');
}
//
$projectInvite = ProjectInvite::with(['project'])->whereCode($code)->first();
if (empty($projectInvite)) {
return Base::retError('邀请code不存在');
}
if ($projectInvite->already) {
return Base::retSuccess('已加入', $projectInvite);
}
if (!$projectInvite->project?->joinProject($user->userid)) {
return Base::retError('加入失败,请稍后再试');
}
$projectInvite->num++;
$projectInvite->save();
//
$projectInvite->project->syncDialogUser();
$projectInvite->project->addLog("会员ID" . $user->userid . " 通过邀请链接加入项目");
//
$data = $projectInvite->toArray();
$data['already'] = true;
return Base::retSuccess('加入成功', $data);
}
/**
* 移交项目(限:项目负责人)
*

View File

@ -24,7 +24,8 @@ class SystemController extends AbstractController
*
* @apiParam {String} type
* - get: 获取(默认)
* - save: 保存设置参数reg、login_code、password_policy、chat_nickname
* - all: 获取所有(需要管理员权限)
* - save: 保存设置参数reg、reg_invite、login_code、password_policy、project_invite、chat_nickname
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@ -40,7 +41,7 @@ class SystemController extends AbstractController
User::auth('admin');
$all = Request::input();
foreach ($all AS $key => $value) {
if (!in_array($key, ['reg', 'login_code', 'password_policy', 'chat_nickname'])) {
if (!in_array($key, ['reg', 'reg_invite', 'login_code', 'password_policy', 'project_invite', 'chat_nickname'])) {
unset($all[$key]);
}
}
@ -49,9 +50,17 @@ class SystemController extends AbstractController
$setting = Base::setting('system');
}
//
if ($type == 'all') {
User::auth('admin');
$setting['reg_invite'] = $setting['reg_invite'] ?: Base::generatePassword(8);
} else {
if (isset($setting['reg_invite'])) unset($setting['reg_invite']);
}
//
$setting['reg'] = $setting['reg'] ?: 'open';
$setting['login_code'] = $setting['login_code'] ?: 'auto';
$setting['password_policy'] = $setting['password_policy'] ?: 'simple';
$setting['project_invite'] = $setting['project_invite'] ?: 'open';
$setting['chat_nickname'] = $setting['chat_nickname'] ?: 'optional';
//
return Base::retSuccess('success', $setting ?: json_decode('{}'));

View File

@ -0,0 +1,55 @@
<?php
namespace App\Models;
/**
* App\Models\ProjectInvite
*
* @property int $id
* @property int|null $project_id 项目ID
* @property int|null $num 累计邀请
* @property string|null $code 链接码
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read bool $already
* @property-read \App\Models\Project|null $project
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite query()
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite whereCode($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite whereNum($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite whereProjectId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ProjectInvite whereUpdatedAt($value)
* @mixin \Eloquent
*/
class ProjectInvite extends AbstractModel
{
protected $appends = [
'already',
];
/**
* 是否已加入
* @return bool
*/
public function getAlreadyAttribute()
{
if (!isset($this->appendattrs['already'])) {
$this->appendattrs['already'] = false;
if (User::userid()) {
$this->appendattrs['already'] = (bool)$this->project?->projectUser?->where('userid', User::userid())->count();
}
}
return $this->appendattrs['already'];
}
/**
* @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function project(): \Illuminate\Database\Eloquent\Relations\HasOne
{
return $this->hasOne(Project::class, 'id', 'project_id');
}
}

View File

@ -26,7 +26,7 @@ class SettingsTableSeeder extends Seeder
'id' => 1,
'name' => 'system',
'desc' => '',
'setting' => '{"reg":"open","login_code":"auto"}',
'setting' => '{"reg":"open","project_invite":"open","login_code":"auto"}',
'created_at' => seeders_at('2021-07-01 11:05:06'),
'updated_at' => seeders_at('2021-07-01 12:27:12'),
),

View File

@ -34,7 +34,8 @@
<EDropdownMenu v-if="projectData.owner_userid === userId" slot="dropdown">
<EDropdownItem command="setting">{{$L('项目设置')}}</EDropdownItem>
<EDropdownItem command="user">{{$L('成员管理')}}</EDropdownItem>
<EDropdownItem command="log">{{$L('项目动态')}}</EDropdownItem>
<EDropdownItem command="invite">{{$L('邀请链接')}}</EDropdownItem>
<EDropdownItem command="log" divided>{{$L('项目动态')}}</EDropdownItem>
<EDropdownItem command="archived_task">{{$L('已归档任务')}}</EDropdownItem>
<EDropdownItem command="transfer" divided>{{$L('移交项目')}}</EDropdownItem>
<EDropdownItem command="archived">{{$L('归档项目')}}</EDropdownItem>
@ -320,7 +321,7 @@
v-model="settingShow"
:title="$L('项目设置')"
:mask-closable="false">
<Form ref="addProject" :model="settingData" label-width="auto" @submit.native.prevent>
<Form :model="settingData" label-width="auto" @submit.native.prevent>
<FormItem prop="name" :label="$L('项目名称')">
<Input ref="projectName" type="text" v-model="settingData.name" :maxlength="32" :placeholder="$L('必填')"></Input>
</FormItem>
@ -339,7 +340,7 @@
v-model="userShow"
:title="$L('成员管理')"
:mask-closable="false">
<Form ref="addProject" :model="userData" label-width="auto" @submit.native.prevent>
<Form :model="userData" label-width="auto" @submit.native.prevent>
<FormItem prop="userids" :label="$L('项目成员')">
<UserInput v-if="userShow" v-model="userData.userids" :uncancelable="userData.uncancelable" :multiple-max="100" :placeholder="$L('选择项目成员')"/>
</FormItem>
@ -369,12 +370,39 @@
</div>
</Modal>
<!--邀请链接-->
<Modal
v-model="inviteShow"
:title="$L('邀请链接')"
:mask-closable="false">
<Form :model="inviteData" label-width="auto" @submit.native.prevent>
<FormItem :label="$L('链接地址')">
<Input ref="inviteInput" v-model="inviteData.url" type="textarea" :rows="3" @on-focus="inviteFocus" readonly/>
<div class="form-tip">{{$L('可通过此链接注册、加入项目。')}}</div>
</FormItem>
</Form>
<div slot="footer" class="adaption">
<Button type="default" @click="inviteShow=false">{{$L('取消')}}</Button>
<Poptip
confirm
placement="bottom"
style="margin-left:8px"
@on-ok="inviteGet(true)"
transfer>
<div slot="title">
<p><strong>{{$L('注意:刷新将导致原来的邀请链接失效!')}}</strong></p>
</div>
<Button type="primary" :loading="inviteLoad > 0">{{$L('刷新')}}</Button>
</Poptip>
</div>
</Modal>
<!--移交项目-->
<Modal
v-model="transferShow"
:title="$L('移交项目')"
:mask-closable="false">
<Form ref="addProject" :model="transferData" label-width="auto" @submit.native.prevent>
<Form :model="transferData" label-width="auto" @submit.native.prevent>
<FormItem prop="owner_userid" :label="$L('项目负责人')">
<UserInput v-if="transferShow" v-model="transferData.owner_userid" :multiple-max="1" :placeholder="$L('选择项目负责人')"/>
</FormItem>
@ -404,6 +432,10 @@
</template>
<script>
import Vue from 'vue'
import VueClipboard from 'vue-clipboard2'
Vue.use(VueClipboard)
import Draggable from 'vuedraggable'
import TaskPriority from "./TaskPriority";
import TaskAdd from "./TaskAdd";
@ -451,6 +483,10 @@ export default {
userData: {},
userLoad: 0,
inviteShow: false,
inviteData: {},
inviteLoad: 0,
transferShow: false,
transferData: {},
transferLoad: 0,
@ -1090,6 +1126,12 @@ export default {
this.userShow = true;
break;
case "invite":
this.inviteData = {};
this.inviteShow = true;
this.inviteGet()
break;
case "log":
this.logShow = true;
break;
@ -1153,6 +1195,39 @@ export default {
});
},
inviteGet(refresh) {
this.inviteLoad++;
this.$store.dispatch("call", {
url: 'project/invite',
data: {
project_id: this.projectId,
refresh: refresh === true ? 'yes' : 'no'
},
}).then(({data}) => {
this.inviteLoad--;
this.inviteData = data;
this.inviteCopy();
}).catch(({msg}) => {
$A.modalError(msg);
this.inviteLoad--;
});
},
inviteCopy() {
if (!this.inviteData.url) {
return;
}
this.$copyText(this.inviteData.url).then(() => {
$A.messageSuccess(this.$L('复制成功!'));
}, () => {
$A.messageError(this.$L('复制失败!'));
});
},
inviteFocus() {
this.$refs.inviteInput.focus({cursor:'all'});
},
toggleCompleted() {
this.$store.dispatch('toggleTablePanel', 'completedTask');
this.completeTask = [];

View File

@ -0,0 +1,111 @@
<template>
<div class="page-invite">
<PageTitle :title="$L('加入项目')"/>
<div v-if="loadIng > 0" class="invite-load">
<Loading/>
</div>
<div v-else class="invite-warp">
<Card v-if="project.id > 0">
<p slot="title">{{project.name}}</p>
<div v-if="project.desc" class="invite-desc">{{project.desc}}</div>
<div v-else>{{$L('暂无介绍')}}</div>
<div class="invite-footer">
<Button v-if="already" type="success" icon="ios-checkmark-circle-outline" @click="goProject">{{$L('已加入')}}</Button>
<Button v-else type="primary" :loading="joinLoad > 0" @click="joinProject">{{$L('加入项目')}}</Button>
</div>
</Card>
<Card v-else>
<p>{{$L('邀请地址不存在或已被删除!')}}</p>
</Card>
</div>
</div>
</template>
<style lang="scss" scoped>
.page-invite {
display: flex;
align-items: center;
justify-content: center;
.invite-warp {
.invite-desc {
max-width: 460px;
max-height: 300px;
overflow: auto;
}
.invite-footer {
display: flex;
align-items: center;
justify-content: center;
margin-top: 24px;
> button {
height: 36px;
min-width: 120px;
}
}
}
}
</style>
<script>
export default {
data() {
return {
loadIng: 0,
joinLoad: 0,
already: false,
project: {},
}
},
watch: {
'$route': {
handler(route) {
this.code = route.query ? route.query.code : '';
this.getData();
},
immediate: true
},
},
methods: {
getData() {
this.loadIng++;
this.$store.dispatch("call", {
url: 'project/invite/info',
data: {
code: this.code,
},
}).then(({data}) => {
this.loadIng--;
this.already = data.already;
this.project = data.project;
}).catch(() => {
this.loadIng--;
this.project = {}
});
},
joinProject() {
this.joinLoad++;
this.$store.dispatch("call", {
url: 'project/invite/join',
data: {
code: this.code,
},
}).then(({data}) => {
this.joinLoad--;
this.already = data.already;
this.project = data.project;
this.goProject();
}).catch(({msg}) => {
this.joinLoad--;
$A.modalError(msg);
});
},
goProject() {
this.$nextTick(() => {
this.goForward({path: '/manage/project/' + this.project.id});
})
}
}
}
</script>

View File

@ -4,8 +4,16 @@
<FormItem :label="$L('允许注册')" prop="reg">
<RadioGroup v-model="formDatum.reg">
<Radio label="open">{{$L('允许')}}</Radio>
<Radio label="invite">{{$L('邀请码')}}</Radio>
<Radio label="close">{{$L('禁止')}}</Radio>
</RadioGroup>
<div v-if="formDatum.reg == 'open'" class="form-tip">{{$L('允许开放注册功能')}}</div>
<template v-else-if="formDatum.reg == 'invite'">
<div class="form-tip">{{$L('邀请码:注册时需填写下方邀请码。')}}</div>
<Input v-model="formDatum.reg_invite" style="width:200px;margin-top:6px">
<span slot="prepend">{{$L('邀请码')}}</span>
</Input>
</template>
</FormItem>
<FormItem :label="$L('登录验证码')" prop="loginCode">
<RadioGroup v-model="formDatum.login_code">
@ -23,6 +31,13 @@
<div v-if="formDatum.password_policy == 'simple'" class="form-tip">{{$L('简单大于或等于6个字符')}}</div>
<div v-else-if="formDatum.password_policy == 'complex'" class="form-tip">{{$L('复杂大于或等于6个字符包含数字、字母大小写或者特殊字符。')}}</div>
</FormItem>
<FormItem :label="$L('邀请项目')" prop="projectInvite">
<RadioGroup v-model="formDatum.project_invite">
<Radio label="open">{{$L('开启')}}</Radio>
<Radio label="close">{{$L('关闭')}}</Radio>
</RadioGroup>
<div v-if="formDatum.project_invite == 'open'" class="form-tip">{{$L('开启项目管理员可生成链接邀请成员加入项目')}}</div>
</FormItem>
<FormItem :label="$L('聊天昵称')" prop="chatNickname">
<RadioGroup v-model="formDatum.chat_nickname">
<Radio label="optional">{{$L('可选')}}</Radio>
@ -68,7 +83,7 @@ export default {
systemSetting(save) {
this.loadIng++;
this.$store.dispatch("call", {
url: 'system/setting?type=' + (save ? 'save' : 'get'),
url: 'system/setting?type=' + (save ? 'save' : 'all'),
data: this.formDatum,
}).then(({data}) => {
if (save) {

View File

@ -58,6 +58,11 @@ export default [
},
]
},
{
name: 'manage-project-invite',
path: 'project/invite',
component: () => import('./pages/manage/projectInvite.vue'),
},
{
name: 'manage-project',
path: 'project/:id',

View File

@ -1,5 +1,8 @@
body {
overflow: hidden;
.form-tip {
color: #999999;
}
.ivu-input,
.ivu-select-selection {
border-color: #e8e8e8;

View File

@ -106,9 +106,6 @@
.ivu-form {
overflow: auto;
}
.form-tip {
color: #999999;
}
.setting-color {
min-width: 400px;
max-width: 600px;