mirror of
https://github.com/kuaifan/dootask.git
synced 2026-06-11 18:02:22 +00:00
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:
parent
aa2e0acaba
commit
20b5daba50
@ -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 邮箱验证
|
||||
*
|
||||
|
||||
@ -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
13
app/Module/UserImport.php
Normal 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;
|
||||
}
|
||||
}
|
||||
21
app/Module/UserImportTemplate.php
Normal file
21
app/Module/UserImportTemplate.php
Normal 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字)'];
|
||||
}
|
||||
}
|
||||
@ -977,3 +977,14 @@ LDAP 用户缺少邮箱属性,请联系管理员配置
|
||||
普通成员不能移出群主或群管理员
|
||||
只有群主、群管理员或邀请人可以移出成员
|
||||
仅群主、项目/任务负责人可设置或取消他人待办
|
||||
请选择文件
|
||||
仅支持 xls/xlsx/csv 文件
|
||||
文件中没有可导入的数据
|
||||
导入完成
|
||||
昵称需为2-20个字
|
||||
邮箱、昵称、初始密码均为必填
|
||||
邮箱格式不正确
|
||||
文件内邮箱重复
|
||||
单次最多导入500条
|
||||
没有可导入的数据
|
||||
解析完成
|
||||
|
||||
@ -2396,3 +2396,31 @@ AI任务分析
|
||||
待办设置权限
|
||||
允许:所有成员可设置/取消他人待办。
|
||||
禁止:仅本人、群主(含群管理员)、项目负责人(含项目管理员)、任务负责人可设置/取消待办。
|
||||
|
||||
批量导入用户
|
||||
请按模板填写后上传,列顺序:邮箱、昵称、初始密码、职位(选填);单次最多导入500条。
|
||||
下载模板
|
||||
导入结果:共(*)条,成功(*)条,失败(*)条
|
||||
行号
|
||||
失败原因
|
||||
导入失败
|
||||
仅支持 xls/xlsx/csv 文件
|
||||
创建用户
|
||||
批量导入
|
||||
初始密码
|
||||
请输入邮箱
|
||||
请输入初始密码
|
||||
员工首次登录需修改密码
|
||||
邮箱、昵称、初始密码均为必填
|
||||
员工下次登录需修改密码
|
||||
重新选择文件
|
||||
共(*)条 · 可导入(*)条 · 错误(*)条
|
||||
点击查看明文
|
||||
原因
|
||||
可导入
|
||||
错误
|
||||
确定导入(*)条
|
||||
解析失败
|
||||
|
||||
设置部门到选中(*)项
|
||||
成功导入(*)条
|
||||
143
resources/assets/js/pages/manage/components/CreateUserModal.vue
Normal file
143
resources/assets/js/pages/manage/components/CreateUserModal.vue
Normal 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>
|
||||
365
resources/assets/js/pages/manage/components/ImportUserModal.vue
Normal file
365
resources/assets/js/pages/manage/components/ImportUserModal.vue
Normal 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>
|
||||
@ -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),
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
187
tests/Feature/AdminCreateUserTest.php
Normal file
187
tests/Feature/AdminCreateUserTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
137
tests/Unit/UserImportParseTest.php
Normal file
137
tests/Unit/UserImportParseTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user