feat(team): 团队管理支持标记成员邮箱认证状态

- 成员行操作菜单新增「标记邮箱为已认证/未认证」,复用 users.email_verity
  字段与 api/users/operation 接口,新增 setverity/clearverity 操作类型
- 创建用户:邮箱下方新增「标记邮箱为已认证」复选框(默认勾选),
  「首次登录需改密」复选框移到初始密码下方
- 批量导入:预览列表邮箱右侧显示主题色已认证图标(错误行不显示),
  支持勾选行后批量标记已/未认证;部门与认证批量行加标签对齐、
  三个批量按钮样式随选中状态统一
- createByAdmin 新增 emailVerity 选项,createuser/import 透传逐行认证状态
- 新增导入预览默认认证状态单测

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-06-02 05:49:33 +00:00
parent 08ed396444
commit 8c809bbff1
7 changed files with 143 additions and 14 deletions

View File

@ -1094,6 +1094,8 @@ class UsersController extends AbstractController
* - clearadmin 取消管理员
* - settemp 设为临时帐号
* - cleartemp 取消临时身份(取消临时帐号)
* - setverity 标记邮箱为已认证
* - clearverity 标记邮箱为未认证
* - checkin_macs 修改自动签到mac地址需要参数 checkin_macs
* - checkin_face 修改签到人脸图片(需要参数 checkin_face
* - department 修改部门(需要参数 department
@ -1157,6 +1159,16 @@ class UsersController extends AbstractController
$upArray['identity'] = array_diff($userInfo->identity, ['temp']);
break;
case 'setverity':
$msg = '设置成功';
$upArray['email_verity'] = 1;
break;
case 'clearverity':
$msg = '取消成功';
$upArray['email_verity'] = 0;
break;
case 'checkin_macs':
$list = is_array($data['checkin_macs']) ? $data['checkin_macs'] : [];
$array = [];
@ -1354,6 +1366,7 @@ class UsersController extends AbstractController
* @apiParam {String} email 邮箱
* @apiParam {String} password 初始密码
* @apiParam {String} nickname 昵称
* @apiParam {Number} [email_verity] 是否标记邮箱为已认证1是、0否默认1
* @apiParam {String} [profession] 职位/职称可选2-20字)
* @apiParam {Array} [department] 部门ID列表可选最多10个
*/
@ -1364,10 +1377,12 @@ class UsersController extends AbstractController
$password = trim(Request::input('password'));
$nickname = trim(Request::input('nickname'));
$changePass = intval(Request::input('changepass', 1)) === 1;
$emailVerity = intval(Request::input('email_verity', 1)) === 1;
$profession = trim((string)Request::input('profession', ''));
$department = Request::input('department', []);
$user = User::createByAdmin($email, $password, $nickname, [
'changePass' => $changePass,
'emailVerity' => $emailVerity,
'profession' => $profession,
'department' => is_array($department) ? $department : [],
]);
@ -1405,7 +1420,7 @@ class UsersController extends AbstractController
/**
* @api {post} api/users/import 批量导入用户(管理员)
*
* @apiDescription 需要token身份管理员。提交预览确认后的行数据 rows每行 {email,nickname,password,profession},可选 department[])进行创建
* @apiDescription 需要token身份管理员。提交预览确认后的行数据 rows每行 {email,nickname,password,profession},可选 department[]、email_verity(1已认证/0未认证默认0))进行创建
* @apiVersion 1.0.0
* @apiGroup users
* @apiName import

View File

@ -432,7 +432,7 @@ class User extends AbstractModel
* @param string $email
* @param string $password
* @param string $nickname
* @param array $options changePass(bool,默认true) / department(int[]) / profession(string)
* @param array $options changePass(bool,默认true) / emailVerity(bool,默认false,标记邮箱已认证) / department(int[]) / profession(string)
* @return self
* @throws ApiException
*/
@ -443,6 +443,7 @@ class User extends AbstractModel
throw new ApiException('昵称需为2-20个字');
}
$changePass = ($options['changePass'] ?? true) ? 1 : 0;
$emailVerity = ($options['emailVerity'] ?? false) ? 1 : 0;
$profession = trim((string)($options['profession'] ?? ''));
// 校验前置reg 之前快速失败,且可在无 Swoole 环境单测)
self::assertValidProfession($profession);
@ -454,6 +455,7 @@ class User extends AbstractModel
$user->identity = Base::arrayImplode(array_diff($user->identity, ['temp']));
}
$user->changepass = $changePass; // 复用现有首登强制改密机制
$user->email_verity = $emailVerity; // 管理员可在创建时直接标记邮箱认证状态
if ($profession !== '') {
$user->profession = $profession;
}
@ -619,6 +621,7 @@ class User extends AbstractModel
try {
self::createByAdmin($row['email'], $row['password'], $row['nickname'], [
'changePass' => $changePass,
'emailVerity' => !empty($row['email_verity']),
'department' => $row['department'] ?? [],
'profession' => $row['profession'] ?? '',
]);
@ -692,6 +695,7 @@ class User extends AbstractModel
'nickname' => $row['nickname'] ?? '',
'password' => $row['password'] ?? '',
'profession' => $row['profession'] ?? '',
'email_verity' => 1, // 默认标记为已认证,前端可在预览中按行调整
'status' => $ok ? 'ok' : 'error',
'reason' => $reason ?? '',
];

