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('拖动卡片调整顺序,保存后仅自己可见') }}
+
+
+
+
+
+
+
+
-
- {{ t == 'base' ? $L('常用') : $L('管理员') }}
-
-
-
-
-
-
-
-
-
-
+
+
+ {{ t === 'base' ? $L('常用') : $L('管理员') }}
+
+
+
+
+
+
-
{{ $L(item.label) }}
+
{{ card.micro.label }}
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
{{ $L(card.system.label) }}
+
+
+
+
+
+
+
{{ $L(card.system.label) }}
-
-
{{ $L(item.label) }}
+
-
-
-
+
+
+
@@ -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 {