From d30b38d4b9593b36aee28687942ef7c0627dc667 Mon Sep 17 00:00:00 2001 From: kuaifan Date: Mon, 10 Nov 2025 07:47:00 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Api/UsersController.php | 47 ++ app/Models/UserAppSort.php | 96 ++++ ..._20_000000_create_user_app_sorts_table.php | 37 ++ .../assets/js/pages/manage/application.vue | 485 +++++++++++++++--- resources/assets/sass/pages/page-apply.scss | 65 ++- 5 files changed, 656 insertions(+), 74 deletions(-) create mode 100644 app/Models/UserAppSort.php create mode 100644 database/migrations/2025_11_20_000000_create_user_app_sorts_table.php diff --git a/app/Http/Controllers/Api/UsersController.php b/app/Http/Controllers/Api/UsersController.php index 37cb4e102..5289eed6c 100755 --- a/app/Http/Controllers/Api/UsersController.php +++ b/app/Http/Controllers/Api/UsersController.php @@ -36,6 +36,7 @@ use App\Models\UserFavorite; use App\Models\UserRecentItem; use App\Models\UserTag; use App\Models\UserTagRecognition; +use App\Models\UserAppSort; use Illuminate\Support\Facades\DB; use App\Models\UserEmailVerification; use App\Module\AgoraIO\AgoraTokenGenerator; @@ -3456,6 +3457,51 @@ class UsersController extends AbstractController return Base::retSuccess('删除成功'); } + /** + * @api {get} api/users/appsort 获取个人应用排序 + * + * @apiDescription 需要token身份 + * @apiVersion 1.0.0 + * @apiGroup users + * @apiName appsort + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据 + */ + public function appsort() + { + $user = User::auth(); + $sorts = UserAppSort::getSorts($user->userid); + return Base::retSuccess('success', [ + 'sorts' => $sorts, + ]); + } + + /** + * @api {post} api/users/appsort/save 保存个人应用排序 + * + * @apiDescription 需要token身份 + * @apiVersion 1.0.0 + * @apiGroup users + * @apiName appsort__save + * + * @apiParam {Object} sorts 排序配置,示例:{"base":["micro:calendar"],"admin":["system:ldap"]} + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据 + */ + public function appsort__save() + { + $user = User::auth(); + $sorts = UserAppSort::normalizeSorts(Request::input('sorts')); + $record = UserAppSort::saveSorts($user->userid, $sorts); + return Base::retSuccess('保存成功', [ + 'sorts' => $record->sorts ?? $sorts, + ]); + } + /** * @api {get} api/users/favorites 获取用户收藏列表 * @@ -3679,4 +3725,5 @@ class UsersController extends AbstractController // return Base::retSuccess('success', ['favorited' => $isFavorited]); } + } diff --git a/app/Models/UserAppSort.php b/app/Models/UserAppSort.php new file mode 100644 index 000000000..891f1391c --- /dev/null +++ b/app/Models/UserAppSort.php @@ -0,0 +1,96 @@ + 'array', + ]; + + /** + * 获取用户排序配置 + * @param int $userid + * @return array + */ + public static function getSorts(int $userid): array + { + $record = static::whereUserid($userid)->first(); + if (!$record) { + return self::normalizeSorts([]); + } + return self::normalizeSorts($record->sorts); + } + + /** + * 保存排序配置 + * @param int $userid + * @param array $sorts + * @return static + */ + public static function saveSorts(int $userid, array $sorts): self + { + return static::updateOrCreate( + ['userid' => $userid], + ['sorts' => self::normalizeSorts($sorts)] + ); + } + + /** + * 规范化排序数据 + * @param mixed $sorts + * @return array + */ + public static function normalizeSorts($sorts): array + { + $result = [ + 'base' => [], + 'admin' => [], + ]; + if (!is_array($sorts)) { + return $result; + } + foreach (['base', 'admin'] as $group) { + $list = $sorts[$group] ?? []; + if (!is_array($list)) { + $list = []; + } + $normalized = []; + foreach ($list as $value) { + if (!is_string($value)) { + continue; + } + $value = trim($value); + if ($value === '') { + continue; + } + $normalized[] = $value; + } + $result[$group] = array_values(array_unique($normalized)); + } + return $result; + } +} diff --git a/database/migrations/2025_11_20_000000_create_user_app_sorts_table.php b/database/migrations/2025_11_20_000000_create_user_app_sorts_table.php new file mode 100644 index 000000000..925a182ca --- /dev/null +++ b/database/migrations/2025_11_20_000000_create_user_app_sorts_table.php @@ -0,0 +1,37 @@ +bigIncrements('id'); + $table->bigInteger('userid')->unique()->comment('用户ID'); + $table->json('sorts')->nullable()->comment('排序配置'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('user_app_sorts'); + } +} diff --git a/resources/assets/js/pages/manage/application.vue b/resources/assets/js/pages/manage/application.vue index cb6553909..5c23e6a8c 100644 --- a/resources/assets/js/pages/manage/application.vue +++ b/resources/assets/js/pages/manage/application.vue @@ -7,74 +7,116 @@