View File

@ -1668,6 +1668,12 @@ WiFi签到延迟时长为±1分钟。
你确定将【(*)】设为管理员吗?
你确定取消【(*)】管理员身份吗?
你确定将【(*)】的邮箱标记为已认证吗?
你确定将【(*)】的邮箱标记为未认证吗?
标记邮箱为已认证
标记邮箱为未认证
标记选中(*)项为已认证
标记选中(*)项为未认证
你确定要取消任务时间吗?
更新子任务

View File

@ -7,12 +7,14 @@
<Form ref="form" :model="formData" :label-width="80" @submit.native.prevent>
<FormItem :label="$L('邮箱')" required>
<Input v-model="formData.email" :placeholder="$L('请输入邮箱')" clearable/>
<Checkbox v-model="formData.email_verity" style="margin-top:8px">{{$L('标记邮箱为已认证')}}</Checkbox>
</FormItem>
<FormItem :label="$L('昵称')" required>
<Input v-model="formData.nickname" :placeholder="$L('请输入昵称')" clearable/>
</FormItem>
<FormItem :label="$L('初始密码')" required>
<Input v-model="formData.password" type="password" password :placeholder="$L('请输入初始密码')" clearable/>
<Checkbox v-model="formData.changepass" style="margin-top:8px">{{$L('员工首次登录需修改密码')}}</Checkbox>
</FormItem>
<FormItem :label="$L('职位')">
<Input v-model="formData.profession" :maxlength="20" :placeholder="$L('请输入职位/职称')" clearable/>
@ -33,9 +35,6 @@
</Option>
</Select>
</FormItem>
<FormItem>
<Checkbox v-model="formData.changepass">{{$L('员工首次登录需修改密码')}}</Checkbox>
</FormItem>
</Form>
<div slot="footer">
<Button type="default" @click="show=false">{{$L('取消')}}</Button>
@ -61,14 +60,14 @@ export default {
return {
show: false,
loading: false,
formData: {email: '', nickname: '', password: '', changepass: true, profession: '', department: []},
formData: {email: '', nickname: '', password: '', changepass: true, email_verity: true, profession: '', department: []},
}
},
watch: {
value(val) {
this.show = val;
if (val) {
this.formData = {email: '', nickname: '', password: '', changepass: true, profession: '', department: []};
this.formData = {email: '', nickname: '', password: '', changepass: true, email_verity: true, profession: '', department: []};
}
},
show(val) {
@ -115,7 +114,7 @@ export default {
this.show = false;
},
onSubmit() {
const {email, nickname, password, changepass, profession, department} = this.formData;
const {email, nickname, password, changepass, email_verity, profession, department} = this.formData;
if (!email || !nickname || !password) {
$A.messageWarning('邮箱、昵称、初始密码均为必填');
return;
@ -123,7 +122,7 @@ export default {
this.loading = true;
this.$store.dispatch("call", {
url: 'users/createuser',
data: {email, nickname, password, changepass: changepass ? 1 : 0, profession, department},
data: {email, nickname, password, changepass: changepass ? 1 : 0, email_verity: email_verity ? 1 : 0, profession, department},
}).then(() => {
this.loading = false;
$A.messageSuccess('创建成功');

View File

@ -38,6 +38,7 @@
@on-selection-change="onSelectionChange"/>
<!-- 勾选行后批量设置部门错误行不可勾选 -->
<div class="import-setdept">
<span class="import-batch-label">{{$L('所属部门')}}</span>
<Select
v-model="setDepartmentIds"
multiple
@ -54,10 +55,20 @@
<div :class="`department-level-name level-${item.level - 1}`">{{ item.name }}</div>
</Option>
</Select>
<Button type="primary" :disabled="selectedRows.length === 0" @click="onApplyDepartment">
<Button :type="selectedRows.length === 0 ? 'default' : 'primary'" :disabled="selectedRows.length === 0" @click="onApplyDepartment">
{{$L('设置部门到选中(*)项', selectedRows.length)}}
</Button>
</div>
<!-- 勾选行后批量设置邮箱认证状态错误行不可勾选 -->
<div class="import-setverity">
<span class="import-batch-label">{{$L('邮箱认证')}}</span>
<Button :type="selectedRows.length === 0 ? 'default' : 'primary'" :disabled="selectedRows.length === 0" @click="onApplyVerity(1)">
{{$L('标记选中(*)项为已认证', selectedRows.length)}}
</Button>
<Button :type="selectedRows.length === 0 ? 'default' : 'primary'" :disabled="selectedRows.length === 0" @click="onApplyVerity(0)">
{{$L('标记选中(*)项为未认证', selectedRows.length)}}
</Button>
</div>
<div class="import-option">
<Checkbox v-model="changepass">{{$L('员工首次登录需修改密码')}}</Checkbox>
</div>
@ -117,7 +128,22 @@ export default {
previewColumns: [
{type: 'selection', width: 50, align: 'center'},
{title: this.$L('行号'), key: 'line', width: 64, align: 'center'},
{title: this.$L('邮箱'), minWidth: 150, render: (h, {row}) => h('AutoTip', row.email || '-')},
{
title: this.$L('邮箱'),
minWidth: 150,
render: (h, {row}) => {
// Table scoped / $primary-color
const arr = [h('AutoTip', {style: {minWidth: '50px'}}, row.email || '-')];
if (row.email_verity && row.status === 'ok') {
arr.push(h('Icon', {
props: {type: 'md-mail'},
attrs: {title: this.$L('已邮箱认证')},
style: {color: '#84C56A', marginLeft: '6px', fontSize: '16px', flexShrink: 0},
}));
}
return h('div', {style: {display: 'flex', alignItems: 'center'}}, arr);
}
},
{title: this.$L('昵称'), width: 90, render: (h, {row}) => h('AutoTip', row.nickname || '-')},
{
title: this.$L('初始密码'),
@ -254,6 +280,7 @@ export default {
const data = res.data;
(data.rows || []).forEach(row => {
this.$set(row, 'department', []); //
this.$set(row, 'email_verity', row.email_verity ? 1 : 0); //
if (row.status !== 'ok') {
this.$set(row, '_disabled', true); //
}
@ -280,19 +307,32 @@ export default {
}
});
},
onApplyVerity(verity) {
if (this.selectedRows.length === 0) {
return;
}
// onApplyDepartment line preview.rows
const selectedLines = new Set(this.selectedRows.map(row => row.line));
(this.preview && this.preview.rows ? this.preview.rows : []).forEach(row => {
if (selectedLines.has(row.line)) {
this.$set(row, 'email_verity', verity ? 1 : 0);
}
});
},
onConfirmImport() {
if (!this.preview || this.preview.valid === 0) {
return;
}
const rows = this.preview.rows
.filter(row => row.status === 'ok')
.map(({line, email, nickname, password, profession, department}) => ({
.map(({line, email, nickname, password, profession, department, email_verity}) => ({
line,
email,
nickname,
password,
profession: profession || '',
department: Array.isArray(department) ? department : [],
email_verity: email_verity ? 1 : 0,
}));
this.importing = true;
this.$store.dispatch("call", {
@ -336,15 +376,26 @@ export default {
.import-tip { color: #808695; margin-bottom: 12px; }
.import-actions { display: flex; gap: 12px; align-items: center; }
.import-option { margin-top: 12px; }
.import-batch-label {
flex-shrink: 0;
min-width: 64px;
color: #515a6e;
}
.import-setdept {
display: flex;
align-items: flex-start;
align-items: center;
gap: 8px;
margin-top: 12px;
.import-setdept-select {
width: auto;
}
}
.import-setverity {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.import-preview { margin-top: 16px; }
.import-result { margin-top: 16px; }

View File

@ -538,7 +538,7 @@ export default {
align: 'center',
width: 100,
render: (h, params) => {
const identity = params.row.identity;
const {identity, email_verity} = params.row;
const dropdownItems = [];
dropdownItems.push(h('EDropdownItem', {
props: {
@ -573,6 +573,20 @@ export default {
},
}, [h('div', this.$L('设为临时帐号'))]));
}
//
if (email_verity) {
dropdownItems.push(h('EDropdownItem', {
props: {
command: 'clearverity',
},
}, [h('div', this.$L('标记邮箱为未认证'))]));
} else {
dropdownItems.push(h('EDropdownItem', {
props: {
command: 'setverity',
},
}, [h('div', this.$L('标记邮箱为已认证'))]));
}
//
dropdownItems.push(h('EDropdownItem', {
props: {
@ -961,6 +975,32 @@ export default {
});
break;
case 'setverity':
$A.modalConfirm({
content: `你确定将【ID:${row.userid}, ${row.nickname}】的邮箱标记为已认证吗?`,
loading: true,
onOk: () => {
return this.operationUser({
userid: row.userid,
type: name
});
}
});
break;
case 'clearverity':
$A.modalConfirm({
content: `你确定将【ID:${row.userid}, ${row.nickname}】的邮箱标记为未认证吗?`,
loading: true,
onOk: () => {
return this.operationUser({
userid: row.userid,
type: name
});
}
});
break;
case 'setdisable':
this.disableData = {
type: 'setdisable',

View File

@ -141,6 +141,20 @@ class AdminCreateUserTest extends TestCase
$this->assertSame(0, User::whereEmail('newp1@test.local')->count());
}
public function test_import_preview_defaults_email_verity_to_verified()
{
// 预览默认逐行标记为已认证email_verity=1前端可再按行调整
$rows = [
['line' => 2, 'email' => 'verity1@test.local', 'nickname' => '张三', 'password' => 'Abc123456'],
['line' => 3, 'email' => 'bad', 'nickname' => '李四', 'password' => 'Abc123456'], // 错误行同样带默认值
];
$preview = User::importPreview($rows);
$this->assertSame(1, $preview['rows'][0]['email_verity']);
$this->assertSame(1, $preview['rows'][1]['email_verity']);
}
public function test_import_collects_all_invalid_rows_without_creating()
{
// 全部非法 → 不触发 createByAdmin/SO可在无 Swoole 环境稳定运行