feat(manage): 团队管理支持管理员创建/批量导入员工账号(含部门、职位)

单个创建:邮箱/昵称/初始密码 + 可选首登改密、职位、部门(多选,选子部门自动补选上级,并加入对应部门群)。

批量导入:上传 Excel/CSV → 预览逐行校验 → 确认后导入。职位为模板第4列(选填,逐行解析校验),部门在预览表按行勾选后由底部设置部门到选中写入;导入按行返回结果(全成功关弹窗+成功提示;含失败留弹窗显示失败明细;仅 success>0 才刷新列表)。

后端:User::createByAdmin 选项数组化 + 校验助手 assertValidProfession/assertValidDepartments;importUsers 逐行 department/profession;UsersController createuser/import;UserImport/UserImportTemplate(含职位列)。

测试:tests/Feature/AdminCreateUserTest、tests/Unit/UserImportParseTest。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-06-01 01:26:34 +00:00
parent aa2e0acaba
commit 20b5daba50
12 changed files with 1297 additions and 2 deletions

View File

@ -41,6 +41,9 @@ use Illuminate\Support\Facades\DB;
use App\Models\UserEmailVerification;
use App\Module\AgoraIO\AgoraTokenGenerator;
use Swoole\Coroutine;
use App\Module\UserImport;
use App\Module\UserImportTemplate;
use Maatwebsite\Excel\Facades\Excel;
/**
* @apiDefine users
@ -1263,7 +1266,7 @@ class UsersController extends AbstractController
User::passwordPolicy($password);
$upArray['encrypt'] = Base::generatePassword(6);
$upArray['password'] = Doo::md5s($password, $upArray['encrypt']);
$upArray['changepass'] = 1;
$upArray['changepass'] = intval($data['changepass'] ?? 1) === 1 ? 1 : 0;
$upLdap['userPassword'] = $password;
}
// 昵称
@ -1340,6 +1343,98 @@ class UsersController extends AbstractController
return Base::retSuccess($msg, $userInfo);
}
/**
* @api {post} api/users/createuser 创建用户(管理员)
*
* @apiDescription 需要token身份管理员
* @apiVersion 1.0.0
* @apiGroup users
* @apiName createuser
*
* @apiParam {String} email 邮箱
* @apiParam {String} password 初始密码
* @apiParam {String} nickname 昵称
* @apiParam {String} [profession] 职位/职称可选2-20字)
* @apiParam {Array} [department] 部门ID列表可选最多10个
*/
public function createuser()
{
User::auth('admin');
$email = trim(Request::input('email'));
$password = trim(Request::input('password'));
$nickname = trim(Request::input('nickname'));
$changePass = intval(Request::input('changepass', 1)) === 1;
$profession = trim((string)Request::input('profession', ''));
$department = Request::input('department', []);
$user = User::createByAdmin($email, $password, $nickname, [
'changePass' => $changePass,
'profession' => $profession,
'department' => is_array($department) ? $department : [],
]);
return Base::retSuccess('创建成功', $user);
}
/**
* @api {post} api/users/import/preview 批量导入预览(管理员)
*
* @apiDescription 需要token身份管理员。上传 Excel/CSV列顺序邮箱、昵称、初始密码、职位(选填)),仅解析+校验、不创建账号
* @apiVersion 1.0.0
* @apiGroup users
* @apiName import__preview
*/
public function import__preview()
{
User::auth('admin');
$file = Request::file('file');
if (empty($file)) {
return Base::retError('请选择文件');
}
$ext = strtolower($file->getClientOriginalExtension());
if (!in_array($ext, ['xls', 'xlsx', 'csv'])) {
return Base::retError('仅支持 xls/xlsx/csv 文件');
}
$sheets = Excel::toArray(new UserImport, $file);
$sheet = $sheets[0] ?? [];
$rows = User::parseImportRows($sheet);
if (empty($rows)) {
return Base::retError('文件中没有可导入的数据');
}
return Base::retSuccess('解析完成', User::importPreview($rows));
}
/**
* @api {post} api/users/import 批量导入用户(管理员)
*
* @apiDescription 需要token身份管理员。提交预览确认后的行数据 rows每行 {email,nickname,password,profession},可选 department[])进行创建
* @apiVersion 1.0.0
* @apiGroup users
* @apiName import
*/
public function import()
{
User::auth('admin');
$rows = Request::input('rows');
if (!is_array($rows) || empty($rows)) {
return Base::retError('没有可导入的数据');
}
$changePass = intval(Request::input('changepass', 1)) === 1;
$result = User::importUsers($rows, $changePass);
return Base::retSuccess('导入完成', $result);
}
/**
* @api {get} api/users/import/template 下载批量导入模板(管理员)
*
* @apiVersion 1.0.0
* @apiGroup users
* @apiName import__template
*/
public function import__template()
{
User::auth('admin');
return Excel::download(new UserImportTemplate, 'user_import_template.xlsx');
}
/**
* @api {get} api/users/email/verification 邮箱验证
*

View File

@ -89,6 +89,8 @@ use Carbon\Carbon;
*/
class User extends AbstractModel
{
const IMPORT_MAX = 500;
protected $primaryKey = 'userid';
protected $hidden = [
@ -425,6 +427,283 @@ class User extends AbstractModel
return $createdUser;
}
/**
* 管理员创建员工账号(复用注册逻辑,强制正式身份,可选首登改密 / 部门 / 职位)
* @param string $email
* @param string $password
* @param string $nickname
* @param array $options changePass(bool,默认true) / department(int[]) / profession(string)
* @return self
* @throws ApiException
*/
public static function createByAdmin(string $email, $password, string $nickname, array $options = []): self
{
$nickname = trim($nickname);
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
throw new ApiException('昵称需为2-20个字');
}
$changePass = ($options['changePass'] ?? true) ? 1 : 0;
$profession = trim((string)($options['profession'] ?? ''));
// 校验前置reg 之前快速失败,且可在无 Swoole 环境单测)
self::assertValidProfession($profession);
$departmentIds = self::assertValidDepartments($options['department'] ?? []);
// 复用 reg邮箱校验/查重、passwordPolicy、Doo::userCreate、az/pinyin、全员群、索引同步、user_onboard hook
$user = self::reg($email, $password, ['nickname' => $nickname]);
// 管理员显式创建的账号视为正式员工,去除系统 reg_identity 可能带上的 temp
if (in_array('temp', $user->identity)) {
$user->identity = Base::arrayImplode(array_diff($user->identity, ['temp']));
}
$user->changepass = $changePass; // 复用现有首登强制改密机制
if ($profession !== '') {
$user->profession = $profession;
}
if ($departmentIds) {
$user->department = Base::arrayImplode($departmentIds);
}
$user->save();
// 设置了部门 → 加入对应部门群(复刻 operation 的 type=department 入群逻辑)
if ($departmentIds) {
$departments = UserDepartment::whereIn('id', $departmentIds)->get();
foreach ($departments as $department) {
try {
if ($department->dialog_id > 0 && $dialog = WebSocketDialog::find($department->dialog_id)) {
$dialog->joinGroup([$user->userid], 0, true);
$dialog->pushMsg("groupJoin", null, [$user->userid]);
}
} catch (\Throwable $e) {
// 部门入群为尽力投递:单个部门失败不影响账号创建与其他部门
\Log::warning('createByAdmin: 部门入群失败', [
'userid' => $user->userid,
'department_id' => $department->id,
'error' => $e->getMessage(),
]);
}
}
}
return $user;
}
/**
* 将上传表格Excel::toArray 的二维数组)归一化为导入行
* @param array $sheet
* @return array [{line, email, nickname, password}]
*/
public static function parseImportRows(array $sheet): array
{
$rows = [];
foreach ($sheet as $index => $cells) {
if ($index === 0) {
continue; // 表头
}
$email = trim((string)($cells[0] ?? ''));
$nickname = trim((string)($cells[1] ?? ''));
$password = trim((string)($cells[2] ?? ''));
$profession = trim((string)($cells[3] ?? ''));
if ($email === '' && $nickname === '' && $password === '') {
continue; // 空行(仅职位有值也视为空行跳过)
}
$rows[] = [
'line' => $index + 1, // 电子表格行号(从 1 开始)
'email' => $email,
'nickname' => $nickname,
'password' => $password,
'profession' => $profession,
];
}
return $rows;
}
/**
* 校验单条导入行
* @param array $row ['email'=>,'nickname'=>,'password'=>,'profession'=>(选填)]
* @return string|null 错误文案null 表示通过
*/
public static function validateImportRow(array $row): ?string
{
$email = trim((string)($row['email'] ?? ''));
$nickname = trim((string)($row['nickname'] ?? ''));
$password = trim((string)($row['password'] ?? ''));
if ($email === '' || $nickname === '' || $password === '') {
return '邮箱、昵称、初始密码均为必填';
}
if (!Base::isEmail($email)) {
return '邮箱格式不正确';
}
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
return '昵称需为2-20个字';
}
try {
self::passwordPolicy($password);
} catch (ApiException $e) {
return $e->getMessage();
}
// 职位/职称选填,填写则校验 2-20 字
try {
self::assertValidProfession((string)($row['profession'] ?? ''));
} catch (ApiException $e) {
return $e->getMessage();
}
return null;
}
/**
* 校验职位/职称:非空时必须 2-20 字(复用 operation 的现有文案)
* @param string $profession
* @return void
* @throws ApiException
*/
public static function assertValidProfession(string $profession): void
{
$profession = trim($profession);
if ($profession === '') {
return;
}
if (mb_strlen($profession) < 2) {
throw new ApiException('职位/职称不可以少于2个字');
}
if (mb_strlen($profession) > 20) {
throw new ApiException('职位/职称最多只能设置20个字');
}
}
/**
* 规整并校验部门 ID 列表:转正整数去重、最多 10 个、且每个必须存在
* @param mixed $ids
* @return int[]
* @throws ApiException
*/
public static function assertValidDepartments($ids): array
{
if (!is_array($ids)) {
$ids = [];
}
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
if (count($ids) > 10) {
throw new ApiException('最多只可加入10个部门');
}
if ($ids) {
$existing = UserDepartment::whereIn('id', $ids)->pluck('id')->map(fn($v) => (int)$v)->all();
if (count($existing) < count($ids)) {
throw new ApiException('修改部门不存在');
}
}
return $ids;
}
/**
* 批量导入用户(部门/职位逐行department 来自前端逐行设置profession 来自 Excel 行)
* @param array $rows 每行含 email/nickname/password/profession可选 department(int[])
* @param bool $changePass 是否要求首登改密(对本批所有账号生效)
* @return array ['total'=>int, 'success'=>int, 'failed'=>[['line','email','reason']]]
* @throws ApiException 行数超限
*/
public static function importUsers(array $rows, bool $changePass = true): array
{
if (count($rows) > self::IMPORT_MAX) {
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
}
$success = 0;
$failed = [];
$seen = [];
foreach ($rows as $row) {
$error = self::validateImportRow($row);
if ($error === null) {
$emailLower = strtolower(trim((string)$row['email']));
if (isset($seen[$emailLower])) {
$error = '文件内邮箱重复';
} else {
$seen[$emailLower] = true;
}
}
if ($error === null) {
try {
self::createByAdmin($row['email'], $row['password'], $row['nickname'], [
'changePass' => $changePass,
'department' => $row['department'] ?? [],
'profession' => $row['profession'] ?? '',
]);
$success++;
continue;
} catch (ApiException $e) {
$error = $e->getMessage();
}
}
$failed[] = [
'line' => $row['line'] ?? 0,
'email' => $row['email'] ?? '',
'reason' => $error,
];
}
return [
'total' => count($rows),
'success' => $success,
'failed' => $failed,
];
}
/**
* 批量导入预览(只解析+校验,不创建任何账号)
* 逐行判定 ok/error必填/邮箱格式/昵称长度/密码策略、文件内邮箱重复、系统中邮箱已存在
* @param array $rows parseImportRows 的输出
* @return array ['total'=>int,'valid'=>int,'invalid'=>int,'rows'=>[['line','email','nickname','password','status','reason']]]
*/
public static function importPreview(array $rows): array
{
if (count($rows) > self::IMPORT_MAX) {
throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条');
}
// 预查系统中已存在的邮箱(小写比较)
$emails = [];
foreach ($rows as $row) {
$e = strtolower(trim((string)($row['email'] ?? '')));
if ($e !== '') {
$emails[$e] = true;
}
}
$existing = [];
if ($emails) {
foreach (self::whereIn('email', array_keys($emails))->pluck('email') as $em) {
$existing[strtolower($em)] = true;
}
}
$seen = [];
$valid = 0;
$list = [];
foreach ($rows as $row) {
$reason = self::validateImportRow($row);
$emailLower = strtolower(trim((string)($row['email'] ?? '')));
if ($reason === null) {
if (isset($seen[$emailLower])) {
$reason = '文件内邮箱重复';
} else {
$seen[$emailLower] = true;
if (isset($existing[$emailLower])) {
$reason = '邮箱地址已存在';
}
}
}
$ok = $reason === null;
if ($ok) {
$valid++;
}
$list[] = [
'line' => $row['line'] ?? 0,
'email' => $row['email'] ?? '',
'nickname' => $row['nickname'] ?? '',
'password' => $row['password'] ?? '',
'profession' => $row['profession'] ?? '',
'status' => $ok ? 'ok' : 'error',
'reason' => $reason ?? '',
];
}
return [
'total' => count($rows),
'valid' => $valid,
'invalid' => count($rows) - $valid,
'rows' => $list,
];
}
/**
* 获取我的ID
* @return int

13
app/Module/UserImport.php Normal file
View File

@ -0,0 +1,13 @@
<?php
namespace App\Module;
use Maatwebsite\Excel\Concerns\ToArray;
class UserImport implements ToArray
{
public function array(array $array)
{
return $array;
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Module;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithHeadings;
class UserImportTemplate implements FromArray, WithHeadings
{
public function array(): array
{
return [
['employee@example.com', '张三', 'Abc123456', '工程师'],
];
}
public function headings(): array
{
return ['邮箱(必填)', '昵称(必填,2-20字)', '初始密码(必填,6-32位)', '职位(选填,2-20字)'];
}
}

View File

@ -977,3 +977,14 @@ LDAP 用户缺少邮箱属性,请联系管理员配置
普通成员不能移出群主或群管理员
只有群主、群管理员或邀请人可以移出成员
仅群主、项目/任务负责人可设置或取消他人待办
请选择文件
仅支持 xls/xlsx/csv 文件
文件中没有可导入的数据
导入完成
昵称需为2-20个字
邮箱、昵称、初始密码均为必填
邮箱格式不正确
文件内邮箱重复
单次最多导入500条
没有可导入的数据
解析完成

View File

@ -2396,3 +2396,31 @@ AI任务分析
待办设置权限
允许:所有成员可设置/取消他人待办。
禁止:仅本人、群主(含群管理员)、项目负责人(含项目管理员)、任务负责人可设置/取消待办。
批量导入用户
请按模板填写后上传,列顺序:邮箱、昵称、初始密码、职位(选填)单次最多导入500条。
下载模板
导入结果:共(*)条,成功(*)条,失败(*)条
行号
失败原因
导入失败
仅支持 xls/xlsx/csv 文件
创建用户
批量导入
初始密码
请输入邮箱
请输入初始密码
员工首次登录需修改密码
邮箱、昵称、初始密码均为必填
员工下次登录需修改密码
重新选择文件
共(*)条 · 可导入(*)条 · 错误(*)条
点击查看明文
原因
可导入
错误
确定导入(*)条
解析失败
设置部门到选中(*)项
成功导入(*)条

View File

@ -0,0 +1,143 @@
<template>
<Modal
v-model="show"
:title="$L('创建用户')"
:mask-closable="false"
@on-cancel="onCancel">
<Form ref="form" :model="formData" :label-width="80" @submit.native.prevent>
<FormItem :label="$L('邮箱')" required>
<Input v-model="formData.email" :placeholder="$L('请输入邮箱')" clearable/>
</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/>
</FormItem>
<FormItem :label="$L('职位')">
<Input v-model="formData.profession" :maxlength="20" :placeholder="$L('请输入职位/职称')" clearable/>
</FormItem>
<FormItem :label="$L('所属部门')">
<Select
v-model="formData.department"
multiple
:multiple-max="10"
:multiple-max-before="onMultipleMaxBefore"
:placeholder="$L('留空为默认部门')">
<Option
v-for="(item, index) in departmentList"
:value="item.id"
:key="index"
:label="item.chains.join(' - ')">
<div :class="`department-level-name level-${item.level - 1}`">{{ item.name }}</div>
</Option>
</Select>
</FormItem>
<FormItem>
<Checkbox v-model="formData.changepass">{{$L('员工首次登录需修改密码')}}</Checkbox>
</FormItem>
</Form>
<div slot="footer">
<Button type="default" @click="show=false">{{$L('取消')}}</Button>
<Button type="primary" :loading="loading" @click="onSubmit">{{$L('创建')}}</Button>
</div>
</Modal>
</template>
<script>
export default {
name: 'CreateUserModal',
props: {
value: {
type: Boolean,
default: false
},
departmentList: {
type: Array,
default: () => []
}
},
data() {
return {
show: false,
loading: false,
formData: {email: '', nickname: '', password: '', changepass: true, profession: '', department: []},
}
},
watch: {
value(val) {
this.show = val;
if (val) {
this.formData = {email: '', nickname: '', password: '', changepass: true, profession: '', department: []};
}
},
show(val) {
this.$emit('input', val);
},
// UserEditModal
'formData.department': {
handler(value, oldValue = []) {
if (!Array.isArray(value) || value.length === 0 || this.departmentList.length === 0) {
return;
}
const previous = Array.isArray(oldValue) ? new Set(oldValue) : new Set();
const selected = new Set(value);
const hasNewSelection = Array.from(selected).some(id => !previous.has(id));
if (!hasNewSelection) {
return;
}
const departmentMap = this.departmentList.reduce((acc, item) => {
acc[item.id] = item;
return acc;
}, {});
const needAdd = new Set();
value.forEach((id) => {
let cursor = departmentMap[id];
while (cursor && cursor.parent_id && cursor.parent_id > 0) {
if (!selected.has(cursor.parent_id)) {
needAdd.add(cursor.parent_id);
}
cursor = departmentMap[cursor.parent_id];
}
});
if (needAdd.size > 0) {
const merged = Array.from(new Set([...value, ...needAdd])).sort((a, b) => a - b);
if (merged.length !== value.length || merged.some((id, index) => id !== value[index])) {
this.$set(this.formData, 'department', merged);
}
}
},
deep: true
}
},
methods: {
onCancel() {
this.show = false;
},
onSubmit() {
const {email, nickname, password, changepass, profession, department} = this.formData;
if (!email || !nickname || !password) {
$A.messageWarning('邮箱、昵称、初始密码均为必填');
return;
}
this.loading = true;
this.$store.dispatch("call", {
url: 'users/createuser',
data: {email, nickname, password, changepass: changepass ? 1 : 0, profession, department},
}).then(() => {
this.loading = false;
$A.messageSuccess('创建成功');
this.show = false;
this.$emit('created');
}).catch(({msg}) => {
this.loading = false;
$A.modalError(msg);
});
},
onMultipleMaxBefore(num) {
$A.messageError(this.$L('最多选择(*)个部门', num));
return false;
},
}
}
</script>

View File

@ -0,0 +1,365 @@
<template>
<Modal
v-model="show"
:title="$L('批量导入用户')"
:mask-closable="false"
:width="(preview || result) ? 900 : 520">
<div class="import-user-modal">
<div class="import-tip">
{{$L('请按模板填写后上传,列顺序:邮箱、昵称、初始密码、职位(选填)单次最多导入500条。')}}
</div>
<div class="import-actions">
<Button type="default" icon="md-download" @click="onDownloadTemplate">{{$L('下载模板')}}</Button>
<Upload
name="file"
ref="upload"
:action="previewUrl"
:headers="headers"
:format="['xls','xlsx','csv']"
:show-upload-list="false"
:before-upload="handleBeforeUpload"
:on-success="handlePreviewSuccess"
:on-error="handleError"
:on-format-error="handleFormatError">
<Button type="primary" icon="md-cloud-upload" :loading="uploading">{{$L(preview ? '重新选择文件' : '上传文件')}}</Button>
</Upload>
</div>
<!-- 预览点击确定后才真正导入 -->
<div v-if="preview && !result" class="import-preview">
<Alert :type="preview.valid > 0 ? 'success' : 'error'" show-icon>
{{$L('共(*)条 · 可导入(*)条 · 错误(*)条', preview.total, preview.valid, preview.invalid)}}
</Alert>
<Table
:columns="previewColumns"
:data="preview.rows"
:row-class-name="rowClassName"
size="small"
max-height="320"
@on-selection-change="onSelectionChange"/>
<!-- 勾选行后批量设置部门错误行不可勾选 -->
<div class="import-setdept">
<Select
v-model="setDepartmentIds"
multiple
:disabled="selectedRows.length === 0"
:multiple-max="10"
:multiple-max-before="onMultipleMaxBefore"
:placeholder="$L('选择部门')"
class="import-setdept-select">
<Option
v-for="(item, index) in departmentList"
:value="item.id"
:key="index"
:label="item.chains.join(' - ')">
<div :class="`department-level-name level-${item.level - 1}`">{{ item.name }}</div>
</Option>
</Select>
<Button type="primary" :disabled="selectedRows.length === 0" @click="onApplyDepartment">
{{$L('设置部门到选中(*)项', selectedRows.length)}}
</Button>
</div>
<div class="import-option">
<Checkbox v-model="changepass">{{$L('员工首次登录需修改密码')}}</Checkbox>
</div>
</div>
<!-- 导入结果 -->
<div v-if="result" class="import-result">
<Alert :type="result.failed.length ? 'warning' : 'success'" show-icon>
{{$L('导入结果:共(*)条,成功(*)条,失败(*)条', result.total, result.success, result.failed.length)}}
</Alert>
<Table
v-if="result.failed.length"
:columns="failedColumns"
:data="result.failed"
size="small"
max-height="260"/>
</div>
</div>
<div slot="footer">
<Button type="default" @click="show=false">{{$L('关闭')}}</Button>
<Button
v-if="preview && !result"
type="primary"
:loading="importing"
:disabled="preview.valid === 0"
@click="onConfirmImport">
{{$L('确定导入(*)条', preview.valid)}}
</Button>
</div>
</Modal>
</template>
<script>
export default {
name: 'ImportUserModal',
props: {
value: {
type: Boolean,
default: false
},
departmentList: {
type: Array,
default: () => []
}
},
data() {
return {
show: false,
uploading: false,
importing: false,
changepass: true,
setDepartmentIds: [],
selectedRows: [],
preview: null,
result: null,
previewUrl: $A.apiUrl('users/import/preview'),
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('昵称'), width: 90, render: (h, {row}) => h('AutoTip', row.nickname || '-')},
{
title: this.$L('初始密码'),
width: 110,
render: (h, {row}) => {
return h('AutoTip', {
class: 'pwd-cell',
attrs: {title: this.$L('点击查看明文')},
on: {'on-click': () => this.$set(row, '_showPwd', !row._showPwd)}
}, row._showPwd ? (row.password || '') : '••••••');
}
},
{title: this.$L('职位'), width: 90, render: (h, {row}) => h('AutoTip', row.profession || '-')},
{
title: this.$L('部门'),
minWidth: 120,
render: (h, {row}) => h('AutoTip', this.departmentNames(row.department)),
},
{
title: this.$L('状态'),
width: 80,
align: 'center',
render: (h, {row}) => {
const ok = row.status === 'ok';
return h('Tag', {props: {color: ok ? 'success' : 'error'}}, ok ? this.$L('可导入') : this.$L('错误'));
}
},
{
title: this.$L('原因'),
minWidth: 120,
render: (h, {row}) => h('AutoTip', row.reason ? row.reason : '-'),
},
],
failedColumns: [
{title: this.$L('行号'), key: 'line', width: 80},
{title: this.$L('邮箱'), minWidth: 160, render: (h, {row}) => h('AutoTip', row.email || '-')},
{title: this.$L('失败原因'), minWidth: 140, render: (h, {row}) => h('AutoTip', row.reason || '-')},
],
}
},
computed: {
// userToken mixins/state.js Vue.mixin
headers() {
return {token: this.userToken};
},
departmentNameMap() {
return this.departmentList.reduce((acc, item) => {
acc[item.id] = item.name;
return acc;
}, {});
}
},
watch: {
value(val) {
this.show = val;
if (val) {
this.resetState();
}
},
show(val) {
this.$emit('input', val);
},
// /
setDepartmentIds: {
handler(value, oldValue = []) {
if (!Array.isArray(value) || value.length === 0 || this.departmentList.length === 0) {
return;
}
const previous = Array.isArray(oldValue) ? new Set(oldValue) : new Set();
const selected = new Set(value);
const hasNewSelection = Array.from(selected).some(id => !previous.has(id));
if (!hasNewSelection) {
return;
}
const departmentMap = this.departmentList.reduce((acc, item) => {
acc[item.id] = item;
return acc;
}, {});
const needAdd = new Set();
value.forEach((id) => {
let cursor = departmentMap[id];
while (cursor && cursor.parent_id && cursor.parent_id > 0) {
if (!selected.has(cursor.parent_id)) {
needAdd.add(cursor.parent_id);
}
cursor = departmentMap[cursor.parent_id];
}
});
if (needAdd.size > 0) {
const merged = Array.from(new Set([...value, ...needAdd])).sort((a, b) => a - b);
if (merged.length !== value.length || merged.some((id, index) => id !== value[index])) {
this.setDepartmentIds = merged;
}
}
},
deep: true
}
},
methods: {
resetState() {
this.uploading = false;
this.importing = false;
this.changepass = true;
this.setDepartmentIds = [];
this.selectedRows = [];
this.preview = null;
this.result = null;
},
rowClassName(row) {
return row.status === 'ok' ? '' : 'import-row-error';
},
departmentNames(ids) {
if (!Array.isArray(ids) || ids.length === 0) {
return '-';
}
const map = this.departmentNameMap;
const names = ids.map(id => map[id]).filter(Boolean);
return names.length ? names.join(', ') : '-';
},
onDownloadTemplate() {
window.open($A.apiUrl('users/import/template?token=' + this.userToken));
},
handleBeforeUpload() {
this.uploading = true;
this.preview = null;
this.result = null;
this.selectedRows = [];
this.setDepartmentIds = [];
return true;
},
handlePreviewSuccess(res) {
this.uploading = false;
if (res && res.ret === 1) {
const data = res.data;
(data.rows || []).forEach(row => {
this.$set(row, 'department', []); //
if (row.status !== 'ok') {
this.$set(row, '_disabled', true); //
}
});
this.preview = data;
} else {
// language:false msg
$A.modalError({content: (res && res.msg) || '解析失败', language: false});
}
},
onSelectionChange(selection) {
this.selectedRows = selection;
},
onApplyDepartment() {
if (this.selectedRows.length === 0) {
return;
}
const ids = [...this.setDepartmentIds];
// on-selection-change 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, 'department', ids);
}
});
},
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}) => ({
line,
email,
nickname,
password,
profession: profession || '',
department: Array.isArray(department) ? department : [],
}));
this.importing = true;
this.$store.dispatch("call", {
url: 'users/import',
data: {rows, changepass: this.changepass ? 1 : 0},
method: 'post',
}).then(({data}) => {
this.importing = false;
if (data.success > 0) {
this.$emit('imported'); //
}
if (data.failed && data.failed.length) {
this.result = data;
} else {
this.show = false;
$A.modalSuccess({content: this.$L('成功导入(*)条', data.success), language: false});
}
}).catch(({msg}) => {
this.importing = false;
$A.modalError(msg);
});
},
handleError() {
this.uploading = false;
$A.modalError('解析失败');
},
handleFormatError() {
this.uploading = false;
$A.modalWarning('仅支持 xls/xlsx/csv 文件');
},
onMultipleMaxBefore(num) {
$A.messageError(this.$L('最多选择(*)个部门', num));
return false;
}
}
}
</script>
<style lang="scss" scoped>
.import-user-modal {
.import-tip { color: #808695; margin-bottom: 12px; }
.import-actions { display: flex; gap: 12px; align-items: center; }
.import-option { margin-top: 12px; }
.import-setdept {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 12px;
.import-setdept-select {
width: auto;
}
}
.import-preview { margin-top: 16px; }
.import-result { margin-top: 16px; }
::v-deep .ivu-table-cell {
white-space: nowrap;
}
::v-deep .pwd-cell {
cursor: pointer;
letter-spacing: 1px;
user-select: none;
&:hover { color: #2d8cf0; }
}
::v-deep .import-row-error td {
background-color: #fff2f0;
}
}
</style>

View File

@ -169,6 +169,10 @@
@refresh="getLists"
@cancelFilter="keyIs=false"/>
</li>
<li class="search-button">
<Button type="primary" icon="md-person-add" @click="createUserShow=true">{{$L('创建用户')}}</Button>
<Button style="margin-left:8px" icon="md-cloud-upload" @click="importUserShow=true">{{$L('批量导入')}}</Button>
</li>
</ul>
</div>
<div class="table-page-box">
@ -269,6 +273,8 @@
:checkin-mode="checkinMode"
:department-list="departmentList"
@updated="getLists"/>
<CreateUserModal v-model="createUserShow" :department-list="departmentList" @created="getLists"/>
<ImportUserModal v-model="importUserShow" :department-list="departmentList" @imported="getLists"/>
<!--操作离职-->
<Modal
@ -319,11 +325,13 @@ import UserAvatarTip from "../../../components/UserAvatar/tip.vue";
import ResizeLine from "../../../components/ResizeLine.vue";
import SearchButton from "../../../components/SearchButton.vue";
import UserEditModal from "./UserEditModal.vue";
import CreateUserModal from "./CreateUserModal.vue";
import ImportUserModal from "./ImportUserModal.vue";
import {mapState} from "vuex";
export default {
name: "TeamManagement",
components: {SearchButton, ResizeLine, UserAvatarTip, UserSelect, UserEditModal},
components: {SearchButton, ResizeLine, UserAvatarTip, UserSelect, UserEditModal, CreateUserModal, ImportUserModal},
props: {
checkinMode: {
type: Boolean,
@ -644,6 +652,8 @@ export default {
userEditShow: false,
userEditData: {},
createUserShow: false,
importUserShow: false,
departmentWidth: $A.getStorageInt('management.departmentWidth', 239),

View File

@ -42,6 +42,9 @@
type="password"
password
:placeholder="$L('留空则不修改密码')"/>
<Checkbox v-if="formData.password" v-model="formData.changepass" style="margin-top:8px">
{{ $L('员工下次登录需修改密码') }}
</Checkbox>
</FormItem>
<FormItem :label="$L('所属部门')">
@ -177,6 +180,7 @@ export default {
profession: '',
email: '',
password: '',
changepass: true,
department: [],
introduction: '',
faceimg: [],
@ -249,6 +253,7 @@ export default {
profession: profession || '',
email: email || '',
password: '',
changepass: true,
department: Array.isArray(department)
? department.map(id => parseInt(id))
: [],
@ -377,6 +382,7 @@ export default {
}
if (this.formData.password) {
data.password = this.formData.password;
data.changepass = this.formData.changepass ? 1 : 0;
}
this.$store.dispatch("call", {
url: 'users/operation',

View File

@ -0,0 +1,187 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class AdminCreateUserTest extends TestCase
{
use DatabaseTransactions;
/** Swoole 运行时缺失/Task 不可用属环境性失败,与业务无关 */
private function isSwooleInfraFailure(\Throwable $e): bool
{
$msg = $e->getMessage();
return str_contains($msg, 'swoole')
|| str_contains($msg, 'Swoole')
|| str_contains($msg, 'AbstractData::__wakeup')
|| str_contains($msg, 'Undefined array key');
}
public function test_create_by_admin_sets_changepass_and_normal_identity()
{
// Doo::userCreate 内部会调用 DB::commit() 绕过 DatabaseTransactions 的事务回滚。
// 为保证幂等,先提交当前事务(让 DELETE 对 SO 的独立 DB 连接可见),
// 删除残留用户后再重新开启事务,使后续测试代码仍在事务保护下运行。
\DB::commit();
\DB::table('users')->where('email', 'newstaff@test.local')->delete();
\DB::beginTransaction();
try {
$user = User::createByAdmin('newstaff@test.local', 'Abc123456', '新员工');
} catch (\Throwable $e) {
if ($this->isSwooleInfraFailure($e)) {
$this->markTestSkipped('Swoole 运行时不可用createByAdmin 端到端无法验证:' . $e->getMessage());
}
throw $e;
}
$this->assertSame('newstaff@test.local', $user->email);
$this->assertSame('新员工', $user->nickname);
$this->assertSame(1, (int)$user->changepass, '首登应强制改密');
$this->assertNotContains('temp', $user->identity, '管理员创建账号应为正式身份');
$this->assertSame($user->password, \App\Module\Doo::md5s('Abc123456', $user->encrypt));
// 手动清理Doo::userCreate 的 DB::commit 已将创建的用户持久化DatabaseTransactions 无法回滚
\DB::table('users')->where('email', 'newstaff@test.local')->delete();
}
public function test_create_by_admin_can_skip_changepass()
{
// 同上:先提交事务让 DELETE 对 SO 独立连接可见,保证幂等
\DB::commit();
\DB::table('users')->where('email', 'nochg@test.local')->delete();
\DB::beginTransaction();
try {
$user = User::createByAdmin('nochg@test.local', 'Abc123456', '免改密', ['changePass' => false]);
} catch (\Throwable $e) {
if ($this->isSwooleInfraFailure($e)) {
$this->markTestSkipped('Swoole 运行时不可用createByAdmin 端到端无法验证:' . $e->getMessage());
}
throw $e;
}
$this->assertSame(0, (int)$user->changepass, 'changePass=false 时不应要求首登改密');
\DB::table('users')->where('email', 'nochg@test.local')->delete();
}
public function test_create_by_admin_rejects_bad_nickname()
{
$this->expectException(\App\Exceptions\ApiException::class);
User::createByAdmin('x@test.local', 'Abc123456', '王'); // 昵称不足 2 字,校验在 SO 之前
}
public function test_create_by_admin_rejects_bad_profession()
{
$this->expectException(\App\Exceptions\ApiException::class);
$this->expectExceptionMessage('职位/职称不可以少于2个字');
User::createByAdmin('p@test.local', 'Abc123456', '张三', ['profession' => 'A']);
}
public function test_create_by_admin_rejects_too_many_departments()
{
$this->expectException(\App\Exceptions\ApiException::class);
$this->expectExceptionMessage('最多只可加入10个部门');
User::createByAdmin('d@test.local', 'Abc123456', '张三', ['department' => range(1, 11)]);
}
public function test_create_by_admin_rejects_nonexistent_department()
{
$this->expectException(\App\Exceptions\ApiException::class);
$this->expectExceptionMessage('修改部门不存在');
User::createByAdmin('d2@test.local', 'Abc123456', '张三', ['department' => [999999]]);
}
public function test_import_rejects_over_limit()
{
$rows = [];
for ($i = 0; $i <= User::IMPORT_MAX; $i++) { // 501 行
$rows[] = ['line' => $i + 2, 'email' => "u{$i}@test.local", 'nickname' => "员工{$i}", 'password' => 'Abc123456'];
}
$this->expectException(\App\Exceptions\ApiException::class);
User::importUsers($rows);
}
public function test_import_preview_marks_existing_dup_and_invalid()
{
// 造一个已存在用户(直接 createInstance不走 SO
$existing = User::createInstance([
'email' => 'exists@test.local',
'nickname' => '老员工',
'userimg' => '',
'profession' => '',
'password' => md5('x'),
]);
$existing->save();
$rows = [
['line' => 2, 'email' => 'exists@test.local', 'nickname' => '张三', 'password' => 'Abc123456'], // 系统已存在
['line' => 3, 'email' => 'newp1@test.local', 'nickname' => '李四', 'password' => 'Abc123456'], // ok
['line' => 4, 'email' => 'newp1@test.local', 'nickname' => '王五', 'password' => 'Abc123456'], // 文件内重复
['line' => 5, 'email' => 'bad', 'nickname' => '赵六', 'password' => 'Abc123456'], // 邮箱格式
];
$preview = User::importPreview($rows);
$this->assertSame(4, $preview['total']);
$this->assertSame(1, $preview['valid']);
$this->assertSame(3, $preview['invalid']);
$this->assertSame('error', $preview['rows'][0]['status']);
$this->assertSame('邮箱地址已存在', $preview['rows'][0]['reason']);
$this->assertSame('ok', $preview['rows'][1]['status']);
$this->assertSame('error', $preview['rows'][2]['status']);
$this->assertSame('文件内邮箱重复', $preview['rows'][2]['reason']);
$this->assertSame('error', $preview['rows'][3]['status']);
$this->assertSame('邮箱格式不正确', $preview['rows'][3]['reason']);
// 预览不创建账号
$this->assertSame(0, User::whereEmail('newp1@test.local')->count());
}
public function test_import_collects_all_invalid_rows_without_creating()
{
// 全部非法 → 不触发 createByAdmin/SO可在无 Swoole 环境稳定运行
$rows = [
['line' => 2, 'email' => '', 'nickname' => '张三', 'password' => 'Abc123456'],
['line' => 3, 'email' => 'bad-email', 'nickname' => '李四', 'password' => 'Abc123456'],
['line' => 4, 'email' => 'c@test.local', 'nickname' => '王', 'password' => 'Abc123456'],
];
$result = User::importUsers($rows);
$this->assertSame(3, $result['total']);
$this->assertSame(0, $result['success']);
$this->assertCount(3, $result['failed']);
$this->assertSame(2, $result['failed'][0]['line']);
$this->assertSame('邮箱、昵称、初始密码均为必填', $result['failed'][0]['reason']);
$this->assertSame('邮箱格式不正确', $result['failed'][1]['reason']);
$this->assertSame('昵称需为2-20个字', $result['failed'][2]['reason']);
}
public function test_import_marks_row_with_bad_profession()
{
// 行内职位非法1字→ 该行被标记失败,不创建账号
$rows = [
['line' => 2, 'email' => 'badprof@test.local', 'nickname' => '张三', 'password' => 'Abc123456', 'profession' => 'A'],
];
$result = User::importUsers($rows, true);
$this->assertSame(0, $result['success']);
$this->assertCount(1, $result['failed']);
$this->assertSame('职位/职称不可以少于2个字', $result['failed'][0]['reason']);
}
public function test_import_marks_row_with_nonexistent_department()
{
// 行内部门不存在 → createByAdmin 在 reg() 之前抛异常 → 该行失败(无需 Swoole
$rows = [
['line' => 2, 'email' => 'baddept@test.local', 'nickname' => '张三', 'password' => 'Abc123456', 'profession' => '', 'department' => [999999]],
];
$result = User::importUsers($rows, true);
$this->assertSame(0, $result['success']);
$this->assertCount(1, $result['failed']);
$this->assertSame('修改部门不存在', $result['failed'][0]['reason']);
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace Tests\Unit;
use App\Models\User;
use Tests\TestCase;
class UserImportParseTest extends TestCase
{
public function test_parse_skips_header_and_empty_rows()
{
$sheet = [
['邮箱', '昵称', '初始密码'], // 表头,应跳过
['a@test.local', '张三', 'Abc123456'],
['', '', ''], // 空行,应跳过
['b@test.local', '李四', 'Xyz123456'],
];
$rows = User::parseImportRows($sheet);
$this->assertCount(2, $rows);
$this->assertSame('a@test.local', $rows[0]['email']);
$this->assertSame('张三', $rows[0]['nickname']);
$this->assertSame('Abc123456', $rows[0]['password']);
$this->assertSame(2, $rows[0]['line']);
$this->assertSame(4, $rows[1]['line']);
}
public function test_parse_trims_cells()
{
$sheet = [
['邮箱', '昵称', '初始密码'],
[' a@test.local ', ' 张三 ', ' Abc123456 '],
];
$rows = User::parseImportRows($sheet);
$this->assertSame('a@test.local', $rows[0]['email']);
$this->assertSame('张三', $rows[0]['nickname']);
$this->assertSame('Abc123456', $rows[0]['password']);
}
public function test_validate_passes_for_valid_row()
{
$row = ['email' => 'ok@test.local', 'nickname' => '张三', 'password' => 'Abc123456'];
$this->assertNull(User::validateImportRow($row));
}
public function test_validate_requires_all_fields()
{
$this->assertSame('邮箱、昵称、初始密码均为必填', User::validateImportRow(['email' => '', 'nickname' => '张三', 'password' => 'Abc123456']));
$this->assertSame('邮箱、昵称、初始密码均为必填', User::validateImportRow(['email' => 'a@test.local', 'nickname' => '', 'password' => 'Abc123456']));
$this->assertSame('邮箱、昵称、初始密码均为必填', User::validateImportRow(['email' => 'a@test.local', 'nickname' => '张三', 'password' => '']));
}
public function test_validate_rejects_bad_email()
{
$this->assertSame('邮箱格式不正确', User::validateImportRow(['email' => 'not-an-email', 'nickname' => '张三', 'password' => 'Abc123456']));
}
public function test_validate_rejects_bad_nickname_length()
{
$this->assertSame('昵称需为2-20个字', User::validateImportRow(['email' => 'a@test.local', 'nickname' => '王', 'password' => 'Abc123456']));
$this->assertSame('昵称需为2-20个字', User::validateImportRow(['email' => 'a@test.local', 'nickname' => str_repeat('字', 21), 'password' => 'Abc123456']));
}
public function test_validate_rejects_short_password()
{
$this->assertNotNull(User::validateImportRow(['email' => 'a@test.local', 'nickname' => '张三', 'password' => '123']));
}
public function test_assert_valid_profession_passes_for_empty_and_normal()
{
// 空职位允许可选字段2/20 字边界与正常值允许;不抛异常即通过
User::assertValidProfession('');
User::assertValidProfession('工程'); // 恰好 2 字
User::assertValidProfession('工程师');
User::assertValidProfession(str_repeat('字', 20)); // 恰好 20 字
$this->assertTrue(true);
}
public function test_assert_valid_profession_rejects_too_short()
{
$this->expectException(\App\Exceptions\ApiException::class);
$this->expectExceptionMessage('职位/职称不可以少于2个字');
User::assertValidProfession('A');
}
public function test_assert_valid_profession_rejects_too_long()
{
$this->expectException(\App\Exceptions\ApiException::class);
$this->expectExceptionMessage('职位/职称最多只能设置20个字');
User::assertValidProfession(str_repeat('字', 21));
}
public function test_assert_valid_departments_normalizes_ids()
{
// 空/非数组 → 返回空数组
$this->assertSame([], User::assertValidDepartments([]));
$this->assertSame([], User::assertValidDepartments('not-array'));
// 去重 + 转 int + 过滤非正数(这些路径不查库)
$this->assertSame([3, 5], User::assertValidDepartments(['3', 3, 5, 0, -1]));
}
public function test_assert_valid_departments_rejects_over_limit()
{
// 超过 10 个count 校验在查库之前)→ 抛异常
$this->expectException(\App\Exceptions\ApiException::class);
$this->expectExceptionMessage('最多只可加入10个部门');
User::assertValidDepartments(range(1, 11));
}
public function test_parse_reads_profession_column()
{
$sheet = [
['邮箱', '昵称', '初始密码', '职位'],
['a@test.local', '张三', 'Abc123456', '工程师'],
['b@test.local', '李四', 'Xyz123456'], // 无职位列 → profession 为空
];
$rows = User::parseImportRows($sheet);
$this->assertCount(2, $rows);
$this->assertSame('工程师', $rows[0]['profession']);
$this->assertSame('', $rows[1]['profession']);
}
public function test_validate_passes_for_empty_profession()
{
$row = ['email' => 'a@test.local', 'nickname' => '张三', 'password' => 'Abc123456', 'profession' => ''];
$this->assertNull(User::validateImportRow($row));
}
public function test_validate_rejects_bad_profession()
{
$row = ['email' => 'a@test.local', 'nickname' => '张三', 'password' => 'Abc123456', 'profession' => 'A'];
$this->assertSame('职位/职称不可以少于2个字', User::validateImportRow($row));
}
}