diff --git a/app/Http/Controllers/Api/UsersController.php b/app/Http/Controllers/Api/UsersController.php index 7e098d6ee..f8d2420f5 100755 --- a/app/Http/Controllers/Api/UsersController.php +++ b/app/Http/Controllers/Api/UsersController.php @@ -34,6 +34,8 @@ use App\Models\WebSocketDialogUser; use App\Models\UserTaskBrowse; use App\Models\UserFavorite; use App\Models\UserRecentItem; +use App\Models\UserTag; +use App\Models\UserTagRecognition; use Illuminate\Support\Facades\DB; use App\Models\UserEmailVerification; use App\Module\AgoraIO\AgoraTokenGenerator; @@ -355,6 +357,9 @@ class UsersController extends AbstractController $data['nickname_original'] = $user->getRawOriginal('nickname'); $data['department_name'] = $user->getDepartmentName(); $data['department_owner'] = UserDepartment::where('parent_id',0)->where('owner_userid', $user->userid)->exists(); // 适用默认部门下第1级负责人才能添加部门OKR + $tagMeta = UserTag::listWithMeta($user->userid, $user); + $data['personal_tags'] = $tagMeta['top']; + $data['personal_tags_total'] = $tagMeta['total']; return Base::retSuccess('success', $data); } @@ -773,8 +778,12 @@ class UsersController extends AbstractController public function basic() { $sharekey = Request::header('sharekey'); - if (empty($sharekey) || !Meeting::getShareInfo($sharekey)) { - User::auth(); + $shareInfo = $sharekey ? Meeting::getShareInfo($sharekey) : null; + $viewer = null; + if (empty($shareInfo)) { + $viewer = User::auth(); + } elseif (Doo::userId() > 0) { + $viewer = User::whereUserid(Doo::userId())->first(); } // $userid = Request::input('userid'); @@ -792,6 +801,9 @@ class UsersController extends AbstractController $basic = UserDelete::userid2basic($id); } if ($basic) { + $tagMeta = UserTag::listWithMeta($basic->userid, $viewer); + $basic->personal_tags = $tagMeta['top']; + $basic->personal_tags_total = $tagMeta['total']; // $retArray[] = $basic; } @@ -1456,6 +1468,233 @@ class UsersController extends AbstractController return Base::retSuccess('success', $data); } + protected function buildUserTagResponse(?User $viewer, int $targetUserId, string $message = 'success') + { + return Base::retSuccess($message, UserTag::listWithMeta($targetUserId, $viewer)); + } + + /** + * @api {get} api/users/tags/lists 10.1. 获取个性标签列表 + * + * @apiDescription 需要token身份 + * @apiVersion 1.0.0 + * @apiGroup users + * @apiName tags__lists + * + * @apiParam {Number} [userid] 会员ID(不传默认为当前用户) + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据 + * @apiSuccessExample {json} data: + { + "list": [ + { + "id": 1, + "name": "认真负责", + "recognition_total": 3, + "recognized": true, + "can_edit": true, + "can_delete": true + } + ], + "top": [ ], + "total": 1 + } + */ + public function tags__lists() + { + $viewer = User::auth(); + $userid = intval(Request::input('userid')) ?: $viewer->userid; + $target = User::whereUserid($userid)->first(); + if (empty($target)) { + return Base::retError('会员不存在'); + } + return $this->buildUserTagResponse($viewer, $target->userid); + } + + /** + * @api {post} api/users/tags/add 10.2. 新增个性标签 + * + * @apiDescription 需要token身份 + * @apiVersion 1.0.0 + * @apiGroup users + * @apiName tags__add + * + * @apiParam {Number} [userid] 会员ID(不传默认为当前用户) + * @apiParam {String} name 标签名称(1-20个字符) + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据,同“获取个性标签列表” + */ + public function tags__add() + { + $viewer = User::auth(); + $userid = intval(Request::input('userid')) ?: $viewer->userid; + $target = User::whereUserid($userid)->first(); + if (empty($target)) { + return Base::retError('会员不存在'); + } + + $name = trim((string) Request::input('name')); + if ($name === '') { + return Base::retError('请输入个性标签'); + } + if (mb_strlen($name) > 20) { + return Base::retError('标签名称最多只能设置20个字'); + } + if (UserTag::where('user_id', $userid)->where('name', $name)->exists()) { + return Base::retError('标签已存在'); + } + if (UserTag::where('user_id', $userid)->count() >= 100) { + return Base::retError('每位会员最多添加100个标签'); + } + + $tag = UserTag::create([ + 'user_id' => $userid, + 'name' => $name, + 'created_by' => $viewer->userid, + 'updated_by' => $viewer->userid, + ]); + $tag->save(); + + return $this->buildUserTagResponse($viewer, $userid, '添加成功'); + } + + /** + * @api {post} api/users/tags/update 10.3. 修改个性标签 + * + * @apiDescription 需要token身份 + * @apiVersion 1.0.0 + * @apiGroup users + * @apiName tags__update + * + * @apiParam {Number} tag_id 标签ID + * @apiParam {String} name 标签名称(1-20个字符) + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据,同“获取个性标签列表” + */ + public function tags__update() + { + $viewer = User::auth(); + $tagId = intval(Request::input('tag_id')); + $name = trim((string) Request::input('name')); + if ($tagId <= 0) { + return Base::retError('参数错误'); + } + if ($name === '') { + return Base::retError('请输入个性标签'); + } + if (mb_strlen($name) > 20) { + return Base::retError('标签名称最多只能设置20个字'); + } + $tag = UserTag::find($tagId); + if (empty($tag)) { + return Base::retError('标签不存在'); + } + if (!$tag->canManage($viewer)) { + return Base::retError('无权操作该标签'); + } + if ($name !== $tag->name && UserTag::where('user_id', $tag->user_id)->where('name', $name)->where('id', '!=', $tag->id)->exists()) { + return Base::retError('标签已存在'); + } + + if ($name !== $tag->name) { + $tag->updateInstance([ + 'name' => $name, + 'updated_by' => $viewer->userid, + ]); + } else { + $tag->updateInstance([ + 'updated_by' => $viewer->userid, + ]); + } + $tag->save(); + + return $this->buildUserTagResponse($viewer, $tag->user_id, '保存成功'); + } + + /** + * @api {post} api/users/tags/delete 10.4. 删除个性标签 + * + * @apiDescription 需要token身份 + * @apiVersion 1.0.0 + * @apiGroup users + * @apiName tags__delete + * + * @apiParam {Number} tag_id 标签ID + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据,同“获取个性标签列表” + */ + public function tags__delete() + { + $viewer = User::auth(); + $tagId = intval(Request::input('tag_id')); + if ($tagId <= 0) { + return Base::retError('参数错误'); + } + $tag = UserTag::find($tagId); + if (empty($tag)) { + return Base::retError('标签不存在'); + } + if (!$tag->canManage($viewer)) { + return Base::retError('无权操作该标签'); + } + + $userId = $tag->user_id; + $tag->delete(); + + return $this->buildUserTagResponse($viewer, $userId, '删除成功'); + } + + /** + * @api {post} api/users/tags/recognize 10.5. 认可个性标签 + * + * @apiDescription 需要token身份 + * @apiVersion 1.0.0 + * @apiGroup users + * @apiName tags__recognize + * + * @apiParam {Number} tag_id 标签ID + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据,同“获取个性标签列表” + */ + public function tags__recognize() + { + $viewer = User::auth(); + $tagId = intval(Request::input('tag_id')); + if ($tagId <= 0) { + return Base::retError('参数错误'); + } + $tag = UserTag::find($tagId); + if (empty($tag)) { + return Base::retError('标签不存在'); + } + + $recognition = UserTagRecognition::where('tag_id', $tagId) + ->where('user_id', $viewer->userid) + ->first(); + if ($recognition) { + $recognition->delete(); + $message = '已取消认可'; + } else { + UserTagRecognition::create([ + 'tag_id' => $tagId, + 'user_id' => $viewer->userid, + ]); + $message = '认可成功'; + } + + return $this->buildUserTagResponse($viewer, $tag->user_id, $message); + } + /** * @api {get} api/users/meeting/link 20. 【会议】获取分享链接 * diff --git a/app/Models/UserTag.php b/app/Models/UserTag.php new file mode 100644 index 000000000..70c8740f6 --- /dev/null +++ b/app/Models/UserTag.php @@ -0,0 +1,84 @@ +belongsTo(User::class, 'created_by', 'userid') + ->select(['userid', 'nickname']); + } + + public function recognitions(): HasMany + { + return $this->hasMany(UserTagRecognition::class, 'tag_id'); + } + + public function canManage(User $viewer): bool + { + return $viewer->isAdmin() + || $viewer->userid === $this->user_id + || $viewer->userid === $this->created_by; + } + + public static function listWithMeta(int $targetUserId, ?User $viewer): array + { + $query = static::query() + ->where('user_id', $targetUserId) + ->with(['creator']) + ->withCount(['recognitions as recognition_total']) + ->orderByDesc('recognition_total') + ->orderBy('id'); + + $tags = $query->get(); + + $viewerId = $viewer?->userid ?? 0; + $viewerIsAdmin = $viewer?->isAdmin() ?? false; + $viewerIsOwner = $viewerId > 0 && $viewerId === $targetUserId; + + $recognizedIds = []; + if ($viewerId > 0 && $tags->isNotEmpty()) { + $recognizedIds = UserTagRecognition::query() + ->where('user_id', $viewerId) + ->whereIn('tag_id', $tags->pluck('id')) + ->pluck('tag_id') + ->all(); + } + $recognizedLookup = array_flip($recognizedIds); + + $list = $tags->map(function (self $tag) use ($viewerId, $viewerIsAdmin, $viewerIsOwner, $recognizedLookup) { + $canManage = $viewerIsAdmin || $viewerIsOwner || $viewerId === $tag->created_by; + + return [ + 'id' => $tag->id, + 'user_id' => $tag->user_id, + 'name' => $tag->name, + 'created_by' => $tag->created_by, + 'created_by_name' => $tag->creator?->nickname ?: '', + 'recognition_total' => (int) $tag->recognition_total, + 'recognized' => isset($recognizedLookup[$tag->id]), + 'can_edit' => $canManage, + 'can_delete' => $canManage, + ]; + })->values()->toArray(); + + return [ + 'list' => $list, + 'top' => array_slice($list, 0, 10), + 'total' => count($list), + ]; + } +} diff --git a/app/Models/UserTagRecognition.php b/app/Models/UserTagRecognition.php new file mode 100644 index 000000000..1599e2faf --- /dev/null +++ b/app/Models/UserTagRecognition.php @@ -0,0 +1,26 @@ +belongsTo(UserTag::class, 'tag_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id', 'userid') + ->select(['userid', 'nickname']); + } +} diff --git a/database/migrations/2025_10_13_000000_create_user_tags_table.php b/database/migrations/2025_10_13_000000_create_user_tags_table.php new file mode 100644 index 000000000..16d5407da --- /dev/null +++ b/database/migrations/2025_10_13_000000_create_user_tags_table.php @@ -0,0 +1,40 @@ +bigIncrements('id'); + $table->unsignedBigInteger('user_id')->index()->comment('被标签用户ID'); + $table->string('name', 50)->comment('标签名称'); + $table->unsignedBigInteger('created_by')->index()->comment('创建人'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('最后更新人'); + $table->timestamps(); + + $table->unique(['user_id', 'name'], 'user_tags_unique_name'); + $table->foreign('user_id')->references('userid')->on('users')->onDelete('cascade'); + $table->foreign('created_by')->references('userid')->on('users')->onDelete('cascade'); + $table->foreign('updated_by')->references('userid')->on('users')->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('user_tags'); + } +} diff --git a/database/migrations/2025_10_13_000100_create_user_tag_recognitions_table.php b/database/migrations/2025_10_13_000100_create_user_tag_recognitions_table.php new file mode 100644 index 000000000..138747bbb --- /dev/null +++ b/database/migrations/2025_10_13_000100_create_user_tag_recognitions_table.php @@ -0,0 +1,37 @@ +bigIncrements('id'); + $table->unsignedBigInteger('tag_id')->index()->comment('标签ID'); + $table->unsignedBigInteger('user_id')->index()->comment('认可人ID'); + $table->timestamps(); + + $table->unique(['tag_id', 'user_id'], 'user_tag_recognitions_unique'); + $table->foreign('tag_id')->references('id')->on('user_tags')->onDelete('cascade'); + $table->foreign('user_id')->references('userid')->on('users')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('user_tag_recognitions'); + } +} diff --git a/resources/assets/js/pages/manage/components/UserDetail.vue b/resources/assets/js/pages/manage/components/UserDetail.vue index 21151e087..179932dcc 100755 --- a/resources/assets/js/pages/manage/components/UserDetail.vue +++ b/resources/assets/js/pages/manage/components/UserDetail.vue @@ -43,6 +43,23 @@ {{$L('个人简介')}}: {{userData.introduction || '-'}} +
  • + {{$L('个性标签')}}: +
    +
    + {{tag.name}} +
    + {{$L('暂无个性标签')}} +
    + {{$L('共(*)个', personalTagTotal)}} + +
    +
    +
  • {{$L('最后在线')}}: {{$A.newDateString(userData.line_at, 'YYYY-MM-DD HH:mm') || '-'}} @@ -102,6 +119,11 @@ + @@ -109,10 +131,13 @@ import emitter from "../../../store/events"; import transformEmojiToHtml from "../../../utils/emoji"; import {mapState} from "vuex"; +import UserTagsModal from "./UserTagsModal.vue"; export default { name: 'UserDetail', + components: {UserTagsModal}, + data() { return { userData: { @@ -121,6 +146,8 @@ export default { showModal: false, + tagModalVisible: false, + commonDialog: { userid: null, total: null, @@ -160,6 +187,17 @@ export default { commonDialogList() { return this.commonDialog.list || []; }, + + displayTags() { + return Array.isArray(this.userData.personal_tags) ? this.userData.personal_tags : []; + }, + + personalTagTotal() { + if (typeof this.userData.personal_tags_total === 'number') { + return this.userData.personal_tags_total; + } + return this.displayTags.length; + } }, methods: { @@ -172,6 +210,7 @@ export default { this.$store.dispatch("showSpinner", 600) this.$store.dispatch('getUserData', userid).then(user => { this.userData = user; + this.ensureTagDefaults(); this.showModal = true; this.loadCommonDialogCount() }).finally(_ => { @@ -181,7 +220,8 @@ export default { onHide() { this.commonDialogShow = false; - this.showModal = false + this.showModal = false; + this.tagModalVisible = false; }, onOpenAvatar() { @@ -210,6 +250,27 @@ export default { emitter.emit('createGroup', userids); }, + ensureTagDefaults() { + if (!Array.isArray(this.userData.personal_tags)) { + this.$set(this.userData, 'personal_tags', []); + } + if (typeof this.userData.personal_tags_total !== 'number') { + this.$set(this.userData, 'personal_tags_total', this.userData.personal_tags.length); + } + }, + + onOpenTagsModal() { + if (!this.userData.userid) { + return; + } + this.tagModalVisible = true; + }, + + onTagsUpdated({top, total}) { + this.$set(this.userData, 'personal_tags', Array.isArray(top) ? top : []); + this.$set(this.userData, 'personal_tags_total', typeof total === 'number' ? total : this.userData.personal_tags.length); + }, + loadCommonDialogCount() { const target_userid = this.userData.userid; const previousUserId = this.commonDialog.userid; @@ -309,3 +370,52 @@ export default { } }; + + diff --git a/resources/assets/js/pages/manage/components/UserTagsModal.vue b/resources/assets/js/pages/manage/components/UserTagsModal.vue new file mode 100644 index 000000000..9b0ac968f --- /dev/null +++ b/resources/assets/js/pages/manage/components/UserTagsModal.vue @@ -0,0 +1,459 @@ + + + + + diff --git a/resources/assets/js/pages/manage/setting/personal.vue b/resources/assets/js/pages/manage/setting/personal.vue index 6d61f638d..0a47bc4d4 100644 --- a/resources/assets/js/pages/manage/setting/personal.vue +++ b/resources/assets/js/pages/manage/setting/personal.vue @@ -39,23 +39,47 @@ + +
    + + {{$L('暂无个性标签')}} + {{$L('共(*)个', personalTagTotal)}} + +
    +
    + + + diff --git a/resources/assets/sass/pages/page-setting.scss b/resources/assets/sass/pages/page-setting.scss index 73c0d41a5..00290c989 100755 --- a/resources/assets/sass/pages/page-setting.scss +++ b/resources/assets/sass/pages/page-setting.scss @@ -153,10 +153,12 @@ .setting-item { .ivu-input, .ivu-select-default, - .ivu-date-picker { + .ivu-date-picker, + .user-tags-preview { max-width: 460px; } - .ivu-date-picker { + .ivu-date-picker, + .user-tags-preview { width: 100%; } .ivu-form {