{{ $L('应用') }}

+
+ +
+ +
+ + {{ $L('调整排序') }} + {{ $L('退出排序') }} + +
+
+ +
+
+ + {{ $L('拖动卡片调整顺序,保存后仅自己可见') }} +
+
+ + + +
+
+
@@ -289,9 +331,11 @@ import SystemAppPush from "./setting/components/SystemAppPush"; import emitter from "../../store/events"; import ImgUpload from "../../components/ImgUpload.vue"; import {webhookEventOptions} from "../../utils/webhook"; +import Draggable from "vuedraggable"; export default { components: { + Draggable, ImgUpload, UserSelect, DrawerOverlay, @@ -306,6 +350,22 @@ export default { data() { return { applyTypes: ['base', 'admin'], + sortingMode: false, + sortLists: { + base: [], + admin: [], + }, + sortInitialLists: { + base: [], + admin: [], + }, + appSorts: { + base: [], + admin: [], + }, + appSortLoaded: false, + appSortLoading: false, + appSortSaving: false, // mybotShow: false, mybotList: [], @@ -337,6 +397,9 @@ export default { sendType: '', } }, + created() { + this.fetchAppSorts(); + }, activated() { this.$store.dispatch("updateMicroAppsStatus") }, @@ -349,6 +412,7 @@ export default { 'approveUnreadNumber', 'cacheDialogs', 'windowOrientation', + 'windowPortrait', 'formOptions', 'routeLoading', 'microAppsIds' @@ -359,6 +423,7 @@ export default { ]), applyList() { const list = [ + // 常用应用 {value: "approve", label: "审批中心", sort: 30, show: this.microAppsIds.includes('approve')}, {value: "favorite", label: "我的收藏", sort: 45}, {value: "recent", label: "最近打开", sort: 47}, @@ -372,6 +437,14 @@ export default { {value: "addProject", label: "创建项目", sort: 110}, {value: "addTask", label: "添加任务", sort: 120}, {value: "scan", label: "扫一扫", sort: 130, show: $A.isEEUIApp}, + + // 管理员应用 + {type: 'admin', value: "ldap", label: "LDAP", sort: 160, show: this.userIsAdmin}, + {type: 'admin', value: "mail", label: "邮件通知", sort: 170, show: this.userIsAdmin}, + {type: 'admin', value: "appPush", label: "APP 推送", sort: 180, show: this.userIsAdmin}, + {type: 'admin', value: "complaint", label: "举报管理", sort: 190, show: this.userIsAdmin}, + {type: 'admin', value: "exportManage", label: "数据导出", sort: 195, show: this.userIsAdmin}, + {type: 'admin', value: "allUser", label: "团队管理", sort: 200, show: this.userIsAdmin}, ] // 竖屏模式 if (this.windowPortrait) { @@ -381,25 +454,293 @@ export default { {value: "setting", label: "设置", sort: 140}, ]) } - // 管理员 - if (this.userIsAdmin) { - list.push(...[ - {type: 'admin', value: "ldap", label: "LDAP", sort: 160}, - {type: 'admin', value: "mail", label: "邮件通知", sort: 170}, - {type: 'admin', value: "appPush", label: "APP 推送", sort: 180}, - {type: 'admin', value: "complaint", label: "举报管理", sort: 190}, - {type: 'admin', value: "exportManage", label: "数据导出", sort: 195}, - {type: 'admin', value: "allUser", label: "团队管理", sort: 200}, - ]) - } // return list.sort((a, b) => a.sort - b.sort); }, isExistAdminList() { - return this.filterMicroAppsMenusAdmin.length > 0 || this.applyList.map(h => h.type).indexOf('admin') !== -1; + return this.adminAppItems.length > 0; + }, + baseAppItems() { + return this.applySavedSort(this.collectAppItems('base'), 'base'); + }, + adminAppItems() { + return this.applySavedSort(this.collectAppItems('admin'), 'admin'); + }, + sortHasChanges() { + if (!this.sortingMode) { + return false; + } + const groups = ['base', 'admin']; + return groups.some(group => { + const current = (this.sortLists[group] || []).map(item => item.sortKey); + const initial = this.sortInitialLists[group] || []; + if (current.length !== initial.length) { + return true; + } + return current.some((key, index) => key !== initial[index]); + }); + } + }, + watch: { + sortingMode(val) { + if (val) { + this.bootstrapSortLists(); + } else { + this.resetSortState(); + } + }, + baseAppItems() { + if (this.sortingMode) { + this.mergeSortListWithSource('base'); + } + }, + adminAppItems() { + if (this.sortingMode) { + this.mergeSortListWithSource('admin'); + } } }, methods: { + handleActionMenu(action) { + if (action === 'sort') { + this.enterSortMode(); + } else if (action === 'cancelSort') { + this.exitSortMode(); + } + }, + currentCards(type) { + return this.sortingMode ? (this.sortLists[type] || []) : this.getDisplayItems(type); + }, + getDisplayItems(type) { + return type === 'admin' ? this.adminAppItems : this.baseAppItems; + }, + collectAppItems(group) { + const items = []; + const microSource = group === 'admin' ? this.filterMicroAppsMenusAdmin : this.filterMicroAppsMenus; + microSource.forEach(menu => { + if (!menu || menu.show === false) { + return; + } + items.push(this.createMicroCard(menu, group)); + }); + this.applyList.forEach(item => { + if (item.show === false) { + return; + } + const isAdminItem = item.type === 'admin'; + if (group === 'admin') { + if (!isAdminItem) { + return; + } + } else if (isAdminItem) { + return; + } + items.push(this.createSystemCard(item, group)); + }); + return items; + }, + createMicroCard(menu, group) { + const fallback = menu?.id || menu?.value || menu?.url || menu?.label || 'unknown'; + const key = menu?.name || fallback; + return { + sortKey: `micro:${key}`, + category: 'micro', + group, + micro: menu, + }; + }, + createSystemCard(item, group) { + return { + sortKey: `system:${item.value}`, + category: 'system', + group, + system: item, + }; + }, + applySavedSort(items, group) { + const saved = this.appSorts[group] || []; + if (!saved.length) { + return items; + } + const map = {}; + items.forEach(card => { + map[card.sortKey] = card; + }); + const ordered = []; + saved.forEach(key => { + if (map[key]) { + ordered.push(map[key]); + delete map[key]; + } + }); + items.forEach(card => { + if (map[card.sortKey]) { + ordered.push(card); + delete map[card.sortKey]; + } + }); + return ordered; + }, + async enterSortMode() { + if (this.sortingMode) { + return; + } + if (!this.appSortLoaded && !this.appSortLoading) { + await this.fetchAppSorts(); + } + this.sortingMode = true; + }, + exitSortMode() { + this.sortingMode = false; + }, + bootstrapSortLists() { + const base = this.cloneAppItems(this.baseAppItems); + const admin = this.cloneAppItems(this.adminAppItems); + this.$set(this.sortLists, 'base', base); + this.$set(this.sortLists, 'admin', admin); + this.$set(this.sortInitialLists, 'base', base.map(item => item.sortKey)); + this.$set(this.sortInitialLists, 'admin', admin.map(item => item.sortKey)); + }, + resetSortState() { + this.$set(this.sortLists, 'base', []); + this.$set(this.sortLists, 'admin', []); + this.$set(this.sortInitialLists, 'base', []); + this.$set(this.sortInitialLists, 'admin', []); + }, + mergeSortListWithSource(group) { + const source = this.cloneAppItems(this.getDisplayItems(group)); + if (!source.length) { + this.$set(this.sortLists, group, []); + this.$set(this.sortInitialLists, group, []); + return; + } + const sourceMap = new Map(source.map(item => [item.sortKey, item])); + const next = []; + (this.sortLists[group] || []).forEach(item => { + if (sourceMap.has(item.sortKey)) { + next.push(sourceMap.get(item.sortKey)); + sourceMap.delete(item.sortKey); + } + }); + sourceMap.forEach(item => next.push(item)); + this.$set(this.sortLists, group, this.cloneAppItems(next)); + const snapshot = this.sortInitialLists[group] ? [...this.sortInitialLists[group]] : []; + next.forEach(item => { + if (!snapshot.includes(item.sortKey)) { + snapshot.push(item.sortKey); + } + }); + this.$set(this.sortInitialLists, group, snapshot); + }, + cloneAppItems(items = []) { + return items.map(item => Object.assign({}, item)); + }, + getDraggableOptions(type) { + return { + animation: 200, + draggable: '.apply-col-wrapper', + group: { + name: `${type}-apps`, + pull: false, + put: false, + } + }; + }, + async fetchAppSorts() { + if (this.appSortLoading) { + return; + } + this.appSortLoading = true; + try { + const {data} = await this.$store.dispatch("call", { + url: 'users/appsort', + method: 'get', + }); + this.appSorts = this.normalizeSortPayload(data?.sorts); + } catch (error) { + const msg = error?.msg || error?.message; + msg && console.warn(msg); + } finally { + this.appSortLoading = false; + this.appSortLoaded = true; + } + }, + normalizeSortPayload(raw) { + const result = {base: [], admin: []}; + if (!raw || typeof raw !== 'object') { + return result; + } + ['base', 'admin'].forEach(group => { + const list = Array.isArray(raw[group]) ? raw[group] : []; + result[group] = list + .filter(item => typeof item === 'string') + .map(item => item.trim()) + .filter(item => item.length > 0); + }); + return result; + }, + submitSort() { + if (!this.sortHasChanges) { + this.exitSortMode(); + return; + } + const payload = this.buildSortPayload(); + this.appSortSaving = true; + this.$store.dispatch("call", { + url: 'users/appsort/save', + method: 'post', + data: { + sorts: payload, + } + }).then(({data, msg}) => { + this.appSorts = this.normalizeSortPayload(data?.sorts || payload); + this.exitSortMode(); + $A.messageSuccess(msg || this.$L('保存成功')); + }).catch(({msg}) => { + $A.modalError(msg || this.$L('保存失败')); + }).finally(() => { + this.appSortSaving = false; + }); + }, + restoreDefaultSort() { + if (!this.sortingMode) { + return; + } + ['base', 'admin'].forEach(group => { + this.$set(this.sortLists, group, this.cloneAppItems(this.collectAppItems(group))); + }); + }, + buildSortPayload() { + const payload = {base: [], admin: []}; + ['base', 'admin'].forEach(group => { + const keys = (this.sortLists[group] || []).map(item => item.sortKey); + const defaults = this.getDefaultSortKeys(group); + payload[group] = this.arraysEqual(keys, defaults) ? [] : keys; + }); + return payload; + }, + getDefaultSortKeys(group) { + return this.collectAppItems(group).map(item => item.sortKey); + }, + arraysEqual(a = [], b = []) { + if (a.length !== b.length) { + return false; + } + return a.every((item, index) => item === b[index]); + }, + handleCardClick(card, params = '') { + if (this.sortingMode) { + return; + } + if (!card) { + return; + } + if (card.category === 'micro') { + this.applyClick({value: 'microApp'}, card.micro); + return; + } + this.applyClick(card.system, params); + }, normalizeWebhookEvents(events = [], useFallback = false) { if (!Array.isArray(events)) { events = events ? [events] : []; diff --git a/resources/assets/sass/pages/page-apply.scss b/resources/assets/sass/pages/page-apply.scss index 20800e53a..b6f41a48a 100644 --- a/resources/assets/sass/pages/page-apply.scss +++ b/resources/assets/sass/pages/page-apply.scss @@ -30,6 +30,52 @@ font-weight: 600; } } + + .apply-nav-actions { + display: flex; + align-items: center; + + .apply-action-btn { + font-size: 26px; + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + color: #6f6f6f; + cursor: pointer; + transition: all .2s; + + &:hover { + color: $primary-title-color; + } + } + } + } + + .apply-sort-bar { + margin: 16px 32px 0; + padding: 12px 16px; + border: 1px dashed rgba($primary-color, 0.4); + border-radius: 8px; + background-color: mix(#ffffff, $primary-color, 92%); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + + .apply-sort-tip { + display: flex; + align-items: center; + gap: 8px; + color: $primary-color; + font-size: 13px; + } + + .apply-sort-actions { + display: flex; + gap: 8px; + } } .apply-content { @@ -39,7 +85,6 @@ .apply-row-title { margin-bottom: 16px; - } > div.apply-row-title:nth-last-child(2) { @@ -83,11 +128,23 @@ top: -16px; padding: 8px; } + + &.is-sorting { + border-style: dashed; + border-color: $primary-color; + background: rgba($primary-color, 0.05); + } } } } @media (width <= 510px) { + .apply-sort-bar { + margin: 12px; + flex-direction: column; + align-items: flex-start; + } + .apply-row-title { margin-bottom: 8px !important; } @@ -531,7 +588,11 @@ body.window-portrait { background-color: #FFFFFF; .apply-head { - margin: 24px 24px 0 24px; + margin: 24px 24px 0; + } + + .apply-sort-bar { + margin: 16px 20px 0; } .apply-content {